Spring AI 集成 DeepSeek 原生供应商并实现think模式

背景

项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 spring.ai.openai.base-url 指向 https://api.deepseek.com)。这种方式能跑,但有几个痛点:

  • 拿不到 reasoning_content :DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 <think> 标签里,前端再用状态机做标签解析,极其脆弱
  • 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用
  • 语义不清晰 :配置里写着 openai,实际调的却是 DeepSeek,维护成本高

Spring AI 在 1.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。


一、添加依赖

pom.xml 中添加 DeepSeek Starter(版本由 BOM 1.1.3 统一管理):

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>

二、配置供应商

application-dev.yml 中添加 DeepSeek 配置块:

yaml 复制代码
spring:
  ai:
    deepseek:
      api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
      chat:
        options:
          model: deepseek-v4-flash
          temperature: 1.0

API Key 建议通过环境变量注入,避免硬编码。


三、注册 Bean

AiConfig.java 中注册 DeepSeek 的 ChatModel 和 ChatClient:

java 复制代码
import org.springframework.ai.deepseek.DeepSeekChatModel;

@Bean("deepseekChatModel")
public ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {
    return deepSeekChatModel;
}

@Bean("deepseekChatClient")
public ChatClient deepseekChatClient(
        @Qualifier("deepseekChatModel") ChatModel deepseekChatModel,
        MessageFormatAdvisor messageFormatAdvisor,
        LifecycleToolCallAdvisor lifecycleToolCallAdvisor,
        TaskProgressAdvisor taskProgressAdvisor,
        RetryAdvisor retryAdvisor) {
    return ChatClient.builder(deepseekChatModel)
            .defaultToolContext(new HashMap<>(Map.of("debug", true)))
            .defaultAdvisors(
                    messageFormatAdvisor,
                    lifecycleToolCallAdvisor,
                    taskProgressAdvisor,
                    retryAdvisor
            )
            .build();
}

四、Controller 改造 --- 原生推理流式输出

改造前的痛点:每个 SSE Chunk 拿到的是 AssistantMessage<think> 标签可能被切碎在多个 Chunk 里,需要维护复杂的状态机做拼接。

改造后:使用 DeepSeekAssistantMessagereasoningContenttext 是两个独立的字段:

java 复制代码
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;

// 流式处理核心逻辑
.concatMap(response -> {
    AssistantMessage output = response.getResult().getOutput();
    List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();

    // 工具调用
    if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {
        // ... handle tool calls
        return Flux.fromIterable(events);
    }

    // DeepSeek 原生推理内容
    if (output instanceof DeepSeekAssistantMessage dsMsg) {
        String reasoning = dsMsg.getReasoningContent();
        if (reasoning != null && !reasoning.isEmpty()) {
            // 缓冲后发送(见下文)
            state.accumulateReasoning(reasoning, events);
        }
    }

    // 文本内容
    String text = output.getText();
    if (text != null && !text.isEmpty()) {
        state.flushReasoning(events);
        events.add(createEvent("message", state.messageId(), text, null));
    }

    return Flux.fromIterable(events);
})

五、推理内容缓冲优化

reasoningContent 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字/词,直接推给前端会导致渲染碎片化。需要在服务端做缓冲,按语义边界批量下发。

核心实现:累积推理内容到 StringBuilder,遇到句子结束标点时切分发出:

java 复制代码
private static class StreamState {
    private static final int REASONING_FLUSH_THRESHOLD = 50;
    private static final Pattern SENTENCE_BOUNDARY =
            Pattern.compile("[。!?.!?\\n]+");

    private final StringBuilder reasoningBuffer = new StringBuilder();

    public void accumulateReasoning(String delta,
            List<ServerSentEvent<ChatChunk>> target) {
        reasoningBuffer.append(delta);

        // 按标点切分,整句发出
        String buf = reasoningBuffer.toString();
        var matcher = SENTENCE_BOUNDARY.matcher(buf);
        int lastEnd = 0;
        while (matcher.find()) {
            String segment = buf.substring(lastEnd, matcher.end()).trim();
            if (!segment.isEmpty()) {
                target.add(createEvent("thought", "reasoning", segment, null));
            }
            lastEnd = matcher.end();
        }
        reasoningBuffer.delete(0, lastEnd);

        // 无标点时强制 flush(避免长思考无反馈)
        if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {
            String forced = reasoningBuffer.toString();
            reasoningBuffer.setLength(0);
            target.add(createEvent("thought", "reasoning", forced, null));
        }
    }
}

触发策略:

场景 行为
遇到句号/问号/感叹号/换行 按标点切分,整句发出
缓冲区积累超过 50 字符无标点 强制整块发出
切换到文本输出或工具调用 排空缓存

六、效果对比

改造前: 前端收到的是逐个单词的 thought 事件,需要前端做拼接渲染:

json 复制代码
event: thought
data: {"content":"The", "role":"thought"}
event: thought
data: {"content":"user", "role":"thought"}
event: thought
data: {"content":"wants", "role":"thought"}

改造后: 前端收到完整的语义段落,直接展示:

json 复制代码
event: thought
data: {"content":"The user wants me to add a new feature.", "role":"thought"}
event: thought
data: {"content":"Let me think about the best approach.", "role":"thought"}

总结

Spring AI 官方 DeepSeek Starter 带来的核心收益:

  1. 结构化推理内容DeepSeekAssistantMessage.getReasoningContent() 直接获取 CoT,无需 <think> 标签 hack
  2. 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染
  3. 配置语义化 :配置即文档,spring.ai.deepseek.* 一目了然
  4. 扩展性:后续可以无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)
相关推荐
止语Lab1 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活1 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困1 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn1 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530141 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908401 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金1 小时前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端
人间凡尔赛2 小时前
AI-Native 云原生架构:2026 年从容器编排到智能体编排的范式革命
后端·云原生·架构
IT_陈寒2 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端
卷无止境2 小时前
提升 Python 代码健壮性的方法大盘点
后端·python