
把 12 个任务的工具调用序列交给一个桩模型固定输出,合计 37 次调用,按工具名与参数做一次缓存之后,真正落到工具上的只有 9 次。这个数字是 tools/verify-tool-result-dedup.mjs 跑出来的,脚本里模型是写死的序列,缓存与预算逻辑是真的。
| 策略 | 工具执行 | 命中 | 注入上下文字节 | 相对不去重 |
|---|---|---|---|---|
| P0 不去重 | 37 | 0 | 63400 | 100% |
| P1 按工具与参数去重 | 9 | 28 | 63400 | 100% |
| P2 去重加单条结果截断到 800 字 | 9 | 28 | 26715 | 42% |
| P3 去重加重复结果只留指针 | 9 | 28 | 6797 | 11% |
第二列与第四列放在一起看,才是这张表真正要说的:P1 把执行次数从 37 压到 9,注入上下文的字节数却与 P0 一模一样,63400 一个字节没少。原因是缓存只挡住了工具调用,同一份结果第二次仍然原样进上下文。上下文预算吃紧时,只做去重等于白忙。
排在前面的任务最贵
每个任务分到的执行次数(不去重 / 去重,完整分项在文末证据块里):t01 三次调用三次全执行,t02 到 t05 各降到一到两次,t06 之后的每个任务都降到 0 次。
前五个任务承担了全部 9 次真实执行,从 t06 开始一律为 0,因为需要的文件与目录列表在前面都被读过一遍,检索结果也一样。这条分布说明缓存命中与任务顺序强相关:排在后面的任务便宜,排在前面、第一个摸到某个文件的任务最贵。若把任务顺序倒过来,前几个任务的位置互换,总数不变,但谁贵谁便宜会换人。
重复参数最多的三条是 read|src/app.js 七次、grep|fetch( 六次、read|src/util.js 五次。三个名字合计 18 次调用,接近总数的一半,去重后的 9 次真实执行里这几条各占一次。
从 26715 到 6797 的那一处改动
P3 与 P2 的差别只有一处:同一条结果第二次出现时,不再把内容重新放进去,替换成一行指回首次位置的标注字符串,长度二十来个字节。就是这一处改动把注入字节数从 26715 压到 6797,代价是模型在后续步骤里必须回看首次出现的位置,跨轮引用能不能读懂,取决于是哪家的模型与上下文编排方式。这部分的判断没有实测依据,桩模型不产生语言行为,只按固定序列调用工具。
node v26.8.1
任务数 12,桩模型给出的工具调用合计 37 次
策略 工具执行 命中 注入上下文字节 相对 P0
P0 不去重 37 0 63400 100%
P1 按 (工具,参数) 去重 9 28 63400 100%
P2 去重 + 单条结果截断到 800 字 9 28 26715 42%
P3 去重 + 重复结果只留指针 9 28 6797 11%
每个任务的工具执行次数(P0 / P1 / P2)
t01 调用 3 次 -> 3 / 3 / 3 / 3
t02 调用 3 次 -> 3 / 1 / 1 / 1
t03 调用 3 次 -> 3 / 1 / 1 / 1
t04 调用 3 次 -> 3 / 2 / 2 / 2
t05 调用 3 次 -> 3 / 1 / 1 / 1
t06 调用 3 次 -> 3 / 0 / 0 / 0
t07 调用 3 次 -> 3 / 0 / 0 / 0
t08 调用 3 次 -> 3 / 1 / 1 / 1
t09 调用 3 次 -> 3 / 0 / 0 / 0
t10 调用 3 次 -> 3 / 0 / 0 / 0
t11 调用 3 次 -> 3 / 0 / 0 / 0
t12 调用 4 次 -> 4 / 0 / 0 / 0
重复参数最多的三条:
read|src/app.js 被调用 7 次
grep|fetch( 被调用 6 次
read|src/util.js 被调用 5 次
注意:模型本身是桩,只固定调用序列;缓存命中率取决于这组序列,真实模型下没实测。
缓存键该按字面比还是按规范化路径比
缓存键怎么定,决定了命中率。这里用的是工具名拼接参数,参数按字面比,src/app.js 与 ./src/app.js 会算成两条,换成规范化后的路径能再多命中一些,代价是要为每个工具写一层参数归一化。
时效怎么算,脚本里没有做。文件被改过之后缓存的那份内容就过期了,真实项目里要给读类工具挂上修改时间或版本号,写类工具的结果一律不进缓存,这一块我没测,属于已知的缺口。
省下来的东西值不值,按场景分。执行贵、上下文宽裕的场景适合只做去重,改一处缓存键就能拿到四分之三的执行次数;上下文紧张的场景要走 P3,把重复结果换成指针,代价是模型得会往回看。两种策略共用同一份缓存,先上哪一个都不会互相挡路。