☰
Spring Boot + Spring AI 接入 DeepSeek:流式输出与前后端联调实战
2026/10/14 4:21:48 网站建设 项目流程

简介:面向Java后端与AI应用开发者的Spring Boot + Spring AI集成实战代码包,基于DeepSeek大模型完整实现智能问答、文本生成与语义分析等典型AI场景,为快速搭建企业级智能应用提供可直接参考的工程范例。资源共12个文件,以9个Java源文件承担核心业务逻辑,配合YAML配置、XML配置和HTML页面完成环境配置与前端展示,压缩包仅25KB,结构轻量、便于快速阅读。代码采用前后端分离与模块化设计,各模块职责明确,完整展示模型接入、参数配置、服务调用与结果展示的链路;前端负责用户交互与结果呈现,后端处理业务逻辑与数据交互,开发者不仅能直观理解AI能力如何无缝嵌入Spring Boot工程,还能学习如何利用自动配置和起步依赖等特性,快速构建智能问答与文本生成服务,掌握从自然语言理解到结果输出的实际落地方法。已有254人学习,适合有一定Java基础、希望掌握DeepSeek集成方法的开发者,既可当作入门实战模板,也可在此基础上扩展更多AI功能,是企业级AI应用开发的一个良好起点。

1. 这个项目到底在做什么:别把 Demo 当实战

把 Spring Boot 和 Spring AI 接上 DeepSeek,前后端都写完,很多人第一步就卡在“官方给的例子跑通了,但一接前端全乱套”。最典型的情况是:后端自己在 Swagger 里测试没问题,前端一调用就白屏、超时、或者只拿到半个字。这个项目标题真正要解决的是“Spring Boot 与 Spring AI 基于 DeepSeek 的完整代码链路”,核心是把 AI 能力封装成可靠的 REST 接口,再把前端流式渲染串起来,最后让系统能扛住真实用户使用,而不只是演示。适合谁?已经在用 Java 做后端、想把 DeepSeek 接进业务系统的开发者;以及被“Demo 没问题、生产翻车”折磨过的前后端合作团队。这篇我不讲 PPT 架构,只讲怎么一步步搭、参数怎么调、哪些地方你会摔跤。

2. Spring Boot + Spring AI 项目骨架:最小工程与 DeepSeek 接入

2.1 为什么选 Spring AI 而不是手动封装 OpenAI 客户端

常见做法是在 Spring Boot 项目里引入 Spring AI 的spring-ai-starter-model-openai,然后把 base-url 指向 DeepSeek 的兼容接口。有人会问:DeepSeek 本身提供 OpenAI 兼容接口,为什么不直接用 HttpClient 调?我一般会选 Spring AI,理由是它能省掉三类重复劳动:统一 ChatClient 的调用抽象、对话历史的会话管理,以及 JSON Schema 结构化输出的生成逻辑。自己手写的话,第一版能跑,第二版就要开始处理重试、超时、流式解析,这些 Spring AI 已经帮你兜底了。

我用 Spring Boot 3.2 + JDK 17 起步。Spring AI 对 JDK 版本有下限要求,JDK 8 直接编不过,这不是玄学,是 Spring Framework 6 的基线就是 17。你如果是老项目升不上来,就别硬折腾 Spring AI,去用原生 OpenAI SDK 反而清爽。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0-M5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

Spring AI 的版本迭代跨度很大,M 版本和正式版之间的包路径和类名变化不少。你搜到旧博客里写OpenAiChatClient的,在新版里多半已经废弃,统一走ChatClient接口了。所以我不建议在生产项目上追最新 M 版,选一个已经发布过几个月的版本,踩坑记录多,你能搜到解决方案。

spring.ai.openai.base-url=https://api.deepseek.com/v1 spring.ai.openai.api-key=${DEEPSEEK_API_KEY} spring.ai.openai.chat.options.model=deepseek-chat spring.ai.openai.chat.options.temperature=0.7 spring.ai.openai.chat.options.max-tokens=2048

2.2 跑通最小调用:ChatClient 直连 DeepSeek

配置写完,验证链路的动作要小。我习惯先写一个CommandLineRunner,启动时直接调一次模型,输出到控制台,这样能确认密钥、网络、模型名三个变量同时没问题,再往 Controller 层走。

接口代码就一个核心方法:

@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }
@Component public class DeepSeekSmokeTest implements CommandLineRunner { private final ChatClient chatClient; public DeepSeekSmokeTest(ChatClient.Builder builder) { this.chatClient = builder.build(); } @Override public void run(String... args) { String response = chatClient.prompt("用一句话介绍你自己") .call() .content(); System.out.println("=== DeepSeek response ==="); System.out.println(response); } }

