Vibe-Trading 源码:Agent 主循环与 runner 的分工
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
这里说的 Vibe-Trading 不是”凭感觉做交易”那种说法,而是 HKUDS 在 GitHub 上放出的同名开源项目(HKUDS/Vibe-Trading)。读这个仓库最容易栽的第一个跟头是:名字叫 runner 的那个模块,根本不是 Agent 的驱动器。 agent/src/core/runner.py 里的 Runner 类干的是把生成出来的回测代码丢进一个受限子进程去跑;真正一轮一轮推着模型往前走的主循环,在 agent/src/agent/loop.py 的 AgentLoop.run() 里。这两个东西在调用链上隔了好几层——AgentLoop 调工具,工具里的 BacktestTool 才调 Runner。名字对错号,后面读什么都是错的。
Vibe-Trading 是 HKUDS 放出的开源个人交易 Agent,仓库许可证是 MIT(Copyright 2026 Vibe-Trading Contributors)。整个仓库有 2030 个受版本控制的文件,其中 agent/ 占 1805 个,agent/src/ 下有 24 个模块目录。体量不小,但真正值得单独坐下来读一遍的,就是本文这三个文件。
一、先把三个名字对上号
站内已经写过几篇同类主题,分工是这样的:Agent 状态机与自由循环的取舍 讲的是范式选择本身,主流 Agent 框架对比 横着比不同项目的抽象层,pi 的 Agent loop 拆解 是另一个项目的同位置代码;这篇只做一件事——把 Vibe-Trading 这一个仓库的循环体、执行器、状态落盘三者的边界画清楚,是纵向的单点深挖,不做横向评比。
先给一张对照表,里面的路径都是本文实际打开过的文件:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
AgentLoop | ReAct 主循环:迭代计数、上下文压缩、调模型、排工具、判定终止状态 | agent/src/agent/loop.py | 改迭代上限、调压缩策略、想让取消更快生效时 |
_process_tool_calls / _batch_execute | 工具调度:去重、鉴权、只读并行 / 写串行分批 | agent/src/agent/loop.py(同文件内的私有方法) | 新增工具后发现它没被并行,或并行出了竞态时 |
WorkspaceMemory | 单次 run 内的轻量共享状态:run_dir 与工具调用计数 | agent/src/agent/memory.py | 想让工具之间传点东西,却发现它只有两个字段时 |
RunStateStore | 建 run 目录、写 req.json、写 state.json 终态 | agent/src/core/state.py | 排查一次运行到底算成功还是失败时 |
Runner | 用子进程跑入口脚本,收 stdout/stderr 与产物文件 | agent/src/core/runner.py | 回测跑不起来、或要收紧生成代码的执行环境时 |
BacktestTool | 把 Runner 包成一个模型可调的工具 | agent/src/tools/backtest_tool.py | 想知道 loop 和 runner 是怎么接上的 |
TraceWriter | 把每一步写成可回放的持久化轨迹 | agent/src/agent/trace.py | 事后复盘某一轮到底发生了什么时 |
| 构造点 | 用 max_iterations=50 实例化 AgentLoop 并接上 SSE 事件总线 | agent/src/session/service.py | 想知道这些参数从哪来的时候 |
看完这张表,第一节开头那句话就落地了:loop.py 是驱动器,core/runner.py 是被工具层间接调用的执行器,中间还隔着一层工具注册表。
二、一轮迭代里到底发生了什么
AgentLoop.run() 是一个 while iteration < self.max_iterations 的大循环。按代码顺序,一轮里依次发生这些事:
第一步是查取消标志。cancel() 只是 self._cancel_event.set(),循环在迭代开头、流式分块回调里、以及工具批次之间各查一次,所以停下来的粒度是”下一个协作检查点”,不是”立刻”。
第二步是注入后台任务结果。循环会 drain_notifications() 把已完成的后台任务捞出来,包成一段 <background-results> 追加成 user 消息。这一步在压缩之前,所以新捞出来的结果一进来就可能被算进 token 估算里。
第三步是估算 transcript 体积,然后按阶梯压缩。这段是整个文件里最值得抄走的设计:
tokens = estimate_tokens(messages)
if tokens > int(_token_threshold() * 0.5):
_microcompact(messages)
tokens = estimate_tokens(messages)
if tokens > int(_token_threshold() * 0.7):
_context_collapse(messages)
tokens = estimate_tokens(messages)
_tok_threshold = _token_threshold()
if tokens > _tok_threshold:
self._auto_compact(messages, run_dir, trace, iteration=current_iter)
三层是递进关系,每层做完都重新估一次,够了就不往下走。_microcompact 把除最近 KEEP_RECENT(值为 3)条以外的、长度超过 100 的工具结果直接替换成 [cleared];_context_collapse 把老消息里超过 COLLAPSE_TEXT_MIN 的长文本折成”头 COLLAPSE_HEAD 字符 + 折叠标记 + 尾 COLLAPSE_TAIL 字符”,纯字符串操作、零 API 成本;只有第三层 _auto_compact 才真的再调一次模型做结构化摘要。文件顶部的 docstring 还标了第四层和第五层——模型自己调 compact 工具触发第三层,以及第 N 次压缩改成”更新上一版摘要”而不是从头重写。
第四步是接近上限时的收尾提示。wrap_up_at = max(1, int(self.max_iterations * 0.8)),走到这一轮时追加一条 [SYSTEM] 消息告诉模型还剩几轮、该停止调工具给结论了。第一轮和最后一轮都跳过这个注入,理由代码注释写得很直白:最后一轮已经有更硬的手段。
第五步是调模型。最后一轮会把 tool_defs 直接置成 None——工具定义都不给了,模型只能吐文本,同时往轨迹里写一条 forced_text_only。这是一个很省事的收敛手段:不靠提示词求模型停,直接从能力上砍掉继续调工具的可能。
流式回调有两个:_on_text_chunk 直接转发 text_delta 事件;_on_reasoning_chunk 做了节流,因为长推理会产生大量分块,每块都发会把 SSE 重放缓冲挤爆、把 tool_call 和 text_delta 事件顶掉。它只累计字符数、维护一个有界的滚动尾巴,按最小间隔发 reasoning_delta。这是可观测性设计上很实际的一课,可以对照 Agent 可观察日志怎么设计 一起看。
第六步是异常与异常态处理。ProviderStreamError 如果标了可重试,清空这一轮已收到的思考分块、发一个 stream_reset 事件、等一小会儿重试一次;不可重试的直接抛。内容过滤触发时不当成最终答案,而是追加一条系统提示继续下一轮,连续被拦到上限则写 content_filter_circuit_breaker 熔断退出。
第七步是分岔。没有工具调用就走最终答案路径,有工具调用就交给 _process_tool_calls。最终答案还要过一道 GroundingLedger.validate_final_answer;没过就把答案和一段纠正提示一起塞回消息里重来;只有在”还没到最后一轮”且”累计校验次数不足三次”时才继续重试,累计校验次数达到三次就退到 safe_fallback。这一整套是”输出约束”的具体实现,思路可以对照 Agent 输出约束与终止条件。
三、工具是怎么排的:并行、串行、阻断三种节奏
_process_tool_calls 先做一轮预处理,再交给执行层。预处理干四件事:
一是 compact 工具特判——命中就只记一个标记和 focus_topic,立刻回一条占位结果,真正的压缩推迟到这一轮所有工具跑完之后。二是去重:self._called_ok 记录已成功调过的工具名,非 repeatable 的工具再调会被挡下并回一条 skipped 说明。三是鉴权:GroundingLedger.authorize_tool_call 不放行的调用不会进执行层,而是生成一个结构化的错误载荷。四是排序保持——被阻断的调用充当批次边界,相邻的放行调用仍走原有的调度器。
执行层 _batch_execute 把调用切成若干批:连续的只读工具攒成一个 parallel 批,遇到写工具就先把攒着的只读批冲掉,写工具自己单独成一个 serial 批。只读批用线程池并发,工作线程数是批大小和 8 之间取小。工具是不是只读,看注册表里 tool_def.is_readonly。
_invoke_tool 里的超时策略是这个文件第二处值得抄的设计,因为它对只读和写做了不对称处理:
- 只读工具丢进一个 worker 线程,主线程从队列上带超时地取结果。超时就置
timed_out、发一个timeout阶段的进度事件、返回一个error_code为tool_timeout的结构化错误。注意——线程还在后台跑,只是结果被丢弃、事件被压住。 - 写工具永远不杀。代码起一个看门狗线程,超时只发一条
timeout_warning,注释写明理由:写操作无法安全取消,所以只能等它跑完。
两条路径都套着一个心跳定时器和一个线程局部的进度发射器,让工具内部可以在不改签名的前提下往外报进度,这些事件和普通工具事件走同一条总线。
结果收尾统一走 _finalize_tool_result:判定成功与否、更新计数、把结果截断后追加进消息、脱敏后写轨迹、发 tool_result 事件。成功判定是看结果 JSON 里 status 是不是错误类字面量、ok / success 是不是 False,解析不了就默认算成功。
四、状态到底放在哪
这个问题的答案是”分四个地方,而且它们的生命周期完全不同”。
WorkspaceMemory 是最轻的一层,只有 run_dir 和一个工具调用计数字典,文件开头就写明它只在一次 AgentLoop.run() 里存活。它的 to_summary() 会在上下文压缩之后被重新拼回消息里,让模型压缩完还记得自己在哪个目录干活。
RunStateStore 管落盘。create_run_dir 用”时间戳 + 六位随机后缀”建目录,同时建好 code/、logs/、artifacts/ 三个子目录;save_request 写 req.json;终态分三种——mark_success、mark_failure、mark_cancelled,分别写进 state.json。这里有个细节值得留意:三个终态共用的 _write_json 是先 flush 再 os.fsync 才关文件。作者在 mark_cancelled 的注释里说明了为什么要把”用户取消”和”失败”分开——用户主动停不是故障,混在一起会让报表和聊天记录互相打架。
TraceWriter 管可回放。轨迹写在会话目录或 run 目录下,每一步都有类型:start、message、thinking、tool_call、compact、answer、end 等等。主循环还会在启动时读回已有轨迹,把迭代号接着往下数,避免续跑时编号从头开始。
最后是消息列表本身。它是最脆弱的一层:压缩会原地改写它。_auto_compact 在动手之前先把完整 transcript 写成一个 transcript_<时间戳>.jsonl 落盘,然后按 token 预算从尾部往回留、避免在工具调用与结果之间劈开,最后用”系统消息 + 摘要 + 保留的尾巴”重建列表,再调 _fix_tool_pairs 修补孤儿:结果没了的调用补一条占位结果,调用没了的结果直接删掉。这一步不做,下一次请求大概率被服务端判成消息结构非法。
五、runner 这一侧:把生成的代码关进单独的进程
Runner 的入口是 execute(entry_script, run_dir, cwd=..., cli_args=...)。它被 BacktestTool 以 Runner(timeout=300) 构造,入口脚本是仓库里 agent/backtest/runner.py,run 目录当命令行参数传进去。
环境是白名单拼出来的,不是继承的。_copy_runtime_env 只放行 OS/Python 基础项、代理与证书配置、以及若干只读行情数据源的配置键,注释写明理由:生成的策略代码就在这个子进程里跑,不该顺手拿到 LLM、API 服务、券商和实盘相关的凭据。
在这之上还叠了三层:_prepare_sandbox_home 建一个临时 HOME,只把 cache、data-bridge、qveris.json 这几个加载器要用的路径符号链接进去,其余的持久化家目录内容对生成代码不可见;_resolve_sandbox_credentials 在存在 vibe-sandbox 账号时把子进程降权运行;_make_rlimit_preexec 在 POSIX 上限制地址空间和文件描述符数量,地址空间上限可用 VIBE_TRADING_SANDBOX_RLIMIT_AS_MB 调。注释很诚实地写了这几层的适用边界——只在硬化过的 Docker 部署里真正生效,其他环境一律降级成 WARNING,常驻的那道防线是另一处的静态检查。
产物收集是纯路径存在性判断。_ARTIFACTS_SPEC 里定义了各产物的相对路径与列结构,execute 挨个看文件在不在,在就收进 RunResult.artifacts。RunResult 本身带着是否成功、退出码、完整的 stdout / stderr 和产物路径字典——注意它不做截断,把输出截短是上层 BacktestTool 打包返回值时才做的事,读代码时别把这两层的职责记混。
顺带说清楚两件事。其一,仓库里 agent/src/factors/ 有 482 个文件,但那不是”项目自研的因子库”:根目录 NOTICE 写明其中一部分是 Microsoft Qlib 的特征定义(Apache 2.0),另外几组公式来自公开论文与研报,仓库把它们当作数学事实做了重实现,各子目录下另有 LICENSE.md。能不能商用,以许可证原文为准,本文不提供法律意见。其二,本文只讨论工程实现,历史表现不代表未来,回测产物里有哪些字段是代码问题,产物里的数字好不好不在本文讨论范围内。
六、边界与代价:这个设计放弃了什么
token 估算是粗的。 estimate_tokens 是把消息列表序列化成 JSON 再除以 4,不是真的 tokenizer。压缩阶梯的三个触发点都建立在这个粗估上,换个语言、换个模型,误差方向都会变。
第一层压缩是不可逆的。 _microcompact 把老工具结果原地换成 [cleared],而完整 transcript 只在第三层压缩时才落盘。也就是说,一次运行如果压力只到第一层,那些被清掉的结果就真的没了。
只读工具的超时不是取消。 超时只是把结果丢掉、把事件压住,线程还在后台占着资源。一次运行里如果有多个只读工具接连卡住,泄漏的线程会累积。
写工具压根不设超时上限。 一个卡死的写工具能把整轮拖到天荒地老,看门狗只会发一条警告。这是有意的取舍——保证语义正确优先于保证响应时间。
去重是按工具名做的,不是按参数。 非 repeatable 的工具成功一次之后,换一组参数再调也会被挡回去。工具是否声明 repeatable、是否声明 is_readonly,直接决定循环的行为对不对,这两个标注错了排查起来相当难受。
run 目录假设是本地文件系统。 目录名靠时间戳加随机后缀去重,产物靠路径存在性判断,没有任何多机共享或对象存储的抽象。
它明确不管的事。 主循环不管撮合逻辑、不管风控口径、不管交易资格。凡是牵涉实盘下单、券商连接、资金授权与凭据保管的部分,代价必须自己算清楚:凭据一旦进入某个进程的环境就有了暴露面,下错的单不可撤销,程序化交易的申报与合规义务因司法辖区而异。这类问题的答案不在代码里,以你所在司法辖区的监管要求与券商协议为准。
七、上手与避坑清单
把 core/runner.py 当成 Agent 驱动器。 为什么会踩:几乎所有 Agent 框架里 runner 都是循环的驱动器,直觉直接迁移过来。怎么避:进仓库先搜谁 import 了它——只有 agent/src/tools/backtest_tool.py 和测试,看一眼调用点就明白它在工具层之下。
以为 max_iterations 调小只是少跑几轮。 为什么会踩:忽略了最后一轮会把工具定义整个撤掉。怎么避:调成很小的值之前先想清楚”最后一轮强制出文本”这条规则,太小会让模型还没调到关键工具就被迫收尾。
以为模型调了 compact 就立刻压缩。 为什么会踩:_process_tool_calls 遇到它只记标记并立刻回一条占位结果。怎么避:记住压缩发生在这一轮所有工具执行完之后,调试时按这个顺序看轨迹里的 compact_requested 和 compact 两条记录。
给新工具随手标 is_readonly = True。 为什么会踩:这个标注直接决定它会不会被丢进线程池并发跑。怎么避:只要工具会写文件、改状态、发请求改变远端数据,就一律不标只读,宁可慢也别让并发把状态写花。
指望调大并发。 为什么会踩:以为线程数是个配置项。怎么避:并发上限写死在代码里取的是批大小和 8 的较小值,想改只能改代码,不要在配置文件里找。
压缩之后请求被服务端拒绝。 为什么会踩:自己仿写压缩逻辑时漏了修补工具调用与结果的配对。怎么避:照抄 _fix_tool_pairs 的两步——删孤儿结果、补孤儿调用的占位结果。
给 run 目录传相对路径。 为什么会踩:模型经常会顺手写 . 或一个短名字。怎么避:循环里有一步会把相对路径按当前 run 目录解析成绝对路径,自己写工具时别再做第二次拼接,否则路径会翻倍。
把内置因子目录当成项目原创。 为什么会踩:目录挂在项目下,容易默认是自研的。怎么避:先读根目录 NOTICE 和各子目录的 LICENSE.md,把上游来源和许可搞清楚再决定怎么用。
收束:接下来该读哪个文件
按这个顺序往下走性价比最高:先 agent/src/agent/context.py(消息是怎么拼出来的、工具结果是什么格式),再 agent/src/agent/tools.py(is_readonly 和 repeatable 这两个标注定义在哪),再 agent/src/agent/trace.py(轨迹落盘与大文本卸载),最后 agent/src/agent/grounding.py(工具鉴权和最终答案校验的规则从哪来)。
读完给自己三个自检题:这个循环在什么条件下会停?停下来时 state.json 里会写成哪个状态?一条工具结果从产生到进入下一轮请求,中间被截断、脱敏、压缩各改了几次?三个问题都能指着具体行号回答,这段代码就算读透了。至于它能不能用在你的场景里,以你所在司法辖区的监管要求与券商协议为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源项目 Vibe-Trading 仓库结构导读:改一处功能从哪进去 和 开源交易 Agent 项目 Vibe-Trading 的上下文管理:组装、压缩工具与记忆压缩。