我的 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 轮追问的轨迹也是我按经验编的,不是真模型跑出来的对话。比例关系我有把握,绝对值你别直接拿去对账。

最后一句:这类优化很容易做成「越设计越聪明」。我中间有一版想加时间衰减、想把统计按主题合并,写完发现对单次会话毫无帮助——一份结果本来就是一次性的,没有冲突可合。删掉了。

添加新评论

文章目录