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 多轮对话等)
相关推荐
追逐时光者1 小时前
别再满网找零散工具了,腾讯 QQ 浏览器这个“帮小忙”工具箱真能省时间
前端·后端
心静自然凉8001 小时前
Linux网络核心知识+bonding主备模式配置
后端
爻渡3 小时前
异步编程演进史:从回调到Promise再到Async/Await
后端·程序员
要阿尔卑斯吗5 小时前
企业级 RAG 系统的文件标签管理:三层架构与层级优化实战
后端
要阿尔卑斯吗5 小时前
Agent开发之为什么有了LangChain4j框架,我们却不能直接使用它?——桥接层设计详解
后端
用户7713970207065 小时前
从CMD到PowerShell:一个.NET开发者的命令行进化之路
后端
祎雪双十Gy5 小时前
从 DataX 的配置加载说起:我用 FastJson2 做了一个轻量级动态配置管理库
java·后端
Csvn7 小时前
Nginx 配置与运维管理 — 从安装到 SSL 反向代理
后端
mqcode8 小时前
若依框架做大了怎么办?多模块 Maven 拆分的完整指南
后端