切换主题
L03:DeepSeek 调用与响应包装
学习时长:约 45 分钟
本节唯一核心:找到真正的模型调用边界,并看懂 DeepSeek 响应怎样变成 Spring AI 对象。
1. 本节解决什么问题
业务代码只有几行:
java
String answer = chatClient.prompt()
.user("分析这笔退款")
.call()
.content();但排错时必须知道:
- 哪一行真正向 DeepSeek 发 HTTP 请求?
ChatClient怎样进入DeepSeekChatModel?- Spring AI 的
Prompt怎样转换成 DeepSeek 请求? - DeepSeek 的 JSON 怎样包装成
ChatResponse? .content()为什么只能得到普通回答文本?reasoning_content被放在哪里?- 流式 chunk 经过了哪些转换?
- 工具调用为什么可能触发第二次模型请求?
2. 先预测
下面四个位置,哪个才是真正的 DeepSeek HTTP 调用点?
text
A. ChatClient.prompt()
B. ChatClient.call()
C. ChatModelCallAdvisor.adviseCall()
D. DeepSeekApi.chatCompletionEntity(request)正确答案:
text
DChatModelCallAdvisor 是进入模型抽象的最后一层,但真正的供应商网络请求发生在 DeepSeekApi。
3. 非流式调用总图
以后遇到问题,先判断它落在哪一段:
text
Prompt 还没组好
Advisor 改错了
模型参数合并错了
DeepSeek 请求失败
响应映射丢字段
ChatClient 最终提取时丢信息4. 链底:ChatModelCallAdvisor
同步 Advisor 链最后会进入:
java
ChatModelCallAdvisor.adviseCall(request, chain)核心逻辑可以缩成:
java
ChatClientRequest formattedRequest = augmentWithFormatInstructions(request);
ChatResponse chatResponse = chatModel.call(
formattedRequest.prompt()
);
return ChatClientResponse.builder()
.chatResponse(chatResponse)
.context(formattedRequest.context())
.build();它做了三件事:
text
1. 必要时加入结构化输出指令
2. 调用统一 ChatModel 接口
3. 把 ChatResponse 包成 ChatClientResponse注意角色边界:
text
ChatModelCallAdvisor
→ 不知道 DeepSeek HTTP JSON 细节
DeepSeekChatModel
→ 知道怎样适配 DeepSeek5. DeepSeekChatModel.call() 做什么
源码入口:
java
@Override
public ChatResponse call(Prompt prompt) {
Prompt requestPrompt = buildRequestPrompt(prompt);
return internalCall(requestPrompt, null);
}两步:
text
buildRequestPrompt
→ 合并模型参数和工具配置
internalCall
→ 创建 DeepSeek 请求、发起 HTTP、包装响应6. 默认参数与运行时参数在哪里合并
你可能同时配置:
yaml
spring:
ai:
deepseek:
chat:
options:
model: deepseek-chat
temperature: 0.2并在单次请求覆盖:
java
chatClient.prompt()
.options(
DeepSeekChatOptions.builder()
.temperature(0.8)
.build()
);buildRequestPrompt() 会:
text
1. 把运行时 ChatOptions 转为 DeepSeekChatOptions
2. 与 defaultOptions 合并
3. 单独合并工具开关、工具名、ToolCallback、toolContext
4. 校验 ToolCallback
5. 生成带 DeepSeekChatOptions 的新 Prompt最终原则:
text
本次运行时配置优先
未覆盖项继承默认配置调试参数问题不能只看 application.yml,还要检查:
- ChatClient 默认 options;
- 本次
.options(...); - Advisor 是否修改 options;
- 工具配置是否触发 options 类型转换;
- DeepSeekChatModel 最终合并后的 requestOptions。
7. Prompt 怎样转换成 DeepSeek 消息
createRequest(prompt, false) 会遍历:
java
prompt.getInstructions()并按 Message 类型转换。
UserMessage 和 SystemMessage
text
Spring AI USER
→ DeepSeek USER
Spring AI SYSTEM
→ DeepSeek SYSTEM主要传递文本。
AssistantMessage
除了普通回答文本,还可能转换:
- toolCalls;
- DeepSeek prefix 标记。
ToolResponseMessage
一条 Spring AI ToolResponseMessage 可能包含多个工具响应,因此转换后会展开成多条 DeepSeek TOOL 消息。
每条 TOOL 消息携带:
text
responseData
tool name
tool call id最后使用 flatMap(List::stream) 将嵌套消息压平成供应商请求消息列表。
8. 工具定义在哪里加入 DeepSeek 请求
消息转换完成后:
java
List<ToolDefinition> definitions =
toolCallingManager.resolveToolDefinitions(requestOptions);每个 Spring AI ToolDefinition 会转换成 DeepSeek Function Tool:
text
name
description
inputSchema然后合并进 ChatCompletionRequest.tools。
所以工具调用至少有两类数据:
text
发给模型的工具定义
→ 模型用来决定是否调用、怎样构造参数
本地 ToolCallback
→ 应用真正执行工具的方法只有定义,没有 Callback,模型可能请求工具但应用无法执行。
只有 Callback,没有把定义放进请求,模型不知道工具存在。
9. 真正的非流式 HTTP 调用点
internalCall() 中:
java
ResponseEntity<ChatCompletion> completionEntity =
retryTemplate.execute(context ->
deepSeekApi.chatCompletionEntity(request)
);真正发网络请求的是:
java
deepSeekApi.chatCompletionEntity(request)外围还包着:
text
RetryTemplate
Observation所以非流式失败时,要区分:
text
第一次请求失败
→ RetryTemplate 是否重试
→ 最终异常是什么
→ 是否已经产生重复副作用纯模型推理通常可重试,但带工具循环时要特别注意工具是否幂等。
10. DeepSeek 原始响应怎样包装
DeepSeek 非流式返回大体是:
text
ChatCompletion
├─ id
├─ model
├─ usage
└─ choices
└─ Choice
├─ message
│ ├─ content
│ ├─ reasoningContent
│ └─ toolCalls
└─ finishReasonSpring AI 逐层转换:
text
Choice
→ Generation
Choice.message
→ DeepSeekAssistantMessage
List<Generation> + metadata
→ ChatResponseGeneration 保存:
text
output Message
生成级 metadataChatResponse 保存:
text
多个 Generation
响应级 metadata响应级 metadata 包括:
- id;
- model;
- usage;
- created;
- system fingerprint。
11. content 与 reasoningContent 是两条数据
DeepSeekChatModel.buildGeneration() 中明确分开读取:
java
String textContent = choice.message().content();
String reasoningContent = choice.message().reasoningContent();然后构建:
java
DeepSeekAssistantMessage message =
new DeepSeekAssistantMessage.Builder()
.content(textContent)
.reasoningContent(reasoningContent)
.toolCalls(toolCalls)
.properties(metadata)
.build();因此:
text
最终答案
→ AssistantMessage 的普通 text
思考内容
→ DeepSeekAssistantMessage.getReasoningContent()12. 为什么 .content() 看不到思考内容
ChatClient.call().content() 最后提取的是:
text
ChatResponse
→ getResult()
→ Generation.getOutput()
→ AbstractMessage.getText()也就是普通文本。
它不会自动拼接:
text
reasoningContent + content要读取两者,需要拿完整响应:
java
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
ChatResponse response = chatClient.prompt()
.user("分析退款风险")
.call()
.chatResponse();
if (response == null || response.getResult() == null) {
throw new IllegalStateException("DeepSeek 未返回有效结果");
}
DeepSeekAssistantMessage message =
(DeepSeekAssistantMessage) response
.getResult()
.getOutput();
String answer = message.getText();
String reasoning = message.getReasoningContent();生产代码不要无条件强转。
更稳妥:
java
var output = response.getResult().getOutput();
if (output instanceof DeepSeekAssistantMessage deepSeekMessage) {
String answer = deepSeekMessage.getText();
String reasoning = deepSeekMessage.getReasoningContent();
}13. 思考内容的工程边界
不要因为框架能拿到 reasoningContent,就默认全部返回前端或永久保存。
需要明确:
- 产品是否真的需要展示;
- 模型供应商是否保证字段稳定;
- 是否包含敏感推断;
- 是否会显著增加日志和存储量;
- 是否影响流式事件协议;
- Memory 是否应该把思考内容重新发给模型;
- 不同模型没有该字段时怎样兼容。
推荐业务层定义自己的输出结构:
java
public record AiAnswer(
String content,
String reasoningContent,
String model,
long totalTokens
) {
}供应商对象只留在适配层,不要让 Controller 到处强依赖 DeepSeekAssistantMessage。
14. 工具调用为什么可能再次请求模型
第一次 DeepSeek 响应可能不是最终答案,而是:
text
我要调用 get_order 工具
参数:{"orderId":"1001"}在 Spring AI 1.1.3 的 DeepSeekChatModel.internalCall() 中,模型返回后会判断:
java
isToolExecutionRequired(options, response)如果需要执行工具:
text
ToolCallingManager.executeToolCalls
→ 执行本地 ToolCallback
→ 得到 ToolExecutionResult然后分两种情况。
returnDirect = true
text
工具结果直接变成最终响应
→ 不再请求模型returnDirect = false
text
模型第一次响应
+ 工具执行结果
→ 形成新的 conversationHistory
→ 再次 internalCall
→ DeepSeek 第二次请求所以一条业务请求可能出现:
text
模型请求 1
→ 工具调用
→ 模型请求 2
→ 最终回答甚至多轮循环。
排查耗时和 token 时,不能只统计 Controller 调用次数。
15. 流式调用从哪里分叉
流式链底进入:
java
ChatModelStreamAdvisor.adviseStream(request, chain)核心:
java
return chatModel.stream(request.prompt())
.map(chatResponse ->
ChatClientResponse.builder()
.chatResponse(chatResponse)
.context(request.context())
.build()
)
.publishOn(Schedulers.boundedElastic());然后进入:
java
DeepSeekChatModel.stream(prompt)16. 真正的流式 HTTP 调用点
internalStream() 中:
java
Flux<DeepSeekApi.ChatCompletionChunk> completionChunks =
deepSeekApi.chatCompletionStream(request);真正的供应商流式调用点:
java
deepSeekApi.chatCompletionStream(request)请求构建时:
java
createRequest(prompt, true)stream = true 会进入 DeepSeek 请求。
17. 流式 chunk 怎样转换
DeepSeek 持续返回:
text
ChatCompletionChunk每个 chunk 经过:
text
chunkToChatCompletion
→ Choice
→ buildGeneration
→ DeepSeekAssistantMessage
→ ChatResponse所以流式下游拿到的不是原始 SSE 字符串,而是多次发出的 Spring AI ChatResponse。
每个 ChatResponse 可能只包含:
- 一小段普通文本;
- 一小段 reasoningContent;
- tool call 增量;
- finishReason;
- usage 增量或最终 usage。
具体字段是否每个 chunk 都存在,要按 DeepSeek 协议处理,不能假设首尾一致。
18. 为什么源码维护 roleMap
流式响应通常只有第一个 chunk 带角色:
text
第一个 chunk:role = assistant
后续 chunk:role = nullDeepSeekChatModel 使用:
java
ConcurrentHashMap<String, String> roleMap按响应 id 暂存 role,后续 chunk 复用。
这是供应商流式协议与 Spring AI 完整 Message 模型之间的适配细节。
19. stream().content() 做了什么
业务代码:
java
Flux<String> contentFlux = chatClient.prompt()
.user("分析退款风险")
.stream()
.content();内部大体是:
text
Flux<ChatClientResponse>
→ 取 ChatResponse
→ 取第一个 Generation
→ 取 Message.getText()
→ 过滤空字符串
→ Flux<String>所以:
text
方便
但信息被裁剪你会丢掉或不直接获得:
- reasoningContent;
- usage;
- finishReason;
- toolCalls;
- model;
- response id;
- Advisor context。
需要完整事件时,应使用:
java
Flux<ChatClientResponse> responses = chatClient.prompt()
.user(question)
.stream()
.chatClientResponse();或者:
java
Flux<ChatResponse> responses = chatClient.prompt()
.user(question)
.stream()
.chatResponse();20. 同时输出回答和思考内容
可以把流式响应转换为自己的事件:
java
public sealed interface AiStreamEvent {
record ReasoningDelta(String text) implements AiStreamEvent {
}
record ContentDelta(String text) implements AiStreamEvent {
}
record Completed() implements AiStreamEvent {
}
}转换示意:
java
Flux<AiStreamEvent> events = chatClient.prompt()
.user(question)
.stream()
.chatResponse()
.flatMap(response -> {
if (response.getResult() == null) {
return Flux.empty();
}
var output = response.getResult().getOutput();
if (!(output instanceof DeepSeekAssistantMessage message)) {
return Flux.empty();
}
List<AiStreamEvent> current = new ArrayList<>();
if (message.getReasoningContent() != null
&& !message.getReasoningContent().isEmpty()) {
current.add(
new AiStreamEvent.ReasoningDelta(
message.getReasoningContent()
)
);
}
if (message.getText() != null
&& !message.getText().isEmpty()) {
current.add(
new AiStreamEvent.ContentDelta(message.getText())
);
}
return Flux.fromIterable(current);
})
.concatWithValues(new AiStreamEvent.Completed());说明:
- 这是教学结构,不是完整生产协议;
- 真实事件需要 taskId、sequence、timestamp、model、finishReason;
- 需要处理取消、错误、重连和重复;
- 不要用字符串前缀区分事件类型。
21. 为什么流式链还要聚合
一边向前端发送 chunk,框架另一边仍需要完整结果,用于:
- Observation 最终响应;
- Memory 保存完整 AssistantMessage;
- Advisor after 处理;
- 工具调用判断;
- usage 汇总。
Spring AI 使用 MessageAggregator 和 ChatClientMessageAggregator 在流处理旁路维护聚合结果。
重要理解:
text
聚合不等于先收完再一次性返回给用户而是:
text
下游继续收到增量
同时框架积累完整消息
结束时触发聚合处理22. 流式工具调用为什么切到 boundedElastic
Spring AI 1.1.3 的 DeepSeek 流式工具路径中有明确注释:
text
当前工具执行仍是同步的因此执行 ToolCallback 时使用:
java
subscribeOn(Schedulers.boundedElastic())它是为了避免同步工具直接阻塞响应式执行线程。
但这不代表工具已经真正非阻塞。
生产中仍要考虑:
- 工具执行时间;
- boundedElastic 队列;
- 线程容量;
- 超时;
- 取消;
- 幂等;
- 工具内部是否再次阻塞数据库或 HTTP。
23. 非流式与流式对照
| 维度 | 非流式 | 流式 |
|---|---|---|
| ChatClient 入口 | call() | stream() |
| 链底 Advisor | ChatModelCallAdvisor | ChatModelStreamAdvisor |
| ChatModel 方法 | call(prompt) | stream(prompt) |
| DeepSeek API | chatCompletionEntity | chatCompletionStream |
| 返回形态 | 一个 ChatResponse | 多个 ChatResponse |
| 业务快捷输出 | String | Flux<String> |
| Memory 写入 | 完整响应返回后 | 流结束并聚合后 |
| 工具执行 | 同步循环 | 流式链中切换到 boundedElastic 执行同步工具 |
| 取消影响 | 调用通常已等待完成 | 取消信号可能向上游传播,但远端是否中止需实测 |
24. 三层响应对象不要混淆
DeepSeekApi.ChatCompletion
供应商协议对象。
用途:
text
DeepSeek HTTP 适配层内部ChatResponse
Spring AI 模型层统一响应。
包含:
text
Generation
metadata
usage
modelChatClientResponse
ChatClient 层响应。
包含:
text
ChatResponse
Advisor context如果需要观察 Advisor 写入的上下文,必须使用 ChatClientResponse,只拿 ChatResponse 不够。
25. 常见误区
误区一:ChatClient 直接调用 DeepSeek API
中间还有 Advisor 和 ChatModel 抽象。
误区二:.content() 是完整模型响应
它只是快捷提取普通文本。
误区三:DeepSeek 思考内容在 metadata 中
在 1.1.3 官方适配中,它位于 DeepSeekAssistantMessage.reasoningContent。
误区四:流式返回的是原始 SSE 字符串
模型适配层已经将 chunk 转成 Spring AI ChatResponse。
误区五:一次业务请求只调用一次模型
工具循环可能触发多次模型请求。
误区六:用了 boundedElastic 就是全链路非阻塞
它只是把同步工作移到适合阻塞的线程池。
误区七:流取消一定能停止远端推理
取消能否穿透 HTTP 客户端并终止供应商计算,需要结合客户端和 DeepSeek 行为实测。
26. 工程排错地图
Prompt 不对
从这里查:
text
RequestSpec
→ DefaultChatClientUtils
→ Memory / RAG Advisor
→ ChatModelCallAdvisor 前的最终 Prompt模型参数不对
从这里查:
text
默认 DeepSeekChatOptions
→ 本次 options
→ buildRequestPrompt 合并结果
→ ChatCompletionRequest工具不调用
从这里查:
text
ToolCallback 是否注册
→ ToolDefinition 是否进入 request.tools
→ DeepSeek 是否返回 toolCalls
→ eligibility predicate
→ ToolCallingManager思考内容丢失
从这里查:
text
DeepSeek 原始 reasoning_content
→ buildGeneration
→ DeepSeekAssistantMessage
→ 是否错误地只调用 content()
→ 流式聚合是否保留字段流式内容缺块
从这里查:
text
DeepSeek chunk
→ chunkToChatCompletion
→ buildGeneration
→ MessageAggregator
→ ChatClientMessageAggregator
→ 业务层 map/filter
→ SSE 网关和前端27. 工程边界
教学 Demo
text
直接返回 String 或 Flux<String>适合验证最小调用。
工程可用方案
建议定义统一适配层:
java
public interface AiChatGateway {
AiAnswer call(AiChatCommand command);
Flux<AiStreamEvent> stream(AiChatCommand command);
}业务层不直接绑定 DeepSeek 类。
生产级方案
至少补齐:
- 请求级 traceId;
- provider requestId;
- 模型与参数快照;
- token 与费用统计;
- 超时与重试分类;
- 工具调用次数上限;
- 工具幂等;
- 流式 sequence;
- 取消传播;
- 敏感内容脱敏;
- provider 限流;
- 降级模型;
- 响应字段版本兼容;
- reasoningContent 是否保存的策略。
28. 简短练习
练习 1
写出非流式真正 HTTP 调用方法:
text
________________________练习 2
写出流式真正 HTTP 调用方法:
text
________________________练习 3
解释下面三个对象的关系:
text
Choice
Generation
DeepSeekAssistantMessage练习 4
为什么下面代码拿不到 DeepSeek 思考内容?
java
String answer = chatClient.prompt()
.user(question)
.call()
.content();练习 5
画出工具调用循环:
text
第一次模型请求
→ ______
→ ______
→ 第二次模型请求
→ 最终答案练习 6
业务需要同时推送:
text
思考增量
回答增量
完成事件
错误事件为什么不能继续只使用 Flux<String>?
29. 本节掌握标准
不看源码,能够完整说出:
text
ChatModelCallAdvisor
→ DeepSeekChatModel.call
→ buildRequestPrompt
→ internalCall
→ createRequest
→ DeepSeekApi.chatCompletionEntity
→ Choice
→ Generation
→ DeepSeekAssistantMessage
→ ChatResponse并能单独画出流式路径,准确指出:
- chunk 在哪里转换;
- reasoningContent 在哪里保存;
.content()裁剪掉了什么;- 工具调用为什么可能递归进入第二次模型调用。
完成本节后,不立即进入 Agent Framework。先根据练习结果判断下一批优先解剖 Tool Calling、ChatMemory 持久化,还是自定义 Advisor。