离谱,每轮请求 25% 的 token,竟在重发模型想完就扔的内心独白

0 阅读13分钟

前言

用 DeepSeek 这类思考模型跑 coding agent,有个成本黑洞很多人没注意:模型的思考过程,一直在跟着每一轮请求付费旅行。

事情是这样的。思考模式下,模型每次回答前都会先写一大段内心独白(API 里叫 reasoning_content)。而 agent 不是聊天——它每完成一轮"推理→调工具→拿结果",下一轮都要把整个上下文重新发一遍给模型。于是模型历史上每一轮的思考,都被拖着反复重发。我拿真实会话一统计:这些"想过就扔"的独白,占了每轮请求体积的 ~25%。

官方文档说这部分必须回传,不传就报 400。所以很长一段时间里我照办了。直到这周,我们用两根探针、十几条真请求、几分钱成本,把这条规矩掀了——历史思考不再回传,请求瘦了四分之一,中途还被生产环境连打三次 400。这篇文章把整个过程讲清楚:怎么证的伪、怎么落的地、怎么翻的车、又怎么根治的。

一、思考内容是怎么混进每一轮请求的

先自包含地交代背景。DeepSeek 开思考模式后,模型的输出分两股:一股是 reasoning_content(内心独白,"用户让我算 2+2,我应该调 calc 工具,参数这么填…"),一股是 content(最终回答)。思考内容只在生成那一刻有价值——吐给前端折叠展示完,它的历史使命就结束了。

但 agent 的架构决定了它甩不掉。每一轮推理,模型都可能决定调工具;带工具调用的回答,思考部分往往还特别长(想清楚调什么、参数怎么填,全是独白)。这些回答要落盘进会话历史,而会话历史在下一轮要原样重发给模型。工具密集的任务里,assistant 轮几乎轮轮带工具调用——思考就这么一轮轮滚雪球。

更麻烦的是,这东西当初不能随便扔。我们吃过一次真实的 400:请求里带工具调用的历史消息缺了 reasoning_content,API 直接拒绝,报错原文是——

The reasoning_content in the thinking mode must be passed back to the API

官方文档也是这个口径:必须"完整回传"。从那以后,每条落盘消息都老老实实驮着自己的思考,回传链路一个字段不敢少。

注意,"落盘要带"和"回传要带"是两码事。落盘侧的思考内容有真用处:UI 折叠展示靠它、历史召回(recall)检索靠它。真正的问题只在回传侧——模型下一轮真的需要再看一遍自己上一轮、上上轮的独白吗? 文档说要,可文档没说清"完整回传"指的是全部历史,还是只有最近一轮。这一字之差,就是 25% 的账。

二、先算账:这 25% 从哪算出来的

动手之前先量化。写了个脚本 reasoning-share.ts,扫真实会话的落盘数据,按两个口径量历史思考在 prompt 里的占比:

口径算法回答的问题
lastView活动窗口内,思考字符量 ÷ 全部进 prompt 的字符量最后一轮请求长什么样
burden每条消息按"其后还有几轮推理"加权反复重发的累计代价

两个口径算出来同一个量级:~25%。等于每花一块钱,有两毛五在为模型已经想过、再也不看第二眼的独白付路费。

但这里当时看是个死结。DeepSeek 有前缀缓存:上一轮发过的上下文,下一轮前缀没变就走缓存,单价约一折。历史思考一旦进过前缀,后续轮其实在一折价随份子;这时候从中间把思考裁掉,前缀从被裁那条消息起全部击穿,缓存红利一次清零。在"必须回传"的世界观里,这钱省了也白省。

想破局只有一条路:去证明"必须回传"本身不成立。

三、第一根探针:拿真请求问协议

文档模糊,就问 API 本尊。探针 reasoning-passthrough-probe.ts 的思路简单粗暴:编一段两轮工具调用的思考对话(第 1 轮当地历史、第 2 轮当最近一轮),按不同剥法各发一次真请求,看谁 200 谁 400:

变体剥法在问什么
V0全部回传基线——这条都炸说明探针自己搭错了
V1只剥历史轮、留最近轮最小回传集是不是"只看最近一轮"
V2剥历史轮 + content 兜底空串400 是不是卡在消息形态上
V3全剥协议是不是根本不强制
V4全剥 + content 兜底空串最激进的安全形态

成本:每条请求 1-2K token,五条加起来几分钱。

