简介:面向后端开发者与AI应用初学者的 Spring Boot + Spring AI + DeepSeek 集成实战代码包,展示如何通过 Spring Boot 与 Spring AI 框架接入 DeepSeek 大模型,构建智能问答、文本生成和语义分析等功能。项目采用前后端分离与模块化设计,前端页面负责交互展示,后端 Java 实现业务逻辑与模型调用,适合作为企业级 AI 应用开发起点,也便于后续扩展更多 AI 服务。
压缩包共 12 个文件,约 25KB,包含 9 个 Java 源码文件、1 个 YAML 配置文件、1 个 POM 依赖配置和 1 个 HTML 页面,覆盖模型接入配置、服务调用封装、前端展示等完整链路。目录结构清晰,代码量适中,便于快速理解 Spring AI 的集成方式与调用流程。
已有 254 人学习下载。通过学习这份代码,可掌握 Spring AI 与 DeepSeek 的实际整合方法,包括依赖管理、自动配置、语义分析调用等关键环节,为后续自主开发 AI 应用提供可直接参考的实现模板。
1. 一套能直接跑的 DeepSeek 全栈对话项目:Spring Boot 后端、Vue3 前端,照着拆就行
项目要接 DeepSeek 时,网上一搜多是 Python 脚本,Java 这边要么裸用 HTTP 客户端手搓,要么东拼西凑几个轮子,前期跑通很快,一旦上流式输出、多轮上下文、工具调用,代码立刻失控。这套 Spring Boot 与 Spring AI 深度实战的完整代码,把后端 AI 能力和前端交互都打包进同一个工程,后端用 Spring AI 的 OpenAI 兼容层连 DeepSeek,前端用 Vue3 消费流式接口,适合刚完成前后端分离改造、又要快速上 AI 功能的 Java 团队逐行参考。这篇文章按这套代码的拆解顺序来写,从依赖配置到接口联通再到排错,照着可以复现。
2. 环境与骨架:Spring AI 依赖版本与 DeepSeek 配置项逐条拆解
2.1 为什么选 Spring AI,而不是 HttpClient 手搓 OpenAI 接口
我见过不少项目直接拿 RestTemplate 调 DeepSeek 的/chat/completions,同步调用确实能跑,但很快会遇到三件麻烦事:流式响应要自己解析text/event-stream,多轮对话要自己维护 messages 数组,工具调用要自己拼tool_calls和tool消息。这三块代码不难,但每一块都有边界情况,比如流式中途断线、上下文超长截断、tool call 结果未及时返回导致模型报错。手搓到后面,业务代码里全是 JSON 拼接和状态机,维护成本很高。
Spring AI 在这里的价值,是给 Java 生态提供了类似 JDBC 对数据库那样的统一抽象。它把 ChatClient、Prompt、Message、Memory 都封装好了,底层接哪个模型只是配置问题。DeepSeek 官方提供 OpenAI 兼容接口,所以可以直接用 Spring AI 的 OpenAI starter,把base-url指向 DeepSeek 的地址。和 LangChain 那套重框架相比,Spring AI 更轻,能复用 Spring 的 Bean 管理和配置体系,对已有 Spring Boot 项目来说接入成本最低。
2.2 依赖文件与配置文件:OpenAI 兼容协议接入 DeepSeek
这套代码的基础依赖如下,Spring AI 从 1.0 正式版开始统一了包名,使用spring-ai-starter-model-openai:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>依赖说明:这里没有引入spring-boot-starter-web,而是直接用 WebFlux,原因是流式接口返回Flux<String>更适合在 WebFlux 下跑。如果老项目已经用了 Spring MVC,也可以把 webflux 依赖去掉,Controller 里直接返回Flux同样能工作,但要避免两个 Web 框架同时生效带来的路由歧义。
核心配置写在application.yml:
spring: application: name: spring-ai-deepseek-demo ai: openai: base-url: https://api.deepseek.com/v1 api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.7 max-tokens: 1024 server: port: 8080参数说明:base-url指向 DeepSeek 的 OpenAI 兼容端点,注意末尾的/v1要不要保留,不同版本的处理逻辑不一样,后面避坑章节会展开。api-key不要写死在配置文件里,用环境变量注入。模型名写deepseek-chat,这是 DeepSeek 官方对话模型的接入名,不要随手填成gpt-3.5-turbo或别的。temperature控制在 0.7 左右,既能保持回答稳定又不至于太死板。max-tokens决定单次回答最大长度,要结合业务调整。
3. 后端实现拆解:ChatClient 同步、流式与上下文的三条链路
3.1 同步调用 ChatClient:最小可用版本与参数意义
ChatClient 是 Spring AI 1.0 的主力入口,有点类似 JdbcTemplate,所有与大模型交互的操作都从它发起。这套代码里用 Builder 创建一个带默认系统提示的客户端:
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一名 Java 编程助手,回答保持简洁,代码示例优先。") .defaultOptions(ChatOptions.builder() .model("deepseek-chat") .temperature(0.7) .maxTokens(1024) .build()) .build(); } @PostMapping("/sync") public String sync(@RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); } }这段代码的逻辑是:通过ChatClient.Builder注入一个带默认配置的客户端,defaultSystem设置了系统提示词,defaultOptions固定了模型和生成参数。每次请求时,prompt().user(message)组装用户消息,.call()发起同步调用,.content()取出模型返回的文本。
同步调用适合接口内部需要立刻拿到结果的场景,比如生成标题、摘要、分类标签。如果前端页面需要用户看到逐字输出,就必须走流式接口。
3.2 流式输出:WebFlux 转发 SSE 并保留打字机节奏
DeepSeek 的流式返回本身就是 SSE 格式,Spring AI 把底层解析做掉了,我们拿到的是一串String文本块。后端要做的是把这些文本块再以 SSE 格式转发给前端:
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content() .map(chunk -> "data:" + chunk + "\n\n"); }逻辑说明:stream().content()返回Flux<String>,每到一个 chunk 就执行一次map,按 SSE 协议包装成data:xxx加空行的格式。前端 EventSource 收到这种格式会自动解析成event.data。这里没有做跨域处理,因为前后端联调时建议走 Vite 代理或 Nginx 同源部署,避免浏览器跨域限制。
如果你在项目里看到SseEmitter,那是 Spring MVC 的另一种 SSE 实现方式,和 WebFlux 的Flux二选一即可。使用Flux的好处是天然支持背压,模型输出慢时不会把内存撑爆。流式输出还有一个隐藏点:如果同时用到工具调用,tool_calls不会出现在content()流里,需要走 ChatModel 底层去解析,这是后话。
3.3 多轮上下文:MessageChatMemoryAdvisor 的记忆与截断策略
模型本身是无状态的,多轮对话需要把历史消息一起发过去。手搓的方案是自己在 Redis 里存 messages 数组,每次请求前拼接。Spring AI 提供了 ChatMemory 和 Advisor 机制,可以少写不少胶水代码:
@Configuration public class ChatConfig { @Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } @Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultSystem("你是电商客服助手,回答要耐心。") .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); } }逻辑说明:InMemoryChatMemory是进程内实现,按会话 ID 存储消息列表,适合单机演示和中小型项目。MessageChatMemoryAdvisor是拦截器,每次请求时自动把历史消息取出来拼进 prompt,并把当前问答追加回记忆区。
参数说明:MessageChatMemoryAdvisor有个窗口大小参数,默认保留最近 20 条消息,超出后自动丢弃最旧的。这个值要结合模型上下文长度和业务场景调整,客服机器人可以给到 30,问诊类场景建议 10 以内。使用InMemoryChatMemory的另一个注意点是多实例部署时会丢记忆,因为每个实例的内存是独立的。生产环境需要把 ChatMemory 的存储换成 Redis 或数据库,Spring AI 提供了ChatMemory接口,自己实现并不复杂。
4. 前端联调拆解:Vue3 怎么消费 SSE 流式数据
4.1 接口约定:GET + SSE 为什么在这里是成立的
Vue3 连接后端的方式,最常规的是 axios 调 JSON 接口。但流式对话场景不太适合 axios,因为 axios 基于 XHR,没有原生的流式读取能力,虽然可以用onprogress事件勉强读到部分数据,但兼容性和体验都不好。SSE 场景下,浏览器原生提供的 EventSource 是最省事的方案。
EventSource 有几个限制:只能发 GET 请求,不能自定义请求头。正因如此,这套代码把 API Key 放在后端,前端只通过 URL 传用户消息,不接触任何密钥。用户消息通过encodeURIComponent转义后放在 query 参数里,后端@RequestParam直接接收。中断生成时,EventSource 实例调用close()即可断开,不需要像 WebSocket 那样维护连接状态。
4.2 Vue3 消费 SSE:EventSource 的正确打开方式
前端封装一个组合式函数,专门处理流式连接:
import { ref } from 'vue' export function useChatStream() { const content = ref('') let eventSource = null function connect(message) { content.value = '' const url = `/api/chat/stream?message=${encodeURIComponent(message)}` eventSource = new EventSource(url) eventSource.onmessage = (event) => { content.value += event.data } eventSource.onerror = () => { eventSource.close() content.value += '\n[连接中断]' } return () => eventSource.close() } return { content, connect } }逻辑说明:new EventSource(url)建立连接后,后端推送的每个data:块都会触发onmessage,event.data就是文本块,直接追加到响应内容里。这里要注意 URL 必须用encodeURIComponent处理中文,否则浏览器会报编码错误或后端收到乱码。
使用时的注意点:EventSource 默认自带断线重连机制,但重连会重新请求同一个 URL,消息会从空开始。如果业务上需要恢复历史记录,前端要先把历史对话保存在 localStorage 或后端,重连时带上上下文 ID。
4.3 打字机效果与 Markdown 渲染
流式数据边到边显示,本身就有打字机的效果,但网络抖动时文本块会一下子涌进来,视觉上不流畅。常见做法是加一个定时器做平滑输出:
function typewriter(text, onTick, interval = 20) { let index = 0 const timer = setInterval(() => { index += 1 onTick(text.slice(0, index)) if (index >= text.length) { clearInterval(timer) } }, interval) return () => clearInterval(timer) }参数说明:interval是每次输出的间隔毫秒数,20ms 大约是每秒 50 个字,适合正常阅读节奏。如果回答内容很长,可以提高到 30ms,避免用户等待过久。注意这个函数接收的text是已经收集完成的完整文本,实际项目里可以等流式结束后再调用,也可以边收边渲染。
模型回答通常是 Markdown 格式,直接显示在页面上会露出**加粗**等原始符号。组件里引入 markdown-it 做渲染:
import MarkdownIt from 'markdown-it' const md = new MarkdownIt({ html: false, linkify: true, breaks: true }) function renderMarkdown(rawText) { return md.render(rawText) }逻辑说明:html: false禁止渲染原始 HTML,防止 XSS,这是接入模型输出时必须开的开关。linkify让链接自动可点击,breaks把换行符转成<br>。代码高亮需要额外引入 highlight.js,在 markdown-it 的 render 规则里处理code块,否则代码块只是一堆灰底文字。
5. 常见问题与排查:五个让流式对话翻车的典型配置
5.1 base-url 配置错误:请求打到不存在的接口上
现象:后端启动不报错,但调用/api/chat/sync时返回 404,控制台显示请求地址类似https://api.deepseek.com/v1/v1/chat/completions。
原因:DeepSeek 官方兼容地址本身带有/v1前缀,而 Spring AI 的 OpenAI 客户端有的版本会自动拼接/chat/completions,有的版本则把base-url当作完整前缀不做处理。两段/v1叠在一起,路径就错了。
解决:先不要猜,打开浏览器开发者工具或后端日志看完整的请求 URL。如果多了一段/v1,就把base-url改成https://api.deepseek.com;如果少了一段,就保留/v1。我自己的习惯是先拿 curl 跑通接口,再参照 curl 的 URL 去配 Spring AI。
5.2 Nginx 开启缓冲:SSE 变成了一次性吐出来
现象:本地联调时打字机效果正常,部署到服务器后,前端等很久才一次性显示完整回答。
原因:Nginx 默认开启proxy_buffering,会把后端流式响应的内容攒在一起,等到连接结束才转发给客户端。SSE 的实时性完全被破坏。
解决:在 Nginx 的 location 里关掉缓冲:
location /api/chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no; }配置说明:proxy_buffering off让 Nginx 收到一块就转发一块,X-Accel-Buffering no是告诉上游服务器不要对响应做额外缓冲。修改配置后记得nginx -s reload。
5.3 上下文无限增长:请求体超过模型窗口限制
现象:对话进行到十几轮后,后端开始报 400 或 413,提示 token 超限,有时是模型返回maximum context length exceeded。
原因:MessageChatMemoryAdvisor虽然提供了窗口截断,但如果窗口设得过大,或者历史消息里包含大量代码块,很快就会触达 DeepSeek 的上下文上限。还有一种是禁用 advisor 后自己手动拼接历史,每次请求把所有消息全发过去。
解决:显式设置 advisor 的窗口大小,例如new MessageChatMemoryAdvisor(chatMemory, 20),并在压测时监控请求体大小。对历史消息做压缩,早期对话摘要成一句话再放进上下文,这是多轮对话上线前必须做的优化。
5.4 deepseek-reasoner 的思考内容被丢掉
现象:切换到大模型的深度思考模式后,前端只能看到最终答案,看不到推理过程,而官方网页端有完整的思考链展示。
原因:DeepSeek 的 reasoner 模型在返回里带了reasoning_content字段,Spring AI 的 OpenAI 兼容解析默认只映射content,reasoning_content在转换时被丢弃了。
解决:如果只是做日常对话,建议继续用deepseek-chat。如果你确实需要把思考过程展示给用户,需要自定义响应解析器,在 ChatModel 层拦截原始响应并把reasoning_content单独取出来。很多团队把思考链当成产品亮点,这条建议值得投入精力。
5.5 前端用 fetch 读流:拿到一段 Buffer 乱码
现象:没用 EventSource,改走fetch+ReadableStream后,页面显示的不是文字而是Uint8Array或乱码。
原因:fetch 拿到的响应体是二进制流,需要用TextDecoder手动解码。EventSource 内部默认按文本处理,所以没有这个问题。
解决:在读取循环里加一个解码器:
const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') while (true) { const { done, value } = await reader.read() if (done) break const text = decoder.decode(value, { stream: true }) // 按 SSE 格式解析 text }逻辑说明:decoder.decode(value, { stream: true })表示当前数据块可能是不完整的多字节字符,需要留到下一块一起解码。全部读完后再调用一次decoder.decode()冲刷缓冲区。前端手写 SSE 解析器不是不能做,但 EventSource 已经覆盖了大部分场景,不建议重复造轮子。
6. 收尾建议:冒烟测试、模型切换与断连重连的最后一公里
6.1 二十行冒烟测试,先跑通三类请求
接入任何大模型,我一般会先写一个测试类,把同步和流式两条链路都验证一遍再动业务代码:
@SpringBootTest class DeepSeekSmokeTest { @Autowired private ChatClient chatClient; @Test void syncChatShouldReturnContent() { String reply = chatClient.prompt() .user("只回复两个字:收到") .call() .content(); Assertions.assertNotNull(reply); } @Test void streamChatShouldEmitChunks() { Flux<String> flux = chatClient.prompt() .user("从1数到5") .stream() .content(); StepVerifier.create(flux) .expectNextCount(1) .verifyComplete(); } }StepVerifier依赖reactor-test,测试时能看到流式数据是否真的分块返回。这一层跑通后,再去调前端,问题定位会清晰很多。
6.2 一键切换模型厂商的配置姿势
DeepSeek、通义、智谱这类平台大多提供 OpenAI 兼容接口,切换时只动配置不动代码:
spring: ai: openai: base-url: ${AI_BASE_URL:https://api.deepseek.com} api-key: ${AI_API_KEY:} chat: options: model: ${AI_MODEL:deepseek-chat}环境变量里把AI_BASE_URL、AI_API_KEY、AI_MODEL三项改掉即可。注意不同厂商对temperature、max_tokens的取值范围有差异,切换后跑一遍冒烟测试最稳。
6.3 前端断连重连:接管重试而不是依赖默认行为
EventSource 默认会自动重连,但重连后消息是重新开始流的,用户可能看到重复内容。更可控的做法是自己管理重连次数:
let retryCount = 0 const MAX_RETRIES = 3 function connect(url, onData) { const es = new EventSource(url) es.onmessage = (event) => { retryCount = 0 onData(event.data) } es.onerror = () => { es.close() if (retryCount < MAX_RETRIES) { retryCount++ setTimeout(() => connect(url, onData), 2000) } } }重试间隔用 2 秒,最多重试 3 次。超过次数后提示用户手动重发,而不是无限重连把服务器打满。
从那以后,我每接一家大模型供应商,都先把同步、流式、上下文这三条链路用冒烟测试跑通,再让前端介入联调;只要对方兼容 OpenAI 协议,这套流程基本能复用到任何一家模型上。希望帮到你。
本文还有配套的精品资源,点击获取