简介这是一份基于Spring Boot与Spring AI框架深度集成DeepSeek大语言模型的完整前后端代码示例面向正在学习Java企业级开发与AI应用落地的开发者。项目采用模块化设计前端负责用户交互与展示后端承担业务逻辑与模型调用清晰演示了智能问答、文本生成、语义分析等典型场景的完整链路。压缩包内仅12个文件包含9个Java源码文件、1个YAML配置、1个XML配置和1个HTML页面整体约25KB结构精简适合直接对照学习或二次扩展。已有254人学习。通过分析源码可掌握Spring AI的模型配置、服务调用及前后端联调方式理解如何将DeepSeek等大模型与传统Spring Boot项目结合并善用起步依赖、自动配置、模块划分等最佳实践从而快速搭建AI驱动的企业级应用为后续定制化开发提供清晰可复用的起点企业也能在此基础上扩展更多AI能力。1. 先别急着写 HTTP 封装Spring Boot 调 DeepSeek 之前先把 Spring AI 这套抽象用起来Spring Boot 加 Spring AI 深度实战基于 DeepSeek 做一套带完整代码的前端后端应用听起来是个大工程实际核心只有两件事把 DeepSeek 接入 Spring Boot 的 AI 抽象层再让前端以流式方式展示结果。很多后端拿到 API Key 就先写 RestTemplate 封装、解析 choices、处理 SSE代码越滚越多换一个模型又得重来。Spring AI 的定位是收敛这种重复把模型调用变成 ChatClient 接口而 DeepSeek 兼容 OpenAI 协议可以直接复用 OpenAI Starter 并改 base-url。下面会从选型、后端、前端一直讲到踩坑点适合正在搭 AI 问答功能的后端开发也适合想把完整代码跑起来的前端同学。2. 选型与架构为什么用 Spring AI 接 DeepSeek而不是自己写 HTTP 调用2.1 三方各管一段Spring Boot、Spring AI、DeepSeek 的分工先把这张“黑匣子”揭开。Spring Boot 负责的是外围HTTP 接口、参数校验、异常处理、配置注入这些你早就熟悉不用多讲。Spring AI 是 Spring 官方推出的 AI 集成框架它把各家模型的请求协议封装成一个统一接口。你在代码里只需要面向 ChatClient 写业务具体哪个模型在后面跑由配置决定。DeepSeek 在中间扮演的是模型服务方它对外提供的 API 兼容 OpenAI 的消息格式。正是因为这种兼容所以才不需要等一个“DeepSeek 官方 Starter”直接用 Spring AI 的 OpenAI 模块把 base-url 指到 DeepSeek 的接口地址就可以把 DeepSeek 跑起来。为什么不建议自己写 HTTP 调用我见过不少项目起手先封装一个DeepSeekClient里面有chat()方法、streamChat()方法还要维护一轮又一轮的消息数组。写到第三个月需求变成“带上历史上下文”你发现消息数组要另外存需求再变成“流式打字机”你又要自己处理 Server-Sent Events需求再变成“切换模型”你的封装要改。Spring AI 把这些都做成了默认能力真正需要你写的只剩下三件依赖、配置、业务方法。这不是追新是减少重复劳动。2.2 后端接口设计同步接口和流式接口各留一个就行前后端联调的时候最简单可行的方案是后端只暴露两个接口。/api/chat/sync负责一次性返回完整回答方便前端做初始渲染、单元测试、后处理/api/chat/stream负责返回text/event-stream格式的流式数据让前端实现逐字输出。两个接口内部共用同一个 ChatClient只是调用方式不同。为什么要留同步接口Streaming 调试时很难判断是后端没接好还是前端解析问题。同步接口一把梭能快速定位问题在哪一侧。下面是我的工程结构通常一个聊天功能只需要这几个文件com.example.deepseek ├── controller/ChatController.java ├── service/ChatService.java ├── config/DeepSeekConfig.java └── resources/application.ymlController 层只负责RestController接收参数、设置响应类型不直接碰 ChatClient。Service 层负责组装 Prompt、管理上下文是业务逻辑的落脚点。Config 层用来创建带默认 System 消息的 ChatClient Bean。这样层次清楚后面加缓存、加权限都有地方放。2.3 预设 Prompt 放在配置层而不是散落在 Controller 里很多人一开始把 System 提示词直接写在prompt()调用里比如chatClient.prompt() .system(你是一名数据库专家) .user(message) .call() .content();这样写对小 Demo 没问题但真实项目马上会遇到痛点同一个接口要服务多种场景比如“SQL 生成”和“代码审查”System 模板长了以后Controller 里全是字符串拼接阅读体验很差。我一般会用ChatClient.Builder在 Config 层写默认 System然后再在具体的 Service 方法里覆盖或追加。比如Configuration public class DeepSeekConfig { Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem(你是一名资深后端工程师回答问题要尽量给出可运行代码代码用 markdown 标注。) .build(); } }这里的关键点是ChatClient.Builder是 Spring AI 自动装配的你只管注入。这个 Bean 创建后整个应用里任何地方都可以注入ChatClient不需要知道背后是 DeepSeek 还是别家。defaultSystem()是默认系统提示词所有对话都会带上。如果某个业务场景想要不同的人设可以在调用时再用.system()覆盖Spring AI 会采用就近的那一层。这样设计的好处很直接前端不需要关心后端用什么模型后端也不用关心前端什么时候改需求。3. 后端完整实现从 application.yml 到 SSE 流式接口3.1 Maven 依赖一个 Starter 就够不需要手动拼 HTTP Client先看 pom.xml。Spring Boot 建议用 3.2 以上因为 Spring AI 1.x 是基于 Spring Boot 3.2 的自动配置体系做的。完整依赖只需要一个 Spring AI 的 OpenAI starter 以及 Spring Web StarterdependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency /dependencies看见spring-ai-openai不要慌它不是只连 OpenAI 官方。Spring AI 的 OpenAI 模块遵循 OpenAI 请求格式而 DeepSeek 的 API 就是这个格式所以靠修改base-url就能复用。版本选择上我建议直接使用 1.0.0 GA 版本不要去追 0.8.x 的 Maven 快照因为 0.8 到 1.0 之间改了很多 API 签名很多网上的示例是旧写法抄过来会编译失败。3.2 application.yml 参数base-url 别再拼 /v1Spring AI 的自动配置会将spring.ai.openai.*映射到OpenAiApi然后生成ChatModel、ChatClient.Builder。下面是我在项目里使用的配置spring: application: name: deepseek-chat ai: openai: # 不要追加 /v1Spring AI 会自动补齐 base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.7 max-tokens: 2048三点说明第一base-url填https://api.deepseek.com不要填https://api.deepseek.com/v1。Spring AI 在内部拼接路径时会自动补上版本段和/chat/completions。如果你填了带/v1的地址实际请求可能会变成/v1/v1/chat/completions只会换来 404。第二api-key通过环境变量注入不要硬编码到 YAML 提交到代码仓库否则 Key 泄露一次就要去控制台重置。第三model我填的是deepseek-chat这是 DeepSeek 公开的对话模型名如果你的业务要切换为推理模型改这里即可Controller 不需要变。3.3 Service 和 ControllerChatClient 的同步调用与流式调用核心代码来了。Service 只做两件事接收前端消息调用 ChatClient。同步调用用call()流式调用用stream()。Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient chatClient) { this.chatClient chatClient; } // 同步调用等待完整回答返回 public String chatSync(String message) { return chatClient.prompt() .user(message) .call() .content(); } // 流式调用按顺序返回文本片段 public FluxString chatStream(String message) { return chatClient.prompt() .user(message) .stream() .content(); } }chatClient.prompt()是每次请求的组装入口.user(message)传用户消息.call()发起同步请求并返回ChatResponse.content()取出回答文本。流式版本返回的是FluxString也就是一串按顺序到达的文本片段Spring Web 可以直接把Flux写进响应流不需要我们手动循环。这里的Flux是 Project Reactor 的响应式流类型已经在 Spring Boot Web 依赖中传递进来了。Controller 是这样对应收到的RestController RequestMapping(/api/chat) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } GetMapping(/sync) public String sync(RequestParam String message) { return chatService.chatSync(message); } // SSE 响应必须指定 text/event-stream GetMapping(value /stream, produces text/event-stream;charsetutf-8) public FluxString stream(RequestParam String message) { return chatService.chatStream(message); } }两个关键点。第一流式接口必须声明produces text/event-stream;charsetutf-8让浏览器知道这是一条持续的 SSE 流而不是一个普通 JSON。第二方法返回FluxString而不是StringSpring MVC 会感知到返回类型是响应式流自动在每个元素前拼上data:前缀并加上空行这是 SSE 的标准格式。如果你在代码里手动拼接data:前缀反而可能把格式搞成两层前端解析时会出问题。3.4 给流式响应包一层错误兜底别让 Flux 直接裸奔流式接口最容易翻车的地方是调用中途报错。比如网络闪断、API Key 失效、Token 超限此时Flux会把异常直接带给前端前端拿到一半内容后连接突然变成错误。建议在 Service 层面做一个兜底public FluxString chatStreamSafe(String message) { return chatClient.prompt() .user(message) .stream() .content() .onErrorResume(e - { log.error(DeepSeek stream error: , e); return Flux.just( [出错了] e.getMessage()); }); }onErrorResume的作用是上游产生异常时不再把错误抛给前端而是换成一个普通文本片段返回。这样前端不用区分“正常结束”和“异常结束”统一按流式文本展示即可。代价是这个兜底文案会混在回答里需要在错误片段上加上明显标记方便前端识别。我的习惯是让错误片段以[开头前端可以在渲染时用不同颜色标出来。4. 前端承接流式响应的最小实现HTML JS 不用框架也能跑4.1 为什么 axios 拿不到逐字输出只能用 fetch 去读流如果你用 axios 调用/api/chat/stream会发现它要等整个请求结束之后才在then里拿到完整字符串这意味着用户必须等模型全部答完才能看到内容。模型速度稍慢用户就会以为页面坏了。后端把响应改为 SSE 之后前端需要换一种方式用fetch拿到底层Response再用response.body.getReader()不断读取数据块逐块渲染。fetch是浏览器原生 API天然支持流式读取axios 默认走 XMLHttpRequest虽然也能接onDownloadProgress但处理 SSE 的分帧格式比较别扭。为了少踩一次坑直接用fetch。4.2 一个可直接复用的纯 JS SSE 读取函数下面这个函数可以直接粘到 HTML 里跑。它会一直读流把每段data:解出来更新到页面元素上。async function chatWithStream(message) { const res await fetch(/api/chat/stream?message encodeURIComponent(message)); if (!res.ok) { output.textContent HTTP res.status; return; } const reader res.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; let content ; while (true) { const { done, value } await reader.read(); if (done) break; // stream: true 解决中文跨字节包被截断 buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); // 保留末尾不完整的行 for (const line of lines) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.slice(5).trim(); if (data [DONE]) continue; content data; output.textContent content; window.scrollTo(0, document.body.scrollHeight); } } }逻辑拆开看reader.read()每次拿到一个字节块TextDecoder.decode(value, { stream: true })是关键它可以处理一个字符被拆到两个字节块的情况。buffer.split(\n)按行切分因为 SSE 的data行以换行结束。末尾可能留下半行所以lines.pop()把它放回 buffer下一轮再拼。每行以data:开头直接取后边的文本。这里的前端接口是 Spring AI 包装后的格式所以每个data段已经是完整文本片段不需要再用JSON.parse去解析choices[0].delta.content。如果你拿到的后端接口直接转发 DeepSeek 原始响应那data:后面会跟一个 JSON 对象需要改成JSON.parse(data).choices[0].delta.content || 才能取到文本。这就是项目里到底让后端做协议解析、还是让前端做单次解析的取舍。我建议放在后端让前端保持简单。4.3 完整 HTML 页面放到 Spring Boot static 目录就能访问下面是一份能直接跑通链路的最小页面。不要直接双击 HTML 文件用file://打开因为/api/chat/stream是相对路径需要由 Web 服务提供。最简单的方式是把这个文件放到src/main/resources/static/index.html启动 Spring Boot 后访问http://localhost:8080即可。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleDeepSeek Chat Demo/title style body { max-width: 720px; margin: 40px auto; font-family: sans-serif; } #output { white-space: pre-wrap; border: 1px solid #ddd; min-height: 240px; padding: 12px; } /style /head body div idoutput/div input idmsg typetext placeholder输入问题 stylewidth:80% / button onclicksend()发送/button script const output document.getElementById(output); const msg document.getElementById(msg); async function send() { const message msg.value.trim(); if (!message) return; msg.value ; output.textContent ; await chatWithStream(message); } async function chatWithStream(message) { /* 见上面的函数 */ } /script /body /html这段页面连 CSS 都是最简的重点在测试chatWithStream。后端一启动访问根路径就是聊天界面不需要额外配前端工程。前端代码和后端代码在同一个项目里也绕开了跨域问题。4.4 跨域配置后端两步前端零改动如果你用 Vite 启动前端开发服务器默认跑在localhost:5173直接请求localhost:8080会触发 CORS。网上搜索出来的方案大多是让你在 Vite 里配转发但这一步对于很多新手来说是后悔药都救不回来的坑。更简单、更可控的办法是后端直接允许本地开发域的跨域请求前端只管fetch(/api/chat/stream)即可。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173, http://127.0.0.1:5173) .allowedMethods(GET, POST, OPTIONS); } }这段配置可以放在之前说的 Config 包里。addMapping(/api/**)表示只对/api下的接口生效allowedOrigins列一个白名单不要写成*因为浏览器遇到通配符在某些带 Cookie 的场景会拒绝。在这里我们只是文本流接口不涉及 Cookie但养成白名单习惯没有坏处。配完之后前端的 fetch 请求会自动先发一个 OPTIONS 预检Spring MVC 会返回允许跨域的响应头后续真正的 GET 请求就畅通了。5. 避坑指南DeepSeek 接入 Spring AI 最容易被绊倒的 5 个位置5.1 ChatClient 编译报错接口方法签名对不上现象引入依赖后注入ChatClient时 IDE 提示找不到 Bean或者编译报错“ChatClient is not a functional interface”。更典型的是照着网上代码写chatClient.call()编译器说没有这个方法。原因Spring AI 的版本演进很快。0.8.x 版本的ChatClient是接口1.0.0 又把接口重新组织过旧代码里把ChatClient当作函数式接口用到新版本就全对不上了。网上大量博客停留在 0.8 时代有的又跳到 1.0 快照版混着抄必踩坑。解决先确认你项目里实际引入的 Spring AI 版本。我的经验是统一用 Spring Boot 3.3.x 加 Spring AI 1.0.0 GA然后按当前版本文档的示例写。ChatClient应该像上面那样通过构造器注入不要在业务类里手动 new。如果用的是旧版建议直接升级而不是在旧版上打补丁因为后面还有 API 会被替换。5.2 请求地址变两次base-url 重复拼 /v1现象后端日志里看到的请求 URL 是https://api.deepseek.com/v1/v1/chat/completions返回 404或者你只配了api-key没配 base-url请求飞到 OpenAI 官方被拒为 invalid api key。原因DeepSeek 的文档同时提到https://api.deepseek.com和https://api.deepseek.com/v1都可用但 Spring AI 的 OpenAI 模块内部已经有补齐/v1的逻辑。两边一叠加URL 就多了一段。我最初照着文档写确实翻车了后来看日志才发现 URL 重复。解决在application.yml里把base-url统一写成https://api.deepseek.com。如果调试时不确定实际请求地址可以在配置里加一行logging.level.org.springframework.aiDEBUG和logging.level.org.springframework.web.clientDEBUG启动后发起一次请求看日志里打印的完整请求地址和响应状态。这是排查协议层次问题最直接的方式。5.3 流式响应中途断开不是后端 bug而是 Nginx 缓冲现象本地 Start 后端前端请求流式接口前几百毫秒内容正常突然连接被断开页面只显示了一半回答。后端日志也没有异常。把请求发到测试环境整个回答又完整可用。原因本地开发一般没有中间层干扰测试环境很多团队会用 Nginx 统一入口。Nginx 默认会对上游响应做缓冲SSE 是持续输出Nginx 会把它攒到一定大小再发给前端。模型输出稍微磨蹭一点前端等不到合并缓冲区连接就超时断了。解决如果你在用 Nginx 暴露后端接口需要在该路由加两行配置proxy_buffering off; proxy_cache off;再设置一下proxy_read_timeout 300s避免长时间没有新 token 时连接被杀掉。本地联调时如果也遇到类似现象先确认是不是浏览器开发者工具里显示的响应状态有FIN标记一般把 Nginx 缓冲关掉就能解决。这是运维侧的血泪经验后端代码不一定有问题。5.4 模型记忆错乱没加 ChatMemory上下文全混在一起现象第一次问“你叫什么”第二次问“我刚才说了什么”模型答不上来或者回答里出现上一次用户隐私信息。更隐蔽的是一个接口服务多个用户用户 A 的问题混进了用户 B 的回答。原因Spring AI 提供的ChatClient默认是无状态的一次性请求。你不主动放消息历史模型就只看到当前这条 Prompt。很多人以为模型自带记忆实际上它在开一次请求时是“健忘”的。如果用了某个全局变量把消息数组往 Prompt 里拼又没有按用户隔离就会出现串话。解决将上下文交由 ChatMemory 管理。下一章会给出具体做法。这里先说原则每条消息都要带一个sessionId后端用sessionId隔离每个人的对话窗口窗口大小要设置上限比如最近 20 条防止 Token 超限。如果你暂时不想引新组件也可以自己在 Redis 里存 List但别用静态成员变量。5.5 前端拿到 SSE 的 data 字段只有 [DONE] 却没有内容现象控制台 Network 面板里请求返回了text/event-stream最后一个data:[DONE]也在但页面内容始终是空白。原因有两种常见情况。第一前端的lines分割没有考虑 SSE 事件之间的空行把data:后面的一段空行也当成内容塞进页面。第二后端返回的回调没有把内容发出来比如你用的是chatClient.prompt()...stream(),但没调用.content()导致 Flux 流里放的是完整ChatResponse而不是文本片段前端拿到的 data 是{candidates:[...]}而没有解析它来取文本。解决先用 curl 直接打流式接口看返回结构。curl -N --no-buffer http://localhost:8080/api/chat/stream?messagehellocurl 返回的内容如果是一行一个字符串说明后端已经把文本拆好了前端按字符串处理就行如果看到的是 JSON说明后端还在原始响应层面需要调整 Service 返回FluxString让 Spring AI 先拆好内容。用这个命令排查比在前端打 log 快得多。6. 进阶给 DeepSeek 加记忆窗口并用 3 秒验证链路6.1 用 ChatMemory 把“重开会话”做成前端按钮说完避坑这里放一个我个人推荐的最小记忆方案。Spring AI 1.0 后提供了ChatMemory接口配合MessageChatMemoryAdvisor使用可以把消息历史放到内存按一个 Key 区分会话。配置代码非常简单Configuration public class ChatMemoryConfig { Bean ChatMemory chatMemory() { return new InMemoryChatMemory(); } }在原来的DeepSeekConfig里给ChatClient加上 advisorBean ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultSystem(你是资深后端工程师) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); }业务调用时前端额外传一个sessionId后端把它作为 advisor 参数带进提示词public FluxString chatWithMemory(String sessionId, String message) { return chatClient.prompt() .user(message) .advisors(a - a.param(MessageChatMemoryAdvisor.CHAT_MEMORY_KEY, sessionId)) .stream() .content(); }这样前端的“新会话”按钮就不需要后端清空任何东西只要重新生成一个sessionId旧的对话就不会被带入新的请求。当前端重新传同一个sessionId对话上下文又能恢复。这个方案在单机演示、小流量业务里完全够用。要落地到生产建议把ChatMemory的实现替换成 Redis 或数据库版本接口层面不用动。6.2 链路预感用 curl -N 判断是后端慢还是前端慢最后分享一个我每次接新模型都会做的验证curl -N --no-buffer http://localhost:8080/api/chat/stream?message用三句话介绍DeepSeek-N告诉 curl 别缓冲输出。执行后如果第一句话在 1~2 秒内出现在终端说明后端、模型、网络的链路基本正常。如果等 5 秒后一次性冒出全文说明有缓冲层在攒包先查 Nginx 关掉缓冲如果 30 秒都没有首个字符再看后端日志有没有发出请求没有就检查 api-key、base-url 这两个变量。这套检查方法能让你快速区分问题出在哪一段。6.3 参数调优温度与长度需要一起调调用 DeepSeek 时temperature和max-tokens是相互影响的。生成代码这类确定性任务temperature建议 0.2~0.4做文案泛化可调到 0.8 以上。max-tokens不只是一个上限还决定了回复能写多细致如果你发现回答总是“话说到一半”先把 2048 提到 4096 试一下。这俩参数可以在application.yml里直接改也可以运行时由前端传入覆盖。需要记住的是每个值的改动都要用同一批问题去回归否则你根本分不清是调参起了作用还是模型本身带随机性。我接 DeepSeek 这一路最深的一个感受是AI 后端代码的坑很少在“原理”上基本都在“协议兼容层”和“版本差异”上。项目刚开始时我以为难点是写复杂业务后来发现先把版本钉住、把 base-url 钉住、把数据格式钉住后面就是普通 Spring 开发了。这套方法我用了很久希望帮到你。本文还有配套的精品资源点击获取
