简介面向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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version1.0.0-M5/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencySpring AI 的版本迭代跨度很大M 版本和正式版之间的包路径和类名变化不少。你搜到旧博客里写OpenAiChatClient的在新版里多半已经废弃统一走ChatClient接口了。所以我不建议在生产项目上追最新 M 版选一个已经发布过几个月的版本踩坑记录多你能搜到解决方案。spring.ai.openai.base-urlhttps://api.deepseek.com/v1 spring.ai.openai.api-key${DEEPSEEK_API_KEY} spring.ai.openai.chat.options.modeldeepseek-chat spring.ai.openai.chat.options.temperature0.7 spring.ai.openai.chat.options.max-tokens20482.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 ResponseEntityChatResponse 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;charsetutf-8) public SseEmitter streamChat(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(60_000L); FluxChatResponse 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 MapString, DequeMessage sessions new ConcurrentHashMap(); private static final int MAX_HISTORY_ROUNDS 6; private static final int MAX_TOTAL_TOKENS 4000; public ListMessage getHistory(String sessionId) { DequeMessage deque sessions.computeIfAbsent( sessionId, k - new ArrayDeque() ); return new ArrayList(deque); } public void addExchange(String sessionId, String userText, String assistantText) { DequeMessage 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_PROXYSpring 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 三个关键变量再比对实际输出就再也没翻过车。希望帮到你。本文还有配套的精品资源点击获取