把推理一路带过工具调用

12 分钟读完

N8
深入解析 · 推理与测试时计算

把推理一路带过工具调用。

你的外壳几乎肯定会在每一轮重建一次消息列表——塞进一句提醒、删掉一条旧消息、加上用户刚启用的那个工具——而在当下这批推理模型上,这个习惯如今要么在任务中途悄无声息地删掉模型自己的推理,要么直接返回 400。推理不再是那种你可以丢掉的产出,而变成了一个带签名、不透明、且必须原样交还的输入。三家厂商做出了这条规则的三个互不兼容的版本,而会坏掉的那段代码,正是你在 2024 年写下、此后再没想过的那段。

STEP 1

推理曾经是一样你读完就扔的东西。

最初那套心智模型很简单,而且在当时是对的:模型吐出一段思维链,你把它显示在调试面板里,然后只把结论送回去。推理是产出。下游没有任何东西依赖它,剥掉它还能省 token。

如今在每一家主流 API 上,这套模型都错了,而每家厂商打破它的方式还各不相同:

  • Anthropic。推理装在 thinking 块里,每一块都带一个 signature——完整推理的一份加密副本。当你返回工具结果时,必须把那条助手消息里的 thinking 块完整、未经修改地一并送回,与它们所伴随的 tool_use 块放在一起。
  • OpenAI。Responses API 把推理建模为推理条目。要么靠接续上一次响应把它们留在服务端,要么——如果你无法在服务端留存数据——索取加密的推理内容,由你自己把那个 blob 送回去;它会在下次调用时于内存中解密,且从不写盘。
  • Google。Gemini 3 返回思维签名,即推理的加密表示,并在函数调用期间强制要求把它们送回:漏掉,请求就会以校验错误告终,而不是降级运行。

三个不同的名词,一条共有的性质。推理不再是你为人类保留的一份文字记录。它是一块对话状态,钥匙只在厂商手里,而它的完整性如今归你的智能体循环负责。

留意这件事打谁最疼。一个聊天产品发一条消息、收一个答案,那段思考根本不必在任何东西里存活下来。而一个智能体外壳每隔几秒就穿插一次工具调用,这意味着它在一项任务里要跨过那条要紧的边界几十次。这个特性正对着智能体来,失效模式也是。

STEP 2

在一个助手回合内部,一次工具调用是一次暂停——不是一个结尾。

这些块必须回来的理由是结构性的,而不是官僚性的。模型调用工具时,它并没有把自己的回应写完;它是把回应挂起了,去等一份信息。工具结果回到 API 时长得像一条用户消息,但它不是一个新回合——模型是在接着写它已经开了头的那同一份回应,而它先前的推理,正是那份回应中你替它拿着的那一部分。

把它剥掉,你省下的不是 token。你是把一次深思熟虑,换成了一串彼此独立的猜测,每一次都从可见消息里能捞回什么就从头重推什么。那句「先查发票,再查账簿,两边不符就停下上报」的计划,在账簿结果回来时已经不在了;剩下的是一个盯着账簿结果、却不记得自己当初为什么要它的模型。

在交错推理下这点更尖锐——模型是在多次工具调用之间思考,而不只在第一次之前;这在当下这批自适应思考模型上是自动的,也正是这个特性的全部意义。那段对第 3 条工具结果作出反应、并决定第 4 次调用的推理,就住在它们之间的一个块里。那个块不是装饰;它是关节。

User:      Reconcile invoice 8841 against the ledger.
Assistant: [thinking] + [tool_use: get_invoice]
User:      [tool_result: ...]
Assistant: [thinking] + [tool_use: get_ledger_entries]   <- reacts to the result
User:      [tool_result: ...]
Assistant: [thinking] + [text: they disagree by $40; escalating]

Every [thinking] above goes back on every subsequent request,
complete and unmodified, or the turn is no longer one turn.

这些 token 也没白花。在「全保留」型号上,这些块留在上下文里,像任何其他历史一样计入输入 token;在另一些型号上,API 只保留当前模型需要的部分并据此计费。无论哪种,「留什么」的决定权归 API,而不归你那段裁剪代码——这正是下一个问题。

STEP 3

前缀规则把寻常的中间件变成了一次破坏性操作。

