最近这段时间,我一直在玩 Vibe Coding 这种开发方式——说白了,就是不断跟大模型对话,把需求一条条讲清楚,让它把代码一点点生成出来。折腾了一段时间之后,我做出来一个自己每天都在用的工具:一个基于“规则+LLM”的黄金与指数行情分析终端,纯命令行界面,输入品种代码,它负责拉数据、算指标、跑规则,最后让大模型输出一段结构化的行情解读。
这个项目本身不算复杂,但我特别想把“为什么用规则+LLM这个组合”和“落地过程中踩过的坑”记录下来。它不是那种炫酷的Agent框架,也不是一个带界面的量化平台,它就是一个老老实实跑在终端里的分析工具。如果你也在研究怎么把大模型用到真实场景里,或者想自己做一个能看黄金、看指数的小终端,这篇文章应该能给你一些直接能用的参考。
1. 为什么是“规则+LLM”:这个组合到底解决什么问题
1.1 纯规则的痛点:能算,但不会说话
最早我的想法很简单,写一堆技术指标计算和条件判断,比如均线金叉就提示看多、RSI超过70就提示超买,这样够不够?做完第一版之后我发现一个问题:规则引擎确实可以算,而且算得很准、很快,但它不会“说话”。
举例来说,规则可以准确告诉你“黄金1小时级别MA20上穿了MA60”,这是一个金叉信号。但如果你把这一行文字直接扔给使用者,对方会很懵:金叉意味着什么?当前价格距离均线有多远?RSI是多少?波动率大不大?如果价格已经连续上涨了一波,此时金叉还有多少参考价值?这些判断靠一堆规则堆砌,复杂度会爆炸。每多一个条件,规则之间就可能互相冲突,还要考虑优先级、震荡行情下的假信号问题。最后就是代码越写越长,逻辑越来越绕,维护起来非常痛苦。
1.2 纯LLM的痛点:会说话,但会胡扯
那干脆不要规则,直接把行情数据丢给LLM让它分析行不行?我试过,效果很刺激。LLM写出来的分析报告读起来特别流畅,有逻辑、有层次,还有模有样地提到了支撑位和压力位。但你仔细一核对就会发现,它在细节上非常不可靠。
原因在于,LLM的本质是文本生成器,不是一个精确的计算器。它没有实时行情数据,你给它一串K线数据,它能记住大概数字,但一旦涉及“MA20是否真的上穿了MA60”“RSI当前具体数值是多少”这种精确计算,它就很容易在表达时“脑补”出一个看起来合理的数字。行情分析这个场景最怕的就是模型一本正经地胡说八道,尤其是当用户拿它的话当决策参考时。进一步说,LLM对同一次行情输入的输出并不稳定,你换个问法,或者把背景信息重新表述一遍,它的结论倾向就可能出现明显漂移,这在金融分析里是无法接受的。
1.3 分工之后:规则给结论,LLM给表达
所以最终的方案是让两者各管一段:规则负责判断,LLM负责表达。规则引擎做所有确定性计算和信号生成,输出一个高度结构化、可验证的“信号包”;LLM拿到这个信号包之后,把它翻译成自然语言,并结合整体信号做一个综合性的解读。
数据流向是这样一步步走的:
code复制行情数据 -> 指标计算 -> 规则引擎 -> 信号集 -> LLM -> 结构化解读
规则引擎输出给LLM的,不是原始K线数字,而是一条条已经算好的信号。比如:
code复制品种: 黄金现货
周期: 1小时
信号列表:
1. MA20上穿MA60,金叉成立,方向偏多,权重0.6
2. RSI=63,处于中性偏强区间
3. 布林带收口,波动率偏低
4. 价格位于布林带中轨上方
LLM要做的事情,就是基于这些信号去组织语言,比如:“当前价格在短期均线上方,动能偏强,但布林带收口说明波动率尚未放大,可以观察突破方向。”它不能自己发明一个新的指标,也不能把“布林带收口”改成“布林带开口”。这样一来,模型幻觉被限制在一个很小的范围内,输出内容既稳定又有人味。
这里顺便说一下AI领域里那些绕口的名词。常说的LLM就是大语言模型,DeepSeek、GPT、Claude都属于LLM;Agent则是能自主调用工具、拆解任务完成目标的智能体。这个终端其实不算是严格意义上的Agent,它更准确的说法是一个“带有LLM表达能力、由规则引擎驱动的命令行情分析工具”。理解这个区别,有助于你后续设计自己的架构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 终端技术栈与项目骨架设计
2.1 为什么选择终端而不是网页
行情分析工具天然适合跑在终端里,有几个原因非常明确。
第一是轻量。打开一个终端窗口,输入一行命令就能跑完整个分析链路,不需要启动浏览器、不需要处理前端布局,尤其适合每天早上定时看一遍几个关键品种。第二是自动化友好。命令行程序可以配合cron定时任务,或者写个shell脚本批量分析多个品种,输出结果还可以继续用管道传给其他工具做后续处理。第三是跨平台。只要装了Python环境,Linux、macOS、Windows上的WSL都能跑,几乎没有平台兼容性问题。
终端工具的选择上,我日常用的是macOS自带终端和Tabby。Tabby是一个跨平台的终端工具,界面写起来比较舒服,支持全局唤起、多标签页和配色方案自定义,适合长时间挂在那里观察输出。至于tmux这类终端复用工具,如果要做定时任务和多窗口管理,也可以加进来,让分析会话不因为网络断开而中断。纯文本输出对终端没有特殊要求,所以普通终端完全够用。
2.2 项目目录与各模块职责
项目结构经历了多轮Vibe Coding迭代,从最初的一个超大单文件,慢慢拆成了下面的样子:
text复制gold-index-terminal/
├── .env
├── .gitignore
├── requirements.txt
├── config.yaml
├── main.py
├── data_provider.py
├── indicators.py
├── rules.py
├── llm_client.py
├── terminal_view.py
└── utils.py
各模块的职责划分如下:
main.py:入口。接收命令行参数,串联数据采集、指标计算、规则判断、LLM解读,最后把结果打印到终端。data_provider.py:负责拉取行情数据,对外暴露一个统一的接口,这样上游不用关心数据来自哪个源。indicators.py:技术指标计算,MA、RSI、BOLL、ATR等,都是纯函数,方便单独测试。rules.py:规则引擎核心,定义规则数据结构、规则列表,以及信号汇总逻辑。llm_client.py:LLM调用层,统一管理API密钥、请求组织、响应解析和异常处理。terminal_view.py:终端展示层,用富文本输出图表和结果。utils.py:通用工具函数,比如JSON修复、时间格式化、错误日志等。
为什么要拆成这么多文件?因为Vibe Coding最大的风险是让大模型在单文件里不断堆功能,最后代码越来越长、依赖关系混乱。拆文件之后,每个模块可以单独对话、单独测试,不会出现“改一个地方崩三处”的情况。日常使用中,我最常改的是rules.py和config.yaml,其他文件基本不用动。
2.3 依赖清单与数据源替换
requirements.txt是这样写的:
text复制akshare>=1.14.0
pandas>=2.0.0
numpy>=1.26
requests>=2.31
python-dotenv>=1.0
openai>=1.0
rich>=13.0
数据源我这里用的是akshare,纯免费、不注册也能用,能覆盖国内外主流指数和商品行情,包括沪金主力、伦敦金现货、沪深300、恒生指数、标普500等。有一点要注意,akshare的接口偶尔会调整,数据获取必须做好异常捕获和本地缓存,不然定时任务跑着跑着就断了。
如果你有自己维护的行情SDK,也很简单,把data_provider.py里的数据获取函数替换掉就行,只要返回Pandas DataFrame格式,列名保持统一,后面的指标计算和规则引擎完全不用改。这种数据源和逻辑层解耦的设计,是Vibe Coding初期就应该跟模型强调清楚的约束。
2.4 用Vibe Coding搭建骨架时的一些经验
用Vibe Coding做这种小工具,我摸索出一套比较顺的协作方式,分享给你。
第一步,不要上来就让模型写代码,先让它帮你把整体架构描述出来,把模块划分、函数签名、数据流全部想清楚。第二步,一个文件一个文件地生成,让模型集中精力写一个模块,避免它在一个大文件里越写越乱。第三步,要求它为每个函数写清楚docstring和类型标注,这样后续改代码时上下文清晰。第四步,让它直接生成测试用例,尤其是指标计算这类数值型函数,一定要有断言。
一个特别重要的经验是:一定要让模型“先设计接口再写body”。比如你告诉它“我需要一个fetch_kline(symbol, period)函数,返回带时间和OHLCV字段的DataFrame”,它会写得比较规范。如果你让它自由发挥,它可能把参数名、返回结构定义得很随意,后面要用的时候头疼的是你自己。还有,Vibe Coding生成的代码,技术指标公式里的参数一定要自己核对,比如RSI的平滑方式、布林带的默认倍数,不同库默认值不一样,直接照抄很容易算错。
3. 规则引擎设计与行情指标解析
3.1 技术指标的计算细节:MA、RSI、BOLL、ATR
规则引擎的地基是指标计算。指标算错了,规则和LLM解读都会跟着崩。我建议把所有指标都放在indicators.py里,写成纯函数,输入DataFrame,输出数值或序列。
以最常用的MA和RSI为例:
python复制import pandas as pd
def ma(series: pd.Series, window: int) -> float:
if len(series) < window:
raise ValueError(f"数据长度不足,无法计算{window}周期均线")
return float(series.rolling(window).mean().iloc[-1])
def rsi(series: pd.Series, period: int = 14) -> float:
delta = series.diff()
gain = delta.clip(lower=0).ewm(alpha=1 / period, adjust=False).mean()
loss = (-delta.clip(upper=0)).ewm(alpha=1 / period, adjust=False).mean()
rs = gain / loss
return float(100 - (100 / (1 + rs.iloc[-1])))
这里有几个细节值得注意。RSI的计算方式有很多种,有简单平均的,也有指数平滑的,我这里用的是常见的外推法。关键是 ewm(alpha=1/period, adjust=False) 这一行,它保证当前值的权重最高,更贴近行情的最新变化。如果你换成 adjust=True,结果会完全不同。
布林带由中轨(MA20)、上轨(中轨加两倍标准差)和下轨(中轨减两倍标准差)组成,用来判断价格相对位置和波动率收敛状态。ATR(平均真实波幅)则用来衡量波动水平,对风险管理很有价值。这些都是很经典的指标,网上公式一大把,但真正写进代码时要格外小心边界情况,比如K线数量不够一个周期、数据里有空值、涨跌幅为0导致除零等,这些都是在真实数据上才会暴露出来的问题。
3.2 规则怎么抽象:从条件到信号
规则引擎的抽象是整个项目的核心。我把规则定义成这样的数据结构:
python复制from dataclasses import dataclass, field
from typing import Callable, Optional
@dataclass
class SignalContext:
df: pd.DataFrame
ma_fast: float
ma_slow: float
rsi: float
boll_upper: float
boll_lower: float
boll_mid: float
atr: float
close: float
@dataclass
class RuleSignal:
rule_name: str
level: str # "bullish" | "bearish" | "neutral"
weight: float
description: str
meta: dict = field(default_factory=dict)
RuleFunc = Callable[[SignalContext], Optional[RuleSignal]]
每条规则都是一个独立的函数,输入一个包含全部指标值的上下文,输出一个信号。没有命中就返回None。这样的设计最大的好处是扩展性,加新规则只需要写一个函数并注册到规则列表,不用改其他代码。
举个例子,均线金叉规则:
python复制def golden_cross_rule(ctx: SignalContext) -> Optional[RuleSignal]:
if ctx.ma_fast > ctx.ma_slow:
return RuleSignal(
rule_name="golden_cross",
level="bullish",
weight=0.6,
description="MA20上穿MA60,短期均线位于长期均线上方"
)
return None
RSI超卖反转规则:
python复制def rsi_oversold_rule(ctx: SignalContext) -> Optional[RuleSignal]:
if ctx.rsi < 30:
return RuleSignal(
rule_name="rsi_oversold",
level="bullish",
weight=0.5,
description="RSI低于30,进入超卖区间"
)
return None
你会发现,每条规则都必须能解释清楚“如果...那么...”,并且附带一个权重。这个权重很重要,它决定了不同信号在综合评分里占多大话语权。金叉信号权重0.6,说明它比RSI超卖信号更可靠;RSI超卖虽然是一个经典条件,但钝化现象很常见,权重只有0.5。这些数值不需要特别精确,但它们应该能反映你对每个信号可靠程度的主观判断。
3.3 规则加权评分:让互相打架的规则有个统一出口
多品种多周期下的规则必然会出现矛盾信号,比如日线级别RSI超卖,但1小时级别均线仍然是空头排列。针对这种情况,我加了一个综合评分函数,把所有命中信号的权重按方向做加权平均,得到一个落在-1到1之间的分数。
python复制def aggregate_signals(signals: list[RuleSignal]) -> float:
direction = {"bullish": 1, "bearish": -1, "neutral": 0}
total_weight = sum(s.weight for s in signals)
if total_weight == 0:
return 0.0
score = sum(direction[s.level] * s.weight for s in signals) / total_weight
return score
这个分数就是给LLM的“锚”。我要求LLM在解读行情时必须保持与一致的方向倾向。比如综合分数是0.7,LLM不能说“当前空头信号明显”;分数接近0时,LLM才能说“多空信号交织,方向不明朗”。这个约束从根本上守护了结论的确定性,LLM可以做语言润色,但不能改变信号的方向和强度,这是整个系统稳定性的根基。
3.4 规则设计的几条实战心得
第一,规则要少而准。我第一版一口气写了四十多条规则,从斐波那契回撤到成交量突变全都上了,结果就是同一时间大量信号互相打架,综合评分经常在0附近徘徊,LLM完全没法做解读。后来精简到八条核心规则,效果反而立刻变好。先保核心,再慢慢加。
第二,规则别写死数值,一定要放到配置里。比如RSI阈值是30和70,均线周期是20和60,这些参数直接放进config.yaml,方便回测和调整。否则每次改参数都要改代码,既不灵活也容易出bug。
第三,不同周期要分开处理。拿黄金来说,1小时和4小时的信号可能完全不同,聚合时一定要带上周期标签,不能让不同周期的信号混在一起算分。我的做法是每个周期独立算分,然后再做一个二次加权,给长周期更高的权重。
第四,每条规则产出的描述要写得像人话。因为这条描述会直接喂给LLM,如果描述本身含糊不清,LLM的解读质量也会下降。最好的状态是LLM基本可以直接“引述”这条描述作为分析依据,而不是再自行理解一遍。
4. 把行情解读交给LLM:接入、提示与结构化输出
4.1 接入LLM:OpenAI兼容接口与模型选择
LLM这一层我通过OpenAI兼容接口调用DeepSeek的API,这也是很多国内开发者的选择。为什么选它?第一,接口兼容性好,SDK直接用OpenAI官方Python包,代码量很小,以后想换其他模型只要改base_url和model参数。第二,价格相对友好,做个人项目压力不大。第三,它支持Function Calling功能,这个特性在金融分析场景里非常重要,后面会细说。
接入代码其实很简单:
python复制from openai import OpenAI
client = OpenAI(
api_key=api_key,
base_url="https://api.deepseek.com"
)
resp = client.chat.completions.create(
model="deepseek-chat",
messages=messages,
tools=tools,
tool_choice="required",
temperature=0.2,
)
在这个架构里,DeepSeek扮演的角色是纯粹的LLM——它接收规则引擎产生的结构化信号,生成行情解读文本。除了模型调用,我们还需要处理API返回结果的解析、异常重试、日志记录等工作。llm_client.py就是统一管理这些细节的地方,上层不用关心HTTP请求怎么发、密钥从哪来。
4.2 密钥管理:API Key不能写死在代码里
我在这块吃过亏,所以专门拎出来多说几句。第一版做测试图省事,直接把API Key写死在llm_client.py里,后来想截图分享到群里,发现还要先改代码屏蔽密钥,特别麻烦。更严重的是,如果你把带密钥的代码推到Git仓库,哪怕只是推到一个私有仓库,密钥都算是暴露过了,正确的做法是立即吊销换新。
推荐做法是使用.env文件和python-dotenv库管理密钥。
bash复制# .env 文件
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
python复制import os
from dotenv import load_dotenv
load_dotenv()
api_key = os.getenv("DEEPSEEK_API_KEY")
if not api_key:
raise SystemExit("缺少 DEEPSEEK_API_KEY,请检查 .env 文件")
同时,.gitignore里必须要有这样一行:
text复制.env
就算这样,我还要提醒几句。不要在代码日志里打印请求头、不要打印完整的API Key、不要截图时把Key露出来。推送代码到GitHub之前,可以用git secrets或trufflehog这类工具扫一遍历史提交,如果发现可疑痕迹,宁可多花几分钟先吊销密钥再处理代码。
4.3 用Function Calling约束输出结构
让LLM直接输出自然语言分析,解析起来很麻烦,而且格式不稳定。更好的办法是使用Function Calling,强制LLM以函数参数的形式返回结构化的字段。换句话说,我们不是在问它“你觉得行情怎么样”,而是告诉它“请把分析结果填到这个模板里”。
定义这样一个函数工具:
python复制tools = [
{
"type": "function",
"function": {
"name": "analysis_response",
"description": "输出最终行情分析结果",
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "两到三句话的总览"},
"signals": {
"type": "array",
"items": {"type": "string"},
"description": "逐条解读已触发的规则信号"
},
"risk_points": {
"type": "array",
"items": {"type": "string"},
"description": "当前行情需要注意的风险点"
},
"next_observation": {
"type": "string",
"description": "下一步需要观察的关键价位或信号"
}
},
"required": ["summary", "signals", "risk_points", "next_observation"]
}
}
}
]
调用时把规则引擎生成的信号拼接成messages传进去,同时设置tool_choice="required",强制模型必须调用这个函数。这样返回结果就是一个字段齐全的JSON对象,直接放进终端展示层渲染就行,不用费劲去字符串里抠内容。
4.4 temperature、JSON修复与其他稳定性细节
关于temperature这个参数,简单理解就是控制模型输出的随机性。数值越高,输出越天马行空;数值越低,输出越稳定保守。原理上,模型在推理时是根据概率分布来采样下一个词,temperature参数会对这个概率分布做缩放,高温让分布更加平滑、低概率词更容易被选中,低温则让高概率词的优势更明显。在行情分析这种场景下,我们需要的是稳定可复现的输出风格,所以温度设置在0.2左右比较好。实测下来,温度调到0.8以上,经常出现同一份数据被解读成截然相反风格的情况。
即使做了这么多约束,偶尔还是会遇到LLM返回格式异常的情况。比如它在JSON前面加了“这里是的返回结果”这样的前缀,或者末尾多了一个逗号导致json.loads直接报错。网络上有人专门写了修复LLM返回JSON的Java库,Python生态里也有一个叫json_repair的小工具,专门处理这类脏数据。我在utils.py里做了一个兜底解析:
python复制import json
from json_repair import repair_json
def safe_json_parse(content: str):
try:
return json.loads(content)
except json.JSONDecodeError:
return json.loads(repair_json(content))
不过说到底,JSON解析修复只是兜底方案,最重要的是在提示词里明确要求模型“只输出函数调用参数,不要包含任何其他文字”,同时借助Function Calling的规范约束,这类问题出现频率会大幅下降。
另外提示词里有一句非常关键,必须强调:“所有用于表达方向的结论必须基于规则信号,不得新增任何未在信号中出现的指标数据。”这句话能有效阻止模型自己脑补“RSI出现顶背离”这类规则引擎没有算出来的内容。再加上给它传一个综合评分和信号列表,它能自由发挥的边界就非常窄了。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
项目跑了一段时间,积累了一些典型问题和对应的解决方案,整理成表格方便你排查。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| LLM输出JSON解析失败 | 提示词不够明确、模型版本较弱或输出被截断 | 使用Function Calling并设tool_choice="required";降低temperature;用json_repair兜底 |
| 终端出现中文乱码 | 终端编码不是UTF-8 | 统一代码文件UTF-8,终端编码改成UTF-8,展示层用rich库输出 |
| API Key泄露到Git仓库 | .env没被git忽略 |
加入.gitignore,立即吊销旧Key并更新配置,扫描历史提交 |
| 规则信号互相矛盾,评分趋近0 | 规则数量过多或权重设置不合理 | 精简规则,按周期独立计算再二次加权 |
| 数据源接口拉取失败 | akshare接口调整或网络波动 | 增加重试机制和本地缓存,保留上一次成功的K线数据 |
| 模型把规则给出的方向说反了 | 提示词没有明确“必须基于信号方向” | 在系统提示中加入方向约束,并把综合评分一并传给模型 |
| 多次运行输出结果不稳定 | temperature设置偏高 | 调到0.2附近,必要时固定随机种子 |
| 指标计算结果和行情软件对不上 | 指标公式参数或平滑方式不一致 | 逐个指标核对公式细节,比如RSI的ewm参数、布林带标准差的无偏/有偏 |
5.2 规则和LLM结论不一致时听谁的
答案是听规则的。LLM在这个系统里是解说员,不是决策者。规则引擎输出的方向、信号强度、综合评分是经过精确计算得出的,LLM只能在这个结论的边界内组织语言。如果你发现模型写出来的解读跟规则信号明显冲突,比如规则信号整体看多,但LLM大篇幅分析下行风险,那一定是提示词设计有问题。
我的处理方式是在系统提示词的最前面加一段强约束:“你是行情分析工具的表达模块,不是交易决策模型。你必须严格依据给定的结构化信号进行解读,不能发明信号,不能改变信号方向。”然后在messages里把信号列表逐条传过去,并用“综合信心分:0.7(取值-1到1,负值代表偏空,正值代表偏多)”这种明确表述告诉它锚点在哪里。实在不行,就把temperature调到0再观察一次,如果还是方向反了,就要检查传入信号是否是模型能理解的中文描述,太晦涩的规则描述也会导致解读偏差。
5.3 定位问题的一线排查思路
当整个链路出问题的时候,不要一上来就怀疑LLM,先把它拆开来看。
第一步,把LLM模块临时禁用,单独看规则引擎的输出。在命令行里跑一个--dry-run参数,让它只输出指标和信号,不调用任何模型。如果规则层本身就不对,后面的修复都是白搭。
第二步,把规则信号固化成JSON文件。每次运行都会生成一份快照,里面包含指标值、触发信号、综合评分、时间戳。这样再去做LLM的对话调试时,可以拿同一个信号文件反复测模型效果,不用每次重新拉数据,可复现性大大提升。
第三步,单独调试LLM调用。写一个脚本,只输入一套固定信号,观察不同temperature、不同提示词下的输出差异。你可以保存几套提示词模板,在终端里切换测试,快速定位到底是哪段描述导致输出不稳定。
第四步,处理终端展示层的编码问题。我用的展示库是rich,本身会自动处理大部分格式问题。如果你看到乱码,优先检查终端编码设置,尤其是Windows下的旧版终端,建议直接把系统代码页切到UTF-8。
项目本身不复杂,但这个“规则引擎管住确定性、LLM负责表达力”的思路,我是越用越觉得值得推荐。实际开发过程中,Vibe Coding帮我省了大量写样板代码的时间,但也要求我必须保持对核心逻辑的掌控力,尤其是技术指标公式、规则权重、提示词约束这些关键部分,不能盲目相信模型生成的代码。你如果也想做类似工具,我建议从规则层开始,先把指标算准、把信号定义清楚,再慢慢把LLM接进来做表达部分。最后提醒一句,任何行情分析工具的输出都只是辅助参考,不构成投资建议,真正做交易决策时必须独立判断,别让模型给出的那几句“解释”影响了你对风险的把握。希望你能稳住确定性这一端,再用LLM把分析结果讲得清楚明白。
