把「每轮固定开销」当成性能问题:两次实测,砍掉 63% 的工具面和 100% 的错误技能注入
一个 AI 编码 Agent 的上下文里,最贵的往往不是用户说的话,而是那些每一轮都原样重发的东西:工具 schema、技能正文、系统提示词。这篇记录两次针对固定开销的实测——一次把工具面从 24k token 压到 8.8k,一次把「无关技能正文」从每轮注入 1.5KB 降到 0。两次都不是靠猜,而是把真实请求体抓下来数出来的。
起因:先别信「看起来」
我们最初怀疑的是「工具输出每轮被原样重放」。理由是数据库里工具消息占了会话内容的 78%~99%:某个会话 21 条 tool 消息就 138KB,单条最大 33,015 字节。
于是搭了个能记录请求体的 mock LLM(OpenAI 兼容协议,把每次请求的
messages / tools 全量落盘),拿一个 120~160
轮的长会话跑了一遍。结果和怀疑相反:
| 观测项 | 数据 |
|---|---|
| 请求体大小 | 23,554 B(3 条消息)→ 峰值 91,410 B(325 条消息、123 条 tool) |
折叠计数 folded |
0 → 24 → 55 → 90 → 116 |
| 触发压缩后 | 回落到 46~59KB,然后在 15~20k token 区间震荡 |
如果真按「原样重放」,158 条 × 3KB ≈ 474KB ≈ 110k token。实际稳定在 15~20k——DAG 折叠与压缩一直在工作(近处原文、中段把工具输出折成「结论行 + 首尾各 12 行」、远端压成 160 字符骨架)。
教训:存储层的 composition 不能用来推断上下文开销。只有真实请求体算数。
实测一:工具面从 24k token 砍到 8.8k
抓到的请求体里,有一个更隐蔽的开销:tools
数组。全量工具目录是 188 个工具 = 24,103 token,而
ReAct 循环每一轮都重发同一份 schema。
mock 台怎么搭
几句话说不清,直接给关键设计:
- mock server 记录每次请求的
tools个数、tools序列化字节数、整包字节数,追加到calls.jsonl,首个请求全文存first_body.json; - 模型永远回「抱歉,我无法完成该任务」,让 Agent 的续跑护栏自然触发,这样能稳定拿到「第 N 轮的请求」;
- 配置里把
base_url指到本地 mock、context_window设成 0(不猜窗口)、governance.enabled=false(不弹审批)。
对照结果
用同一个任务、同一份配置,分别跑改前/改后的二进制:
| 入口 | 改前 | 改后 |
|---|---|---|
valetbot run 一次性任务 |
186 工具 / 79,176 B / 24,083 token | 12 工具 / 8,347 B / 8,787 token |
| WebChat 子 Agent(Generalist) | 188 工具 / 73,077 B / 20,256 token | 12 工具 / 7,541 B / 8,101 token |
固定工具开销 −89%,整包 prompt −63%。
关键不是「少给工具」,而是「预算有出处」
裁剪的判据是 ToolCatalogContextTokens:优先
env、其次显式配置、最后模型目录,不猜。理由很朴素——猜小了会把工具裁掉导致任务失败,猜大了等于没裁。
裁剪后还要保证同一 run 内 tools[] 逐字节稳定。因为
provider
的前缀缓存是按前缀匹配的,工具数组一变,缓存全灭;换句话说,「按意图动态裁剪」如果不做
run 级快照,省下的 token 会被缓存 miss 吃回去。
顺带修掉的两处「恢复后能力降级」
继续查入口时发现,中断恢复路径只带上了模型配置:
- 少了交互审批能力声明 → 审批中间件认为「没人能回答」,用户在卡片上点了同意,恢复后的写操作仍被 fail-closed 静默拒掉(表现为「批了但没生效」);
- 少了工具面快照 → 恢复后整个后半程退回 188 个 schema,既费 token 又打断前缀缓存。
CLI 的 resume 同样只补了审批、没补
scope。这类「能力声明」缺失没有编译错误、没有日志,只能靠源码守卫(断言
resume 入口必须同时出现 WithInteractiveApproval 与
WithRunToolScope)防止回退。
实测二:无关技能正文,每轮白送 1.5KB
同一批请求体里还发现一个 3 条 system 消息的结构:核心提示词 6,932 字符、工具目录 8,810 字符、末尾 1,578 字符的技能正文。而那段会话用户只说了「第 N 轮:请读一次文件」。
第一层:语义召回没有区分度
技能匹配用的是混合检索(触发词 + 关键词 + 本地 n-gram 向量)。把评分打出来看:
| 查询 | 语义 top1 |
|---|---|
| 第 1 轮:请读一次文件 | Frontend Builder 0.800 / Documentation Writer 0.505 |
| 当前工作目录是什么 | Code Explainer 0.800 |
| 你有哪些工具可以用 | Test Writer 0.800 |
| 审查一下这个函数的逻辑对不对(合法) | Code Review 0.800 / Web Research 0.726 |
top1 对任何查询都饱和在 0.800。 阈值写成 0.2 等于没有阈值——分值字段完全没有区分度。本地 n-gram embedder 在中文短句上的「相似」更多是字符共现,不是语义。
第二层:真正的注入源是「内部提示」
修了语义闸门之后,端到端再测:WebChat 面 20
轮噪声会话,<skill name= 出现 0 次。但换成
run 模式,还是注入 2 个技能。
加一行临时日志(打印本轮匹配用的 query 和命中的技能名),真相很扎眼:
query="【任务契约 · Mission Contract】目标:第 1 轮:请读一次文件…" matched=""
query="你这轮有工具活动但没有产生任何新交付物。排查:测试是否始终失败且未修根因…" matched="Self-Improving,Test Writer"
run 循环在任务未完成时会合成续跑提示,而这段内部文案里的「测试 / 排查 / 验证」正好命中 Test Writer、Self-Improving 的触发词。内部提示反过来把技能正文注进了对话——这是那种看代码永远看不出来、只有把请求体抓下来才发现的 bug。
修法:精度优先,但别把评测一起关掉
生产路径给纯语义候选加两道闸:
- 查询短于 12 个字符不跑语义召回(「好的」「继续」表达的是对话动作,由会话级粘性接管);
- 每个语义候选必须有词法锚点——查询里真的出现该技能的名字或触发词。刻意不看技能描述,因为描述里全是「文件 / 使用 / 代码」这类通用词,一旦计入,「请读一次文件」又会因为 Frontend Builder 描述里的「单文件网页」被锚回来。
同时把匹配文本变成可覆盖的:续跑轮显式传空串,表示「这是续跑,不产生新命中,粘性沿用上一轮」。
这里有个容易被忽略的坑:仓库里有一份语义召回评测(15 条人工标注查询、锁 R@1≥8 / R@3≥11),它的评测语料故意只给 name+description、触发词为空——也就是说它考的是纯语义。加锚点后它直接归零。
正确的做法不是改地板,而是把两条路径分开:matchRelevant(query, allowUnanchoredSemantic bool),生产传
false(精度优先),评测 harness 传
true(继续度量 embedder 本身的排序质量)。改完实测:语义开
R@1=10/15、R@3=13/15,语义关 0/15,地板原样守住。
效果
| 观测项 | 改前 | 改后 |
|---|---|---|
| run 噪声任务每请求注入技能数 | 5 | 0 |
| 请求体 | 41,790~45,121 B | 34,001~37,332 B(−7.4KB/请求 ≈ −2.1k token) |
| system 段长度 | 7,427 / 8,977 交替抖动 | 稳定 5,141 |
| 合法任务「写一个贪吃蛇小游戏」 | 注入 Frontend Builder | 仍注入(6/6 请求) |
system 段从「两种长度交替」变「恒定」这件事,比省下的 token 更值钱:长度抖动意味着命中集合每变一次就打断一次前缀缓存,而被断掉的前缀是要按未命中重新计费的。
三次「我以为」到「实测是」的纠正
- 以为工具输出在重放 → 实际 DAG 折叠早就在工作,稳定在 15~20k token。差一点就去优化一个不存在的问题。
- 以为是语义召回乱匹配 → 实际还有一层:内部续跑提示被当成了用户输入。只修检索精度,run 模式仍然会注入。
- 以为评测红说明改错了 → 实际是评测和生产共用了一条「精度 vs 召回」的路径,拆开即可,两边都不牺牲。
还留着没动的:哪些开销是「该花」的
请求体里那条 8,810 字符的系统消息,拆开是:工具目录 3,849 字符(97 个未下发 schema 的工具,与已下发的 12 个零重叠)+ 环境段 + 技能目录 1,116 字符。
评估结论是不砍:它排在历史之前、内容逐字稳定,属于可被前缀缓存命中的部分,只在冷启动付一次;而它是模型发现另外
97 个工具的唯一线索(tool_search
需要先知道查什么)。改「只留名字」能省
2.4KB,但丢掉选择依据,会让模型改用错工具——那类代价通常比 token 贵。
可复用的检查清单
- 怀疑上下文开销时,先抓真实请求体,别看数据库 composition;
- 固定开销逐项算:
tools序列化字节、system 段字符数、每次请求的不变前缀; - 任何「动态裁剪」都要配 run 级快照,否则缓存失效会吃掉收益;
- 检索类改动先问「top1 分数有没有区分度」,饱和的分数等于没有分数;
- 内部合成提示(续跑、纠偏、重试)不要参与用户输入相关的语义匹配;
- 守卫测试红了先分清「行为退化」还是「评测前提变了」——后者要拆路径,不要改地板。
本文的实测环境:macOS arm64,本地 mock LLM(OpenAI 兼容),长会话
120~160 轮。工具面数据来自 valetbot run 与 WebChat
两条入口的对照二进制;技能注入数据来自
grep -c '<skill name=' 逐请求体统计。
- 感谢你赐予我前进的力量