这根探针自己还差点翻车,值得单独讲。第一版伪造对话时,把 assistant 工具轮的 content 随手写成了空字符串——四个剥法变体全 200,眼看就要收工宣布"协议不强制"。复查当初那次真实 400 的案底才发现:生产里纯工具轮的消息 contentnull,不是空串。也就是说探针压根没测产品的真实形状,那几个 200 是在一条没人走过的路上得出来的。

于是把 content 形态钉成第二个变量重跑。这次结论才作数:产品真实形态下,剥历史轮 200,全剥也 200——协议实际不强制回传任何历史思考。

探针必须复刻产品的真实 wire 形态。content: ""content: null 在你眼里差不多,在服务端校验器眼里是两个世界。

四、落地:只在出站口剥,落盘一根毛不动

既然历史思考可以不传,就在 API 请求的出口加了个剥离器 stripHistoricalReasoning。代码十来行,但立了三条规矩:

规矩一:出站剥,落盘不剥。 会话历史是唯一事实源——UI 展示、recall 召回、会话恢复全靠它身上的思考字段过日子。所以剥离走拷贝后删除:只拷带思考的 assistant 消息、只删这一个字段,其他消息原对象透传,入参数组一个字节不动。

  1. 敲黑框:「剥离不在消息身上动刀,只在每次出站时拍照」——上下文里的 assistant 永远是带思考的完全体,模型每次收到的只是剥过的照片,拍完即弃;
  2. 守护轮时间线:草稿落盘(带思考)→ 守护轮请求里以剥过的副本出场 → 结论落盘(还是带思考)→ 磁盘上两条 assistant 各自带妆,模型后续再没见过它们的思考,「见过的只是照片」。

规矩二:两个出站口都过一遍。 流式对话和压缩摘要是仅有的两个把消息数组发给 API 的地方。压缩摘要那处尤其值——它是一次独立请求,吃不到对话缓存的折扣,全程全价,在那里省下的每一分都是不打折的钱。

规矩三:留逃生舱。 环境变量 DEEP_SEEK_REASONING_PASSTHROUGH=1 一键还原旧行为。对协议下的判断,永远要留一条"判断错了能原路返回"的路。

收益立竿见影:每轮请求瘦 ~25%,上下文窗口同步变松,压缩触发得更晚。压缩不是免费操作——每次压缩都是一轮上下文的有损代际更替,能晚一次是一次。

到这里,本该是篇爽文。

五、翻车:上线当天,守护轮连吃三个 400

真实剧情是:上线当天,用户会话连吃三次 400,报错还是那句老话——thinking 模式的 reasoning_content 必须回传。

三次的位置高度一致,全在同一个特殊场景:守护轮。解释一下这个东西——agent 有套防模型"草草收尾"的机制:发现模型像是要敷衍收场时,就在请求尾部临时挂一条"自检一下,任务真完成了吗?没完成继续干"的提醒,推模型续写。这条提醒是推理时的临时副本,不落盘。

守护轮的请求形状长这样:

[.., 历史消息(已剥), 草稿 assistant, 尾部临时提醒(system)]   ← 守护轮:400
[.., 历史消息(已剥), 工具结果 / 用户新输入]                  ← 正常轮:200

和正常轮唯一的差别在请求尾巴的形状。剥离器对形状一视同仁地剥——偏偏服务端对"尾巴是 assistant"的请求,校验口径完全不同。

六、第二根探针:锁死「形状」,然后改掉它

第二根探针 reasoning-guard-probe.ts 冲着这个悬案去,六发子弹把"尾巴形状"逐个隔离:

变体请求尾巴结果
W1user(正常轮对照)✅ 200
W2assistant 正文草稿❌ 400
W3assistant 草稿 + 尾部 system 提醒(守护轮原形状)❌ 400,案底复现
W4历史剥 + 草稿留(混合态)❌ 400
W5assistant 工具轮结尾❌ 400
W6全带(旧行为)✅ 200

结论钉死了:只要请求的"最后一条非 system 消息"是 assistant(推模型续写的场景),服务端就切换到严格校验——历史里任何一条 assistant 缺思考字段都 400,混合状态也炸,全带才 200。 其余形状(以 user/工具结果结尾的正常轮)走宽松校验,全剥随便。

回头看,当初立的"必须回传"没有错,只是缺了半句:它是续写场景的规矩。 平时每轮请求都以工具结果或用户输入收尾,校验本来就松;只有守护轮这个形状落进严格档。

解法顺势出来:剥离器加一档。从尾部往前扫,最后一条非 system 消息是 assistant 就整请求原样回传(旧行为,生产验证过),否则照常剥。