这段代码的逻辑很简单:注入ChatClient.Builder,构建出带默认配置的ChatClient,然后发起一次 prompt 调用。重点是.call()和.content()的搭配,call()是同步阻塞调用,适合用来做冒烟测试;等到接前端流式输出时,你要换用.stream()方法。

参数说明方面,模型名deepseek-chat是 DeepSeek 的主对话模型,如果你买的是推理增强型模型,这里要换上对应的推理模型名。temperature建议在 0.7 附近起步,太高回答发散、太低容易变成复读机。启动时如果看到 404 或者 401 报错,九成是base-url或者api-key的问题。我第一次接入时把 api-key 直接写在配置文件里提交了,后来换成了${DEEPSEEK_API_KEY}环境变量占位,避免密钥泄给协作同事。

这里有一个关键的选型考量:ChatClient.Builder每次构建出来的实例是线程安全的,可以全局复用。不要在接口里每次都new一个 ChatClient,那会反复加载配置,浪费连接资源。

3. 从最小 Demo 到可复用业务接口:把 DeepSeek 能力封装给前端

3.1 普通问答接口:从 shell 测试到 Controller

冒烟测试通过后,下一步是把模型调用暴露成 HTTP 接口。我先写一个最简单的 POST 接口,接收用户问题,返回完整回答。这里不要急着做流式,先把同步链路跑通,让前端能拿到一个完整的 JSON,后面再优化体验。

@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @PostMapping("/sync") public ResponseEntity<ChatResponse> syncChat(@RequestBody ChatRequest request) { String content = chatClient.prompt(request.message()) .call() .content(); return ResponseEntity.ok(new ChatResponse(content)); } }

在这个接口里,ChatRequest和ChatResponse是两条记录类,前者只带一个message字段,后者只带一个content字段。这样的设计意图很明确:把 Spring AI 的模型返回结构隔离在业务层之外,前端只拿到一个干净的字符串,不暴露 Token 用量、模型名这些内部细节。

要注意.call()的默认行为是一次性返回完整回复。如果你的业务场景是内部测试工具、批量生成、离线处理,同步接口完全够用。但凡是用户对着屏幕等回答的场景,同步接口很容易被嫌慢——DeepSeek 生成 500 字可能需要 10 到 20 秒,HTTP 连接很容易在这段时间被网关掐断。这就是为什么下一步必须做流式。

3.2 流式输出接入:SseEmitter 与前端逐字渲染

我一般用 Spring MVC 自带的SseEmitter,因为项目没有引入 WebFlux,为了流式输出把整个 Web 栈换掉成本太高。SseEmitter 是 Servlet 容器原生支持的,和spring-boot-starter-web配合得特别好,不需要额外依赖。

@PostMapping(value = "/stream", produces = "text/event-stream;charset=utf-8") public SseEmitter streamChat(@RequestBody ChatRequest request) { SseEmitter emitter = new SseEmitter(60_000L); Flux<ChatResponse> stream = chatClient.prompt(request.message()) .stream() .chatResponse(); stream.subscribe( response -> { String delta = response.getResult().getOutput().getText(); if (delta != null && !delta.isEmpty()) { try { emitter.send(SseEmitter.event().data(delta)); } catch (IOException e) { emitter.completeWithError(e); } } }, error -> { emitter.completeWithError(error); }, emitter::complete ); return emitter; }

这段代码有三个要点。第一,SseEmitter构造参数 60_000L 是超时时间,单位毫秒,意思是 60 秒内如果没有事件发送,服务端会自动断开。DeepSeek 生成长文时可能中间停顿几秒,这个值设短了容易断流。第二,.stream().chatResponse()返回的是一个反应式流,你不需要引入 WebFlux 依赖,Spring AI 底层自己处理了响应式适配。第三,subscribe里的三个回调分别对应数据块、错误、完成,任何异常都要completeWithError,否则前端会一直挂着等不到结束信号。

这里容易踩坑的是getResult().getOutput().getText()这个取值链路,有时候结果里还带metadata,但那不是文本内容,别直接toString(),否则前端会收到一坨包含时间戳和 token 数量的 JSON。我第一次写顺手用了toString(),前端渲染出一堆乱码,后来改成上面这个链路才正常。

3.3 多轮对话历史:循环队列实现上下文记忆

DeepSeek 的接口本身是无状态的,只有把历史消息一起传过去,它才能记住前文。Spring AI 提供了MessageHistory相关抽象,但默认实现在多用户高并发下有点吃内存。我一般自己实现一个简单的队列方案,每个会话保存最近 N 轮,既控制 token 消耗,又避免内存无限增长。

