Vibe-Trading 项目怎么处理工具返回太长:分页、后台与进度三层

2026-08-05

本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。

把一个超大结果塞给模型,真正要命的不是塞不下,而是塞进去一半、看上去却像塞完了。 HKUDS 开源的 Vibe-Trading 这个仓库里有一处注释把这件事讲得很直白:一次实测中,get_financial_statements("AAPL.US", statement="income", period="quarter") 序列化出来是 28,498 个字符共 40 个报告期,被字符预算一刀切下去之后,实际送到模型手里的大约是 12 个期——而模型完全没有办法知道自己拿到的是残缺的。这段记录同时出现在 agent/src/config/limits.py 的函数 docstring、agent/src/tools/_result_paging.py 的模块 docstring 和 agent/tests/test_tool_result_paging.py 的开头,三处记的数字完全一致(limits.py 那处把调用示例写得略简,省了 statement 参数)。一个数字被抄进三个文件还没走样,本身就说明它被当成了设计依据而不是随口一提。

这篇拆的就是它为此搭起来的三层结构:结果分页、后台任务、进度上报。它们解决的是同一个矛盾的三个侧面——数据量、耗时、可见性——少任何一层,上下文都会以不同方式被撑爆或被误读。

站内已经有几篇相邻的文章,先把分工说清楚:工具返回结构该怎么设计 谈的是通用约定,输出被截断怎么排查 谈你已经发现输出不对之后怎么定位,日志太多怎么喂给模型 谈海量文本进上下文前的筛选策略。本篇不重复这些通则,只做一件事:把 Vibe-Trading 这个具体仓库里的三层落地代码拆开看。

一、先看被撑爆的是什么预算

Vibe-Trading 把「一次工具返回最多给模型多少字符」抽成了一个独立常量 TOOL_RESULT_LIMIT,放在 agent/src/config/limits.py。这个文件的模块 docstring 交代了为什么要单独开一个文件:这个上限原本写在 src/agent/loop.py 里,又被当作裸字面量抄进了 src/swarm/worker.py,两处会各自漂移;现在它住在一个自身没有任何 import 的叶子模块里,Agent 主循环、swarm worker、以及各个工具都能直接读,谁也不用因此依赖谁。

这是个很小但很值得抄的决定。上下文预算这种数值一旦在两个地方各写一份,你调了一处、忘了另一处,表现出来的症状是「同一个工具在单 Agent 模式下正常、在多 Agent 编排下被截断」,排查起来极其费劲。关于预算怎么分配,可以对照 上下文预算怎么算 那一篇。

同一个文件里还有一个兜底函数 truncate_tool_result。它做的事很简单:结果没超就原样返回,超了就切到预算内,并在尾部拼上 _TRUNCATION_NOTICE。这段提示的措辞值得看一眼——它明确告诉模型「不要把这当成完整结果」,并提示可以缩小请求范围、或者用工具自带的分页参数再调一次。测试里甚至专门验证了「预算被设得荒谬地小时,这条提示依然要保住」。

但兜底终归是兜底。对 JSON 信封来说,字符切口必然落在结构中间,模型拿到的是一段语法都不完整的碎片。所以真正的解法在上一层。

二、第一层:整条记录地切,让半截答案自己报家门

agent/src/tools/_result_paging.py 整个文件只有一个公开函数 fit_records。它的签名是 fit_records(records, offset, build, *, limit=TOOL_RESULT_LIMIT, max_records=None),其中 build 是调用方传进来的回调,接收 (page, paging_meta) 并返回要序列化的完整信封字典。

这个设计有两个点:

第一,分页元信息是强制随行的。每一页都会带上一个 paging 块,字段固定为 totaloffsetreturnednext_offsetcomplete。一个不完整的答案因此是自描述的,而不是和完整答案长得一模一样:

meta = {
    "total": len(records),
    "offset": offset,
    "returned": len(page),
    "next_offset": None if next_offset >= len(records) else next_offset,
    "complete": next_offset >= len(records),
}

第二,页大小是试出来的,不是猜的。它先按 max_records 取一个上限,序列化,超了就缩,再序列化,直到装得下或者只剩一条:

count = max(1, min(count - 1, int(count * limit / len(payload))))