但这只是兜底,它有个堵不上的洞:守护轮的草稿是模型刚吐出来的消息——模型简答时根本没有思考过程(生产实测有轮次思考是 0 字符),草稿天然不带 reasoning_content。严格档的本事是"把已有的都保留",保不了"本来就没有"。

所以根治不在剥离器里,在形状上。挂提醒时判断一下:上一条消息是 assistant,就把提醒的角色从 system 改写成 user——

const withNudgeTail = (base, nudge) => {
    if (!nudge) return base;
    const lastRole = base[base.length - 1]?.role;
    return [...base, lastRole === "assistant" ? { ...nudge, role: "user" } : nudge];
};

请求以 user 结尾,落进宽松档,剥离全线生效,严格档连同它的洞一起消失。守护轮的形状对照着看:

改前:[.., 历史消息(已剥), 草稿 assistant, 尾部临时提醒(system)]   ← 严格档:400
改后:[.., 历史消息(已剥), 草稿 assistant, 尾部临时提醒(user)]    ← 宽松档:200,剥离照常

这个改写理直气壮:提醒说的是"自检、继续干",用用户的口吻讲完全成立,模型不在乎说话的是谁;提醒本来就不落盘,改的只是发出去那一刻的形状;请求真实地变成了宽松形状,服务端按它自己的规则给出宽松待遇——不是绕,是换了一条本来就通的路。

七、代价与账本

诚实交代这笔改动的另一面:

  • 赌协议有风险。 "协议不强制"是今天用探针证出来的事实,不是合同条款。所以逃生舱 env 变量一直在,哪天服务端收紧校验,一键回旧行为;
  • 形状敏感。 严格档的判定依赖"尾巴形状"这个观察结论,以后任何往请求尾部追加消息的新功能,都得想一想自己把尾巴变成了什么——190 行回归测试把已知形状全部钉死,就是防这个;
  • 缓存不受伤,前提是从会话头就剥。 剥离是确定性的、每轮请求都一致执行,会话内前缀依旧逐轮稳定——变的只是"稳定的那份前缀比原来小了 25%"。真正会打穿缓存的是中途改变口径,这一点靠"出站口统一剥离"保证。

收益端再报一遍账:每轮请求瘦 ~25%;压缩更晚触发(省的是有损代际更替的次数);压缩摘要全价输入同步变小;UI 折叠展示、历史召回、会话恢复——零变化,模型侧卸掉的负担,人侧一点没少。

结语

整个故事最值钱的不是那 25%,而是一次心态转变:文档里"必须"两个字,值得花几毛钱追问一句——问的是全部历史还是当前一轮?问的是你发的请求长没长成它说的那个形状?

当初立规矩靠一次真实 400,这周改规矩靠两根探针加三次生产翻车。协议约束是有适用条件的,而适用条件写在服务端的校验器里,不在你的想象里。探针的子弹便宜,但两样都别浪费:打探针要用产品的真实形状,吃了 400 要接得住、追问到底——三次翻车没有白吃,它们把"宽松"和"严格"的分界线钉在了代码注释里。

项目已经开源:github.com/xknk/deepSe…,剥离器在 src/core/src/llm/providers/deepseek/stream.ts,两根探针在 scripts/ 下,欢迎对着源码看。觉得这套"拿真请求问协议"的思路有点意思,点个 star 就是对我最大的鼓励。

总结

  1. 成本黑洞:思考模型的内心独白落盘后随每轮请求反复重发,实测占请求体积 ~25%;官方文档称"必须完整回传否则 400",但没说清"完整"指全部历史还是最近一轮;
  2. 探针问协议:第一根探针五变体证明正常轮全剥也 200(顺带钉死 content:null"" 的形态差异——探针必须用产品真实形状);在"必须回传"世界观下这笔钱因前缀缓存省不掉,所以证伪是省钱的前提;
  3. 出口剥离三规矩:只在出站口剥(落盘/UI/召回零感知)、两个出站口全覆盖(压缩摘要是全价请求省得最实)、env 逃生舱可回退;
  4. 翻车与根治:守护轮(推模型续写的形状)触发服务端严格校验连吃三个 400;第二根探针六变体钉死分界线——"最后一条非 system 消息是 assistant"即严格档;根治靠把尾部提醒从 system 改写为 user,让守护轮落进宽松档,剥离全线生效;
  5. 方法论:协议约束有适用条件,写在服务端校验器里而非文档里;几毛钱的探针买确定性,生产翻车买边界——两样都别浪费。