Spring Boot + Spring AI + DeepSeek实战:Java生态快速集成大模型
简介这是一份基于Spring Boot与Spring AI框架、接入DeepSeek大语言模型的完整实战代码面向Java后端开发者和AI应用落地人员。资源围绕智能问答、文本生成与语义分析三类典型应用场景采用前后端分离的模块化设计后端统一封装模型调用与业务逻辑前端通过简洁页面完成交互与结果展示整体结构清晰适合作为企业级AI应用开发的入门范本。压缩包共12个文件包括9个Java源文件、1个yml配置、1个xml依赖和1个HTML页面Java代码覆盖服务封装、提示词构建、模型请求与响应处理等环节yml与xml负责模型参数和依赖管理HTML页面用于快速验证功能整包仅25KB轻量易读便于梳理从请求到模型返回的完整链路。已有254人浏览学习代码遵循Spring Boot工程化实践能帮助读者掌握大模型与传统后端框架的集成思路学习模块划分、配置外部化与轻量界面搭建等可复用方法为后续接入更多AI能力或替换其他大模型提供参考基础。 最近做AI应用集成我一直被一个问题困扰Java后端要接大模型难道非得用Python写一堆胶水代码后来我把方案落到了Spring Boot Spring AI DeepSeek上前后端完整跑通了效果比我预期好很多。这篇文章就把这套实战代码和思路整理出来包含项目结构、核心配置、后端接口、前端页面以及几个不翻文档根本发现不了的坑。这个项目解决什么问题简单说就是让Java体系内的开发者不用离开Spring生态也能轻松调通DeepSeek大模型。它既能支撑一个简单的聊天页面也能作为后续RAG、Function Calling、NL2SQL等功能的地基。适合正在做AI应用集成、想快速DeepSeek API入门、或者打算在业务系统里塞一个“AI助手”按钮的Java工程师。1. 整体设计思路为什么选Spring AI做中间层1.1 直接HTTP调用和Spring AI的取舍DeepSeek的API本质上是OpenAI兼容协议所以很多人的第一反应是直接用HttpClient打个POST请求不就行了确实能通但问题在于一个正经的AI应用不只是“调一次API”。多轮对话时你要自己维护messages历史数组把user、assistant的对话记录来回传。流式对话时你要手工解析SSE格式的data流还得处理每段JSON的截断问题。再加上超时重试、并发控制、模型切换代码会迅速失控。我之前用原生HTTP写过一次光SSE解析那部分就写了快两百行后面加需求时根本不想维护。Spring AI的价值就是把这些脏活累活抽象成了ChatClient、ChatModel、Message、Prompt这一套标准编程模型。你只需要面向ChatClient写业务逻辑底层的协议封装、响应解析、流式处理都由框架处理。就像用Spring Data JPA操作数据库你不需要关心JDBC连接怎么写一样。很多人用过ccswitch、deepseek harness这类工具给编辑器接入DeepSeek本质上就是配置base-url、api-key、model三个关键参数。Spring Boot里接DeepSeek也是同一个逻辑只是多了一层Spring AI帮我们管好了请求生命周期。1.2 技术栈选型与版本搭配我这套方案的技术栈是这样搭配的JDK 17 Spring Boot 3.2.xSpring AI 1.0基于Spring Framework 6.1要求JDK 17起所以Spring Boot 2.x用户需要先升级。Spring AI 1.0.0稳定版这是目前市面上用得最稳的版本2.0还在迭代中API有不少调整。后面章节我会单独提到版本差异。DeepSeek API按token计费个人项目调试成本很低而且不需要本地GPU资源。前端原生HTML JavaScript我记得这个项目核心是演示后端AI集成的完整链路前端不做框架约束用原生页面反而最能看清数据是怎么流动的。还有一个选择要点Spring AI官方并没有单独的deepseek-starter但DeepSeek兼容OpenAI协议所以引入的是spring-ai-starter-model-openai然后把base-url指向DeepSeek的接口地址就行。这也是这套方案最巧妙的地方切换模型厂商往往只改一行配置。2. 环境准备与项目骨架搭建2.1 Maven依赖与BOM版本管理需要用Maven方式构建Spring Boot项目第一步就是把依赖坐标搞对。Spring AI的依赖包很多最好用BOM统一管理版本避免各个子模块版本不一致导致奇怪的兼容性问题。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.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 dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency /dependencies这里有两个容易踩的坑。第一Spring AI 1.0.0正式版已经发布到Maven Central不需要额外配置仓库但如果你用的是RC版或者M版本需要在pom里加Spring的里程碑仓库。第二artifactId是starter-model-openai不是starter-model-deepseek因为DeepSeek目前走的是OpenAI兼容通道。我第一次找DeepSeek专用starter找了大半天结果发现方向就错了。2.2 配置文件的落地与目录规范配置文件这步最关键直接决定能不能连上DeepSeek。建议把API密钥放到环境变量里不要硬编码在yml中否则代码一旦泄露密钥就暴露了。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: 2048 model: chat: openai注意base-url只需要填写到域名根路径不需要带 /v1Spring AI的OpenAI客户端会自动拼接。model这里我用的deepseek-chat对应DeepSeek的通用对话模型便宜且响应快。如果你要深度推理场景可以换成deepseek-reasoner但后面我会讲到它有一个大坑。项目目录我建议遵循Spring Boot标准分层同时单独拆出一个包放AI相关的配置和客户端这样后续扩展RAG、Function Calling时不会污染业务代码。src/main/java/com/example/ai/ ├── AIApplication.java ├── config/ │ └── ChatConfig.java ├── controller/ │ └── ChatController.java ├── service/ │ └── ChatService.java └── common/ └── ChatRequest.java src/main/resources/ ├── application.yml └── static/ └── index.html3. 后端核心代码实现3.1 构建可复用的ChatClient实例Spring AI的用法里ChatClient是个门面它整合了模型调用、提示词模板、参数传递的全过程。我们通过自动注入的ChatClient.Builder来构建一个带默认系统提示词的实例。系统提示词很关键它决定了整个对话的基调相当于给AI设定角色。Configuration public class ChatConfig { Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem(你是一位专业的Java技术博主回答问题时请保持简洁、准确、条理清晰。) .build(); } }我演示用的是系统提示词但如果你做的是客服机器人建议把系统提示词设计得更详细包括语气、禁忌词、回复长度等。Spring AI也支持通过prompt().system()动态指定适合多场景复用同一个ChatClient的情况。3.2 普通对话接口与多轮上下文处理后端接口设计上我提供一个同步对话接口和一个流式对话接口。同步接口适合简单问答流式接口适合聊天页面。这里需要处理一个重要问题DeepSeek API本身是无状态的多轮对话时你必须把历史消息传给它。最简单可靠的方式是前端把历史对话数组传过来后端原样透传给大模型。消息对象只用三个字段roleuser/assistant、content、name可选。Spring AI的Message接口封装了这个约定我们直接用UserMessage、AssistantMessage组装即可。public record ChatRequest(String message, ListMapString, String history) { }RestController RequestMapping(/api/chat) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping(/sync) public MapString, String syncChat(RequestBody ChatRequest request) { String reply chatClient.prompt() .user(request.message()) .call() .content(); return Map.of(reply, reply null ? : reply); } }上面的代码只处理单轮对话。要做多轮可以在prompt()调用时传入完整的历史消息列表让模型根据上下文生成回复。Spring AI的ChatClient支持通过messages()方法传入List 这样每次请求都携带完整上下文。因为我这个演示项目里历史让前端维护后端接口保持简洁所以只接收message字段。实际业务系统中你可以引入ChatMemory抽象来管理会话历史避免前端每次传一长串历史数组。3.3 流式对话接口的SSE返回流式对话是大模型应用体验的分水岭。用过ChatGPT的人都知道等待一整段文字出来才显示那种体验是灾难性的。这里用Spring AI的stream()方法返回Flux 响应流配合Spring Web的SSE支持每个token生成后立刻推给前端。PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString streamChat(RequestBody ChatRequest request) { return chatClient.prompt() .user(request.message()) .stream() .content() .map(content - content null ? : content); }这里有一个容易忽略的细节返回类型必须是Flux 不能是其他类型否则Spring Web不会按SSE格式输出。还有一点produces要明确指定为TEXT_EVENT_STREAM_VALUE不然浏览器解析流式响应时容易出问题。4. 前端页面实现从表单到流式渲染4.1 页面基础结构与消息展示前端我特意没有用Vue、React这类框架纯原生HTML就能跑通。整个页面就三块消息展示区、输入框、发送按钮。消息区动态渲染用户和AI的对话气泡这个逻辑比较简单但有一个地方要注意用户消息立即插入AI回复则在流式接收过程中逐字追加而不是等到全部接收完成后再一次性渲染。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleSpring AI DeepSeek 聊天/title style body { max-width: 800px; margin: 40px auto; font-family: system-ui, sans-serif; } #messages { border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; height: 500px; overflow-y: auto; margin-bottom: 16px; } .message { margin-bottom: 12px; padding: 10px 14px; border-radius: 10px; white-space: pre-wrap; word-break: break-word; } .user { background: #2563eb; color: white; margin-left: 40px; } .ai { background: #f3f4f6; color: #111827; margin-right: 40px; } #inputArea { display: flex; gap: 8px; } #messageInput { flex: 1; padding: 12px; border-radius: 8px; border: 1px solid #d1d5db; font-size: 14px; } #sendBtn { padding: 12px 24px; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; } /style /head body h2Spring AI DeepSeek Chat/h2 div idmessages/div div idinputArea input idmessageInput typetext placeholder输入你的问题按回车发送... button idsendBtn发送/button /div /body /html4.2 使用fetch处理SSE流式响应流式接收这块是整个前端最核心的部分。Spring AI返回的SSE格式每一段是这样的data:{content:你好}\n\n注意这里每段事件之间用空行分隔。前端用fetch拿到响应体后通过ReadableStream读取字节流再按行拆解找出data开头的数据。这里有一个必须注意的细节中文在UTF-8编码下可能被截断在流的边界上所以必须用TextDecoder的stream模式解码否则会出现乱码。const sendBtn document.getElementById(sendBtn); const messages document.getElementById(messages); const input document.getElementById(messageInput); function appendMessage(role, text) { const div document.createElement(div); div.className message role; div.textContent text; messages.appendChild(div); messages.scrollTop messages.scrollHeight; return div; } sendBtn.addEventListener(click, sendMessage); input.addEventListener(keydown, (event) { if (event.key Enter) sendMessage(); }); async function sendMessage() { const message input.value.trim(); if (!message) return; input.value ; appendMessage(user, message); const aiMessageDiv appendMessage(ai, ); const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: message }) }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; let reply ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop(); for (const line of lines) { if (line.startsWith(data:)) { const data line.substring(5).trim(); if (data data ! [DONE]) { try { const parsed JSON.parse(data); const content parsed.content || ; reply content; aiMessageDiv.textContent reply; messages.scrollTop messages.scrollHeight; } catch (e) { console.warn(解析SSE数据失败:, data); } } } } } }这个read循环就是整个流式教程的精髓所在。buffer的处理很关键因为一次网络读取可能包含多条data也可能一条data只读了一半所以需要先用split(\n\n)切分完整事件再把剩余的半截数据留在buffer里等下一次读取这样才能保证SSE解析不出错。5. 核心参数调优与可观测性建设5.1 对话参数中每个值背后的含义很多人在application.yml里抄了一堆参数但不理解它们到底影响什么。我挑最常用的几个说一下。temperature控制随机性数值越低回答越确定适合代码生成、SQL转换这类要求精确的场景数值越高回答越发散适合头脑风暴。top_p和temperature是互补关系它控制候选词的概率累加范围一般两者只挑一个调就行。max_tokens限制生成的最大token数注意DeepSeek的deepseek-chat模型上下文窗口比较大但这个值是限制单次回复长度的不是上下文长度。presence_penalty惩罚重复内容数值越高模型越不愿意重复已说过的词适合长文本生成。参数推荐值适用场景注意事项temperature0.7通用对话、文案生成代码生成建议降到0.1-0.3max_tokens2048普通问答长文总结时调到4000以上top_p1.0默认值与temperature二选一调整presence_penalty0.0默认值长文生成可设为0.3-0.5你可以一边调整参数一边通过前端页面对比输出效果不要一次性大改。我在调试过程中最常用的做法是固定max_tokens不变先把temperature从0.1到1.0每隔0.2测一轮找到稳定输出和创意输出的分界线再根据业务场景落到具体值。5.2 Actuator与Micrometer监控AI调用链路生产环境不能只“能跑”你还得看到AI调用的耗时、token消耗、错误率。Spring Boot Actuator加上Micrometer这套组合正好能把这些指标暴露出来。Spring AI内部通过Micrometer Observation机制对所有AI调用埋点注册一个ObservationHandler就能自动收集指标。management: endpoints: web: exposure: include: health,info,metrics这里我必须强调一个安全细节。很多网上教程让开发者把actuator的端点全部暴露出来比如management.endpoints.web.exposure.include*这在生产环境非常危险。一旦你的Spring Boot应用对公网开放攻击者可以通过/env端点读取环境变量通过/heapdump下载堆内存文件甚至能扒出数据库密码和API密钥。spring boot actuator相关的安全漏洞这些年出过不少核心原因就是端点暴露过度。正确做法是只暴露必要的health、info、metrics并把管理端口和业务端口分开或者加上Spring Security鉴权。如果你想看AI调用的具体指标可以注册一个简单的MeterHandler类继承ObservationHandler接口把Spring AI产生的指标都记录下来。然后通过actuator的metrics端点查询类似spring.ai.client.operation的指标数据就能看到每次pipeline的调用次数、耗时分布、token用量。这个对于一个要上线的AI应用来说属于必须做的一步。6. 常见问题与排查实录6.1 DeepSeek reasoner的reasoning_content报错这个坑是我花了两天才爬出来的。DeepSeek的deepseek-reasoner模型推理模式在返回结果时除了正常的content字段还有一个reasoning_content字段用于存放模型的推理过程。问题在于Spring AI的OpenAI客户端基于OpenAI标准协议实现这个协议里没有reasoning_content字段。当你连续多轮对话时客户端的消息列表里只保留了content和role丢失了reasoning_content。而DeepSeek服务端要求推理模型的reasoning_content在多轮对话时必须原样回传否则直接返回HTTP 400错误提示“reasoning_content in thinking mode must be passed back to the api”。我当时的实际排查步骤是先用curl手动调试API单轮对话正常多轮对话必现400然后打开Spring AI的日志对比请求体和服务端要求发现缺少reasoning_content字段。解决方案有两个。最简单的就是不要用deepseek-reasoner改用deepseek-chat日常问答完全够用。如果一定要用推理模型就不能依赖Spring AI的原生客户端需要自定义一个WebFilter或者拦截器在请求发出前把历史消息中的reasoning_content补回去这个实现起来工作量不小。6.2 依赖下载失败与Spring AI版本API差异如果你使用的是雷打不动的Spring Boot 3.2.xSpring AI版本又用的是M1、RC1这类早期版本很容易拉不到依赖。这类版本发布在Spring里程碑仓库里需要单独配置。repositories repository idspring-milestones/id nameSpring Milestones/name urlhttps://repo.spring.io/milestone/url /repository /repositories另外要注意Spring AI 2.0相比1.0在API上有不少调整。1.0时代我们用的是ChatClient.Builder注入到了2.0流式响应类型、ObservationHandler的处理方式都有变化。如果你的项目从1.0升级到2.0不要指望代码无缝迁移先去看官方Migration Guide否则一堆编译错误会让你怀疑人生。6.3 中文乱码与SSE半字截断这个问题前面提过我再详细展开一下。用SSE做流式对话时如果只做一次response.text()然后直接按行解析最新一个字经常变成乱码。原因是服务端在返回中文时一个字可能占据多个字节一次网络传输刚好把一个字的字节切开了导致解码失败。解决方式很简单前面代码里的TextDecoder加上{ stream: true }参数就能正确处理跨块字符。还有一个更隐蔽的问题如果你的后端接口返回的SSE中content带着双引号转义前端解析时字符串被切割错位这是JSON序列化格式不一致导致的。你可以统一在服务端设置消息格式或者前端解析时做一次en/decoding处理实际项目中两种都见过我的建议是后端直接输出纯文本格式的SSE不要套JSON最省事。6.4 超时、重试与密钥安全DeepSeek接口的默认超时时间是固定的但深度推理时响应可能非常慢尤其是deepseek-reasoner简单问题也可能思考半分钟。如果你没有设置超时时间前端请求很容易断开。建议在配置里显式增加超时配置spring: ai: openai: client: connect-timeout: 30s read-timeout: 60s密钥安全再啰嗦一遍不要在前端代码里写api-key不要在后端代码里硬编码用环境变量注入而且生产环境的密钥尽量使用独立的key控制额度这样即使泄露也能快速吊销不会影响主账号。7. 个人经验与后续扩展方向这套Spring Boot Spring AI DeepSeek全栈代码跑通之后我最大的感触是Java生态接入大模型不需要为了调API去专门搭一套Python服务Spring AI把模型调用的复杂度收敛得很好前端、后端、配置加起来不到三百行代码就能出一个真正能用的AI聊天应用。在实际调试中我建议你先把同步接口跑通再切换流式接口不要一上来就搞SSE否则前端解析逻辑出问题时你分不清是后端Flux的问题还是前端解析的问题。另外每次调整prompt或者参数时在代码里加一个简单的日志输出把最终发送给DeepSeek的请求体打出来排查问题时效率会高很多。这个项目后续的扩展方向其实很清晰。想给文档做问答就往Spring AI里接入向量数据库做RAG想让模型能查数据库、调接口就研究Function Calling如果业务偏阿里系可以关注spring-ai-alibaba它对NL2SQL这类场景做了不少开箱即用的支持。总而言之地基已经打好了往上盖什么楼就看你的业务需求了。本文还有配套的精品资源点击获取