注释里解释了为什么要在按溢出比例缩放之外再强制减一:碰上异常宽的记录,纯比例缩放收敛得慢,多减一条能让它在两三轮内停下来。这个循环的终止条件是 len(payload) <= limit or count <= 1——也就是说,哪怕单条记录本身就超预算,它也会把这一条整个吐出来,交给上一层的 truncate_tool_result 兜底,而不是在这里死循环。

调用方目前有两个,都在 agent/src/tools/ 下。financial_statements_tool.py 对报告期分页,末尾一行是 fit_records(periods, offset, _build, max_records=_MAX_PERIODS)sec_filings_tool.py 对 SEC 备案列表分页,它的注释记了另一次实测:一个不带任何参数的 get_sec_filings(ticker="AAPL") 默认调用就已经溢出,备案索引本身占了信封的绝大部分字符。

这里还藏着一个容易做反的动作。financial_statements_tool.py 里的 _cap_periods 函数注释写道:解析层现在只裁每条记录的字段数,不再裁记录条数——因为一旦在解析层就把老数据丢掉,那些历史「对任何 offset 都永远够不到了」。限流要放在最靠近输出的那一层,放早了就是数据丢失。

另外,模型要会用分页,工具描述就得教它。这个仓库把这件事写进了 offset 参数的 description 里,原文是提示模型「读响应里的 paging.totalpaging.next_offset,再调一次以继续」。

技能文档走的是另一种分页。agent/src/tools/load_skill_tool.py 处理的是 markdown 正文而非记录列表,所以它按字符切:页大小常量 _PAGE_CHARS 是从 TOOL_RESULT_LIMIT 里扣掉一段给 JSON 键名和计数器留位,另有一个 _MIN_PAGE_CHARS 作为收缩循环的下限,免得一个转义字符密集的文档退化成一次一个字符地翻。这个文件的 docstring 里,项目自己写道:仓库自带的 88 个技能中有 31 个超出单次结果上限,并列了几个具体技能的交付比例。这个 88 与 agent/src/skills/ 目录下能数出的 88 个技能子目录(共 404 个文件)是对得上的。

三、第二层:跑得久的活挪出主对话

分页解决「结果太大」,解决不了「跑得太久」。agent/src/tools/background_tools.py 处理的是后者。

核心是 BackgroundManager,内部三样东西:tasks 字典、_notifications 列表、一把 threading.Lockrun(command) 会生成一个 uuid.uuid4().hex[:8] 的短 task_id,起一个 daemon 线程去执行,然后立刻返回一个只含 statustask_idmessage 的极小 JSON。主对话里因此只多了几十个字符。

进程是怎么起的很关键。_start_process 在 POSIX 上传 start_new_session=True,在 Windows 上传 CREATE_NEW_PROCESS_GROUP,目的写在 docstring 里:让这条命令连同它的子孙成为一个能被整体停掉的单位。有了这个前提,超时和取消才有干净的语义。

超时路径走 _terminate_process_tree。POSIX 分支先对进程组发 SIGTERM,等一个宽限期,没退就 SIGKILL;有意思的是即便 communicate 正常返回了,它还会再补一次 SIGKILL,注释解释了原因:shell 可能先于某个忽略了 SIGTERM 的后代退出,而进程组 id 还挂在 shell 上,这一刀是为了防止那个后代变成孤儿——哪怕它已经关掉了继承来的输出管道。Windows 分支则调 taskkill /PID <pid> /T /F,并在 OSError 时退回 process.kill()

取消是一个显式状态机。cancel(task_id) 先在锁里判断当前状态:已经是 cancelling 就返回一个幂等的 ok;不是 running 就报错并带上真实状态;进程已经 poll() 到退出码就回「正在收尾」。只有通过这些检查,才置 cancel_requestedcancelling,然后在锁外调 _force_stop_process_tree。任务线程在最终写回结果时会再检查一次 cancel_requested,把状态改写为 cancelled 并在输出末尾追加说明。

配套的安全阀是 _shell_safety.py 里的 broad_python_kill_error:提交上来的命令如果是按可执行名宽泛地杀 Python 进程,run 直接返回错误信封,压根不起进程。三个工具类的描述也在反复强调同一件事——只能用 cancel_background 停任务,不要用 taskkill/pkill/killall。这条约束的道理很朴素:Agent 用名字杀进程,杀掉的很可能是它自己。相关的权限收敛思路可以看 最小权限怎么设计