@Component public class SessionHistoryManager { private final Map<String, Deque<Message>> sessions = new ConcurrentHashMap<>(); private static final int MAX_HISTORY_ROUNDS = 6; private static final int MAX_TOTAL_TOKENS = 4000; public List<Message> getHistory(String sessionId) { Deque<Message> deque = sessions.computeIfAbsent( sessionId, k -> new ArrayDeque<>() ); return new ArrayList<>(deque); } public void addExchange(String sessionId, String userText, String assistantText) { Deque<Message> deque = sessions.computeIfAbsent( sessionId, k -> new ArrayDeque<>() ); deque.addLast(new UserMessage(userText)); deque.addLast(new AssistantMessage(assistantText)); while (deque.size() > MAX_HISTORY_ROUNDS * 2) { deque.pollFirst(); } } }

这个设计的关键在于Deque的容量控制。MAX_HISTORY_ROUNDS是保留的对话轮数,每轮包含 user 和 assistant 两条消息,所以判定条件是deque.size() > MAX_HISTORY_ROUNDS * 2。MAX_HISTORY_ROUNDS设成 6,是因为大多数业务场景下最近的 6 轮已经足够承载上下文,再老的消息通常是对 API 的 token 浪费。

这里还有一个额外收益:因为历史消息里带着UserMessage和AssistantMessage类型,Spring AI 在组装 prompt 时会自动识别角色,你不需要手动拼接“用户说:… 助手答:…”这样的模板。前端请求的时候带上sessionId,后端拿sessionId去队列取历史,就能实现多轮对话。内存方案只适合单机部署和中小并发,如果你是集群部署,要把sessions换成 Redis,按 sessionId 存取 JSON 序列化后的消息列表。

4. 前端接入:把 SSE 流式渲染成聊天体验

4.1 fetch 流式读取 SSE 并用 React 增量渲染

后端接口通了,前端才是用户体验的最后一公里。我拿 React + fetch 举例,因为fetch天然支持 ReadableStream,不需要额外装 SSE 库。关键思路是:不要等整个响应结束才 setState,而是每收到一块数据就追加到消息列表里。

async function streamChat(message, sessionId, onDelta, onError, onDone) { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message, sessionId }), }); if (!response.ok) { onError(new Error(`HTTP ${response.status}`)); return; } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split('\n\n'); buffer = events.pop(); for (const event of events) { if (event.startsWith('data:')) { const data = event.replace('data:', '').trim(); if (data && data !== '[DONE]') { onDelta(data); } } } } onDone(); }

这段前端代码的核心是buffer.split('\n\n')。SSE 协议规定每个事件以空行结束,网络传输过程中一个 chunk 可能包含多个事件,也可能一个事件被拆成两个 chunk,所以必须做缓冲区拼接。buffer = events.pop()保留最后一个不完整的数据段,等下一次reader.read()再续上。这个细节是我前后端联调时反复踩的坑,如果不做缓冲区,消息最后几个字很容易被截断。

4.2 流中断、超时与聊天界面的工程化收尾

流式接口接入后,有几个体验问题必须处理。第一是“停止生成”按钮,用户不想等了要能中断请求,需要借助AbortController把reader.cancel()调起来。第二是断线重连,如果生成了半句话网络断了,前端要提示“回答不完整,点此重试”,最简单粗暴的做法是在 onDone 里检查是否出现了完整结束标记。

我在实际项目中把回答文本按“是否调用了 onDone”来区分完整和不完整。onDone永远会被执行,但如果流是异常中断的,onDone之前会先走onError。所以界面上的渲染逻辑是:isError ? '生成中断' : '生成完成'。

前端展示层另一个关键就是 Markdown 渲染。DeepSeek 返回的内容默认带 Markdown 语法,比如代码块和加粗,界面上得用react-markdown渲染。但要注意,流式渲染时 Markdown 经常是半截的,比如代码块的三个反引号还没收尾,直接渲染会把整段内容包在代码块样式里。我一般会等一次流的三个反引号都到齐再渲染,或者干脆把代码块部分的样式放宽,容忍渲染时的临时错乱。

5. Spring AI 接 DeepSeek 避坑:现象、原因、解决

5.1 现象:接口报 404 或者找不到模型

第一次配 Spring AI 接 DeepSeek,启动后调用直接 404,日志里写着类似model not found的报错。原因很直接:某个版本的 Spring AI 对 OpenAI 的模型名有内置校验,传了配置默认的gpt-3.5-turbo,没有真正把application.properties里的spring.ai.openai.chat.options.model读透。解决方式是检查两个地方:一是配置项有没有写成spring.ai.openai.chat.model(旧版写法),二是确认 DeepSeek 的base-url以/v1结尾。如果版本升级过,注意配置路径可能已经变成了spring.ai.openai.chat.options.model。