这里是会弄坏在跑的系统的那一节。一个带签名的推理块,只在产生它的那段一模一样的对话里有效。Anthropic 把条件说得很准:只要顶层系统提示词、工具定义,以及它之前的每一条消息都没变,这个块就保持有效。其中任何一样变了,这个块——以及它之后的每一个 thinking 块——就失效了。随后请求要么以 400 失败,要么把失效的块丢掉,取决于你配置了哪种行为。

现在列一列一个成熟外壳在一个寻常回合里都干些什么。下面每一件都是一次前缀编辑:

  • 往系统提示词后面追加一句提醒。「你还剩 4 步。」「该用户是企业版。」这是世上最常见的那一件智能体中间件。
  • 运行途中增删工具。用户接上一个集成、一项权限被授予、你为省 token 收窄了工具集。工具清单是被签名的前缀的一部分。
  • 裁剪或摘要旧消息。你那一步压缩从设计上就在改写历史。而那正是这条规则所禁止的操作。
  • 改动努力度或思考配置。为一个困难子任务把努力度调高,碰的是同一段前缀,另外还会把你的缓存重来一遍。
  • 把消息数组重新序列化。从你自己的数据库重建对话,而不是把 API 返回的东西原样回送。字段顺序、被丢掉的未知键、被规范化的空白——任何一样都可能算数。

替代手段是存在的,而它们正是真正的迁移工作:把新指令作为一条对话中的系统消息发出,而不是靠编辑顶层提示词;用 API 自己的工具变更块来增删工具,而不是靠重写工具清单;按消息改努力度,而不是全局改;用服务端压缩或上下文编辑,而不是自己改写历史。这条规则的形状很简单——只追加,并把收到的东西原样回送——而多数外壳当初不是照这个写的。

要刻意选定失效模式。在你迁移期间,不匹配就报错是吵闹而正确的;悄悄丢掉失效的块能让运行活下去,但那意味着一次你在日志里永远看不见的细微退化。Anthropic 的「保留推理」控制项让你自己挑,并且还会返回一份 API 丢掉的块的清单——那是这里最接近一个指标的东西,在你需要它之前就把它送进你的 trace 里。

STEP 4

那些安静的失效:一个开关、一次降级、一次换模型。

吵闹的那种失效是一个 400,你一小时内就会修好。昂贵的那些,什么都不会抛出来。

在回合中途切换思考。如果配置在「发出工具调用」与「返回其结果」之间变了,API 不会报错——它会为那次请求悄悄关掉思考,并且可能剥掉那些会让回合结构变得不合法的块。你的智能体在推理被关掉的情况下把任务做完了,而任何地方都没有一句话说过这件事。唯一的检验是响应里有没有 thinking 块,也就是说,这是一项得由你自己写的检查。

降级到另一家厂商或另一个模型。一个推理块,只有产生它的那个模型或更新的模型读得了,更旧的读不了。于是切换的方向决定了什么能活下来:往新模型上切,会把这段对话的推理带过去;往旧模型上切则会丢掉它——不报错,也不为丢掉的那些块向你计费。因此每一条过载降级路径、每一个省钱的路由器,同时也是一次关于推理连续性的决定,而做这个决定的人通常想的是延迟。

续跑一次运行。一个会持久化状态、崩掉、再续跑的持久智能体,必须把那些助手回合逐字节原样存下来,包括那些文本为空的块。如果你的持久化层会规范化消息——多数都会,因为它们当初是为聊天记录写的——那么续跑起来的运行,一上来推理就是失效的。这是持久化执行设计上的一条真实约束,而且很容易漏掉,因为崩溃测试是过的:运行确实接着跑了,只是接着跑得更笨了。

一条能抓住其中大部分的廉价不变式:在你构造的每一次请求上断言——你送回去的每一条助手消息,与 API 返回的那一条逐字节相同。如果你的代码做不出这个断言,那它就是在改什么东西,而你现在知道该往哪儿看了。

STEP 5

你被要求留着它,而你已经读不了它。

这次转变的后一半得到的关注更少,也更该得到关注。在当下这批 Anthropic 模型上,thinking 的默认显示设置是omitted:块回来时 thinking 字段是空的,而签名携带着加密的推理以维持连续性。你因此拿到更快的首个文本 token,也拿不到任何可看的文本。OpenAI 的加密推理内容与 Gemini 的思维签名,从构造上同样是不透明的。

