我的 Agent 挂着一个查订单的工具,跑一次返回 200 条记录。我本来没当回事,直到我看了一眼每次请求的输入 token:光这一份返回值就 36.4 KB,我按「汉字 1 字 1 token、其余 4 字符 1 token」估算下来是 10080 token。用户只是问了一句「这批订单一共多少条」。
更难受的是这 10080 token 不是花一次就完了。它进了上下文,之后每多一轮对话,它都要跟着被重新送一遍。我问了 6 轮,它就交了 6 次学费。
我之前已经给 Agent 做过缓存(重复读同一个文件不用再读)和上下文预算器(历史太长就滚动摘要)。但这两个都不解决眼前这个问题:缓存管的是「第二次别再读」,预算器管的是「太长了要丢」,而这一次是第一次返回就太大,而且丢了之后就再也答不了。
所以这次我加的是另一层:工具返回值不进上下文,只回一个句柄。
一次查询吃掉一万 token
我先把账算清楚。造 200 条订单记录,7 个字段:
条数 : 200
原始 JSON 字节 : 37248 (36.4 KB)
估算 token : 10080我的做法是:结果照样算,但存进一个仓库,只把 result_id 和一份结构化摘要放回上下文。模型要看细节,就调 result_query(result_id, path) 自己来取。
这个思路不是我原创的。MCP 规范里工具可以返回 resource_link 而不是把数据内嵌进结果(核对日期 2026-10-09,依据 modelcontextprotocol.io 规范 server/tools 一节,定义字段为 uri / name / description / mimeType,原文说「A tool MAY return links to Resources, to provide additional context or data」)。我做的相当于把这套思路往里再走一步:不只要链接,还要一份能直接回答大部分追问的摘要,以及一个能按路径取子集的查询器。
第一版我只留了预览,然后踩了个蠢 bug
第一版(v1)很简单:存起来,只回前 3 条预览 + 总数。载荷 195 token,压缩到原来的 1.9%。
然后我拿 6 条典型追问去测,一次能直接从摘要答出来的只有 1 条:
-- 3 轮 --
全量塞上下文 : 30240 token
v1 句柄 : 1383 token (一次命中 1/3, 取回 798)「总金额多少」「已付款几笔」这种,摘要里没有,模型只能把整列取回去自己算。
在写查询器时我还踩了个很蠢的坑。工具返回的顶层有时候是裸数组 [{...},{...}],有时候是对象 {rows:[...]}。我 path 语法统一写成了 rows[0].amount,结果对裸数组直接报错:
✗ rows[0].amount 取第 1 条金额 -> NO_SUCH_FIELD 可选: 0/1/2/3/.../199 @rows报错信息把 200 个下标全列出来了,这本身就是个教训——错误信息也要设上限。我的修法是入库时归一化,裸数组一律包一层:
put(raw) {
const data = Array.isArray(raw) ? { rows: raw } : raw;
const id = 'res_' + String(++this.seq).padStart(3, '0');
this.map.set(id, { data, createdAt: Date.now(), bytes: Buffer.byteLength(JSON.stringify(data)) });
this._sweep();
return { id, data };
}第二个 bug 紧接着来了。rows[?amount>=4900].order_id 也报错,因为过滤完之后结果还是数组,我忘了在数组上跟字段名时自动投影成列。补了三行:
if (Array.isArray(cur)) {
if (tk === 'length') { cur = cur.length; continue; }
cur = cur.map(o => (o == null ? undefined : o[tk]));
continue;
}摘要里真正值钱的是列级统计
v1 的问题不是省得不够,是省错了地方。预览那 3 条记录看着有用,其实模型绝大多数时候要的不是「第 1 条长什么样」,而是「一共多少、总共多少钱、状态怎么分布」。
所以我加了 v2:数值列给 min/max/sum/avg,字符串列在 distinct ≤ 8 时当枚举给 top 5 及计数,超过就只给长度范围。载荷从 195 涨到 405 token,但命中率变了:
-- 3 轮 --
v1 句柄 : 1383 token (一次命中 1/3, 取回 798)
v2 句柄 : 1215 token (一次命中 3/3, 取回 0)v2 载荷更贵,3 轮下来反而更省,因为它省掉的是两次整列取回。更重要的是少两轮往返,这个收益没算进 token 里。
实际的摘要长这样(200 条的真实输出):
"status": {
"type": "enum", "count": 200, "distinct": 3,
"top": [["paid", 116], ["unpaid", 46], ["refunded", 38]]
},
"amount": {
"type": "number", "count": 200,
"min": 27.01, "max": 4950.49, "sum": 467625.48, "avg": 2338.13
}不过 v2 也不是全赢。6 轮跑下来 v1 是 12303、v2 是 12765,v1 反而低一点——因为摘要多出来的 210 token 要按轮次反复交。我最后的折中是:预览降到 2 条,只统计数值列和低基数枚举列,载荷压到 353 token。
取回:一个 60 行的 path 查询器
取回这一侧我写了四种语法,够用就好:
✓ rows[0].amount -> 4032.91
✓ rows[0:3] -> array[3]
✓ rows[*].amount -> array[200] 386 token
✓ rows[?status=="paid"] -> array[116]
✓ rows[?amount>=4900].order_id -> ["SO20260002","SO20260093",...] 4 条
✗ rows[999].amount -> INDEX_OUT_OF_RANGE @999
✗ rows[0].nope -> NO_SUCH_FIELD 可选: order_id/customer/status/...最后那条报错是刻意做的:字段名写错时把可选字段列出来,模型下一轮就能自己改对。这比一句「查询失败」有用得多。
还有一件我一开始没想到的事:取回本身也可能很大。rows[*].amount 在 200 条上是 386 token,还撑得住;同样的语法放到 2000 条上是 3818 token,之前省下来的会一次性全吐回去。我加了个打包环节,超过 500 token 就只给前 10 个 + 后 5 个,并明说被截断:
200 条 -> rows[*].amount 原始 384 token, 打包后 384 token, truncated=false
2000 条 -> rows[*].amount 原始 3818 token, 打包后 67 token, truncated=true
"共 2000 项,只给前 10 + 后 5,需要中间部分请加切片 rows[a:b]"关键是那句提示。悄悄给一个看起来完整的数组,模型会以为自己看全了;说清楚被截断,它下一轮会自己加切片。
仓库本身要有边界。我用 LRU + TTL,容量 3 塞 5 份会淘汰 2 份,超时再取会拿到 RESULT_EXPIRED。关键是过期之后怎么办——不能让模型卡住,要让它知道「这份没了,你得重新调一次工具」,这就是往回退化成普通模式,不会比不句柄化更差。
什么时候不该这么干
这是我最想记下来的一段。我跑了个规模梯度(阈值 800 token,低于它的直接原样返回):
5 条: 全量 251 -> 直出 251 token (不值得,原样返回)
10 条: 全量 502 -> 直出 502 token (不值得,原样返回)
20 条: 全量 1005 -> 句柄化 347 token 省 65.5%
50 条: 全量 2513 -> 句柄化 348 token 省 86.2%
200 条: 全量 10051 -> 句柄化 353 token 省 96.5%
1000 条: 全量 50368 -> 句柄化 358 token 省 99.3%摘要本身要 350 token 左右,所以原始结果不到它的两三倍时,句柄化是纯亏。10 条记录省 30%,我还要为此多承担一次往返失败的风险,不值。
另一个拐点是「模型到底要不要看全量」。6 轮里如果有 6 轮都要全量,句柄方案 62910,直接塞 60480——多花 4%。规模越大这个差距越小,但方向不会变:整份数据每轮都要用,就别绕这一圈。
我的判断
这套东西我现在只在「返回行数不可控」的工具上开,比如查询类、导出类。定长返回(查一条详情、查一个状态)我一律直出,不做句柄。
列级统计是这次最值钱的改动,比预览有用得多。但它是有成本的,而且成本按对话轮次线性累积,所以我把它做得尽量瘦——只统计数值列和 distinct ≤ 8 的枚举列,其它一律只给类型和长度范围。
还有一点我要说清楚:上面所有 token 数都是我那个线性估算模型算出来的,不是真实 tokenizer 跑的;6 轮追问的轨迹也是我按经验编的,不是真模型跑出来的对话。比例关系我有把握,绝对值你别直接拿去对账。
最后一句:这类优化很容易做成「越设计越聪明」。我中间有一版想加时间衰减、想把统计按主题合并,写完发现对单次会话毫无帮助——一份结果本来就是一次性的,没有冲突可合。删掉了。