把对话历史写进数据库时,字段一般是挑着存的:role、content,再加上 tool_calls 和 tool_call_id,够回放就行。DeepSeek 思考模式下的 assistant 消息还多一个跟 content 平级的 reasoning_content,装的是思维链。只要带着 tools 参数,这个字段哪一轮漏存了,后面的调用就会被拒。
网上贴出来的报错里有 content[].thinking ... must be passed back to the API 这样的字样,意思是思考内容必须原样传回给接口。
文档里的规则
思考模式文档对回传写得很清楚,分两种情况:
- 请求带了
tools:之前所有轮次的reasoning_content都要完整回传,接口会把它拼进上下文。哪怕某一轮模型没真正调工具,也得传。没传对,接口返回 400。 - 请求没带
tools:不需要回传,传了也会被忽略,不会拼进上下文。
这也解释了为什么同一套聊天代码,普通问答一直正常,加了个工具就开始报错。还有一点容易漏看:按文档,思考模式本来就是开着的,强度档位是 high。没主动开过思考的客户端,照样会收到 reasoning_content。
「新会话第一个问题就报错」也说得通。文档的示例里,一个用户问题会拆成好几次子请求:模型先调工具,拿到结果再接着推理。从第二次子请求起就得带上前面的 reasoning_content,用不着等用户问下一个问题。
流式输出要两份都攒
流式最容易漏。文档的流式示例是把两种增量分开累加,最后拼成一条 assistant 消息放回去,大意如下:
reasoning_content, content = "", ""
for chunk in response:
delta = chunk.choices[0].delta
if delta.reasoning_content:
reasoning_content += delta.reasoning_content
elif delta.content:
content += delta.content
messages.append({
"role": "assistant",
"reasoning_content": reasoning_content,
"content": content,
})
带工具调用的流式还得把 tool_calls 的增量也拼起来,这段示例里没写,按 SDK 的方式补上即可。
一般是哪一层弄丢的
- 自己拼 assistant 消息时只取了 content 和 tool_calls。文档推荐直接
messages.append(response.choices[0].message),等价于把 content、reasoning_content、tool_calls 三个字段一起放回去。 - 流式输出时只累加了
delta.content,delta.reasoning_content被扔掉了。 - 会话表里压根没有这一列,存的时候就没了,读回来自然是空的。
- 中间的代理或网关为了省 token,顺手把思维链清掉。不带工具时这么做无害,带上就出事。
走 Anthropic 格式接入的话,DeepSeek 的兼容说明里,type 为 thinking 的内容块标的是支持,redacted_thinking 则不行。报错里那个 content[].thinking 看上去就是这种格式的路径,历史消息里的 thinking 块应该也要原样带回去。这一条文档没有单独写,是我按 OpenAI 格式那边的规则推的。
表结构加一列存 reasoning_content 之后,老会话的历史里还是没有这个字段。这些对话接着带工具往下聊,照样会撞上 400。比较省事的办法是让用户另开一个新会话。思维链有时很长,落库前想想存储空间和保留期限。这部分数据也可能含用户输入的敏感信息,按普通对话内容的标准管理就好。
怎么确认
抓一次真实请求体最省事。看出错那次请求的 messages 里,上一条 assistant 消息还有没有思考内容:OpenAI 格式查 reasoning_content 字段,Anthropic 那边看 content 数组里有没有 thinking 块。不在的话,顺着存储、序列化、代理一层层往回找,看是谁删的。
一时改不完存储层,可以先把带工具的请求切到非思考模式。Chat Completion 接口的开关是 {"thinking": {"type": "disabled"}},走 OpenAI SDK 时要放进 extra_body。代价是工具调用前少了一段推理,效果好不好得自己测。
你们存会话历史时,思维链是整段落库,还是只在内存里留着?