技能文档该写多长:Vibe-Trading 88 份文档量出的长度预算

2026-08-05

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

给 Agent 读的技能文档,长度不是文风问题,是预算问题:Vibe-Trading 里单次工具结果的硬顶写死在 10,000 个字符,一份文档超过这条线不会报错、不会警告,只会被切成好几页分批送过去。 这条预算平时看不见,但它决定了一件很实在的事——模型要花几轮对话才读得完你写的那份手册。

先把项目名说清楚:这里的 Vibe-Trading 指 HKUDS 放出的那个开源交易 Agent(仓库 https://github.com/HKUDS/Vibe-Trading ,MIT 许可证),是专有项目名,不是「凭感觉做交易」这类泛指说法。本文一律走工程视角,只讨论文档长度与工具返回的实现,不涉及任何标的、策略或投资判断。

站内已有一篇Vibe-Trading 的技能体系,讲的是这套机制整体长什么样:目录怎么从磁盘生成、load_skill 怎么按名字取全文、返回的那个 JSON 信封里每个字段分别什么意思、用户技能层怎么覆盖随包版本。那些这篇不重讲。这篇只盯一个数——长度:预算是多少、谁超了、超了付什么代价、以及你自己动手写技能文档时该把这条线定在哪。

一、预算的三个数:10,000、9,400,和实际装得下的那点

第一个数是 10_000,写在 agent/src/config/limits.pyTOOL_RESULT_LIMIT 上,注释一句话:交给模型的单次工具结果最多多少字符。

这个常量不是 load_skill 私有的。limits.py 的模块文档专门交代了它为什么住在这儿:这条上限原来写在 src.agent.loop 里,又被当成一个裸字面量抄进了 src.swarm.worker,两边会各自漂移。现在它落在一个自身不 import 任何东西的叶子模块里,主循环、swarm worker、各个工具都能读,谁也不用依赖谁。

它的执行点在 agent/src/agent/loop.py:每一次工具返回,都要先过一遍 truncate_tool_result(result) 再拼进消息。也就是说,这条预算对所有工具一视同仁,不是给技能文档单独定的。

第二个数是 9_400,来自 agent/src/tools/load_skill_tool.py 里的 _PAGE_CHARS = TOOL_RESULT_LIMIT - 600。那 600 是留给信封本身的:JSON 的键名、几个计数器,都要占地方。

第三个数没有常量,因为它算不出定值。get_content 返回的不是裸正文,还要包一层 <skill name="..."> 标签;信封序列化成 JSON 之后,换行、引号这些字符还要再膨胀。所以工具里跑的是一个收缩循环:切一页、序列化、量长度,超了就按溢出比例缩、再多减 64 个字符重来,直到装得下或者触到 _MIN_PAGE_CHARS = 1_000 这个下限(这段循环的具体写法,总览篇里拆过)。

三个数摞起来,结论很朴素:一份技能文档想被一次读完,正文得控制在八千多个字符以内。 这就是全部的预算。

二、超预算不会报错,只会变成翻页

load_skill_tool.py 的模块文档自己把这件事写清楚了,原文是这样:

A tool result is capped at :data:`src.config.limits.TOOL_RESULT_LIMIT`
characters, and 31 of the 88 bundled skills exceed it: ``tushare`` delivers
9.7% of its 103,037 characters, ``options-payoff`` 33.8%,
``credit-analysis`` 43.0%. The cut used to be silent, so the agent could not
tell a complete document from an amputated one and had no way to reach the
rest.

翻成中文:单次工具结果卡在 TOOL_RESULT_LIMIT 个字符,88 份随包技能里有 31 份超过了它——tushare 只送得进它那 103,037 个字符的 9.7%,options-payoff 是 33.8%,credit-analysis 是 43.0%。以前这一刀是静默的,Agent 分不出完整文档和被截肢的文档,也没有办法够到剩下的部分。

这几个百分比是怎么算的,值得掰开看一眼,因为它决定了你该怎么读这句话。103,037tushare/SKILL.md 这个文件的字符数,我数了一遍能对上。10,000 ÷ 103,037 = 9.7%10,000 ÷ 29,570 = 33.8%10,000 ÷ 23,248 = 43.0%——三个数全部吻合。所以分子是硬顶,不是实际页宽。按工具真实的分页算法跑一遍,tushare 首页送出去的是 9,400 个字符,占全文 9.1%,比文档里那个数还要再低一点。

超预算的代价不是数据丢失,是这四条:

往返次数直接变成对话轮次。 我按工具里那段收缩循环模拟了一遍,tushare 要 11 次 load_skill 调用才能读到底。这 11 次不是并行的,是 11 轮「模型说要读、工具返回一页、模型再说要读」。

读完的东西全都留在上下文里。 翻 11 页意味着上下文里躺着十万字符级的一份文档。渐进加载省下的是「不读的那些不进来」,不是「读了的能变小」。

模型未必翻到底。 信封里有 completenext_offset 提醒它没读完,但要不要接着翻是模型的判断。文档越长,半途停手的概率越大——而写在文档最后一屏的那些约束,恰好是最容易被漏掉的。

在多智能体那条路上,兜底还更薄。 agent/src/swarm/worker.py 拼工具结果时用的是 result[:TOOL_RESULT_LIMIT],一刀裸切,不加任何说明;主循环那边的 truncate_tool_result 至少会追一段 [TRUNCATED: ...] 的提示,把送出多少、总共多少都写进去。load_skill 因为自己把页宽算好了,这一刀咬不到它——但这正好说明分页得工具自己做,指望外层帮你兜是靠不住的。

关于「返回值该怎么自述完整性」这个通用问题,站内生成到一半就断了:输出上限、上下文挤爆、网络中断还是工具超时里从排查角度讲过另一面。

三、我把 88 份文档量了一遍:长度分布长什么样

上面那些数字都是仓库自己写的。这一节的数字是我自己数的,方法写清楚,你可以照着复现:遍历 agent/src/skills/ 下每个含 SKILL.md 的目录,按仓库 agent/src/agent/frontmatter.py 里那条正则把 frontmatter 和正文切开,用 Python 的 len() 数正文字符数(不是字节数)。

口径数值
技能份数88
正文最短harmonic,1,160 字符
正文中位八千出头(第 44、45 名是 thesis-tracker 8,037 与 macro-analysis 8,101)
正文最长tushare,102,858 字符
正文合计约 87 万字符

再按预算分桶:正文不到 4,000 字符的有 24 份,落在 4,000 到 10,000 之间的有 36 份,超过 10,000 的有 28 份。

但分桶还不够准,因为决定翻不翻页的不是文件大小。我把工具那段收缩循环原样搬出来,对 88 份逐一跑到底,得到的页数分布是:一页装得下的 53 份,两页的 26 份,三页 4 份,四页和五页各 2 份,十一页 1 份(就是 tushare)。

这里有两个数值得对照着看。

第一,按整份文件的字符数算,超过 10,000 的正好是 31 份,跟模块文档里那个数对得上;但按工具真实的分页算法算,需要翻页的是 35 份。差出来的这 4 份,就栽在 XML 包裹层和 JSON 转义那点开销上——它们文件本身没到线,套上信封就到了。所以「我的文档 9,800 字符,刚好压线」这种算法是不成立的,留够余量才安全。

第二,一页装得下的那 53 份,加起来只占全部正文字符的 29.5%。也就是说六成的文档是「一次读完」的,但七成的内容量攒在剩下那 35 份里。技能文档的长度分布是明显偏斜的,均值在这里没什么意义。

四、决定长度的不是分类,是「清单」还是「判断」

直觉上会觉得数据源类的技能天生就长——毕竟要列接口。我按 frontmatter 里的 category 分组量了一遍正文长度中位数,结果正好相反:

分组份数正文中位数
strategy192,431
data-source104,631
research27,589
asset-class97,717
flow87,912
tool108,122
analysis228,764
crypto79,965
risk-analysis119,490

data-source 这一组的中位数只有 4,631,在九个分组里排倒数第二。展开看这 10 份:tushare 102,858、yfinance 8,259、data-routing 7,956、sec-edgar 5,163、okx-market 4,662、qveris 4,600、mootdx 3,962、eastmoney 3,834、akshare 2,889、ccxt 2,004。一份拖着九份,超预算的只有 tushare 一个。

所以分类不是那条分界线。真正的分界线是:这份文档里有没有一张会跟着上游一起长的清单。

tushare/SKILL.md 是最干净的样本。它只有 291 行,却有 103,037 个字符——因为其中 231 行是表格行,表格吃掉了全文 98% 的字符,平均每行 439 个字符。它的正文骨架其实很短:## 概述## 快速上手## 参数格式说明## python脚本示例,然后是 ## 数据接口列表——一张按 ID、接口名、标题、分类、说明排开的接口目录。这种长度不是作者写出来的,是上游有多少个接口决定的。上游多两百个接口,这份文档就多两百行。

反过来看另外两份大的:social-media-intelligence 40,675 个字符、1,296 行、53 个标题、26 个代码块,表格只占 7% 的字符;correlation-analysis 40,194 个字符、1,124 行,也是同型。它们不是清单撑大的,是没拆的长文——一路小节套小节写下来,而且技能目录里就 SKILL.md 这一个文件,什么都没往外挪。

短的那一端更能说明问题。harmonic 1,420、smc 1,439、ichimoku 1,666、technical-basic 1,968,这几份的表格字符占比都在三到四成,剩下的是判断规则。方法论型的文档,写清楚「什么条件下怎么判断」就到头了,天然收敛在一两千字符。

一句话记住这条:清单型文档的长度由上游决定,判断型文档的长度由作者决定。 只有后者是你能通过写作控制的。

五、拆进 references/ 不等于绕开预算

看到这儿很自然会想:那把长清单挪进子目录不就完了?

tushare 已经这么干了,而且干得很彻底——这个技能目录下有 232 个文件、接近 982 KB,references/ 里 229 份,还带一个 scripts/。可它的 SKILL.md 仍然是 102,858 个字符,因为留在正文里的那张索引表本身就超预算十倍。拆分只解决细节,解决不了索引。

拆出去的文件模型怎么拿?走 agent/src/tools/read_file_tool.pyread_file。这个工具的 path 参数说明写着「相对 run_dir 或 skills/」,实现里把技能目录加进允许根,还额外做了一层容错:模型习惯性多写的 skills/ 前缀会被剥掉再试一次。

但这条路的预算比 load_skill 还紧,紧在两点:

一是要过两道刀。 read_file 自己有个 _OUTPUT_LIMIT = 50_000,超了就截断加一句 ... (truncated);出了工具,主循环那把 10,000 的刀还等着。真正生效的是后面那把。

二是它没有续读参数。 read_file 的参数只有 pathlimit,而 limit 限的是行数,不是偏移量。也就是说一份附属文件超了预算,你没有 next_offset 可以接着读——只能靠改 limit 反复试,或者干脆读不全。有意思的是,主循环截断时追加的那句提示里写的是「如果这个工具有分页参数,就带上它再调一次」,而 read_file 恰恰没有。

拿实测数据对一下就清楚了。tushare/references 那 229 份文件,中位数 2,150 个字符,绝大多数安全;但有 4 份超过 10,000,最大的一份是 references/指数专题/申万行业分类.md,34,693 个字符——这几份读起来是拿不全的。相比之下 eastmoney 的 16 份附属文件最大才 2,798 个字符,全部在预算内。

所以拆分的正确姿势不是「把长的挪出去」,是「挪出去的每一份也按同一条预算切」。附属文件的目标值应该比 SKILL.md 更保守,因为那条路上没有分页兜底。

六、仓库自己怎么守这条线:三条断言和一个 9,500

这条预算之所以还没崩,是因为它被写成了可执行的断言,而不是写在某份规范文档里。

agent/tests/test_load_skill_paging.py 里有三条关键测试:

  • test_no_single_page_exceeds_the_tool_result_limit:把 88 份技能全部翻到底,任何一页的信封长度都不许超过 TOOL_RESULT_LIMIT
  • test_paging_reconstructs_every_skill_byte_for_byte:分页读回来拼起来的文本,要和 get_content 的结果逐字节相同。
  • test_the_largest_skill_pages_in_a_sane_number_of_calls:最大那份技能的页数必须落在 1 < pages <= 20 之间。

第三条是这套预算里唯一的警报器。我实测 tushare 现在是 11 页,离 20 这条红线还有 9 页的余量。换句话说,这份文档再长个八九万字符,测试就会红——不是提醒,是构建失败。把「文档别写太长」这句话变成一条断言,比写进贡献指南有用得多。

同一条预算在别的工具里也留下了痕迹,可以互相印证:

agent/src/tools/taiwan_stock_data_tool.py 里有个 RESPONSE_CHAR_BUDGET = 9_500,注释写得很直白:loop.py 会在 10,000 个字符处截断每一个工具结果,盲切会切出非法 JSON,而且行是从旧到新排的,切掉的正好是最新的那几根。所以它自己先收在 9,500,给信封留出余量。

agent/src/tools/_result_paging.py 里的 fit_records 是另一套解法:按整条记录装页,装不下就减记录数,信封里带一个 paging 块说明总数、本次返回多少、从哪续。它的模块文档记了一笔实测——get_financial_statements("AAPL.US", statement="income", period="quarter") 取 40 个季度,序列化出来 28,498 个字符,静默截断年代实际只送到大约 12 条。

三个工具、三套写法,守的是同一个 10,000。

七、那你的技能文档该写多长

把上面的东西收成一张能直接用的预算表:

位置建议预算为什么是这个数
目录里那一行 description一句话写完它每轮对话都常驻,不按需
SKILL.md 正文目标 8,000 字符以内硬线是 9,400,扣掉包裹层和转义要留余量
单份附属文件目标 8,000 字符以内read_file 没有续读参数,截了就补不回来
会跟上游长的清单单独立项,不进正文它的长度不受你控制

配四个动作:

1. 动笔前先判类型。 这份文档写的是判断规则,还是一份清单?判断规则型的,一两千到三千字符通常就够用,写超了多半是把背景知识也塞进来了——模型不需要你介绍这是什么,它需要知道什么时候选它、怎么用它。

2. 清单型的,正文只留「怎么找」。 把清单本身放进附属文件,正文里写清楚检索路径和命名规律。要是索引表本身也超了预算,就再分一层,按主题拆成几份索引。

3. 关键约束往前放。 长文档会被翻页,而翻页是模型自己决定要不要继续的。限速红线、参数格式、禁止绕过的调用路径这类东西,写在最后一屏等于没写。这一点和技能文件的跨平台契约里讲的顺序原则是一回事。

4. 把预算写成断言。 加一条测试:遍历技能目录、按真实分页算法跑一遍、断言页数上限和单页长度。上面那些统计我全是用这个思路数出来的,几十行 Python 就够,跑一次几秒钟。规范会被忘记,断言不会。

至于常驻的那部分索引该占多大、按需的那部分怎么排优先级,属于更上层的问题,站内上下文塞不下了:系统说明、历史、检索结果各该占多少里有通用的分配方法。

收个尾

这篇从头到尾就三个数:硬顶 10,000、页宽 9,400、页数警报线 20。

它们合起来回答了开头那个问题——一份给 Agent 读的技能文档,写到八千字符以内就是一次调用的事;写过去了不会有人拦你,只是从此每读一次它都要多花几轮对话,而且越往后的内容越可能没人读到。88 份文档里 53 份守住了这条线;越线的 35 份里,26 份只是刚过线、翻第二页就够,真正拖成四页以上的只有 5 份。

真要动手核对,从 agent/src/config/limits.py 那 13 行看起最快:一个常量、一个截断函数,这条预算的全部权威就在那儿,剩下的都是各个工具怎么想办法不撞上它。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源项目 Vibe-Trading:30 份 yaml 定义的多智能体团队编制Vibe-Trading 项目怎么处理工具返回太长:分页、后台与进度三层

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