由此生出三个后果,值得挑明说,因为它们落在团队自以为握着的那些控制上:

  • 「我们会复核模型的推理」不是一项你默认就有的控制。它是一个你必须逐请求打开的选项,而且你打开后拿到的可能是一份摘要而非原始轨迹。这是个重读思维链忠实性的好时机:那段推理从来就不是对实际计算的可靠交代,而如今它连「可靠地看得见」都不是了。
  • 你的轨迹存储会被你审计不了、却必须留存的不透明 blob 填满。签名是运行得以续跑所必需的,它对你的复核人员毫无意义,而它依然是你在持有的数据。你的脱敏流水线没法查看它,所以要按它是从什么派生出来的——那段对话——去定它的留存,而不是按你能从中看见什么去定。
  • 基于推理的评测需要显式索取。如果你的过程评测要给模型陈述出来的推理打分,它就必须设置那个显示选项并接受延迟代价,而且应当记下是哪个设置产生了这条轨迹。否则你是在拿「模型隐形推理」的运行去和「模型把推理留在记录里」的运行作比,还把两者之差叫作回归。

这一切背后有一套站得住的逻辑——推理是厂商不希望被反向摄取、被编辑或被伪造的生成内容,而加密是「既要连续性又不要暴露」的实现方式。它仍然是一次转移:模型的中间工作变成了一件由你携带、由你付费、且你无法查看的持久产物。

STEP 6

它要花多少钱,以及由此落下的六条规矩。

这笔经济账并不中立,而且大体上是站在「把它做对」这一边的。

在工具调用循环里,当你发送工具结果时,thinking 块会连同对话的其余部分一起被缓存——自动发生,无需显式的缓存标记——这正是为什么被保留下来的块会在一次多步运行中抬高缓存命中率,而不是压低它。反面是:那些你再也见不到的块,从缓存读出来时仍计入输入 token。而对思考或努力度配置的任何改动,都会被渲染进提示词本身,于是它开启一段新的缓存前缀;把一次努力度变更当作缓存从头再来,并去看缓存经济学,算算这在一次长的智能体运行上值多少钱。

规矩如下:

  • 回送,绝不重建。把助手回合按收到的样子存下来、原样送回。新内容一律追加在末尾。把消息数组当作一份只追加的日志,因为它现在就是。
  • 把你的中间件挪到受支持的机制上。提醒用对话中的系统消息,工具集用工具变更块,努力度按消息改,裁剪用服务端压缩。如果某件你在做的事,厂商没有提供受支持的路径,那是一条设计约束,不是一处绕过去就行的小麻烦。
  • 开发期吵闹地报错,生产期把丢弃数量埋点。把每次运行中失效或被丢弃的推理块计数,作为一等公民指标记录下来。这个数字上升,是一次没有任何评测会告诉你的外壳回归。
  • 让换模型这件事对推理是显式的。搞清楚你的降级往哪个方向走、会丢掉什么。如果一次运行的推理挺不过这次切换,宁可干净地重开这一回合,也别带着一个被做了脑叶切除的回合往下走。
  • 当你以为思考开着的时候,就去断言它开着。在推理承重的那些运行上,对响应里的 thinking 块做一次存在性检查。三行代码,抓住的是那个「悄悄被关掉」的情形。
  • 暂时别为这件事造一层可移植的抽象。三家的模型在「谁持有状态」「什么会使其失效」「缺失时是否报错」上都不同。一个取最小公分母的包装层,会恰好把那些咬人的差异糊过去。按厂商各写一个适配器、把差异明明白白留在代码里,会活得更久——这与努力度预算对参数层得出的结论是同一个。

今天就用一个查询把这件事审一遍,别搞成一个项目:取一批生产运行的样本,数一数你送回去的助手消息里,有多少条与 API 返回的那条在任何一个字节上不同。零,说明你没事,可以不用往下读了。任何大于零的数,就是你的外壳在每次运行里删掉或作废模型推理的次数——而它几乎肯定来自一行中间件,那行代码在这一切出现之前就已经躺在那儿了。

相关:自适应思考与努力度预算讲控制「产出多少这类东西」的那个旋钮,上下文预算讲留着它要花多少钱,以及模型迁移——那一天你终归得在飞行途中换模型。