check(task_id) 返回的信封同样是刻意做小的:命令被切到前 60 个字符,任务还在跑时 result 字段直接填一句人话,告诉模型它已经跑了多久、还有多久自动超时、以及要停请用哪个工具。完成事件另走 _notifications 队列,由 drain_notifications() 一次性取走,每条只保留结果的前若干字符。三个工具分别叫 background_runcheck_backgroundcancel_background,其中 CheckBackgroundTool 标了 repeatable = True,另外两个标了 is_readonly = False

四、第三层:进度走旁路,不花结果预算

前两层都在压缩「进入上下文的字节」。但压得太狠,人和调度器就看不见了:一个跑了几十秒没有任何输出的工具,在 UI 上和死掉没有区别。agent/src/agent/progress.py 给的是第三条通路——进度信息不进模型的结果,只进事件流。

这个文件提供两套机制。

心跳HeartbeatTimer,一个上下文管理器,进入时起 daemon 线程,每隔 interval 秒调一次 emit,负载里只有 toolelapsed_s。它的防御性细节值得记:interval 小于 0.5 会被 clamp 并打一条 warning;退出时 join(timeout=1.0) 是有界的,注释写明「一个卡死的 emitter 不能把主循环拖死」;tick 里的回调异常被吞掉,「回调失败不能弄崩心跳线程」。它的 docstring 给了标准用法:

with HeartbeatTimer(tool_name="run_backtest", interval=3.0, emit=fn):
    result = registry.execute(...)

结构化进度emit_progress(stage, *, current, total, message),工具主动调。它不接收任何回调参数——emitter 存在一个 threading.local 槽里,由 Agent 主循环在调工具之前 _set_emitter 装上、调完清掉。文件顶部专门解释了为什么是 thread-local:只读工具会在 worker 线程里并行跑,每个线程一个槽,结构化进度才能回到正确的 AgentLoop 实例。同样地,emit_progress 在没有 emitter 时静默返回,异常一律吞掉,注释一句话:「进度上报绝不能弄坏一个工具。」

接线在 agent/src/agent/loop.py_invoke_tool 里。主循环把这次工具调用的 call_id 作为参数传进来,_invoke_tool 再把 ProgressEvent.to_dict() 的负载补上 toolcall_id,然后统一走 self._emit("tool_progress", ...)self._emit("tool_heartbeat", ...)。这里有一处判断很硬派:只读工具和写工具的超时处理完全不同。只读工具丢进 worker 线程加队列,超时就置 timed_out,迟到的结果丢弃、两个 emitter 一并静音;写工具「从不被杀」,只有一个 watchdog 在超时点发一条 stage="timeout_warning" 的进度事件,然后老老实实等它跑完——注释给的理由是它无法被安全取消。

消费端在 agent/cli/ui/rail.py(终端面板)和 frontend/src/hooks/useSSE.tsfrontend/src/pages/Agent.tsx(Web 端),另有 agent/src/openbb_bridge/event_mapper.py 做事件映射。

心跳还被复用到了工具之外。agent/src/swarm/worker.py 用它包住 LLM 流式调用,agent/src/swarm/runtime.py 用它包住多标的行情预取,理由写在注释里:这类抓取可能要几十秒,不发心跳的话 events.jsonl 长时间没有新行,失联检测会把一个健康的新任务误判成僵死。对应的判定逻辑在 agent/src/swarm/store.pycompute_stale_threshold——它直接用心跳间隔的整数倍作为「多久没动静算失联」的自然阈值,并给了上下界钳制。心跳从一个 UI 装饰变成了调度器的输入。

顺带一提,实际调 emit_progress 的工具包括 agent/src/tools/backtest_tool.py(三个 stage:validatesimulatefinalize)、agent/src/tools/doc_reader_tool.pyreading_pdf,带 current/total 页码)和 agent/src/tools/web_reader_tool.py。这里只讨论它们的工程结构:backtest_tool.py 做的是校验 config.jsoncode/signal_engine.py 是否存在、source 是否在合法集合内,然后调内置引擎、收集产物路径并返回;历史表现不代表未来,本文只讨论工程实现,不涉及任何策略结论。仓库根目录的 NOTICE 也说明了 agent/src/factors/ 下几个因子库各自的上游来源与许可(其中 Microsoft Qlib 的特征定义走 Apache 2.0,另有若干组公式来自公开论文与研报、被当作数学事实重新实现),各因子库子目录下另有 LICENSE.md,能不能商用以许可证原文为准,本文不提供法律意见。

