
简介这是一套基于 Spring Boot 与 Spring AI 框架深度集成 DeepSeek 大语言模型的完整前后端代码面向 Java 后端开发者及 AI 应用初学者帮助理解从模型配置到业务集成的全过程。项目采用模块化设计涵盖智能问答、文本生成和语义分析等典型场景前端负责交互展示后端处理业务逻辑与大模型调用适合作为企业级 AI 应用的开发起点。压缩包共含 12 个文件以 9 个 Java 源码文件为主覆盖控制器、服务与配置类另有 YAML 配置文件用于应用与模型参数设定XML 描述依赖关系HTML 页面提供简易交互界面整体仅 25KB轻量且结构清晰。已有 254 人学习。通过代码包读者可快速掌握 DeepSeek 接入 Spring AI 的配置要点、前后端联调方法以及模块化组织思路从环境搭建到接口返回均有清晰示例便于直接运行与二次开发为扩展更多智能化功能打下基础。 如果你已经在用 Spring Boot 做后端突然接到一个“接一下 DeepSeek 大模型弄个聊天页面”的需求第一反应往往是去翻 DeepSeek 的 API 文档然后写个 RestTemplate 或者 WebClient 封装再手动拼 Prompt、手动解析 JSON、手动处理流式返回。这套流程一次两次还好真要做到能上线、能维护问题就来了Prompt 越拼越长上下文管理全靠 List 硬扛换了模型厂商又得重写对接层。Spring AI 的出现就是把这套“对接大模型”的脏活抽象成了类似 Spring Data 的编程模型。这篇文章我直接用完整的后端代码加上前端页面带你把 Spring Boot Spring AI DeepSeek 从依赖引入到前后端联调整个跑通。文章针对的是真实项目落地不是 Demo 级别的一次性调用适合已经写过 Spring Boot、想快速把 AI 能力整合进业务系统的开发者。1. 为什么选 Spring AI 来接 DeepSeek而不是自己封装 HTTP1.1 直接调 DeepSeek API 的三个痛点先说我最早用原生 HTTP 调 DeepSeek 的经历。接口本身是 OpenAI 兼容格式POST 一个 JSON 给/chat/completions返回里带上choices[0].message.content就能拿回答。看着简单但项目一复杂就难受了。第一个痛点是 Prompt 拼接。业务上往往不是“一句话聊天”而是带系统人设、带历史上下文、带用户当前输入。自己写的话你会有一个ListMapString,String来堆消息每次请求前做数组拷贝再手动限制历史轮数这块逻辑写多了全是边界 bug。第二个痛点是流式响应。DeepSeek 支持 SSE 流式返回你需要在 WebClient 里处理text/event-stream逐行解析data:前缀还要自己处理[DONE]结束标志。前端如果要用 EventSource 对接后端还得转成 SseEmitter这一串代码写出来少说两三百行而且每个模型 API 细节都略有不同。第三个痛点是切换成本。今天接 DeepSeek明天可能换成通义千问后天可能要接本地部署的 Ollama。如果对接层是手写的换一家就要改一遍 HTTP 封装、改一遍解析逻辑。项目里所有调用大模型的地方都会跟着遭殃。1.2 Spring AI 把复杂度收在了哪Spring AI 参考了当年 Spring 生态解决数据访问的思路把“对话模型”抽象成ChatClient把“提示词”抽象成Prompt把“模型应答”抽象成ChatResponse。底层管你接的是 DeepSeek、OpenAI 还是 Ollama只要引入对应的 starter然后配置base-url和api-key业务代码几乎不用动。这样做的好处非常明显一是模型厂商的差异被隔离在 spring-ai 的 autoconfigure 里你只需要关注业务二是 Spring AI 自带 ChatMemory 机制专门管理多轮对话的历史不用再手写消息数组拷贝三是从同步调用切换到流式响应只需要把.call()换成.stream()返回值从String变成FluxString这个 API 设计很接近 Spring WebFlux 的习惯后端同学上手没有负担。我实测下来用 Spring AI 接 DeepSeek 最大的感受是代码量少了大概一半而且聊天历史、系统 Prompt、函数调用这些复杂场景都能用统一概念做不用自己造轮子。2. 环境准备与依赖引入2.1 版本选型Spring Boot 3.3.x Spring AI 1.0.0先说版本这是最容易翻车的地方。Spring AI 早期版本迭代非常快0.8.x 时候的 artifactId 和 1.0.0 正式版差很多网上很多教程用的 0.8.x包名和配置项在 1.0 里已经调整过了。我建议直接上 Spring Boot 3.3.x 搭配 Spring AI 1.0.0 及以上版本JDK 用 17 或 21。创建工程时推荐用 Mavenpom.xml里需要加一个 spring-ai 的 BOM方便统一管理版本。核心依赖是spring-ai-starter-model-openai因为 DeepSeek 走的是 OpenAI 兼容接口直接用这个 starter 指向 DeepSeek 的地址就行。如果你用的是 Spring AI 1.0.0 GA 之前的版本对应的 artifactId 可能是spring-ai-openai-spring-boot-starter两者配置方式相同但依赖坐标不同别混。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.5/version relativePath/ /parent properties java.version17/java.version spring-ai.version1.0.0/spring-ai.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/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-starter-model-openai/artifactId /dependency /dependencies注意一个细节spring-ai-starter-model-openai在 1.0.0 里已经默认内置了 Json 相关的序列化能力不需要再单独引 Jackson。如果你的项目里本来就有 Web 依赖也不用担心版本冲突BOM 统一管好了。2.2 application.yml 关键配置依赖引进来之后在application.yml里配置 DeepSeek 的接入参数。这里踩过一个坑很多人把base-url配成https://api.deepseek.com/v1结果请求路径里又被 spring-ai 自动拼了一次/v1/chat/completions导致 404。DeepSeek 官方兼容地址其实可以直接用https://api.deepseek.comSpring AI 的 OpenAI 自动配置会在后面补上完整路径。spring: application: name: spring-ai-deepseek-demo ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.7 max-tokens: 2048api-key这里强烈建议不要硬编码在配置文件里我习惯用环境变量注入本地开发放.env生产环境走配置中心或者 K8s Secret。后面我会单独说生产环境 key 管理的问题这里先记住这个原则。还有一个容易忽略的配置是超时时间。Spring AI 默认的 WebClient 超时可能不够用DeepSeek 在生成较长回复时如果超过默认超时前端会收到 504。可以在配置里加一段spring: ai: openai: chat: options: timeout: 60s但要注意options.timeout在部分版本里不是对网络连接的超时而是对模型响应的整体超时。如果这个字段对你用的版本不生效就要在代码里自定义WebClient的HttpClient超时这个我在第四节联调部分会给出完整做法。3. 后端完整实现从同步聊天到流式输出3.1 ChatClient 的基本用法Spring AI 的入口是ChatClient。以前你可能见过ChatClient.builder(chatModel).build()这种写法在 1.0.0 里可以直接注入ChatClient.BuilderSpring 容器会帮你配置好。最简单的调用是这样的Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient builder.build(); } public String chat(String userMessage) { return chatClient.prompt() .system(你是一个乐于助人的中文AI助手) .user(userMessage) .call() .content(); } }.prompt()里面可以同时设置system和userSpring AI 会帮你组装成符合 OpenAI 格式的 messages 数组。.call()是同步阻塞调用拿到的是完整字符串前端如果是简单的表单提交场景返回 JSON 就够了。很多人第一次用的时候会问那我怎么传多轮历史答案是别自己拼。Spring AI 提供ChatMemory你可以用一个MessageWindow来维护最近 N 轮对话。举个实际例子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(); } }然后用Advisor的时候在prompt里传入会话 IDpublic String chatWithMemory(String conversationId, String userMessage) { return chatClient.prompt() .user(userMessage) .advisors(a - a.param(chat_memory_conversation_id, conversationId)) .call() .content(); }这样上下文就按conversationId隔离了不同用户之间不会串线。InMemoryChatMemory适合单机场景如果部署多实例需要扩展成 Redis 实现。这里我不展开但实际项目一定要考虑不然负载均衡之后记忆会丢。3.2 Controller 里怎么设计接口后端接口我一般设计两个一个是同步返回 JSON适合网页首次加载或非流式场景一个是 SSE 流式返回适合真正的对话效果。先看同步版本RestController RequestMapping(/api/chat) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } PostMapping(/sync) public ResultString syncChat(RequestBody ChatRequest request) { String answer chatService.chatWithMemory(request.conversationId(), request.message()); return Result.success(answer); } }请求体我建议用 Java Record简洁且不可变。比如public record ChatRequest(String conversationId, String message) {}返回结果用一个统一的ResultT包装里面放 code、message、data 三个字段。这样前端处理起来逻辑统一也不用在 Ajax 里到处判断response.data到底存的是字符串还是对象。关于错误码我后面有一节专门讲这里先记住所有和大模型相关的异常在 Controller 层都应该被捕获并转换成对应错误码而不是直接抛 500。3.3 流式对话的 SSE 实现流式是 AI 聊天体验的关键用户不想等十几秒才看到完整回复。Spring AI 的.stream()返回FluxString配合 Spring MVC 的SseEmitter可以逐段推给前端。如果你项目用了 WebFlux那更简单直接返回FluxString即可。但大多数 Spring Boot 老项目还是 MVC所以我这里给出 MVC SseEmitter 的完整写法PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(60_000L); FluxString stream chatClient.prompt() .user(request.message()) .stream() .content(); stream.subscribe( content - { try { emitter.send(SseEmitter.event() .name(message) .data(content)); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); return emitter; }这段代码有几个细节要解释一下。SseEmitter(60_000L)是设置超时时间 60 秒注意这和后端连接 DeepSeek 的超时是两个概念前者是后端到前端的超时后者是后端到模型的超时。emitter.send(SseEmitter.event().name(message).data(content))表示发送一个名字为message的事件。前端如果用原生EventSource需要监听message事件而不是默认的onmessage。这个细节很坑因为 EventSource 默认只处理未命名消息命名消息必须用addEventListener才行。emitter::completeWithError在模型调用出错时触发这样前端就能收到 error 事件并中断 loading 状态避免 UI 一直转圈。不过这种写法有一个隐患SseEmitter在连接中断时如果stream还在继续会往已关闭的 emitter 里写数据而报错。严谨一点的做法是在onCompletion和onTimeout回调里做清理这里为了篇幅先不展开但生产环境建议参考官方文档细调生命周期。4. 前端接入EventSource 与手动 fetch 流式读取4.1 最简单的前端页面前端我直接用原生 HTML JavaScript 来做不依赖框架方便你拿过去就能试。完整页面包含一个输入框、一个发送按钮、一个显示对话区域的容器。先看 HTML 结构!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleDeepSeek 对话测试/title /head body div idchatBox styleheight:400px;overflow-y:auto;border:1px solid #ccc; /div input idmessageInput typetext stylewidth:70% placeholder输入你的问题/ button idsendBtn发送/button button idstopBtn停止/button script srcapp.js/script /body /htmlapp.js里核心是用fetch发起 POST 请求然后通过ReadableStream读取 SSE 数据。这里我为什么不用EventSource因为 EventSource 只支持 GET 请求而我们后端接口是 POST传 JSON body 更方便。所以选择fetch手动解析。一个关键点是fetch不会自动解析 SSE 格式你需要自己处理返回的流。完整代码如下let controller null; async function sendMessage() { const message document.getElementById(messageInput).value; if (!message) return; appendMessage(user, message); const assistantDiv appendMessage(assistant, ); controller new AbortController(); try { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ conversationId: test-001, message }), signal: controller.signal }); 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 }); // 按换行分割 SSE 数据块 const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { if (line.startsWith(data:)) { const data line.substring(5).trim(); if (data [DONE]) continue; assistantDiv.textContent data; scrollToBottom(); } } } } catch (e) { if (e.name AbortError) { assistantDiv.textContent [已停止]; } else { console.error(e); } } }这里有个重要细节SSE 的数据是按\n\n分割的事件每一行可能是data:开头也可能是注释或空行。我的代码里用\n做切分然后把最后一段留到 buffer 里这是为了处理网络分片导致的半行数据。如果不这么做遇到长响应时经常出现尾部文字被截断或者 JSON 解析失败。4.2 流式中断、取消与重连异步场景下用户点了“停止”按钮必须能真正中断请求。上面代码里用了AbortController停止按钮的监听器如下document.getElementById(stopBtn).addEventListener(click, () { if (controller) { controller.abort(); } });fetch的signal传入AbortController.signal后调用abort()会抛AbortError我们在 catch 里捕获并提示用户。如果你更倾向于用 EventSource也可以把后端接口改成 GET或者使用 POST text/event-stream但不兼容 EventSource所以实际项目里用 fetch 是更通用的方案。这里有一点要注意fetch读取流时如果中途断网或者服务器主动断连reader.read()会抛错需要做好错误提示和自动重连机制。简单项目可以不做自动重连但至少要让用户能看到“连接断开”的提示否则容易误以为模型没有回答。关于前后端部署如果前端页面直接由 Spring Boot 的静态资源目录src/main/resources/static提供那么请求路径用相对路径/api/chat/stream就行完全不需要处理跨域。但如果前端单独部署在 3000 端口比如 Vite 开发服务器后端在 8080 端口就必须要处理跨域下一节我会给出配置。5. 前后端联调中必须处理的四个工程问题5.1 跨域配置前后端分离部署CORS 是第一个绕不开的坎。Spring Boot 里配置跨域最简单的是实现WebMvcConfigurerConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:3000) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意这里allowedOrigins不要随便写*因为你开了allowCredentials(true)浏览器要求跨域来源必须具体指定。如果确实需要多环境通用用环境变量读白名单列表。另外SSE 接口的跨域请求也要注意fetch跨域读取流式响应时后端响应头里需要显式带上Cache-Control: no-cache不然一些代理服务器会缓冲整个响应用户就看不到流式效果了。Spring AI 自动配置已经做了大部分事情但如果你自己封装响应头这一点务必加上。5.2 超时设置两处都要管AI 接口慢是常态但超时必须分清楚。前端有前端的超时后端有后端的超时不能混。前端方面fetch默认没有超时时间你可以用AbortSignal.timeout(60000)来设置 60 秒超时但注意这样就没法手动中断了。更好的做法是结合AbortController手动实现超时const timeoutId setTimeout(() controller.abort(), 60000); // 请求结束后 clearTimeout(timeoutId)后端方面除了前面提到的 Spring AI 配置项我强烈建议在代码里自定义一个带有超时的WebClient然后传给OpenAiChatModel。Spring AI 的OpenAiChatModel构造函数需要OpenAiApi你可以这样定制Bean public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi) { return OpenAiChatModel.builder() .openAiApi(openAiApi) .defaultOptions(OpenAiChatOptions.builder() .model(deepseek-chat) .build()) .build(); }不过 Spring AI 1.0.0 大多数配置都能通过 yml 搞定。如果你实在需要自定义超时直接修改WebClient的HttpClient更底层Bean public HttpClient httpClient() { return HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) .responseTimeout(Duration.ofSeconds(60)); }这样连接超时 10 秒响应超时 60 秒基本覆盖了 DeepSeek 通常的响应时间。5.3 并发控制不要把所有线程都堵在大模型上同步调用.call()会阻塞 Tomcat 的工作线程。如果同时有几十个人在聊天每个请求都要等模型生成完可能三五秒甚至更长线程池很快会被占满整个应用其它接口也跟着卡住。解决办法有几个方向一是把接口改成流式异步.stream()返回后立刻释放线程给容器SSE 推送不占 Tomcat 线程二是给 AI 接口单独配置线程池隔离比如用Async 自定义ThreadPoolTaskExecutor三是在网关或应用层做并发限流配合信号量控制同时调用大模型的请求数。我自己的习惯是聊天接口必须用流式然后加一个简单的并发信号量比如最多同时 20 个请求在调大模型超过直接返回“系统繁忙”错误码。这样既保护了 DeepSeek 的配额也保护了后端应用不被拖垮。5.4 错误码统一DeepSeek 接口的错误和标准 OpenAI 类似常见的有 401API key 错误、429触发速率限制、500/502模型服务端异常。如果这些错误直接透传给前端前端拿到的是网络层错误没法给用户友好提示。所以我一般定义如下错误码public enum ErrorCode { SUCCESS(0, ok), AUTH_ERROR(401, API Key 无效), RATE_LIMITED(429, 请求过于频繁请稍后再试), MODEL_ERROR(500, 模型服务异常请稍后再试), TIMEOUT(504, 模型响应超时), STREAM_BROKEN(1001, 连接已断开); public final int code; public final String message; ErrorCode(int code, String message) { this.code code; this.message message; } }然后在全局异常处理器里捕获 Spring AI 抛出的OpenAiApiException、ResponseErrorException按状态码映射成上面的错误码。前端拿到非 0 的 code 后直接弹错误信息不要继续往对话区追加内容。6. 我踩过的坑和留给你的建议6.1 模型名别配成 deepseek-chat 以外的东西DeepSeek 的 API 模型名是deepseek-chat有些鱼目混珠的教程会让你填deepseek-coder或者deepseek-reasoner但其实 DeepSeek 官方开放平台上对应模型 ID 是deepseek-chat和deepseek-reasoner。如果你用旧版的deepseek-coder大概率会返回 400 模型不存在。我建议始终以官方文档为准接入前先看一眼模型列表别凭记忆填。另一个容易踩坑的是base-url尾部斜杠问题。我之前配成https://api.deepseek.com/Spring AI 拼路径时可能出现双斜杠虽然很多 HttpClient 能容忍但有些网关会直接 404。稳妥做法是去掉尾部斜杠。6.2 上下文记忆别自己拼字符串前面我已经展示过ChatMemoryMessageChatMemoryAdvisor的用法这里再强调一下为什么不要自己拼。很多人图省事把历史消息直接用换行符拼成一个字符串塞进 user 消息里这样短期看没毛病但问题是模型无法区分哪部分是历史哪部分是当前指令遇到长对话时很容易“记住”了不该记的东西而且 token 浪费特别严重。用ChatMemory的好处是它按conversationId管理消息列表可以精确控制保留窗口大小比如只保留最近 10 轮。同时它对每一轮消息有明确的 user/assistant 角色标记模型理解上下文更准确。如果你要上生产建议自己实现一个ChatMemory接口把消息存到 Redis配合 TTL 做自动过期。6.3 生产环境的 Key 管理这个问题我觉得值得多说一句。开发阶段把DEEPSEEK_API_KEY放环境变量已经很好了但生产环境如果多个微服务都要调用最好把 key 放到配置中心统一管理并设置定期轮换。你还要在 DeepSeek 开放平台里设定消费上限防止 key 泄露后被刷爆。另外前端代码里绝对不能出现 api-key 或者任何代理地址所有和 DeepSeek 的通信必须经由后端。我见过一些网上示例直接把 key 写在application.yml里前端通过接口返回 key这是非常危险的做法。要时刻记住大模型 API 的 key 就是钱暴露一次就有被刷爆的风险。最后再分享一个小技巧。如果你要给多套环境做配置可以用spring.config.activate.on-profile分离开发和生产配置比如开发环境用deepseek-chat模型生产环境切到deepseek-reasoner过程中业务代码完全不用改。Spring AI 的模型配置只占application.yml几行但换来的是整个项目接入 LLM 能力的高度可维护性这套组合我用了大半年目前看是最省心的方案。本文还有配套的精品资源点击获取