ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot集成AI对话:从API调用到多轮上下文管理实战

Spring Boot集成AI对话:从API调用到多轮上下文管理实战 1. 为什么要在 Spring Boot 里集成 AI 对话能力1.1 从业务需求到技术选型的思考过程做过 Java 后端的人都有一个共同的感受业务代码写多了总想找点新鲜东西折腾一下。AI 对话服务就是这两年最热的方向之一。不管是给内部系统加一个智能客服还是给产品嵌一个问答助手底层逻辑都绕不开“调用大模型 API 并管理对话上下文”这件事。那为什么选 Spring Boot 作为载体原因很直接。第一Spring Boot 的自动装配机制让 HTTP 客户端、配置管理、依赖注入这些基础设施几乎零成本第二团队里 Java 开发者占多数用大家熟悉的技术栈做 AI 集成维护成本最低第三Spring 生态里已经有 Spring AI 这样的官方项目在快速迭代后续从手写调用迁移到框架化方案时代码结构不会大改。这个项目要解决的问题很明确搭建一个能接收用户消息、调用 OpenAI 兼容接口、维护多轮对话上下文、并把结果返回给前端的服务。适合有一定 Java 基础、想了解 AI 服务端集成套路的开发者参考。哪怕你之前没接触过大模型 API跟着思路走也能跑通。1.2 整体架构与核心模块拆解整个服务的骨架其实不复杂我把它拆成四层来看接口层对外暴露 REST 接口接收用户提问返回 AI 回复。用RestController就够了不需要上 WebSocket除非你要做流式打字机效果。会话层管理每个用户的对话历史。这是最容易被忽略但最影响体验的部分因为大模型本身是无状态的你不传历史它就不知道前面聊了什么。服务层封装对 OpenAI API 的调用逻辑包括请求构造、超时处理、异常重试、响应解析。配置层管理 API Key、模型名称、超时时间、最大 Token 数等参数通过application.yml注入。分层的好处是将来如果要换模型供应商只需要改服务层的实现接口层和会话层基本不动。这也是我在实际项目里踩过坑之后总结出来的一开始图省事把所有逻辑写在一个 Controller 里后来要加流式输出和重试机制改得痛不欲生。提示不要把 API Key 硬编码在代码里也不要在本文或任何公开场合分享真实的 Key。用环境变量或配置中心管理这是底线。2. 环境准备与项目骨架搭建2.1 依赖选型与版本对齐新建一个 Spring Boot 项目我习惯用 Spring Initializr 生成骨架选 Maven 构建、Java 17 或 21LTS 版本更稳。核心依赖只需要两个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependencyHTTP 客户端这块我推荐用 Spring 6 自带的RestClientSpring Boot 3.2或者经典的RestTemplate。为什么不直接用 OkHttp 或 HttpClient因为RestClient是 Spring 官方的新一代同步客户端API 流畅和 Spring 的异常体系、消息转换器无缝集成少引一个第三方库就少一份版本冲突的风险。如果你打算用 Spring AI 的 starter那依赖会变成spring-ai-openai-spring-boot-starter但要注意 Spring AI 的版本迭代很快1.0 之前的 API 变动频繁。我的建议是先用原生 HTTP 客户端手写一遍理解请求响应的每个字段再决定要不要上框架。这样出了问题你能定位到根因而不是对着框架的黑盒干瞪眼。2.2 配置文件的关键参数设计application.yml里我一般这样组织openai: api-key: ${OPENAI_API_KEY} base-url: https://api.openai.com/v1 model: gpt-4o-mini timeout: 30000 max-tokens: 2048 temperature: 0.7这里每个参数都有讲究。base-url单独抽出来是因为很多兼容 OpenAI 协议的服务只需要改这个地址就能切换。timeout设 30 秒是因为大模型生成一段几百字的回复通常需要 5 到 15 秒设太短会频繁超时设太长又会拖垮线程池。temperature控制随机性做客服问答建议 0.3 到 0.5做创意写作可以调到 0.8 以上。max-tokens这个参数特别容易踩坑。它限制的是模型输出的最大 Token 数不是输入。如果你设成 100模型回复到一半就被截断了前端看到的就是一句没说完的话。我一般设 2048够生成一篇中等长度的回答。注意api-key用${OPENAI_API_KEY}占位符从环境变量读取本地开发时在 IDE 的运行配置里设置不要写进 yml 提交到代码仓库。2.3 配置类与 Bean 的注入方式写一个OpenAiProperties类用ConfigurationProperties绑定配置ConfigurationProperties(prefix openai) public class OpenAiProperties { private String apiKey; private String baseUrl; private String model; private int timeout; private int maxTokens; private double temperature; // getter 和 setter 省略 }然后在配置类里注册RestClientBeanConfiguration EnableConfigurationProperties(OpenAiProperties.class) public class OpenAiConfig { Bean public RestClient openAiRestClient(OpenAiProperties props) { var factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(props.getTimeout()); factory.setReadTimeout(props.getTimeout()); return RestClient.builder() .baseUrl(props.getBaseUrl()) .requestFactory(factory) .defaultHeader(Authorization, Bearer props.getApiKey()) .defaultHeader(Content-Type, application/json) .build(); } }把超时设在ClientHttpRequestFactory上而不是靠默认值是因为默认的读超时是无限的一旦对端不响应线程就挂死了。这个坑我在生产环境遇到过一个请求卡住导致 Tomcat 线程池被占满整个服务不可用。所以超时一定要显式设置。3. 核心对话逻辑的实现细节3.1 请求体构造与消息角色设计OpenAI 的对话接口接收的是一个messages数组每条消息有role和content两个字段。role有三种system设定 AI 的人设和行为边界user是用户输入assistant是 AI 的历史回复。构造请求体的代码大概长这样public MapString, Object buildRequestBody(ListChatMessage history, String userInput) { ListMapString, String messages new ArrayList(); messages.add(Map.of(role, system, content, 你是一个专业、简洁的助手。)); for (ChatMessage msg : history) { messages.add(Map.of(role, msg.getRole(), content, msg.getContent())); } messages.add(Map.of(role, user, content, userInput)); return Map.of( model, props.getModel(), messages, messages, max_tokens, props.getMaxTokens(), temperature, props.getTemperature() ); }system消息放在最前面它决定了 AI 的整体风格。我试过不写 system 消息结果模型有时候会用很啰嗦的语气回答加上一句“简洁回答”之后效果好很多。这个细节在官方文档里不会强调但实际用起来差别很明显。3.2 多轮对话上下文的存储策略大模型是无状态的每次请求都要把完整的历史消息带上。那历史存哪里最简单的方案是用ConcurrentHashMapString, ListChatMessagekey 是会话 IDvalue 是消息列表。适合单机部署和开发测试。但生产环境要考虑几个问题内存会随着会话增多而膨胀服务重启后历史丢失多实例部署时会话不共享。所以更靠谱的方案是存 Redis设置合理的过期时间比如 30 分钟无活动就清理。如果对话量很大还要考虑只保留最近 N 轮因为 Token 数是按输入加输出总量计费的历史越长成本越高。我一般的做法是保留最近 10 轮对话超过的部分从头部丢弃。这样既保证了上下文连贯性又控制了成本。你可以根据业务需要调整这个窗口大小。private static final int MAX_HISTORY_SIZE 20; // 10 轮问答 public void addMessage(String sessionId, ChatMessage message) { ListChatMessage history historyMap .computeIfAbsent(sessionId, k - new ArrayList()); history.add(message); while (history.size() MAX_HISTORY_SIZE) { history.remove(0); } }3.3 响应解析与异常处理OpenAI 返回的 JSON 结构里回复内容在choices[0].message.content。解析的时候要注意choices数组可能为空极少见但会发生所以取值前要判空。public String extractContent(JsonNode root) { JsonNode choices root.path(choices); if (choices.isMissingNode() || choices.isEmpty()) { throw new AiServiceException(模型返回内容为空); } return choices.get(0).path(message).path(content).asText(); }异常处理这块我分了三种情况网络超时重试一次4xx 错误直接抛给用户通常是 Key 无效或参数错误5xx 错误重试并记录日志。重试不要无脑循环加个指数退避第一次等 1 秒第二次等 2 秒最多重试两次。这样既给了对端恢复的时间又不会让用户等太久。4. 接口设计与前后端联调要点4.1 REST 接口的参数校验对外暴露的接口我设计成POST /api/chat请求体包含sessionId和message两个字段。用Valid加NotBlank做参数校验避免空消息打到模型那边浪费一次调用。PostMapping(/api/chat) public ChatResponse chat(Valid RequestBody ChatRequest request) { String reply chatService.chat(request.getSessionId(), request.getMessage()); return new ChatResponse(reply); }sessionId由前端生成并维护可以用 UUID。这样服务端不需要管理会话的创建和销毁逻辑更简单。如果前端不传服务端就自动生成一个返回给前端后续请求带上即可。4.2 流式输出的实现思路普通请求要等模型生成完整回复才返回用户会盯着屏幕等好几秒。流式输出SSE可以让文字像打字一样逐字出现体验好很多。实现方式是用SseEmitter服务端以流的方式读取 OpenAI 的响应每收到一个 chunk 就推给前端。GetMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String sessionId, RequestParam String message) { SseEmitter emitter new SseEmitter(60000L); executor.execute(() - { try { chatService.streamChat(sessionId, message, chunk - { emitter.send(SseEmitter.event().data(chunk)); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }流式输出的坑在于OpenAI 返回的是 SSE 格式每行以data:开头最后以data: [DONE]结束。解析时要按行读取跳过空行和[DONE]把每个 chunk 里的delta.content提取出来。另外SseEmitter的超时时间要设得比模型生成时间长否则连接会被提前关闭。4.3 跨域与前端对接注意事项前端如果是独立部署的需要配置 CORS。在 Controller 上加CrossOrigin或者在配置类里全局配置。流式接口要注意某些浏览器对 SSE 的跨域有额外限制建议把Access-Control-Allow-Origin设成具体域名而不是*。前端对接时普通接口用fetch或axios都行。流式接口要用EventSource但EventSource只支持 GET 请求所以参数只能放在 URL 上。如果消息内容很长URL 长度可能超限这时候可以考虑用fetch加ReadableStream来手动处理 SSE。5. 常见问题排查与实战避坑经验5.1 高频报错与对应解决方案报错信息可能原因解决方式401 UnauthorizedAPI Key 无效或未正确传递检查 Key 是否过期Header 格式是否为Bearer xxx429 Too Many Requests请求频率超限或余额不足降低并发检查账户额度加重试退避400 Bad Request请求体格式错误或参数越界检查messages结构确认max_tokens未超模型上限连接超时网络不通或超时设置过短检查网络把超时调到 30 秒以上返回内容被截断max_tokens设得太小调大max_tokens或提示模型简短回答这张表是我在实际调试中整理出来的基本上覆盖了 90% 的问题。其中 429 最容易被忽视很多人以为是代码问题其实是账户额度用完了。5.2 成本控制与性能优化建议Token 就是钱这话一点不夸张。几个实用的省钱技巧第一system消息尽量短不要写一大段人设描述第二历史对话窗口不要开太大10 轮足够第三简单问题用便宜的小模型复杂问题再路由到大模型第四对高频重复问题做缓存相同问题直接返回缓存结果。性能方面RestClient底层用的是 JDK 的HttpURLConnection并发量大的话可以换成带连接池的客户端。另外调用模型的线程和 Tomcat 的工作线程要隔离用独立的线程池处理 AI 调用避免慢请求把 Web 线程占满。5.3 我从实际项目中总结的几条硬核经验第一条永远不要相信模型的输出格式。你让它返回 JSON它可能给你返回带 markdown 代码块的 JSON也可能在 JSON 前后加一段解释文字。解析前先做清洗用正则把代码块标记去掉。第二条日志要记录完整的请求和响应但要注意脱敏。API Key 绝对不能进日志用户消息如果涉及隐私也要过滤。我一般只记录 Token 消耗量和耗时用于监控和计费分析。第三条给模型调用加熔断。当错误率超过阈值时直接返回兜底话术不要让请求堆积。Resilience4j 或者 Sentinel 都可以配置一个简单的熔断器就行。第四条测试的时候用 mock 数据不要每次都打真实 API。写一个ChatService的接口测试时注入 mock 实现既快又省钱。集成测试再打真实接口验证端到端流程。这些经验在官方文档里找不到都是真金白银的 Token 和熬夜调试换来的。希望对你有所帮助。后续如果要做 RAG 或者 Agent这套基础架构可以直接复用只需要在服务层扩展检索和工具调用的逻辑。
返回列表