五、三块拼图速查

组成部分它负责什么对应仓库位置你什么时候会碰到它
TOOL_RESULT_LIMIT + truncate_tool_result单次工具结果的字符预算,超了就切并声明切了agent/src/config/limits.py任何工具返回都过它;看到结果尾巴上带 TRUNCATED 提示时
fit_records按整条记录分页,附带 paging 元信息agent/src/tools/_result_paging.py写返回记录列表的工具时
报表 / 备案分页调用点offset 暴露成工具参数并教模型续页agent/src/tools/financial_statements_tool.pyagent/src/tools/sec_filings_tool.py拉多期财务数据或长备案列表时
技能文档字符分页长 markdown 按字符切页,声明交付比例与续读位置agent/src/tools/load_skill_tool.py加载超长技能文档时
BackgroundManager 三工具长命令挪到可整体停止的进程组,主对话只留 task_idagent/src/tools/background_tools.py跑耗时脚本、需要中途取消时
宽泛杀进程拦截拒绝按可执行名批量杀 Python 的命令agent/src/tools/_shell_safety.py模型试图自己清理进程时
HeartbeatTimer / emit_progress进度与存活信号走事件流,不占结果预算agent/src/agent/progress.py工具跑得久、UI 需要不像卡死时
工具调用接线装/卸 thread-local emitter,区分只读与写工具的超时策略agent/src/agent/loop.py_invoke_tool排查工具超时行为不一致时
心跳驱动的失联判定用心跳间隔推导「多久没动静算僵死」agent/src/swarm/store.pycompute_stale_threshold多 Agent 编排里任务被误判失联时

六、边界与代价:这套设计放弃了什么

它放弃了「一次拿全」。 分页把一次调用变成多次往返,每一轮都要过一次模型。对本来就装得下的小结果,这是纯开销;对需要横跨全量数据做聚合的问题,模型必须自己攒完所有页再算,中途一旦忘了续页,得到的就是基于部分数据的结论——而 paging.complete 是唯一的把关信号。这也是为什么工具描述里要专门写一段话教模型读 next_offset

它不保证模型会正确使用分页。 元信息只是把「答案不完整」这件事变得可见,看不看是模型的事。真正的兜底仍然是那条截断提示,而那条提示能做的也只是让模型知道自己被截了。

后台任务这一层放弃了流式输出。 background_run 的结果是任务结束后一次性写回的,中途只能靠 check_background 看状态,拿不到滚动的 stdout。要看实时输出,就不该用这条路径。

它明确不管跨会话持久化。 BackgroundManager 是一个进程内的全局单例,任务字典和通知队列都在内存里。进程退出,一切归零;任务也不会跨机器迁移。想要「关掉终端明天再来看」,这个模块不提供。

心跳不是取消。 心跳只证明「主机还在动」,不证明「这个工具还有意义地在推进」。真正的时限是超时;而对写工具,仓库选择的是不杀——一个卡住的写工具会一直占着主循环,你只会先收到一条 timeout_warning

进度上报是自愿的。 emit_progress 需要工具作者主动调,没调就完全没有结构化进度,只剩心跳的时间流逝。这是一条约定而不是机制,靠代码评审维持。

涉及实盘那一侧,代价要单独算。 仓库 agent/src/trading/connectors/ 下有 12 家券商/交易所连接器子目录(README 亦自述 12 brokers),agent/src/channels/ 下有 16 个具体渠道实现文件(另有 base/manager/registry 等公共文件)。一旦把凭据配进去,这三层设计一个都保护不了你:凭据在配置与日志里都有暴露面,下错单在多数场合是不可撤销的,程序化交易的合规义务因司法辖区而异。要不要打通实盘、以什么授权范围打通,以你所在司法辖区的监管要求与券商协议为准。日志里怎么防止凭据外泄,可以对照 日志里的敏感信息

七、上手与避坑清单

1. 别在解析层就砍记录条数。 为什么会踩:在数据解析处加一个「最多返回 N 条」看起来最省事,也确实能压住体积。踩了之后:那些被砍掉的记录对任何 offset 都永远够不到,分页参数形同虚设。怎么避:解析层只压单条记录的宽度(这个仓库压的是字段数),条数交给最靠近输出的分页函数。

