Vibe-Trading 的目标账本:开源交易 Agent 如何防跑偏
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
长任务跑偏的根因不是模型记性差,而是「这次要干什么」从来没被写进任何一个可以查询、可以更新、可以在写入时被拒绝的地方——它只活在最初那句提示词里。 HKUDS 放出的开源项目 Vibe-Trading 给的答案是:把目标从提示词里抽出来,做成一张 SQLite 表、一组 dataclass、一套准入策略,再配四个让模型自己维护它的工具。这篇只讲这套工程结构怎么搭的,不讲任何投资方法,也不给任何交易上的判断。
先说清楚命名:Vibe-Trading 是 HKUDS 放出的开源个人交易研究 Agent 的项目名,不是「凭感觉交易」这类泛指说法。仓库许可证是 MIT(Copyright 2026 Vibe-Trading Contributors)。你在 agent/src/ 下能数到 23 个模块目录,goal/ 是其中之一,只有 5 个文件,但它几乎接进了主循环的每一步。
一、它到底在解决哪个问题
一个跑很久的 Agent 会遇到三种典型失控。第一种是漂移:跑到第七轮时它在回答一个和最初请求只沾边的问题。第二种是虚假完成:它输出了一段像结论的文字,但没人能说清哪几条要求真的被满足了。第三种是无声停止:它遇到查不到的东西,于是含糊地写一句「数据有限」就收工了。
这三种失控的共同点是,系统里没有一个地方能回答「现在到底做到哪一步」。你可以靠更长的提示词缓解,但提示词是只读的——模型改不了它,运行时也校验不了它。
Vibe-Trading 的做法是让目标变成可写状态。agent/src/goal/models.py 里定义了这一层的全部数据形状:GoalRecord 是目标主记录,GoalCriterion 是验收条目,GoalClaim 是被追踪的主张,EvidenceInput 是写入证据时的入参形状、EvidenceRecord 是落库后的证据行(两者分开,落库那份多出 retrieved_at、freshness_status、verification_status 这几个只能由存储层判定的字段),AuditRow 是完成时的逐条审计。外加两个枚举:GoalStatus 有 12 个生命周期状态,RiskTier 有 4 档风险分级。
注意 GoalStatus 的取值密度——active、paused、waiting_user、needs_refresh、insufficient_evidence、compliance_blocked、blocked、budget_limited、usage_limited、complete、cancelled、superseded。真正值钱的不是 complete,而是中间那批:证据不足、需要刷新、等用户、被预算掐住,各自是独立状态。前面说的「无声停止」在这套模型里没有对应状态可用,模型要么继续,要么必须显式地把目标切到某个受阻状态上去。
二、七块拼图分别在哪
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 数据模型 | 6 个冻结 dataclass + GoalStatus/RiskTier 两个枚举 + StaleGoalError | agent/src/goal/models.py | 决定一次研究要留哪些字段时 |
| 准入策略 | normalize_required_text 与 reject_live_execution_objective | agent/src/goal/policy.py | 想改判定口径或补语种时 |
| SQLite 台账 | 5 张表、会话唯一当前目标索引、陈旧写守卫、完成审计 | agent/src/goal/store.py | 排查「目标写不进去」时 |
| 模型侧工具 | start_research_goal 等 4 个工具的参数 schema 与错误信封 | agent/src/tools/goal_tool.py | 调工具描述、加溯源字段时 |
| 上下文与续跑 | format_goal_context、format_goal_continuation_prompt、goal_needs_continuation | agent/src/goal/context.py | 改默认验收清单、调续跑判据时 |
| 主循环接线 | 拼目标上下文、用量记账、续跑与抑制 | agent/src/agent/loop.py | 定位「它为什么自己又跑一轮」时 |
| 用法约定 | 何时挂目标、证据规则、完成规则 | agent/src/skills/research-goal/SKILL.md | 想让模型按项目预期使用这套工具时 |
这七行按依赖顺序从上到下,中间任何一层单独拿出来都能用在别的领域——它跟金融的耦合其实只在措辞和 policy.py 那几行正则上。
三、准入:目标文本在落库前就被过一遍
policy.py 全文不到 50 行,只做两件事。normalize_required_text 把字符串 strip 后判空,空则抛 ValueError 并带上字段名。reject_live_execution_objective 拿三条正则去匹配目标文本,命中就抛 ValueError("live trading or execution goals are not supported")。
三条正则是这样的:
_EXECUTION_PATTERNS = (
re.compile(r"\b(place|submit|execute|send)\b.{0,40}\b(order|trade)\b", re.I),
re.compile(
r"\b(buy|sell|short|long)\b.{0,40}\b(now|immediately|market order|limit order|shares?|contracts?|btc|eth|usdt)\b",
re.I,
),
re.compile(r"(下单|市价单|限价单|马上买|立即买|现在买|马上卖|立即卖|现在卖)"),
)
工程上值得学的是调用位置,不是正则本身。store.py 的 replace_goal 里,这个函数被调用了两次:一次针对 objective,一次在循环里针对每一条 criteria。也就是说验收条目不能夹带被目标文本挡掉的意图。同一个文件里还有一道硬门:risk_tier 若是 RiskTier.LIVE_TRADING_OR_EXECUTION,直接抛错。而模型侧工具 start_research_goal 的参数 schema 里,risk_tier 的 enum 只列了三档,最高那档根本不在可选值里。
三层——枚举不给、策略函数拦、存储层再判一次——同一条约束在不同抽象层重复三遍。这在一般 Agent 里可能算冗余,在这个语境下是刻意的。
正则拦截的局限也很明显:换个说法就能绕过去,中文那条只覆盖了有限的固定词。它防的是无心之失和顺手一句,不是对抗性输入。你要是把这套搬到自己项目里当安全边界用,得先想清楚这个差别。相关的通用讨论可以看 Agent 权限台阶怎么搭。
四、存储层:唯一当前目标、陈旧写守卫、完成审计
store.py 是这个模块里最长的文件,GoalStore 建表时一次性起了 5 张:goals、goal_claims、goal_criteria、goal_evidence、goal_audits。连接层面开了 foreign_keys=ON、journal_mode=WAL、synchronous=NORMAL 和 busy_timeout,写操作统一走 BEGIN IMMEDIATE,方法上再包一层 RLock。多进程加多线程共享同一个库文件的场景下这套组合是必要的。
三个设计点值得单独看。
一个会话同时只能有一个当前目标。 建表脚本里有个部分唯一索引 idx_goals_one_current_per_session,ON goals(session_id) 但带 WHERE status IN (...),列的正是那批「进行中」状态。约束交给数据库而不是应用层判断,意味着并发路径下也不会出现两个 active 目标。想换目标只能走 replace_goal,它在同一个事务里先把旧目标批量置为 superseded,再插入新目标,同时自动建一条 claim_type='thesis' 的主张记录,并把每条 criteria 写进 goal_criteria、按顺序标上 step_1、step_2 这样的 protocol_step。
陈旧写守卫。 每个变更方法都要求传 expected_goal_id,第一步就进 _require_mutable_goal。这个私有方法连查四道:expected_goal_id 与 goal_id 不等、目标不存在或不属于该 session、状态不在可变集合、它不是这个 session 的当前目标——任意一条不过就抛 StaleGoalError。这个异常在工具层被单独 catch,返回 error_type: "stale_goal" 的 JSON,模型能据此判断该重新取快照而不是重试。
完成不是模型说了算。 update_status 里,只要目标状态要切到 complete,就先进 _validate_completion_audit。这个函数遍历所有 required 的 criterion,逐条要求:审计行必须存在;result 必须落在 satisfied / satisfied_with_caveat / not_applicable_user_accepted 三者之一;标 satisfied 的必须给 evidence_ids;标「不适用」的必须写 notes;引用的每条证据必须属于同一个目标、且 criterion_id 对得上;最后,凡是标了两档 satisfied 之一的条目,它引用的这批证据里至少要有一条 verification_status 是 verified。另外,工具层参数 schema 里 result 还允许一个 unsatisfied,但存储层的可完成结果集合并不收它——写得进审计行,换不到 complete。
那 verified 从哪来?_verification_status 只认两种来源:证据带的 artifact_path 经 safe_document_path 解析后确实是文件,且实算的 sha256 与传入的 artifact_hash 一致;或者 run_id 经 safe_run_id 解析后确实是个存在的目录。都不满足就是 unverified。SKILL.md 里写得很直白:tool_call_id 只用于溯源,不构成验证。
模型可以编一段描述,但编不出一个哈希对得上的本地文件。这是整套设计里唯一一处真正卡住幻觉的地方。
五、四个工具与主循环的续跑回路
agent/src/tools/goal_tool.py 暴露四个工具:start_research_goal、get_research_goal、add_goal_evidence、update_research_goal_status。它们都继承 _GoalToolBase,共享 session 解析和 _emit 事件回调(发 goal.created、goal.updated、goal.evidence 给宿主 UI,回调抛异常直接吞掉,不影响主流程)。
add_goal_evidence 的参数表最长,除了 text 还有 run_id、artifact_path、artifact_hash、source_provider、source_type、source_uri、symbol_universe、benchmark、timeframe、method、data_as_of、confidence、caveat。两个细节:一是提供 criterion_index 作为 criterion_id 的替代,1 起算,模型不用记 ID;二是 _trace_fields_from_runtime 会把相对 artifact_path 拼到宿主注入的 run_dir 下解析,逃出 run_dir 直接抛 ValueError,若文件存在而调用方没给 hash,则自动分块算 sha256 补上。让模型少填一个容易填错的字段,同时不牺牲可验证性。
写入端还有个联动:append_evidence 里,只要证据挂了 criterion_id,就把该条目状态从 pending/open/unsatisfied 更新为 covered。进度是证据写入的副作用,不是模型另外声明的。
回到主循环。agent/src/agent/loop.py 在每次请求开始时调 get_current_goal_context(session_id),把 format_goal_context 产出的 <current-research-goal> 块拼在 <user-message> 前面。这个块里带 goal_id、status、objective、risk_tier、证据总数、逐条 criteria 的状态与证据计数,末尾附一段 instructions,其中一条是「Do not treat a normal answer as finished while the goal status is still active.」
每轮结束时循环调 goal_store.account_usage,把 token 增量和轮次增量记进目标。account_usage 内部一比对 token_budget / turn_budget / time_budget_seconds,任一越界就把状态改成 budget_limited——注意 budget_limited 仍在可变状态集合里,目标没死,只是被标记了。
最有意思的是续跑判据。模型给出最终回答后,循环用 goal_needs_continuation 判断是否还要再跑一轮:只有状态落在 active / needs_refresh / insufficient_evidence 才继续。要继续,就把这次的回答当中间产物写进 trace,追加一条 format_goal_continuation_prompt 生成的 <goal-continuation> 消息——里面列出未覆盖的必需条目、最近几条证据的 provider 与 verification 状态,并明确要求「Prefer the highest-priority open criterion with zero evidence.」
刹车装在 goal_progress_tuple 上。它返回 (covered_criteria, evidence_count) 这个可比较的二元组。如果本轮进度不大于上一轮,且已经续跑过至少一次,循环就写一条 goal_continuation_suppressed 到 trace 并停手。用进度而不是相似度判断原地打转,这个思路挺值得抄。续跑次数上限由 VIBE_TRADING_GOAL_MAX_CONTINUATIONS 控制。
站内几篇相邻的文章分工是这样的:任务分解粒度怎么定 讲人该把任务切多细,Superpowers 的起步工作流 讲另一套开源方案怎么用文件承载流程,上下文预算怎么分 讲 token 该怎么花——本篇不重复这些,只看 Vibe-Trading 这一个仓库把目标做成持久化状态之后,运行时多出了哪些能力和哪些代价。
六、边界与代价
它放弃了轻量。 起一个目标就要落一条主记录、一条主张、N 条验收条目,之后每一步研究都要多一次工具调用。短问答里这套纯属负担,所以 SKILL.md 明确写了「Do not start a goal for a tiny one-shot answer unless the user explicitly asks.」
它放弃了并行目标。 那个部分唯一索引意味着一个 session 只能有一个当前目标。想同时推进两条线,要么起两个 session,要么把两条线塞进同一组 criteria。这是刻意的取舍:并行目标会让「现在做到哪一步」这个问题重新变得没有唯一答案。
证据验证只认本地。 verified 的两条路径都指向本地文件系统:本地产物文件的哈希,或本地 run 目录。一条来自公开网页的引用,即便 URI 写得再全,在这套判定里也只是 unverified。这个口径挡住了凭空编造,但也意味着一切外部信源都无法直接支撑目标完成。
新鲜度只是个标记。 append_evidence 里 freshness_status 的赋值是:传了 data_as_of 就记 fresh,没传就是 unknown。它不判断那个日期是不是真的够新——GoalCriterion 上的 freshness_requirement 字段有位置,但这一层不做比对。别把它当过期检测用。
它明确不管的事。 目标层不做数据获取、不做因子计算、不做回测执行,这些在别的模块里;它也不下单——policy.py 那三条正则和 enum 里缺席的那一档就是态度。仓库里确实有 agent/src/trading/connectors/ 下 12 家券商与交易所连接器子目录,但那是另一条链路,和 goal 模块没有调用关系。
顺带把风险说透:任何涉及实盘下单、券商连接、资金授权的链路,凭据都会成为你机器上暴露面最大的东西;下错的单通常不可撤销;程序化交易还有申报与合规义务,各司法辖区要求不同。能不能这么用、以什么身份用,以你所在司法辖区的监管要求与你签的券商协议为准,本文不提供任何这方面的意见。
因子库不是这个项目自研的。 仓库根目录的 NOTICE 写得很清楚:agent/src/factors/zoo/qlib158/ 下的特征定义来自 Microsoft Qlib,走 Apache 2.0;alpha101、gtja191、academic 几组公式分别来自公开论文与研报,仓库把它们当作不受版权保护的数学事实重新实现,原文的散文、表格与图表并未复制。各子目录下另有 LICENSE.md。你要商用得自己看许可证原文,本文不提供法律意见。也说一句常识:历史表现不代表未来,本文只讨论工程实现,不讨论任何策略或因子的好坏。
七、上手与避坑清单
先跑通台账再接模型。 直接让模型调这四个工具,你会分不清是提示词问题还是存储层报错。GoalStore 可以单独实例化,VIBE_TRADING_GOAL_DB_PATH 指到一个临时文件,手动跑一遍 replace_goal → append_evidence → update_status,先把 StaleGoalError 和完成校验的报错都触发一次。仓库 agent/tests/ 下有对应测试可以照着改。
expected_goal_id 不是可选装饰。 它默认取 goal_id,看起来像冗余参数,很容易在自己改造时顺手删掉。但正是这个字段配合「必须是当前目标」的检查,才让并发会话或用户中途换目标的场景下不会写脏数据。删了它,replace_goal 之后的旧引用会静默写进已 superseded 的目标里。
别指望 complete 能靠说服拿到。 常见踩法是模型写了一堆证据文本、审计行也齐了,就是完不成——因为 _validate_completion_audit 要至少一条 verified。而 verified 需要真实存在的本地产物或 run 目录。接自己的工具时,如果工具产出没有落盘、没有回填 run_id,这条路就永远走不通。要么让工具落盘,要么想清楚你是不是要放松这个校验。
criterion_index 是 1 起算的。 从 0 开始传会撞上范围检查直接报错。这个约定在参数描述里写了,但模型和人都容易顺手当成 0-based。
默认验收清单只有三条。 context.py 里的 DEFAULT_GOAL_CRITERIA 是三条通用条目,start_research_goal 在调用方没给 criteria 时就用它。三条通用条目撑不起一个具体任务的验收,容易导致目标很快「全覆盖」然后草草完成。SKILL.md 给的模板是四条,也建议在上下文足够时给 3 到 5 条。真要用,自己写。
续跑不动时先看 trace 里的 goal_continuation_suppressed。 它出现说明进度二元组没前进,被刹车拦了。这时该查的是证据为什么没写进去——是工具没调,还是挂错了 criterion_id 导致条目状态没翻成 covered——而不是去调大续跑上限。相关排查思路可以参考 Agent 目标漂移怎么发现。
收个尾
这套设计的核心判断其实只有一句:目标必须是可写的、有状态的、能拒绝写入的。提示词做不到前两条,只读的配置文件做不到第三条,所以它落到了数据库上。
如果你要把这个思路搬进自己的长任务 Agent,按这个顺序自检会快一些:你的目标现在存在哪里,模型能不能读到;有没有一个地方能回答「哪几条要求还没满足」;「完成」这个状态是模型自己声明的,还是要过一道运行时校验;那道校验里有没有至少一个模型伪造不了的东西;跑偏或原地打转时,你靠什么信号刹车。
接下来该读哪个文件?先 agent/src/goal/models.py 建立词汇,再 agent/src/goal/store.py 的 _validate_completion_audit 和 _verification_status 两个私有方法——整套设计的重量都压在那几十行上。想看它怎么接进循环,再翻 agent/src/agent/loop.py 里 goal_needs_continuation 附近那一段。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源项目防模型编造数字的两层设计与代价 和 Vibe-Trading 假设注册表:开源交易 Agent 怎样拦住越研究越自信。