Skip to content

L03:DeepSeek 调用与响应包装

学习时长:约 45 分钟
本节唯一核心:找到真正的模型调用边界,并看懂 DeepSeek 响应怎样变成 Spring AI 对象。

1. 本节解决什么问题

业务代码只有几行:

java
String answer = chatClient.prompt()
    .user("分析这笔退款")
    .call()
    .content();

但排错时必须知道:

  1. 哪一行真正向 DeepSeek 发 HTTP 请求?
  2. ChatClient 怎样进入 DeepSeekChatModel
  3. Spring AI 的 Prompt 怎样转换成 DeepSeek 请求?
  4. DeepSeek 的 JSON 怎样包装成 ChatResponse
  5. .content() 为什么只能得到普通回答文本?
  6. reasoning_content 被放在哪里?
  7. 流式 chunk 经过了哪些转换?
  8. 工具调用为什么可能触发第二次模型请求?

2. 先预测

下面四个位置,哪个才是真正的 DeepSeek HTTP 调用点?

text
A. ChatClient.prompt()
B. ChatClient.call()
C. ChatModelCallAdvisor.adviseCall()
D. DeepSeekApi.chatCompletionEntity(request)

正确答案:

text
D

ChatModelCallAdvisor 是进入模型抽象的最后一层,但真正的供应商网络请求发生在 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
→ 知道怎样适配 DeepSeek

5. 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
      └─ finishReason

Spring AI 逐层转换:

text
Choice
→ Generation

Choice.message
→ DeepSeekAssistantMessage

List<Generation> + metadata
→ ChatResponse

Generation 保存:

text
output Message
生成级 metadata

ChatResponse 保存:

text
多个 Generation
响应级 metadata

响应级 metadata 包括:

  • id;
  • model;
  • usage;
  • created;
  • system fingerprint。

11. contentreasoningContent 是两条数据

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 = null

DeepSeekChatModel 使用:

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 使用 MessageAggregatorChatClientMessageAggregator 在流处理旁路维护聚合结果。

重要理解:

text
聚合不等于先收完再一次性返回给用户

而是:

text
下游继续收到增量
同时框架积累完整消息
结束时触发聚合处理

22. 流式工具调用为什么切到 boundedElastic

Spring AI 1.1.3 的 DeepSeek 流式工具路径中有明确注释:

text
当前工具执行仍是同步的

因此执行 ToolCallback 时使用:

java
subscribeOn(Schedulers.boundedElastic())

它是为了避免同步工具直接阻塞响应式执行线程。

但这不代表工具已经真正非阻塞。

生产中仍要考虑:

  • 工具执行时间;
  • boundedElastic 队列;
  • 线程容量;
  • 超时;
  • 取消;
  • 幂等;
  • 工具内部是否再次阻塞数据库或 HTTP。

23. 非流式与流式对照

维度非流式流式
ChatClient 入口call()stream()
链底 AdvisorChatModelCallAdvisorChatModelStreamAdvisor
ChatModel 方法call(prompt)stream(prompt)
DeepSeek APIchatCompletionEntitychatCompletionStream
返回形态一个 ChatResponse多个 ChatResponse
业务快捷输出StringFlux<String>
Memory 写入完整响应返回后流结束并聚合后
工具执行同步循环流式链中切换到 boundedElastic 执行同步工具
取消影响调用通常已等待完成取消信号可能向上游传播,但远端是否中止需实测

24. 三层响应对象不要混淆

DeepSeekApi.ChatCompletion

供应商协议对象。

用途:

text
DeepSeek HTTP 适配层内部

ChatResponse

Spring AI 模型层统一响应。

包含:

text
Generation
metadata
usage
model

ChatClientResponse

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。

30. 对应源码

Built with VitePress. Deployed on Cloudflare Pages.