5.2 现象:本地一切正常,部署到某 Linux 机器上连接超时

现象是同一个 jar,本地调用 DeepSeek 正常,放到某台云服务器上就报连接超时。我排查了防火墙、安全组都没有问题,最后发现是服务器上的 JVM 读到了系统环境变量里的HTTPS_PROXY,Spring AI 的 HttpClient 默认会走这个变量指定的地址,导致流量被引导到了一个不通的出口。解决方式是启动命令里显式清掉这两个变量:export HTTPS_PROXY="",或者在 JVM 参数里加-Dhttps.proxyHost=强制置空。这个问题特别隐蔽,因为本地开发机的环境变量和服务器不一致,很容易让人怀疑是网络配置问题,其实只是 JVM 读取了不该读的系统变量。

5.3 现象:JSON 结构化输出报 400 格式错误

调用.entity(SomeClass.class)想拿到结构化 JSON,结果接口返回 400,日志提示 schema 格式不兼容。原因在于 Spring AI 生成的 JSON Schema 是 draft-07 格式,DeepSeek 兼容层对$schema字段版本有校验要求,两者不一致会直接拒绝。解决方法是升级 Spring AI 到较晚的版本,或者手动调整生成的 schema,把$schema字段改成 DeepSeek 兼容的版本号。这个坑在 JDK 17 + 较老版本组合下最容易触发,我当时升级 spring-ai-starter-model-openai 后问题消失,所以建议遇到 400 先查版本再改代码。

5.4 现象:前端界面一次性出字,不是流式

后端SseEmitter正常,但前端等很久才一次性拿到全部内容。原因通常是网关或者代理缓冲了响应,X-Accel-Buffering之类的头没关。解决方式是在 controller 方法上显式设置响应头X-Accel-Buffering: no,并且确认produces = "text/event-stream"写对了。Nginx 默认会缓冲后端响应,等到整个响应结束才发给前端,流式接口必须关掉缓冲。这个不关的话,你后端写再多流式代码,用户看到的还是“转圈然后整段蹦出来”。

5.5 现象:多轮对话聊着聊着上下文就丢了

前端每次请求都带上了sessionId,第一轮能记住,第五轮再问“我刚才说了什么”它就不知道了。原因八成是SessionHistoryManager的MAX_HISTORY_ROUNDS设太小,或者消息类型混用了。注意一个问题:UserMessage和AssistantMessage要交替存放,不能两条 user 连续,否则模型会搞混角色。我出现过一次连续 push 了两条UserMessage,结果回答时模型把自己当成了用户,语气全乱。

6. 进阶技巧:把单轮 DeepSeek 能力变成可维护的 AI 服务

这章的三个技巧是我在真实项目里被逼出来的,价值比重写十个接口都大。

第一个技巧是设计一个统一的“AI 调用日志”切面。每个请求记录 sessionId、消息长度、耗时、token 用量,打到日志文件里。没有这套切面,前端反馈“回答不对”的时候,你根本不知道模型当时收到了什么历史记录。有了日志,排查问题从“猜”变成“看数据”。

第二个技巧是给超时分级。流式接口的超时不能只设一个SseEmitter的 60 秒就完事,底层 HttpURLConnection 的 readTimeout 也要设。我一般设连接超时 10 秒、读取超时 120 秒,两者独立。连接慢和生成慢是两种完全不同的问题,混在一起时会互相干扰判断。

第三个技巧是成本与一致性之间的平衡。DeepSeek 的上下文越长,单次调用成本越高,但把MAX_HISTORY_ROUNDS调小又会影响回答质量。我总结的经验是:把“系统提示词”单独拎出来,每次请求都带;对话历史只带最近 4 轮;如果是一场长对话的开头,额外保留第一轮用户原始输入。这个策略在成本和效果之间取得了一个很好的折中。

最后我养成了一个习惯:每次下午四点后,自己攒一个新版本的 bad case 集合,专门测试模型回答的稳定性。AI 模型的输出天然有随机性,同一句 prompt 每次回答可能不同——优化 prompt 或调整参数后一定要跑一轮冒烟测试再发版,用同一批问题对比新旧答案,看有没有恶化。你一定要把参数调节当成代码变更一样对待,配一个测试集来回归。某个下午我调了个 temperature,没做对比测试就上线了,结果夜间用户反馈回答变得“又臭又长”,回滚配置后才恢复。后来每次改参数都留一份截图,记录 prompt、temperature、max-tokens 三个关键变量,再比对实际输出,就再也没翻过车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询