塑形工具返回值:工具契约里没人写下来的那一半。
一份工具定义值几百个 token,而且每轮只付一次;一次没有塑形过的工具返回值可以值四万个 token,并且之后的每一轮你都要为它再付一次。团队花好几周去精简 schema,然后把上游 API 恰好发来的东西原封不动交回去——那不仅是账单上最大的一条,还是上下文窗口里最大的一整块不可信文本。返回值配得上一份和参数一样严格的契约:一个由框架强制执行的字节预算、一份由工具作者挑定的字段投影,以及一个指向「没装下的部分」的句柄。
那处不对称:schema 有界,返回值无界。
一件工具的两半活在同一个上下文窗口里,而几乎全部的优化力气,都花在了那个不可能给你惊喜的一半上。
- schema 有一个已知的最坏情况。它是你写的。200 到 500 个 token,跑多少次都不变,而且它待在稳定前缀里,提示词缓存让它在第一次调用之后近乎免费。
- 返回值根本没有最坏情况。
list_issues在你的测试夹具里返回十一条,在那个提了工单的客户账号里返回一千四百条。大小是由你控制不了的数据、在你没盯着的那一天决定的。 - 返回值落在缓存边界之后,并被永远重发。每一次工具返回值都会追加进对话记录,并在之后每一步被重新提交。第 3 步取回的 40,000 token 负载,到第 12 步你还在为它付钱——这正是 智能体成本控制所描述的二次增长,而它几乎全部来自返回值而非定义。
- 那些被公开的战果,赢在返回值这一侧。那条从 Google Drive 到 Salesforce、token 从 150,000 降到 2,000 的工作流,靠的是把中间数据挡在上下文之外,见代码即动作。Anthropic 的 Tool Search Tool 削的是定义那一半,报告约 85% 的节省——真实,但那是另一半。
- 长返回值在耗尽窗口之前就先把模型弄坏了。长上下文中的检索准确率,远在标称上限之前就开始下滑,见有效上下文与标称上下文。一次 40k token 的倾倒不只是花钱,它还让模型更不会用那真正要紧的 800 个 token。
把这个比例贴在你看得见的地方:在一个成熟的智能体里,工具定义通常只占消耗 token 的个位数百分比,工具返回值才是大头。而优化的注意力,通常是反着分配的。
返回值是你会注入的、权限最高的不可信文本。
这就是塑形不只是一道成本习题的原因。参数由模型向外流,并在边界处被校验;返回值向内流,不经校验,而且是披着系统的权威到达的。
- 模型读到的、不是它自己写的东西,全从这里进来。issue 正文、网页、文件内容、邮件、CRM 备注、另一个智能体的输出。每一条有据可查的间接提示注入链路,中间某处都有一次工具返回值——这正是提示注入防御出发的那个机制。
- 没塑形,等于给攻击者无上限的预算。如果一件工具返回整篇文档正文,那么控制这篇文档的攻击者,就控制了你上下文里一段任意长的文字。对同一篇文档做一次 200 token 的投影,就把他们能在你提示词里说的话封顶在 200 个 token。
- 你不需要的那些字段,恰恰是伤到你的字段。API 响应里带着 HTML 描述、用户填写的标签、内嵌 URL 和自由文本备注,因为程序会无视自己不读的东西。模型无法无视一个字段。它要为它付钱,而且可能照着它行动。
- 来源标记必须活着走完这一程。把返回的内容裹进一个显式且一致的分隔符里,并标明它是来自某个具名来源的数据。这本身不构成防御——指令层级是建议性的,不是强制的——但如果对话记录根本没标出这条边界,那么一个被要求区分「内容」与「指令」的模型,压根就无从区分。
- 塑形是一项你能量化的控制措施。「每次工具调用中攻击者可控的 token 上限」是一个可以按工具、从它的投影与预算算出来的数字,它该和这件工具的权限放进同一场评审。
四个动作,按「有多值钱」和「有多少人真做」排序。
这些动作可以叠加,而且顺序有讲究:先做投影,意味着下游一切都在一个更小、更干净的对象上操作。
- 投影——一份由工具作者挑定的显式字段白名单。想清楚模型为了做出下一个决定,可能真正需要哪些字段,其余的在边界处丢掉。这是最大的一笔收益,也是几乎没人实现的一个,因为默认做法——把上游 JSON 原样递回去——一行代码都不用写。一个 GitHub issue 大约有四十个字段;一个做 issue 分诊的智能体需要六个左右。
- 先排序,再截断。只有当你丢掉的尾巴确实是最没用的那一段,截断才是安全的,而这要求你在下刀之前先给集合排序。截断一个没排过序的列表,等于赌答案有没有活下来。
- 用稳定的游标做分页。在一个会变动的集合上用 offset,会在跨轮之间悄悄地重复和漏掉记录。返回一个不透明的游标,并把还剩多少条明说出来,让「要不要继续」成为模型做的一个决定,而不是一次猜测。
- 递回一个引用。把完整负载存在对话记录之外,返回一个句柄加一段简短摘要,再给模型第二件工具去带着查询重新打开这个句柄。正是这个模式让 40k token 的产物变得可处理——和代码即动作利用的是同一处不对称,只是这里不需要沙箱。
- 当模型只需要知道「形状」时,就做聚合。「1,412 条 issue,87% 开在最近 30 天内,标签前三名是……」用三十个 token 回答了「这个仓库健康吗」。为了回答一个关于计数的问题而把行返回回去,是一个范畴错误,而工具作者比模型更有条件抓住它。
判断一次投影的一句话测试:去掉这个字段,模型还能做出下一个动作吗?能,就去掉——而如果你发现自己对大多数字段都拿不准,那说明这件工具管得太多了,修法在工具粒度那一页,不在这里。
静默截断,是塑形变成正确性缺陷的那条路。
下面每一种失败都同根同源:模型拿到的是一份被排版成「完整」的部分答案,而对话记录里没有任何东西提醒它。
- 绝不要从结构中间下刀。按字节偏移切开一段 JSON,产出的是一个片段,而模型仍会去解析它,并把收尾的形状编出来。请按记录边界截断并重新序列化;如果做不到,那就返回那条「已省略」的说明,而不是那个片段。
- 没有标记的截断,是一句关于完整性的谎话。「已处理全部匹配记录」是模型看着一份被截断的列表就会说出的断言,因为被截断的列表看上去和一份本来就短的列表一模一样。这个标记必须在模型读到的返回值里面,而不是在一行日志里。
- 把「省略了什么」和「怎么办」放进同一句话。
"showing 20 of 1412; call again with cursor=… for the next page"同时告诉了模型发生了什么、下一步做什么。"[truncated]"只告诉它「你失败了」,这与把错误消息当作提示所列的是同一个缺陷。 - 给工具设预算,而不是给请求设预算。一件返回 30k token 的工具,已经把整个步骤吃光了。由框架强制的按工具上限,胜过靠约定维持的按工具自觉,因为把预算撑爆的那件工具,通常是最后写的那件,作者从没读过这一页。
- 把丢掉的东西记下来。当一次评测回退时,「返回值在 4k 处被截断,而匹配的那条记录在第 63 位」就是全部诊断——而只有当 span 里记录了塑形前的大小和下刀位置,你才说得出这句话。
给所有工具一个统一信封,胜过给每件工具一个精巧形状。
一致性比逐件工具的最优更值钱,因为模型的恢复行为,是从它读到的东西的形状里学来的。一个统一的信封意味着:一套习惯,覆盖你今后会加的每一件工具。
# Same five keys from every tool, always in this order. { "status": "partial", # ok | partial | empty | error "summary": "20 of 1412 open issues, sorted by last update", "data": [ /* projected records only — six fields, not forty */ ], "omitted": {"count": 1392, "reason": "result_budget"}, "next": "cursor:eyJvIjoyMH0" # null when nothing remains }
summary是最先被读的,而且常常是唯一被读的。把它写给一个会照着这一行就动手、根本不去解析data的模型看——因为在一段长对话记录上,实际发生的恰恰就是这个。status: "empty"不是status: "error"。把两者混为一谈,正是智能体会把一条正确的查询重试五次的原因。零结果是一项发现;说清楚,然后停。omitted让看不见的东西现形。模型现在可以去推理:那 1,392 条没看到的记录会不会改变它的答案——而当省略未被记录时,这个判断它根本做不了。- 信封属于框架。请在分发工具调用的那一层强制它,好让一件新工具无需作者主动选择,就继承到预算、标记与游标。在 MCP 工具设计的服务端一侧,同一条规则成立,只是再往外一层。
- 凡是只给模型读的东西,散文胜过 JSON。只有当真有程序去解析它时,结构化输出才配得上那些括号。如果返回值只有模型消费,一句紧凑的话能用更少的 token 传达同样的信息,也更少解析出错的机会。
把它装进你真正拥有的那份预算。
塑形是一个按工具做出的决定,但对着的是整段运行的预算,而这些数字应当被写下来,而不是在生产环境里被发现。
- 从整段运行出发,而不是从单次调用出发。先决定在一段长运行的末尾,窗口里最多有多大比例可以是工具返回值——三分之一是一个站得住的起点——再除以预期调用次数,得到单次调用的上限。这套算术住在上下文预算那一页。
- 先测量,再调优。在每一条 span 上,按工具记录塑形前与塑形后的 token 数。那两三件吃掉你大部分账单的工具,一天之内就会自己显形,而它们很少是任何人事先怀疑的那几件。
- 塑形在压缩之前,而不是替代压缩。压缩是一次有损的补救,补的是「已经放进来太多」;塑形则从一开始就不让它进来。两个都要,而把前一个做好,会让后一个变得罕见。
- 换模型时重做塑形。为一个能忍受极简字段的模型调出来的投影,换到另一个模型上可能就信息不足。预算与投影是配置,不是常量,它们该和提示词进同一场评审。
- 把一件新工具的返回值当作一处新的攻击面。在它上线之前先问:一个控制着上游数据的对手,能往你的上下文里写进什么,能拿到多少个 token。如果答案是「整篇文档」,那说明投影还没写。
请按最划算的顺序来做:这一周先在每件工具前面放上一个统一信封和一个由框架强制的按工具上限,在每条 span 上记录塑形前后的 token 数,然后为那两件最终被证明主导账单的工具写出显式的字段白名单。通常第一遍就能砍掉你返回值 token 的大半,而同一处改动也顺手给「一篇攻击者可控的文档能在你提示词里说多少话」封了顶。参数之所以有 schema,是因为模型不被信任去产出它们;返回值配得上一份 schema,理由一模一样,只是方向相反。
延伸阅读:模式、契约与默认值讲的是这份契约里已经被写下来的那一半,工具设计反模式讲的是本页要替换掉的那些形状,上下文工程讲更大的那份预算,而上下文压缩讲的是当塑形还不够时你仍然需要什么。