2. 分页信息不能只写在文档里。 为什么会踩:你以为把「支持分页」写进工具描述的第一句就够了。踩了之后:模型拿到一页数据后不知道还有没有下一页,直接当全量用。怎么避:把 total / returned / next_offset / complete 作为响应结构的固定字段,并在 offset 参数的描述里直接点名让模型去读这两个字段。

3. 收缩循环一定要有下限和终止条件。 为什么会踩:按溢出比例缩页大小很自然,但转义字符密集的内容会让比例失真,缩一轮还是超。踩了之后:循环收敛极慢甚至退化成一次一条。怎么避:照抄这个仓库的两个保险——每轮至少减一,以及 count <= 1 时无条件返回(字符分页那侧对应的是 _MIN_PAGE_CHARS 下限)。

4. 后台命令必须起在独立进程组里。 为什么会踩:直接 subprocess.Popen(shell=True) 最快,超时时 kill() 一下看着也生效了。踩了之后:被杀的只有 shell,真正干活的子进程变成孤儿继续跑,占着端口和文件锁。怎么避:POSIX 用 start_new_session,Windows 用 CREATE_NEW_PROCESS_GROUPtaskkill /T /F,并在 SIGTERM 宽限期之后补一次 SIGKILL。

5. 永远不要让 Agent 按进程名杀进程。 为什么会踩:模型看到自己起的 Python 脚本没退出,最顺手的指令就是杀掉所有同名进程。踩了之后:它把自己的宿主进程也杀了。怎么避:在提交入口就拦(这个仓库用的是 broad_python_kill_error),只允许按记录在案的 task_id 停任务,并把这条规则写进工具描述本身。

6. 取消要做成状态机,不是一个布尔标记。 为什么会踩:只加一个 cancelled = True 标记,重复取消、取消已结束任务、取消正在收尾的任务,行为全都没定义。踩了之后:模型收到自相矛盾的返回,反复重试。怎么避:区分 running / cancelling / cancelled / completed / timeout / error,重复取消返回幂等 ok,取消非运行态返回带真实状态的错误。

7. 进度 emitter 用 thread-local,不要用全局变量。 为什么会踩:全局变量最省事,单线程下也确实能跑通。踩了之后:只读工具并行执行时,A 工具的进度事件发到了 B 的会话里。怎么避:threading.local 一线程一槽,调用前装、finally 里卸。

8. 心跳的 join 要设超时,回调异常要吞掉。 为什么会踩:心跳线程看起来无关紧要,退出时顺手 join() 不带参数。踩了之后:emitter 卡住,整个 Agent 主循环跟着卡死;或者某次回调抛异常,心跳线程直接死了而工具还在跑。怎么避:join(timeout=...) 有界,tick 内 try/except 全吞。

9. 区分只读工具和写工具的超时策略。 为什么会踩:给所有工具套同一个超时加强杀。踩了之后:一个写到一半的工具被杀,留下半个文件或半条记录。怎么避:只读工具超时即丢弃结果(记得同时静音它迟到的进度事件),写工具只告警不杀——代价是必须接受主循环被占住。

收个尾

这三层的分工其实可以压成一句话:分页管「多少字节进上下文」,后台管「多少时间占主循环」,进度管「这段时间里外面看得见什么」。 三者共用同一个前提——所有对模型不可见的删减,都必须以模型能读到的形式声明出来。

要把这套东西迁到自己的 Agent 上,建议的阅读顺序是:先看 agent/src/config/limits.py(预算和兜底怎么定义),再看 agent/src/tools/_result_paging.py(六十来行,一次读完),然后拿 agent/src/tools/financial_statements_tool.py 的末尾当调用范例,最后看 agent/src/agent/loop.py_invoke_tool 的完整接线——那里才能看清进度、心跳、超时三者是怎么合在一起的。回归测试在 agent/tests/test_tool_result_paging.py,它同时也是一份可执行的行为说明。

自检三问:你的工具返回里,有没有一个字段能让模型判断「这是不是全部」?你的长任务被取消时,子进程真的死干净了吗?你的进度事件,是不是正在和结果抢同一份上下文预算?

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 技能文档该写多长:Vibe-Trading 88 份文档量出的长度预算Vibe-Trading 开源项目给 Agent 开 shell 怎么兜底:命令校验与工作区访问控制

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