我们的内部 Agent 平台用 DeepSeek V4 系列模型做推理主力,前端需要实时展示"思考过程 → 调工具 → 继续思考 → 给答案"的完整链路。这篇文章讲清楚三个工程问题:reasoning_content 怎么流式转发到前端、思考与工具调用交错时谁在编排、以及接入 Spring AI 时的几个坑。
一、背景:思考模式解决什么问题
DeepSeek V4 系列原生支持思考模式。开启后,模型在一次 HTTP 调用中即可完成:
- 内部推理(
reasoning_content)→ 流式输出思考过程 - 决策工具调用(
tool_calls)→ 执行工具 → 结果喂回 - 基于工具结果继续推理 → 输出最终答案(
content)
我们此前走过一条弯路:用应用层两轮调用模拟这个行为——第一轮不传工具让模型"纯思考",第二轮带工具让它执行。这个方案最终被放弃,原因有二:
- 无工具轮中模型总是倾向于直接给出答案,而不是规划工具调用——你无法通过 prompt 强制它"只思考不回答";
- 后来发现
reasoning_content和tool_calls的所谓"互斥"根本不是模型限制,而是没传thinking: enabled。开了思考模式后,模型在一次 API 调用中自然完成"思考 → 工具 → 再思考 → 输出"的完整循环,应用层的编排代码可以全部删掉。
教训值得记住:在应用层硬编一个 loop 之前,先确认模型协议层是否已经原生支持你要的行为。
二、开启思考模式
改动只有一行——在自定义 ChatModel 实现组装请求体的地方:
// com.example.agent.model.GatewayChatModel#buildApiBody
body.put("thinking", Map.of("type", "enabled"));
发给我们自建模型代理网关的请求体:
{
"model": "deepseek-v4-flash",
"stream": true,
"thinking": { "type": "enabled" },
"tools": [...],
"messages": [...]
}
不需要 reasoning_effort 参数——DeepSeek 默认 high,对复杂 Agent 类请求自动提升到 max。
三、reasoning_content 的流式转发
3.1 解析侧:借 finishReason 打标
SSE 流的每个 chunk 里,思考内容出现在 delta.reasoning_content 字段(与 delta.content 平级)。自定义 ChatModel 在解析时把它提取出来,包装成一个普通的 ChatResponse 帧,但用 finishReason = "thinking" 打上类型标记:
// 从 delta 中提取 reasoning_content
String rc = (String) delta.get("reasoning_content");
if (rc != null && !rc.isEmpty()) {
sink.next(new ChatResponse(List.of(new Generation(
new AssistantMessage(rc),
ChatGenerationMetadata.builder().finishReason("thinking").build()))));
}
这里的设计取舍:不为思考内容发明新的帧类型,而是复用 Spring AI 的 ChatResponse 管道、用 metadata 区分语义。好处是下游所有基于 Flux<ChatResponse> 的 advisor、日志、汇总逻辑不用动;代价是 finishReason 这个字段被"借用"了,语义上有点 hack,但在自有链路内可控。
3.2 出口侧:映射为独立 SSE 事件类型
下游构建 SSE 流时,按 finishReason == "thinking" 把思考帧格式化为 th(thinking)事件,与正文 t(text)事件区分开:
| SSE 事件 | 含义 | 数据来源 |
|---|---|---|
th |
思考过程 | delta.reasoning_content(finishReason=thinking) |
t |
正文 token | delta.content |
ts / tr |
工具调用开始 / 结果 | 工具调用 advisor 采集 |
d |
流结束 | [DONE] |
前端用 th 渲染灰色的"思考中"折叠区,t 渲染正式回答。思考与正文在同一条 SSE 连接里交错下发,时序与模型产出严格一致。
四、思考与工具调用交错:谁在编排
关键认知:thinking: enabled 模式下,DeepSeek 在同一轮 HTTP 请求中可以多次输出 tool_calls——相当于模型在一个请求里自己做了多步"推理 + 工具调用"。而工具的实际执行和结果回传,由 Spring AI 的 ChatClient(ToolCallingChatOptions + 内部 advisor)在框架层完成:
- 模型吐出
tool_calls→ Spring AI 拦截、本地执行工具回调 → 把结果以role: tool消息追加进对话 → 在同一连接内让模型继续生成; - 应用层没有自己的 ReAct while 循环。
也就是说,整个"思考 → 工具 → 再思考"的多步循环对应用代码是透明的,应用层只做两件事:解析时转发 reasoning_content、用 advisor 把工具调用过程(ts/tr)采集进 SSE 事件流。
完整时序(一次 HTTP 连接)
应用 ChatModel DeepSeek API 工具
│ │ │ │
│ ChatClient.prompt() │ │ │
│ .tools([list_fruits]) │ │ │
│ .stream() │ │ │
│──────────────────────────→│ │ │
│ │ POST /chat/completions │
│ │ thinking:enabled + tools │
│ │───────────────────→│ │
│ │ │ │
│ │ ① SSE: reasoning_content │
│ │ "用户想知道有哪些红色水果" │
│ SSE th: "用户想知道…" │←───────────────────│ │
│←──────────────────────────│ │ │
│ │ ② SSE: reasoning_content │
│ │ "可以用 list_fruits 工具查询" │
│ SSE th: "可以用…" │←───────────────────│ │
│←──────────────────────────│ │ │
│ │ ③ SSE: tool_calls │
│ │ [{name:"list_fruits", arguments:{}}]│
│ SSE ts: list_fruits │←───────────────────│ │
│←──────────────────────────│ │ │
│ │ Spring AI 执行工具 ────────────────────→│
│ │ 结果 "苹果(红),樱桃(红)" ←───────────────│
│ SSE tr: "苹果(红),…" │ │
│←──────────────────────────│ │
│ │ 结果以 role:tool 追加,模型继续(同连接) │
│ │ │ │
│ │ ④ SSE: reasoning_content │
│ │ "工具返回了红色水果:苹果和樱桃" │
│ SSE th: "工具返回了…" │←───────────────────│ │
│←──────────────────────────│ │ │
│ │ ⑤ SSE: content │
│ │ "根据查询结果,目前红色的水果有…" │
│ SSE t: "根据查询结果…" │←───────────────────│ │
│←──────────────────────────│ │ │
│ │ ⑥ SSE: [DONE] │
│ SSE d │←───────────────────│ │
│←──────────────────────────│ │ │
| # | 内容 | 前端事件 | 说明 |
|---|---|---|---|
| ① | reasoning_content: "用户想知道有哪些红色水果" |
th |
模型开始内部推理 |
| ② | reasoning_content: "可以用 list_fruits 工具查询" |
th |
模型规划工具调用 |
| ③ | tool_calls: [{name:"list_fruits"}] |
ts |
模型决定调用工具 |
| — | Spring AI 执行工具并回传结果 | tr |
应用层无感知 |
| ④ | reasoning_content: "工具返回了红色水果" |
th |
基于结果继续推理 |
| ⑤ | content: "根据查询结果…" |
t |
最终答案 |
| ⑥ | [DONE] |
d |
流结束 |
注意 ③ → ④ 之间没有新的 HTTP 请求——工具结果回传和继续生成都在同一条流式连接里完成,这是和应用层 Agent loop 最本质的区别:没有"断开 → 拼 messages → 重发"的往返,思考的上下文(reasoning_content)在连接内天然连续。
对既有架构的影响(改动面)
| 组件 | 变动 | 说明 |
|---|---|---|
自定义 ChatModel.buildApiBody() |
+1 行 | 加 thinking: enabled |
| 应用层推理服务 | 删约 100 行 | 移除"思考轮 / 工具轮"两轮编排代码 |
| SSE 事件分派 | 不变 | th/t/ts/tr/d 协议完全复用 |
| 前端 | 不变 | 事件协议无修改 |
五、坑与注意事项
1. reasoning_content 的多轮回传问题(最重要)。 DeepSeek 要求:模型如果在某个 turn 内调用了工具,后续 API 请求中必须完整回传该 turn 产生的 reasoning_content。而 Spring AI 的 ChatClient 走 OpenAI 协议,目前不会自动把 reasoning_content 塞进回传的 assistant 消息里——在多轮工具调用场景下,这可能导致 DeepSeek 报 400。如果你的链路是"SDK 自动拼 messages 重发"(而非同连接内续传),必须自己拦截并补上这个字段,或在自定义 ChatModel 层缓存并回注。接入时务必用"一轮思考 → 工具 → 再思考 → 再调工具"的 case 实测验证。
2. 模型兼容性。 thinking 参数仅对 DeepSeek V4 系列有效。如果 Agent 配置了其他模型(如 gpt-4o-mini),该参数会被忽略,不影响正常调用——所以可以放心地全局开启,不需要按模型特判。
3. 采样参数失效。 思考模式下 temperature / top_p / presence_penalty / frequency_penalty 会被服务端忽略。如果你依赖调温控制输出风格(比如创意写作 Agent),要注意思考模式会把它覆盖掉。
4. 思考内容的帧级时延。 reasoning_content 和 content 走同一条流,思考阶段往往持续数秒甚至更久。前端如果只渲染 content,用户会看到长时间空白——把 th 事件实时渲染出来既是体验需求,也是"系统没死"的存活性信号。另外整条 SSE 链路上要避免在中间环节引入会缓冲思考的阻塞点(比如 Flux.create consumer 里同步阻塞读会饿死背压,详见本系列另一篇《WebFlux + 虚拟线程》)。
六、小结
- 思考模式的正确打开方式是协议参数
thinking: enabled,不是应用层两轮编排; reasoning_content的转发技巧是:解析侧复用现有流管道、用 metadata(finishReason)打标,出口侧映射为独立 SSE 事件类型;- 思考与工具调用的交错由模型 + Spring AI 工具机制在一条连接内完成,应用层只做采集和转发;
- 最大的坑在
reasoning_content的多轮回传——SDK 不帮你做,多轮工具调用场景要实测。
本文涉及的代码已脱敏为示意结构,类名/包名与线上实现无关。