一个 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 吃回去。

顺带修掉的两处「恢复后能力降级」

继续查入口时发现,中断恢复路径只带上了模型配置:

  1. 少了交互审批能力声明 → 审批中间件认为「没人能回答」,用户在卡片上点了同意,恢复后的写操作仍被 fail-closed 静默拒掉(表现为「批了但没生效」);
  2. 少了工具面快照 → 恢复后整个后半程退回 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。

修法:精度优先,但别把评测一起关掉

生产路径给纯语义候选加两道闸:

  1. 查询短于 12 个字符不跑语义召回(「好的」「继续」表达的是对话动作,由会话级粘性接管);
  2. 每个语义候选必须有词法锚点——查询里真的出现该技能的名字或触发词。刻意不看技能描述,因为描述里全是「文件 / 使用 / 代码」这类通用词,一旦计入,「请读一次文件」又会因为 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 更值钱:长度抖动意味着命中集合每变一次就打断一次前缀缓存,而被断掉的前缀是要按未命中重新计费的。

三次「我以为」到「实测是」的纠正

  1. 以为工具输出在重放 → 实际 DAG 折叠早就在工作,稳定在 15~20k token。差一点就去优化一个不存在的问题。
  2. 以为是语义召回乱匹配 → 实际还有一层:内部续跑提示被当成了用户输入。只修检索精度,run 模式仍然会注入。
  3. 以为评测红说明改错了 → 实际是评测和生产共用了一条「精度 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=' 逐请求体统计。