我们的内部 Agent 平台用 DeepSeek V4 系列模型做推理主力,前端需要实时展示"思考过程 → 调工具 → 继续思考 → 给答案"的完整链路。这篇文章讲清楚三个工程问题:reasoning_content 怎么流式转发到前端、思考与工具调用交错时谁在编排、以及接入 Spring AI 时的几个坑。


一、背景:思考模式解决什么问题

DeepSeek V4 系列原生支持思考模式。开启后,模型在一次 HTTP 调用中即可完成:

  1. 内部推理(reasoning_content)→ 流式输出思考过程
  2. 决策工具调用(tool_calls)→ 执行工具 → 结果喂回
  3. 基于工具结果继续推理 → 输出最终答案(content

我们此前走过一条弯路:用应用层两轮调用模拟这个行为——第一轮不传工具让模型"纯思考",第二轮带工具让它执行。这个方案最终被放弃,原因有二:

  1. 无工具轮中模型总是倾向于直接给出答案,而不是规划工具调用——你无法通过 prompt 强制它"只思考不回答";
  2. 后来发现 reasoning_contenttool_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 的 ChatClientToolCallingChatOptions + 内部 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_contentcontent 走同一条流,思考阶段往往持续数秒甚至更久。前端如果只渲染 content,用户会看到长时间空白——把 th 事件实时渲染出来既是体验需求,也是"系统没死"的存活性信号。另外整条 SSE 链路上要避免在中间环节引入会缓冲思考的阻塞点(比如 Flux.create consumer 里同步阻塞读会饿死背压,详见本系列另一篇《WebFlux + 虚拟线程》)。


六、小结

  • 思考模式的正确打开方式是协议参数 thinking: enabled,不是应用层两轮编排;
  • reasoning_content 的转发技巧是:解析侧复用现有流管道、用 metadata(finishReason)打标,出口侧映射为独立 SSE 事件类型;
  • 思考与工具调用的交错由模型 + Spring AI 工具机制在一条连接内完成,应用层只做采集和转发;
  • 最大的坑在 reasoning_content 的多轮回传——SDK 不帮你做,多轮工具调用场景要实测。

本文涉及的代码已脱敏为示意结构,类名/包名与线上实现无关。