
1. 为什么要在 Spring Boot 里接一个对话服务很多 Java 后端同学第一次接触大模型 API脑子里冒出来的第一个念头是我直接写个 HTTP 请求不就行了。这个想法没错但真到项目里落地你会发现事情远不止发一个 POST 那么简单密钥怎么管、超时怎么设、流式返回怎么推给前端、多轮上下文怎么存、异常怎么兜底、并发上来之后连接池怎么配——这些才是真正决定这个服务能不能上生产的关键。Spring Boot 集成 OpenAI API 这件事本质上是在做一层适配层把大模型厂商的 HTTP 接口包装成符合 Spring 生态习惯的 Bean、Service 和 Controller让业务代码像调用本地方法一样调用 AI 能力。它解决的核心问题是解耦——业务逻辑不应该关心你用的是哪家模型、请求体长什么样、返回的 JSON 怎么解析。适合阅读这篇内容的人有三类一是想给自己的管理系统加个智能问答模块的后端开发二是正在做课程设计或毕业设计、需要 AI 功能加分的学生三是想从传统 CRUD 转向 AI 应用开发、但不知道从哪下手的 Java 工程师。我下面讲的东西不是那种复制粘贴就能跑的玩具 Demo而是我实际在几个项目里趟过之后觉得值得沉淀下来的做法。包括依赖怎么选、配置怎么写、流式响应怎么处理、上下文怎么维护以及那些文档里不会写、但一踩就疼的坑。2. 动手之前先把技术选型想清楚2.1 三种集成路线的取舍在 Spring Boot 里接大模型市面上大致有三条路我先把它们摆出来对比你再决定走哪条。路线核心做法优点缺点适用场景原生 HTTP 客户端用 RestTemplate / WebClient / OkHttp 直接调零额外依赖完全可控手写请求体、解析响应、处理流重复劳动多只想快速验证、接口极简官方/社区 SDK引入厂商提供的 Java SDK封装好、类型安全版本更新快、文档参差单一模型厂商深度使用Spring AI用 Spring 官方抽象层统一接口、切换模型成本低、生态契合版本较新、部分功能仍在演进想长期做 AI 应用、可能换模型我的建议是如果是新项目、且预期未来可能换模型或加 RAG直接上 Spring AI如果只是给老项目加一个调一下 API的小功能用 WebClient 手写反而更轻。下面两条线我都会讲你可以按需取用。2.2 版本对齐这件事比你想的重要Spring AI 对 Spring Boot 版本是有要求的。截至我写这篇内容时Spring AI 1.x 系列基本要求 Spring Boot 3.2 以上JDK 17 起步。如果你还在用 Spring Boot 2.7 JDK 8那 Spring AI 这条路基本走不通只能走 WebClient 手写路线。这里有个特别容易踩的坑Spring Boot 3 之后javax.*全部换成了jakarta.*。你如果从网上抄了一段 Spring Boot 2 的代码里面写着javax.servlet.http.HttpServletResponse在 Boot 3 里是编译不过的。这个报错信息有时候很隐晦新手容易卡半天。提示动手前先执行mvn -v和java -version确认环境再去看你打算引入的 Spring AI 版本对应的官方兼容矩阵别凭感觉选版本。2.3 密钥管理永远不要写进代码这是我最想强调的一点。我见过太多项目把 API Key 直接硬编码在application.yml里然后提交到代码仓库这是非常危险的做法。正确姿势是本地开发用环境变量application.yml里写${OPENAI_API_KEY}服务器部署用配置中心或容器编排的 Secret 机制注入绝对不要把真实 Key 提交到任何版本控制系统# application.yml spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL:https://api.openai.com} chat: options: model: gpt-4o-mini temperature: 0.7base-url单独抽出来是有讲究的——很多团队会用兼容 OpenAI 协议的中转服务或自建网关把它做成可配置项切换时不用改代码。temperature设 0.7 是个比较中庸的值做客服问答可以调到 0.2 让它更稳定做创意文案可以调到 1.0 让它更发散。3. 用 Spring AI 搭出第一版对话接口3.1 依赖引入与自动装配原理Spring AI 的 starter 设计得很Spring引入依赖之后只要配置了api-key它就会自动帮你装配好ChatClient和OpenAiChatModel这些 Bean。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0/version /dependency这里有个新手经常困惑的点为什么我什么都没写就能Autowired一个 ChatClient答案在 Spring Boot 的自动装配机制里。starter 的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里声明了自动配置类这个类上带着ConditionalOnProperty之类的条件注解只有检测到spring.ai.openai.api-key存在时才生效。理解这一点你以后遇到Bean 注入不进来的问题就知道该去检查配置项拼写对不对了。3.2 一个能用的 ChatController先上一个最小可用版本把链路跑通RestController RequestMapping(/api/chat) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder .defaultSystem(你是一个专业、简洁的助手回答控制在200字以内。) .build(); } PostMapping(/simple) public String simple(RequestBody String message) { return chatClient.prompt() .user(message) .call() .content(); } }这段代码里有几个设计点值得说。第一我用构造器注入ChatClient.Builder而不是直接注入ChatClient因为 Builder 允许我在构建时设置默认的 system prompt这样每个请求不用重复传。第二defaultSystem里限定了回答长度这是防止模型话痨拖慢响应、浪费 token 的实用技巧。第三.call()是同步阻塞调用.content()直接拿字符串结果适合简单场景。3.3 结构化输出让模型返回对象而不是一段话实际业务里你往往不希望拿到一段自然语言而是想要一个能直接用的对象。比如用户问帮我查一下北京明天的天气你希望模型返回{city: 北京, date: 明天}这样的结构而不是好的北京明天……。Spring AI 提供了.entity()方法来做这件事public record WeatherQuery(String city, String date) {} WeatherQuery query chatClient.prompt() .user(帮我查一下北京明天的天气) .call() .entity(WeatherQuery.class);底层原理是 Spring AI 会自动在 prompt 里追加一段请以 JSON 格式返回字段为 xxx的指令然后用 Jackson 把返回内容反序列化成你的 record。这里有个坑模型有时候会在 JSON 外面包一层 json 的 markdown 代码块导致反序列化失败。Spring AI 较新版本已经内置了清理逻辑但如果你用的是早期版本可能需要自己写个后处理。我一般会在 system prompt 里明确加一句只返回纯 JSON不要任何额外说明和代码块标记能大幅降低出错率。4. 流式响应对话体验的分水岭4.1 为什么必须做流式同步调用的问题在于用户发一句话要盯着转圈等三五秒甚至更久才看到一大段文字啪地全冒出来。而流式响应是模型每生成一个 token 就推给前端用户能看着文字一个个蹦出来体感上快得多——哪怕总耗时一样。从技术上讲流式用的是 SSEServer-Sent Events。Spring Boot 里返回FluxString配合text/event-stream的 Content-Type 就能实现。4.2 流式接口的写法GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); }注意这里从.call()换成了.stream()返回类型从String变成了FluxString。Flux是 Reactor 的响应式类型代表一个 0 到 N 个元素的异步序列。这里有个非常隐蔽的坑如果你在流式接口里做了任何阻塞操作比如查数据库、调同步的第三方接口整个响应式链路会被阻塞流式效果直接退化甚至卡死。响应式编程的铁律是——不要在响应式线程里做阻塞调用真要做得用subscribeOn(Schedulers.boundedElastic())把它挪到弹性线程池。4.3 前端怎么接前端用EventSource或者fetch的流式读取都能接。用EventSource的话要注意它只支持 GET 请求所以上面我写的是GetMapping。如果你需要传复杂的请求体就得改用 POST fetch手动读ReadableStream。const es new EventSource(/api/chat/stream?message encodeURIComponent(msg)); es.onmessage (e) { document.getElementById(output).textContent e.data; }; es.onerror () es.close();实测下来SSE 在大多数场景够用。但如果你要做的是双向实时交互比如语音对话那 WebSocket 会更合适——这也是为什么热词里会同时出现 WebSocket 配置的原因两者解决的是不同层次的问题。5. 多轮对话的上下文管理5.1 模型本身是无状态的这是新手最容易误解的一点大模型 API 本身不记得你上一句说了什么。每次请求都是独立的所谓多轮对话是你在每次请求时把历史消息一起发过去。Spring AI 里用ChatMemory来管理这个历史Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory memory) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(memory)) .build(); }MessageChatMemoryAdvisor会在每次请求前自动把历史消息拼进去请求后再把新的问答追加到记忆里。5.2 内存版记忆的致命问题InMemoryChatMemory默认是按conversationId隔离的但它是存在 JVM 堆里的。这意味着服务一重启所有对话历史全没了多实例部署时用户在 A 实例聊的内容切到 B 实例就丢了。生产环境必须换成持久化方案。常见做法是存 Redis 或数据库自己实现ChatMemory接口。我一般用 Rediskey 设计成chat:memory:{conversationId}value 存消息列表的 JSON再设个过期时间比如 2 小时避免历史无限增长。5.3 上下文窗口的预算控制还有个绕不开的问题模型的上下文窗口是有限的。你不可能把一百轮对话全塞进去一是超长会被截断或报错二是 token 是要花钱的。我的做法是滑动窗口 摘要保留最近 N 轮完整对话更早的内容用一次额外的模型调用压缩成一段摘要。这样既控制了 token 消耗又不至于完全丢失早期信息。N 取多少要看你的模型窗口大小和业务特点一般 10 到 20 轮是个合理起点。注意conversationId一定要和用户身份绑定并做校验否则 A 用户可能通过伪造 ID 读到 B 用户的对话历史这是实打实的安全漏洞。6. 生产环境必须处理的几件事6.1 超时与重试大模型接口的响应时间波动很大快的时候一两秒慢的时候十几秒甚至超时。默认的超时配置往往不够用需要显式调整spring: ai: openai: chat: options: model: gpt-4o-mini超时一般在底层 HTTP 客户端层面配。如果用 WebClient通过HttpClient的responseTimeout设置如果用 RestTemplate配RestTemplateBuilder的setConnectTimeout和setReadTimeout。我的经验值是连接超时 5 秒、读取超时 60 秒——流式场景下读取超时要设得更长因为整个流可能持续很久。重试要谨慎。对于非幂等的场景比如已经扣了费的调用盲目重试可能造成重复计费。我一般只对连接超时、5xx 这类明确可重试的错误做有限次重试且用指数退避。6.2 限流与降级大模型 API 通常有 RPM每分钟请求数和 TPM每分钟 token 数限制。并发一上来很容易触发 429 错误。应对手段有三层客户端限流用 Resilience4j 或 Sentinel 做信号量限流把并发控制在配额内队列缓冲超出部分进队列排队而不是直接失败降级兜底真扛不住时返回一个预设的友好提示而不是把异常抛给用户6.3 日志与可观测性AI 服务的日志和传统服务不太一样你要记录的不只是请求成功失败还有 token 消耗、响应耗时、模型版本这些。这些数据是后续做成本核算和性能优化的基础。但要注意日志里不要打印完整的用户输入和模型输出可能包含敏感信息。我一般只记录长度、耗时、token 数这些元数据内容本身按需脱敏。7. 那些文档里不会写的坑7.1 中文乱码与编码问题流式返回中文时如果编码没处理好前端可能看到乱码。确保produces里带上charsetUTF-8服务端的server.servlet.encoding也配好。这个问题在 Windows 开发环境尤其容易出现。7.2 代理与网络环境企业内网环境下访问外部 API 往往需要经过网络出口配置。这块要提前和运维确认好否则本地能跑、服务器上一直超时排查起来很费劲。配置方式因环境而异核心是确保应用能正常访问目标地址。7.3 模型返回内容的安全过滤模型可能返回一些你不希望出现的内容。生产环境建议在返回给用户前做一层过滤尤其是面向 C 端的产品。这个过滤可以是关键词黑名单也可以是再调一次模型做内容审核。7.4 成本失控这是最容易被忽视的。一个没做限制的接口被爬虫或者恶意用户刷起来账单能吓死人。必须做的单用户频率限制、单次请求 token 上限、每日总额度告警。我见过有团队因为没做限制一晚上跑掉几千块的。8. 不用 Spring AI 的手写方案如果你的项目还在 Spring Boot 2.x或者你就是想完全掌控每一个字节那用 WebClient 手写也不难。核心就是构造请求体、发请求、解析响应。public String chat(String message) { MapString, Object body Map.of( model, gpt-4o-mini, messages, List.of(Map.of(role, user, content, message)) ); return webClient.post() .uri(/v1/chat/completions) .header(Authorization, Bearer apiKey) .bodyValue(body) .retrieve() .bodyToMono(JsonNode.class) .map(node - node.path(choices).get(0).path(message).path(content).asText()) .block(); }手写的好处是透明坏处是所有细节都得自己管流式解析要自己处理 SSE 格式、错误码要自己映射、重试要自己写。如果你的项目会长期演进我还是建议尽早迁到 Spring AI因为随着功能变多RAG、工具调用、多模态手写的维护成本会指数级上升。9. 我个人的几点实操体会做了几个 AI 集成项目之后有几个感受特别深。第一别一上来就追求功能全先把能对话这条链路跑通再逐步加流式、加记忆、加限流每一步都验证过再往下走比一次性堆一堆功能然后到处 debug 高效得多。第二prompt 是要迭代的我通常会把它抽成独立的配置或模板文件方便不改代码就能调整A/B 测试不同 prompt 的效果也是常规操作。第三给模型的能力加边界system prompt 里明确告诉它不知道就说不知道比让它一本正经地胡说八道要好得多尤其在客服、医疗、金融这类场景。最后分享一个我常用的调试技巧把每次请求的完整 prompt包括 system 和 history打到 debug 日志里出问题时一眼就能看出是 prompt 拼错了还是模型理解偏了。这个习惯帮我省了无数次排查时间。