Vibe-Trading 开源项目防模型编造数字的两层设计与代价
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
这套设计真正值钱的地方,不是在提示词里写了一句「不许编造」,而是把两条事实从建议变成了结构:市场数据类工具只能使用在本轮工具调用批次开始之前就锁定的标的身份;最终答案里的价格断言必须能在完整未截断的工具返回里找到对应值,否则这一版草稿根本不会发给用户。 提示词层面的约束,模型可以违反而系统无感;结构层面的约束,模型违反了就会被拦下来并拿到一份结构化的错误说明。
Vibe-Trading 是 HKUDS 放出的开源个人交易 Agent(MIT 许可,Copyright 2026 Vibe-Trading Contributors),本质是一套把金融研究工作流拆成技能、工具与多智能体团队的 Agent 工程。它值得单独看的原因是场景本身:金融语境里编错一个价格,和代码里少写一个分号完全不是一回事。
一、它防的到底是哪一类编造
先把问题收窄。模型在金融话题上编数字,至少有三种来源:
一是训练数据里的旧价格。模型见过某个标的在某个时点的报价,于是在今天的对话里直接说出来。对任何在模型知识截止之后仍在交易的资产,这个数按定义就是错的。agent/src/swarm/grounding.py 的模块注释把这条写得很直白:swarm worker 就是 LLM,没有显式 grounding 它们会愉快地引用训练数据里的价格。
二是身份错位。用户说一个公司名,模型自己脑补出一个代码,或者在 .SH 和 .SS 这类后缀约定之间静默改写,取回来的行情根本不是用户问的那只。
三是叙述里的算术漂移。数字本身来自工具,但在推导、换算、加总的过程中被搓错了,或者干脆凭印象填了一个「看起来合理」的中间值。
Vibe-Trading 对这三类分别放了东西:主循环侧一层、多智能体侧一层、外加一个专门的只读校验工具。
站内已经有几篇相邻的文章,分工不重叠:大模型幻觉的成因与分类 讲的是幻觉本身从哪来;对手验证:让另一个 Agent 来挑毛病 讲的是用第二个模型做交叉审查;输出约束的忠实性 讲的是格式与内容约束能不能被真正遵守。本篇不重复这些,只做一件事:把一个真实开源仓库里的三段确定性代码摊开,看它在没有第二个模型参与的前提下,用纯规则拦住了哪些东西。
二、主循环侧:身份闸门、证据台账与终答校验
agent/src/agent/grounding.py 里的核心类是 GroundingLedger,按一次 run 的生命周期存在。文件开头就声明了它刻意不依赖任何 provider 或工具注册表,目的是让状态机和终答检查保持确定性、可测试。
它做三件事。
第一件是身份状态机。 用户消息进来时,GroundingLedger 先用一条正则判断这轮请不请求「可执行的市场结论」——买入、卖出、目标价、现价、估值、是否上市这类措辞。命中了就把 _identity_required 置真,同时打开输出缓冲。接着扫描用户消息里显式写出的规范代码(形如 600519.SH、NVDA.US、BTC-USDT),直接锁定为 locked。
之后每一个工具调用都要过 authorize_tool_call。这里有个细节容易被忽略:授权比对用的不是「当前」身份,而是 agent/src/agent/loop.py 在处理整批 tool_calls 之前拍下的快照 batch_authorized_symbols 和 batch_identity_status。含义是,模型在同一次回复里既调 search_symbol 又调 get_market_data,后者拿不到前者的结果——解析结果必须落到下一轮才能被消费。这条规则堵掉的正是「一口气把查代码和取行情写在一起,然后用自己脑补的代码去取数」这种模式。
身份的状态取值包括 unresolved、locked、ambiguous、conflicting、invalidated、not_found,还有一个 superseded。被拦下来的调用不会抛异常,而是通过 ToolAuthorization.error_payload 渲染成一条普通的结构化错误结果塞回模型,带 error_code(identity_required / identity_conflict / identity_mismatch)和一段 required_action 文本,明确告诉模型:在单独一轮里调 search_symbol,等结果,然后原样复用锁定的代码与交易所。这是把闸门做成模型能读懂的反馈,而不是一个静默失败。
几个规则值得单拎出来:
.SS与.SH永远不视为等价。同一个数字基码在两种后缀之间切换会被判成 conflicting,而不是悄悄放行。- 只有一份写死的白名单工具(
get_equity_quotes、get_stock_profile、place_equity_order、trading_quote等)允许用裸代码消费带.US的锁定身份,且必须唯一匹配。 - 历史消息明确不作为本轮的授权来源。注释写得很清楚:上一轮用户问的标的,不能给这一轮的新标的解锁工具。
- ambiguous 状态没有被放进「解析未完成」集合。注释里给了理由并挂了 issue 号:筛选类请求本来就会解析出一大堆候选,要求这里必须 locked 会把每一个探索型任务卡死在加载技能之前。
第二件是证据台账。 ingest_tool_result 吃的是完整的、未经上下文截断的工具返回。也就是说,哪怕模型上下文里只看到了被裁剪过的摘要,台账里存的是全量。get_market_data 的返回走专门的路径,逐行拆成 EvidenceRecord,字段包括 call_id、tool、symbol、source、timestamp、field、value、status、currency、venue、currency_conversion,其中 source 优先取返回里 _provenance 声明的实际来源而不是请求参数里的 auto。取不到数的代码会记成 status 为 unavailable 的证据行——「没有」也是一条证据。其它工具的 JSON 返回走通用路径,递归摊平数值叶子,上限两千条。
整个台账会原子写到本次 run 目录下的 artifacts/grounding_evidence.json,先写 .tmp 再 replace,且写失败时只是静默返回、决策仍留在内存里,不会因为目录只读把 Agent 的错误路径也搞崩。这份产物的价值和 Agent 可观察日志怎么设计 里讲的是一回事:事后你能逐条回放它凭什么放行、凭什么拦截。
第三件是终答校验。 模型给出不带工具调用的最终回复时,validate_final_answer 会跑一遍。它做两类检查:
身份类。如果这轮要求身份而聚合状态还停在 unresolved / ambiguous / conflicting / invalidated,直接记一条 identity_not_locked。如果已经锁定成上市证券却在正文里被改口成「未上市」「私人公司」,记一条 listed_identity_relabelled_private。
数值类。它会解析正文里的 Markdown OHLC 表格和散文中的价格断言,把每个候选数字拿去和台账里的观测值比对,容差 0.5%。对不上的分三种:找不到匹配证据(numeric_claim_unavailable)、和观测区间冲突(numeric_claim_conflict)、多个证据标的之间指代不清(numeric_claim_ambiguous_symbol)。还有一组溯源检查,要求正文必须写出规范代码、实际数据源名和报价币种,缺哪个报哪个。
这里最见功夫的是取数前的掩码。_numbers_without_dates_or_percent 会先把规范代码、ISO 日期、中文年月日、聚合金额(成本/总额/市值这类)、带单位的数量(股/手/周/个月)、百分比全部挡掉,剩下的才当作候选价格。原因写在注释里:000543.SZ 不掩码的话会贡献一个 543 出去,然后被判成价格冲突,把一版本来正确的回答毙掉。这一段是所有做数值校验的人都会踩的坑——误报比漏报更容易让规则被整个关掉。
允许的例外只有一条:显式推导。正文里出现推导词,并且写成 (a - b) / 2 = c 这种形式、输入锚定在观测值上、算术自洽(用 AST 解析,只认数字和四则运算,不执行代码),才放行。
草稿被拒之后不会直接返回给用户。loop.py 里把被拒的草稿写进 trace,追加一条 [GROUNDING GATE] 开头的纠正提示(最多列 12 条问题),让模型重写。校验次数到 3 次仍不过,就落到 safe_fallback:按用户消息里有没有中日韩字符选中英文,如实说明上一版因与工具证据冲突被拒、列出可验证的已观测区间、并明确表示在重新核对或展示推导前不再生成结论。整个过程中流式文本是被缓冲的,所以用户从头到尾看不到那版被拒的草稿。
三、多智能体侧:开跑前把真行情写进 worker 提示词
agent/src/swarm/grounding.py 走的是完全不同的路子——它不校验,它预防。
流程是:扫 swarm run 的 user_vars 里所有字符串值,用四条正则找出带后缀的代码;再对剩下的文本做一次「裸代码提升」,把 2 到 5 个大写字母的 token 补成 .US。提升这一步带了一串护栏:一个上百项的停用词表(ETF、CEO、GDP、USD、BTC、BUY、SELL、API、LLM 这些全在里面)、先把已匹配的后缀代码位置抹空以免 BTC-USDT 漏出一个假的 BTC.US、显式代码永远排在提升结果前面从而优先占住数量上限。
拿到代码后按窗口取日线(默认 30 个自然日),走 backtest.loaders.registry 的 loader 解析,逐个代码 try/except,单个失败只记日志不影响整批。agent/src/swarm/runtime.py 里的 _prefetch_grounding_data 还额外把这次拉取包在心跳计时器里——注释解释得很实在:多标的拉取可能超过三十秒,不发心跳的话陈旧任务回收器会把一个健康的新 run 误判成僵死。
最后 format_grounding_block 渲成一段 Markdown:每个代码一个小节,一张 Date / Close / Volume 表(只渲最近五行,但区间统计用全窗口算),加一行最新收盘与窗口区间。块头是一段硬话,大意是「这些是本次运行的权威当前价格,不要引用训练数据里的价格、估值、倍数或收益率,需要窗口外的数据就去调 get_market_data,报价时引用表里的日期」。这段块由 agent/src/swarm/worker.py 的 build_worker_prompt 拼在执行规则之前,让 worker 在做任何工具决策之前先看到真实数据。空串则整节省略,不会渲一个空标题出来。
这里顺带说清楚一件事:这一层调用的是回测数据加载层,取的是历史行情。历史表现不代表未来,本文只讨论工程实现,不讨论任何数据的解读方式。
模块注释还主动交代了两个残余风险,这种写法本身就值得学:一是它不会中途刷新,长跑的 swarm 会看到过期快照,但「过期几十分钟」仍严格优于「过期一年」;二是某个全大写的普通英文词恰好撞上真实上市产品(注释举的例子是 MOAT),会白白 ground 一张无关的表——代价是提示词预算,不是正确性,因为 worker 被要求只引用自己分析的标的。
四、financial_rigor:只算不取数的严谨性工具
第三块在 agent/src/tools/financial_rigor_tool.py,工具名 financial_rigor,is_readonly 为真,repeatable 为真(因为主循环会按名字给不可重复工具去重,而这个工具在一次会话里本来就要用不同子命令调多次)。
它的定位在文件开头一句话讲完:只接受原始数字,不取数;和行情、财报工具配对使用,那边取数,这边验算。全部算术在 decimal.Decimal 的 28 位精度共享上下文里做,避开 IEEE-754 漂移,结果可复现可审计。
六个子命令,通过 command 参数选:
verify_market_cap:拿价格乘股数,和给定的市值比。判定分三档,偏差不超过 1% 是 pass,1% 到 5% 是 warn,超过 5% 是 fail。verify_valuation:从每股口径的原始输入推导 PE、PB、ROE、P/FCF、FCF 收益率、股息率、PS 这些字段。哪个输入给了就出哪个指标。cross_validate:同一个字段跨多个来源比对,用中位数当参考值,默认容忍 2% 偏差,返回逐来源明细和一个all_consistent标志。benford:对一组数值做首位数字的本福特检验。样本不足 50 条直接返回reliable为假并附说明;够了才给 MAD、卡方和一致性档位。calc:把一个算术表达式字符串在精确十进制域里求值。AST 白名单只放行数字和加减乘除,不走eval。three_scenario:按乐观/中性/悲观三组「EPS 增速 + 目标 PE」假设,各推一个目标价字段。
three_scenario 这个子命令需要框定清楚:它是仓库里对模型输出格式的一种约束——工具要求调用方提供三组增速与目标 PE,返回结构里带 future_eps、target_price、upside_pct 这些字段名。这是在描述这个开源项目的接口长什么样,不是本文对读者的任何建议,本文也不给出任何具体数值。这个子命令里有一处很典型的防御:注释直说 LLM 经常把 15% 写成 15 而不是 0.15,所以绝对值大于 1 的增速会被当成百分比自动除以 100,并在返回里通过 growth_normalized_from_percent 和 note 字段把这次修正显式标出来。修正而不静默,是工具返回设计里的一个好习惯,工具返回值怎么设计 里讲的就是这类取舍。
顺带提一句因子相关的事实来源,免得误读:仓库根目录的 NOTICE 声明了几个因子库各自的上游——Microsoft Qlib 的特征定义按 Apache 2.0 引入,另有几组公式来自公开论文与券商研报(NOTICE 里点名了 101 Formulaic Alphas、国泰君安 191 短周期因子、Fama-French 五因子等),仓库把它们当作数学事实重实现,各因子库子目录下另有 LICENSE.md。这些不是「项目自研的赚钱方法」,就是公开公式的工程化实现。本文不提供法律意见,能否商用以许可证原文为准。
五、三块的分工与仓库位置
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 身份状态机与工具闸门 | 按批次快照授权工具调用,代码/交易所对不上就拦,拦截结果以结构化错误回喂模型 | agent/src/agent/grounding.py | 单 Agent 主循环里任何带 symbol/ticker/code 类参数的工具调用 |
| 批次快照与终答重试 | 在处理整批 tool_calls 前冻结身份状态;草稿被拒则纠正重写,三次不过落安全兜底 | agent/src/agent/loop.py | 模型给出最终文本、以及一次回复里并发调多个工具时 |
| 证据台账产物 | 把完整未截断的工具返回摊成结构化证据行,原子落盘供事后回放 | 运行目录下 artifacts/grounding_evidence.json(由 agent/src/agent/grounding.py 写出) | 排查「这条数字凭什么被放行/拦下」时 |
| 多智能体开跑前预取 | 从 user_vars 抽代码、拉窗口行情、渲成 Ground Truth 提示块 | agent/src/swarm/grounding.py | 启动 swarm 编制、worker 提示词组装阶段 |
| 预取的运行时接线 | 数量上限截断、心跳保活、整体失败降级 | agent/src/swarm/runtime.py、agent/src/swarm/worker.py | swarm run 启动慢、或提示里没出现 Ground Truth 段时 |
| 财务严谨性校验工具 | 精确十进制的六类验算,只接数字不取数 | agent/src/tools/financial_rigor_tool.py | 模型要给出比率、市值、跨源一致性结论时 |
| 身份解析与行情工具 | 分别提供 search_symbol 与 get_market_data 两个被闸门特判的工具 | agent/src/tools/symbol_search_tool.py、agent/src/tools/market_data_tool.py | 闸门报 identity_required 时你要去看的两个实现 |
把这套搬到别的领域,可迁移的其实是三条抽象,跟金融没关系:
第一,先锁身份再取数据。任何「实体名 → 唯一标识符」的场景都有同款风险:客户名到客户 ID、药品名到国药准字号、零件名到物料编码。规则是同一批工具调用里,解析结果不能被消费;解析没完成或有冲突时,下游工具直接拒绝执行,并把拒绝原因写成模型读得懂的结构化错误。这一层和 Agent 工具参数怎么校验 是同一族技术,区别在于它校验的不是参数格式,而是参数与此前证据的一致性。
第二,留全量证据,不是留摘要。模型上下文里的工具返回一定会被截断,但校验必须拿全量比。这决定了台账要在工具执行处旁路记录,而不是从消息历史里反推。
第三,输出前拿规则跑一遍,不合格就重写而不是直接发。关键在于给模型的反馈要具体到哪个数字和哪条证据冲突,而不是笼统一句「你编了」。以及要有一个确定性的兜底答案,保证重试用尽之后系统仍能说人话。
六、边界与代价
这套设计放弃了不少东西,说清楚才好判断要不要抄。
它只管价格类数值,不管论述质量。 终答校验里的价格字段集合是写死的那几个 OHLC 字段。一段文字里的逻辑推理是否成立、结论是否与数据方向一致、风险是否被提及,规则一概不看。
它有明确的检出盲区,而且是主动放弃的。 注释里坦白:写成「成本 8.20」这种没有单位、没有金额关键词的每股数字会被聚合金额规则误伤掉,从而漏检。项目选择接受这个漏检,换来的是不误伤「100 股成本 820 CNY」这类正确表述——把每仓总额拿去和每股 OHLC 区间比是范畴错误。减少误报优先于提高召回,这是这份代码反复做出的取舍。
多智能体侧的预取是一次性快照,不刷新。 长时间运行的 swarm 后半程看到的是旧数据。模块注释明写了这一点,并说明这是刻意的权衡。
它不阻止模型引用不带价格上下文的数字。 散文里的校验要先命中价格语境正则才会触发,一句不带任何价格词的陈述里的数字不进入比对。
它不做单位与币种换算。 币种只做推断和「有没有在正文里写出来」的检查,不替你换算,也不检查换算对不对。
它不是合规层。 一旦话题走到实盘下单、券商连接、资金授权,代价是另一个量级的:凭据一旦落到本地配置或环境变量里就有了暴露面,下错单在成交后通常不可撤销,程序化交易与自动化下单的合规义务因司法辖区而异。这套 grounding 机制拦的是「数字编造」,不拦「这笔操作你能不能做」。仓库 agent/src/trading/connectors/ 下有 12 家券商连接器子目录(README 亦自述 12 brokers),要不要接、能不能接,以你所在司法辖区的监管要求与券商协议为准。
它有确定的成本。 强制隔离批次意味着解析和取数至少多一个来回;输出缓冲意味着有身份需求的会话里流式体验会打折;终答重试最多三轮,每轮都是完整的一次模型调用。
七、上手与避坑清单
先跑一个会触发闸门的请求,再跑一个不触发的。 为什么会踩:_identity_required 只在用户消息命中「可执行市场语境」正则时才置真,你拿一个纯概念问题去测,会发现什么都没发生,然后误判为功能没生效。怎么避:测试语句里带上现价、目标价、买入这类词,或者直接写一个带后缀的规范代码进去。
别在同一次回复里同时安排解析和消费。 为什么会踩:模型天然倾向于把「查代码」和「取行情」并列写在一个 tool_calls 数组里,看起来更高效。怎么避:接受被拦一次,读 required_action 的提示,把消费挪到下一轮。如果你在改这套代码,注意授权比对的是批前快照,改成读当前状态就等于把这条规则整个废掉。
.SS / .SH 混用会直接报冲突,不要当成 bug。 为什么会踩:不少数据源对上交所用 .SS,另一些用 .SH,人工习惯了随手互换。怎么避:全链路统一一种后缀约定;确实要混用,就在解析层做显式映射,而不是指望闸门放行。
跑一次筛选类请求,确认它不会被卡死。 为什么会踩:宽泛的筛选请求会解析出一堆候选,落到 ambiguous。项目已经把 ambiguous 排除在阻塞集合之外,并且在后续锁定某个候选时把旧的候选清单标成 superseded。怎么避:如果你自己写类似状态机,务必想清楚「多候选」是一种答案而不是一种未完成,否则每个探索型任务都会在第一步就停住。
校验规则上线前,先用一批正确的答案跑误报率。 为什么会踩:数值校验的失败模式几乎都是误伤——代码里的数字、日期、数量、百分比,全会被当成价格候选。怎么避:照着 _numbers_without_dates_or_percent 的思路,先建掩码层再取数,并且用历史上确认正确的输出当回归集。误报一多,团队第一反应就是把整个校验关掉。
别指望 financial_rigor 帮你取数。 为什么会踩:工具名字听起来像个全能的财务核查器,实际它只接原始数字。怎么避:把它当计算器和一致性检查器用,取数交给行情与财报工具,形成「那边取、这边验」的固定配对。
多智能体侧看不到 Ground Truth 段时,先查三个地方。 为什么会踩:三条降级路径都不抛异常——预取整体失败被 try/except 吞掉只留一条 warning,代码数量超上限时先记一条「limiting run … symbols」的 warning 再截断,从 user_vars 里一个代码都没抽到则直接 return。这三种情况在业务层看起来一模一样:提示词里就是没有那一段。怎么避:依次确认 user_vars 里有没有可识别的代码形态、你的 token 是不是落在停用词表里被丢掉了、以及日志里有没有那条数量截断告警。三条都排除还没有,就去看 format_grounding_block 是不是因为逐标的取数全失败而返回了空串——它对空结果的处理是整节省略,不会留下痕迹。
收尾
这个项目在 grounding 这件事上的核心判断可以压成一句:模型负责研究与解释,但有几件事必须是结构性的而不是建议性的。 哪几件,取决于你的领域里编错了会死人的是什么。
如果你要接着读代码,顺序建议这样:先读 agent/src/agent/grounding.py 的模块 docstring 和 authorize_tool_call,那是整套规则的入口;再跳到 agent/src/agent/loop.py 看批次快照怎么取、被拒草稿怎么回环;然后是 agent/src/swarm/grounding.py 的模块注释,它把「为什么必须结构化」和「主动放弃了什么」写在了同一段里,是难得的诚实;最后翻 agent/src/tools/financial_rigor_tool.py,把它当成一个「工具应该怎么写返回值」的样本读。
给自己项目做同款设计时,可以对着这四条自检:我的领域里有没有「实体名到唯一标识」这一步,它现在是不是由模型自由发挥的?工具返回进模型上下文之前会不会被截断,被截断的部分我有没有另存一份?最终输出发给用户之前,有没有任何一条确定性规则跑过?规则拦下之后,模型拿到的反馈够不够具体到让它知道改哪个数字?
四条里有一条答不上来,那一层就还是纯提示词约束。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源项目的记忆分层:四个模块与它们的新麻烦 和 Vibe-Trading 的目标账本:开源交易 Agent 如何防跑偏。