Vibe-Trading 源码:Agent 主循环与 runner 的分工

2026-08-05

本文基于 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.pyAgentLoop.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 这一个仓库的循环体、执行器、状态落盘三者的边界画清楚,是纵向的单点深挖,不做横向评比。

先给一张对照表,里面的路径都是本文实际打开过的文件:

组成部分它负责什么仓库位置你什么时候会碰到它
AgentLoopReAct 主循环:迭代计数、上下文压缩、调模型、排工具、判定终止状态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回测跑不起来、或要收紧生成代码的执行环境时
BacktestToolRunner 包成一个模型可调的工具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_calltext_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_codetool_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_requestreq.json;终态分三种——mark_successmark_failuremark_cancelled,分别写进 state.json。这里有个细节值得留意:三个终态共用的 _write_json 是先 flushos.fsync 才关文件。作者在 mark_cancelled 的注释里说明了为什么要把”用户取消”和”失败”分开——用户主动停不是故障,混在一起会让报表和聊天记录互相打架。

TraceWriter 管可回放。轨迹写在会话目录或 run 目录下,每一步都有类型:startmessagethinkingtool_callcompactanswerend 等等。主循环还会在启动时读回已有轨迹,把迭代号接着往下数,避免续跑时编号从头开始。

最后是消息列表本身。它是最脆弱的一层:压缩会原地改写它。_auto_compact 在动手之前先把完整 transcript 写成一个 transcript_<时间戳>.jsonl 落盘,然后按 token 预算从尾部往回留、避免在工具调用与结果之间劈开,最后用”系统消息 + 摘要 + 保留的尾巴”重建列表,再调 _fix_tool_pairs 修补孤儿:结果没了的调用补一条占位结果,调用没了的结果直接删掉。这一步不做,下一次请求大概率被服务端判成消息结构非法。

五、runner 这一侧:把生成的代码关进单独的进程

Runner 的入口是 execute(entry_script, run_dir, cwd=..., cli_args=...)。它被 BacktestToolRunner(timeout=300) 构造,入口脚本是仓库里 agent/backtest/runner.py,run 目录当命令行参数传进去。

环境是白名单拼出来的,不是继承的。_copy_runtime_env 只放行 OS/Python 基础项、代理与证书配置、以及若干只读行情数据源的配置键,注释写明理由:生成的策略代码就在这个子进程里跑,不该顺手拿到 LLM、API 服务、券商和实盘相关的凭据。

在这之上还叠了三层:_prepare_sandbox_home 建一个临时 HOME,只把 cachedata-bridgeqveris.json 这几个加载器要用的路径符号链接进去,其余的持久化家目录内容对生成代码不可见;_resolve_sandbox_credentials 在存在 vibe-sandbox 账号时把子进程降权运行;_make_rlimit_preexec 在 POSIX 上限制地址空间和文件描述符数量,地址空间上限可用 VIBE_TRADING_SANDBOX_RLIMIT_AS_MB 调。注释很诚实地写了这几层的适用边界——只在硬化过的 Docker 部署里真正生效,其他环境一律降级成 WARNING,常驻的那道防线是另一处的静态检查。

产物收集是纯路径存在性判断。_ARTIFACTS_SPEC 里定义了各产物的相对路径与列结构,execute 挨个看文件在不在,在就收进 RunResult.artifactsRunResult 本身带着是否成功、退出码、完整的 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_requestedcompact 两条记录。

给新工具随手标 is_readonly = True 为什么会踩:这个标注直接决定它会不会被丢进线程池并发跑。怎么避:只要工具会写文件、改状态、发请求改变远端数据,就一律不标只读,宁可慢也别让并发把状态写花。

指望调大并发。 为什么会踩:以为线程数是个配置项。怎么避:并发上限写死在代码里取的是批大小和 8 的较小值,想改只能改代码,不要在配置文件里找。

压缩之后请求被服务端拒绝。 为什么会踩:自己仿写压缩逻辑时漏了修补工具调用与结果的配对。怎么避:照抄 _fix_tool_pairs 的两步——删孤儿结果、补孤儿调用的占位结果。

给 run 目录传相对路径。 为什么会踩:模型经常会顺手写 . 或一个短名字。怎么避:循环里有一步会把相对路径按当前 run 目录解析成绝对路径,自己写工具时别再做第二次拼接,否则路径会翻倍。

把内置因子目录当成项目原创。 为什么会踩:目录挂在项目下,容易默认是自研的。怎么避:先读根目录 NOTICE 和各子目录的 LICENSE.md,把上游来源和许可搞清楚再决定怎么用。

收束:接下来该读哪个文件

按这个顺序往下走性价比最高:先 agent/src/agent/context.py(消息是怎么拼出来的、工具结果是什么格式),再 agent/src/agent/tools.pyis_readonlyrepeatable 这两个标注定义在哪),再 agent/src/agent/trace.py(轨迹落盘与大文本卸载),最后 agent/src/agent/grounding.py(工具鉴权和最终答案校验的规则从哪来)。

读完给自己三个自检题:这个循环在什么条件下会停?停下来时 state.json 里会写成哪个状态?一条工具结果从产生到进入下一轮请求,中间被截断、脱敏、压缩各改了几次?三个问题都能指着具体行号回答,这段代码就算读透了。至于它能不能用在你的场景里,以你所在司法辖区的监管要求与券商协议为准。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源项目 Vibe-Trading 仓库结构导读:改一处功能从哪进去开源交易 Agent 项目 Vibe-Trading 的上下文管理:组装、压缩工具与记忆压缩

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。