☰
Spring Boot集成AI对话:从API调用到多轮上下文管理实战
2026/9/29 16:26:16 网站建设 项目流程

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 或 21(LTS 版本更稳)。核心依赖只需要两个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

HTTP 客户端这块,我推荐用 Spring 6 自带的RestClient(Spring 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 省略 }

然后在配置类里注册RestClientBean:

@Configuration @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 Map<String, Object> buildRequestBody(List<ChatMessage> history, String userInput) { List<Map<String, 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 多轮对话上下文的存储策略

大模型是无状态的,每次请求都要把完整的历史消息带上。那历史存哪里?最简单的方案是用ConcurrentHashMap<String, List<ChatMessage>>,key 是会话 ID,value 是消息列表。适合单机部署和开发测试。

但生产环境要考虑几个问题:内存会随着会话增多而膨胀,服务重启后历史丢失,多实例部署时会话不共享。所以更靠谱的方案是存 Redis,设置合理的过期时间(比如 30 分钟无活动就清理)。如果对话量很大,还要考虑只保留最近 N 轮,因为 Token 数是按输入加输出总量计费的,历史越长成本越高。

我一般的做法是保留最近 10 轮对话,超过的部分从头部丢弃。这样既保证了上下文连贯性,又控制了成本。你可以根据业务需要调整这个窗口大小。

private static final int MAX_HISTORY_SIZE = 20; // 10 轮问答 public void addMessage(String sessionId, ChatMessage message) { List<ChatMessage> 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 xxx
429 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,这套基础架构可以直接复用,只需要在服务层扩展检索和工具调用的逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询