ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot集成OpenAI API:构建企业级AI对话服务实战

Spring Boot集成OpenAI API:构建企业级AI对话服务实战 1. 为什么要自己动手搭AI对话服务而不是直接用现成客户端先说个我自己的经历。去年团队里有个需求要在内部管理系统里加一个AI助手入口给运营同学做数据查询和文案润色用。当时第一反应是直接用ChatGPT网页版不就完了吗但实际一推演问题马上出来了账号怎么统一管理对话记录要不要留痕怎么和我们自己的业务数据打通总不能让人家复制粘贴完再手动填回系统里吧。后来我们决定自己用Spring Boot封装一层AI对话服务把OpenAI API接进来做成内部统一入口。做完之后运营同学的使用率比预期高了一倍因为入口就在他们天天用的系统里不用来回切换。这件事给我的体会是集成AI服务这件事真正的价值不在于调通一个API而在于把它变成你业务里顺手能用的一部分。这篇内容就是围绕这个目标展开的。我会从项目结构设计开始讲覆盖API Key的安全管理、核心对话接口的实现、流式输出的接入最后聊一聊生产环境部署时容易被忽略的几个坑。适合的人群有两类一是Spring Boot用得还算熟、但没碰过OpenAI API的Java后端工程师二是想把AI能力嵌进现有系统但还在纠结从哪下手的团队技术负责人。2. 先想清楚对话服务的交互模型再动手写代码很多新手拿到OpenAI API文档就急着写RestTemplate调用结果写完发现只是把官网的curl示例翻译成了Java根本没考虑到自己系统的实际场景。我建议先花半天时间把交互模型想清楚再动手写代码这个时间花得非常值。2.1 同步请求和流式响应两种模式怎么选OpenAI的对话接口/v1/chat/completions支持两种响应方式一次性返回完整结果或者通过SSEServer-Sent Events流式逐段返回。同步模式写起来简单一个HTTP请求发出去等响应回来解析JSON就行。流式模式则是连接建立后模型每生成一小段内容就推给你一次体验上更接近人逐字打字。理论上是这样但实际使用中你会发现如果对话内容偏长同步模式会让前端等很久体验很差。就算是后端调用如果下游服务在同步等你的接口返回超时时间还要专门调大这在微服务架构里是个麻烦事。所以我个人建议优先支持流式模式同步模式作为兜底保留。流式响应的协议是SSE不是WebSocket。它本质上是HTTP响应里Content-Type设为text/event-stream然后按照固定格式一段一段推数据。前端用EventSource或者fetch的ReadableStream都能消费不需要额外引入WebSocket依赖。2.2 定义我们的服务边界只做转发还是做业务封装我问过几个做集成的朋友他们的第一版基本都是从拿到用户输入直接转给OpenAI把结果返回开始的。这种做法跑通demo没问题但很难直接用到生产——因为你没考虑多轮对话的上下文管理、角色设定system prompt、敏感词过滤、调用审计这些事。所以我们在设计服务边界时就定了三条规则对外暴露的是业务语义接口不是裸的OpenAI接口。比如前端调用的是/api/ai/assistant请求体里带的是业务参数后端负责拼装成OpenAI API的请求格式。所有外部依赖的调用都走服务端API Key永远不出服务器。多轮对话的历史消息由我们管理而不是完全交给调用方拼接。这样做的核心原因是OpenAI API只是个能力提供方你和它之间必须有业务适配层否则后面接别的模型比如国产模型时改动成本会大到你不想动。3. 项目结构和API Key管理最容易出问题的两个地方这一节我踩过不少坑特别是API Key的管理很多人嫌麻烦直接硬编码在application.yml里项目传到Git仓库后Key就泄露了。我见过不止一次因为这种事被平台风控的案例轻则封号重则账单爆炸。所以这里专门展开讲。3.1 项目包结构与依赖选择我用的是Java 21 Spring Boot 3.5如果你还在用Java 8后面的代码可能需要微调。先展示下我的项目基础结构com.example.aichat ├── AiChatApplication.java ├── config │ ├── OpenAiConfig.java // 读取配置构建RestClient │ └── WebConfig.java // 跨域、拦截器注册 ├── controller │ └── ChatController.java // 对外HTTP接口 ├── service │ ├── ChatService.java // 业务封装拼装消息、调API、解析 │ └── ConversationService.java // 会话上下文管理 ├── dto │ ├── ChatRequest.java // 外部请求体 │ ├── ChatResponse.java // 外部响应体 │ └── OpenAiMessage.java // 发送给OpenAI的消息结构 ├── properties │ └── OpenAiProperties.java // 配置绑定类强类型读写配置 └── interceptor └── ApiUsageInterceptor.java // 调用审计、限流入口Spring Boot 3.x里推荐用RestClient代替RestTemplate它支持流式响应更自然API设计也更现代。RestClient是Spring Framework 6.1引入的如果你是3.x版本直接用就行。依赖方面最核心的就两个spring-boot-starter-web和spring-boot-starter-validation。前者提供Web能力和RestClient在spring-web里后者用来校验请求参数。不需要额外加OpenAI的SDK官方虽然有个Java库但封装度不高还不如自己写来得灵活。3.2 API Key的安全管理从配置到环境变量再到密钥中心硬编码Key是最不能接受的。至少要放到环境变量里Spring Boot的application.yml支持${OPENAI_API_KEY}这种占位符。再进一步用ConfigurationProperties绑定成强类型配置类读取时就不会出现字符串拼错的问题。我现在的做法是这样的openai: api-key: ${OPENAI_API_KEY} base-url: https://api.openai.com/v1 model: gpt-4o-mini max-tokens: 2048 temperature: 0.7对应的配置类ConfigurationProperties(prefix openai) public class OpenAiProperties { private String apiKey; private String baseUrl; private String model; private Integer maxTokens; private Double temperature; // getter / setter 略 }主类上记得加EnableConfigurationProperties(OpenAiProperties.class)或者ConfigurationPropertiesScan。如果你所在公司有密钥管理平台比如Vault、KMS建议把Key从这里拿。代码里完全不用知道真实Key是什么。但这里有一个非常现实的问题不少团队没有专门的密钥管理平台环境变量已经是能落地的上限了。没关系环境变量加.gitignore配置文件够绝大多数项目用了。还有一点要特别提醒如果API Key意外泄露了马上去OpenAI后台吊销这把Key重新生成一把不能有侥幸心理。泄露的Key如果被发现可能被刷掉几万块钱的额度。3.3 配置跨域和统一响应结构前端调用接口时如果域名不一致需要处理跨域。开发环境最简单的方式是加一个全局CORS配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:3000) // 按实际情况收紧 .allowedMethods(GET, POST, OPTIONS) .allowedHeaders(*) .maxAge(3600); } }生产环境里我建议用Nginx反向代理来统一入口这样CORS可以在Nginx层解决后端代码不用开放跨域。开发环境放开只是为了调试方便别图省事直接allowedOriginPatterns(*)。响应结构统一用ResultT包装{ code, message, data }这种。代码在这层看起来有点形式化但真的很有用——调用方解析格式统一后面加异常处理、错误码枚举都方便。4. 核心代码实现从同步调用到流式输出的完整演进这一节是全文的正文中的正文。我会分两步走先写一个同步版本的完整实现让整个链路跑通再改造为流式输出解决响应慢的问题。每条代码我都写注释方便你直接抄。4.1 同步调用版本先让链路跑通先定义对外的请求和响应DTO。请求体里带了conversationId方便后面做多轮会话管理public class ChatRequest { NotBlank(message 消息内容不能为空) private String message; private String conversationId; private String systemPrompt; // 可选不传用默认角色设定 // getter / setter 略 }public class ChatResponse { private String conversationId; private String reply; private long timestamp; // getter / setter 略 }接下来是OpenAI消息结构的DTO。注意role有两种常用取值system表示系统角色设定user表示用户输入。多轮对话里还会有assistant角色的历史回复消息用来告诉模型之前你已经说过什么public class OpenAiMessage { private String role; private String content; // 几个静态工厂方法少写点new public static OpenAiMessage system(String content) { OpenAiMessage m new OpenAiMessage(); m.setRole(system); m.setContent(content); return m; } // user / assistant 类似 }请求体DTOpublic class OpenAiChatRequest { private String model; private ListOpenAiMessage messages; private Double temperature; private Integer maxTokens; public void setMaxTokens(Integer maxTokens) { this.maxTokens maxTokens; } }请求体里有一个细节我吃了亏OpenAI的max_tokens在实际调用时如果设置得太小比如128长一点的回答会被截断但是不会报错。我当时排查了好久最后才发现是token限制问题。所以如果要生成完整内容至少给2048以上除非你明确只要简短回复。配置类里构建RestClient这是核心Configuration public class OpenAiConfig { Bean public RestClient openAiRestClient(OpenAiProperties props) { return RestClient.builder() .baseUrl(props.getBaseUrl()) .defaultHeader(Authorization, Bearer props.getApiKey()) .defaultHeader(Content-Type, application/json) .requestInterceptor((request, body, execution) - { // 这里可以打日志但是注意别把整个请求体打出去 // 日志里要过滤掉Authorization头 return execution.execute(request, body); }) .build(); } }注意RestClient的defaultHeader一旦设置所有走这个client的请求都会带上这个Header。如果后面要申请多个Key做负载均衡这个设计就要调整成每次请求动态设置Header。Service层是业务逻辑的集中地。我先用最简单的方式拼装消息Service public class ChatService { private final RestClient openAiRestClient; private final ConversationService conversationService; private final OpenAiProperties props; public ChatResponse chat(ChatRequest request) { // 1. 从会话服务里拿历史消息 ListOpenAiMessage history conversationService.getHistory(request.getConversationId()); // 2. 拼装完整消息列表 ListOpenAiMessage messages new ArrayList(); String sysPrompt request.getSystemPrompt() ! null ? request.getSystemPrompt() : 你是一个乐于助人的中文AI助手; messages.add(OpenAiMessage.system(sysPrompt)); messages.addAll(history); messages.add(OpenAiMessage.user(request.getMessage())); // 3. 构建OpenAI请求体 OpenAiChatRequest openAiRequest new OpenAiChatRequest(); openAiRequest.setModel(props.getModel()); openAiRequest.setMessages(messages); openAiRequest.setTemperature(props.getTemperature()); openAiRequest.setMaxTokens(props.getMaxTokens()); // 4. 同步调用 String responseBody openAiRestClient.post() .uri(/chat/completions) .body(openAiRequest) .retrieve() .body(String.class); // 5. 解析结果这里先不引入Jackson对象映射直接手动解析最直观 String reply parseReply(responseBody); // 6. 保存这轮对话到历史 conversationService.saveExchange(request.getConversationId(), request.getMessage(), reply); ChatResponse resp new ChatResponse(); resp.setConversationId(request.getConversationId()); resp.setReply(reply); resp.setTimestamp(System.currentTimeMillis()); return resp; } private String parseReply(String responseBody) { // 实测返回的choices[0].message.content就是这个回复内容 // 用Jackson或者JsonNode解析都行下面这个写法最直观 try { ObjectMapper mapper new ObjectMapper(); JsonNode root mapper.readTree(responseBody); return root.path(choices).get(0).path(message).path(content).asText(); } catch (Exception e) { throw new RuntimeException(解析OpenAI响应失败, e); } } }手动解析responseBody这个方式很适合做第一版因为你能直观看到OpenAI返回了什么结构。choices是数组因为一次请求理论上可以配多个候选结果实际我们只用choices[0]。Controller层就很简单了RestController RequestMapping(/api/ai) public class ChatController { private final ChatService chatService; PostMapping(/chat) public ResultChatResponse chat(RequestBody Valid ChatRequest request) { ChatResponse response chatService.chat(request); return Result.success(response); } }到这里一个能用的同步接口就完成了。你本地起服务用Postman发个{message: 你好}应该能收到OpenAI的回复。4.2 改造为流式输出SSE接入的完整步骤为什么同步版本不能用两个原因一是响应慢GPT-4级别的模型回答一段200字的内容可能要10到20秒接口一直hold住连接容易被网关断开二是体验差用户看着页面长时间空白以为系统坏了。流式输出的核心是SSE协议。服务端不断输出data: {json}格式的块直到data: [DONE]结束。前端拿到每个块就追加到界面上形成打字机效果。Spring Boot里用SseEmitter就能实现不用额外依赖。改造Service层public SseEmitter streamChat(ChatRequest request) { SseEmitter emitter new SseEmitter(60_000L); // 60秒超时 // 组装请求跟同步版完全一样 ListOpenAiMessage messages buildMessages(request); OpenAiChatRequest openAiRequest new OpenAiChatRequest(); openAiRequest.setModel(props.getModel()); openAiRequest.setMessages(messages); openAiRequest.setStream(true); // 关键开启流式 openAiRequest.setTemperature(props.getTemperature()); openAiRequest.setMaxTokens(props.getMaxTokens()); // 异步发起请求避免阻塞Tomcat线程 Thread executor new Thread(() - { try { // 这里用exchange而不是retrieve openAiRestClient.post() .uri(/chat/completions) .body(openAiRequest) .exchange((requestCallback, response) - { // 读取响应流逐行解析 BufferedReader reader new BufferedReader( new InputStreamReader(response.getBody())); String line; StringBuilder fullReply new StringBuilder(); while ((line reader.readLine()) ! null) { if (line.startsWith(data:)) { String data line.substring(5).trim(); if ([DONE].equals(data)) { emitter.send(SseEmitter.event().name(done).data()); break; } // 解析data里的JSON提取content片段 String contentDelta parseDelta(data); if (contentDelta ! null !contentDelta.isEmpty()) { fullReply.append(contentDelta); emitter.send(SseEmitter.event() .name(message) .data(contentDelta)); } } } // 整个流结束保存对话记录 conversationService.saveExchange( request.getConversationId(), request.getMessage(), fullReply.toString()); emitter.complete(); }); } catch (Exception e) { emitter.completeWithError(e); } }); executor.start(); return emitter; }parseDelta方法负责从每个SSE事件里提取增量内容。OpenAI的流式响应里增量内容在choices[0].delta.content字段里private String parseDelta(String data) { try { ObjectMapper mapper new ObjectMapper(); JsonNode root mapper.readTree(data); return root.path(choices).get(0).path(delta).path(content).asText(null); } catch (Exception e) { return null; } }Controller也改一下返回类型PostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestBody Valid ChatRequest request) { return chatService.streamChat(request); }这里有个关键点produces必须指定为text/event-stream否则Spring会用默认的JSON序列化方式处理SseEmitter结果完全不对。我在第一次改造时遇到过一个诡异问题前端拿到的SSE数据是乱码。原因后来定位到是响应头里Content-Type被Nginx覆盖成了text/html。解决办法是在Nginx配置里加一句proxy_buffering off;因为SSE是长连接必须关闭Nginx的响应缓冲否则数据会攒到一定量才推送一次体验上还是卡顿的。这个坑非常典型建议你在生产环境部署时提前处理。4.3 与Spring AI框架的对比什么时候用它什么时候自己封装说到Spring Boot集成大模型API就绕不开Spring AI项目。这是Spring官方出的AI应用框架抽象了ChatClient、EmbeddingClient这些接口兼容OpenAI、Azure OpenAI、Ollama、阿里云等多家模型。用Spring AI的好处是代码更简洁换模型商时比较方便。我自己也在两个项目里试过。但它也有几个现实问题版本迭代太快API变动频繁今年写的代码明年可能要改。项目还比较年轻踩坑时GitHub issues里不一定有答案。如果你只需要对接OpenAI一家引入它反而增加学习成本。所以我建议的决策路径是只对接OpenAI、想完全掌控底层细节、或者团队对新技术比较谨慎的用原生封装需要快速集成多家模型、搭个演示原型、或者想减少样板代码的可以试试Spring AI。两种方案我都跑通过没有绝对的对错。5. 多轮对话的上下文管理一个经常被忽略的复杂问题OpenAI的接口本身是无状态的你每次调用都要把整个对话历史都发过去它才知道上下文。这就带来一个问题历史消息怎么存、存多少、什么时候清理。5.1 用Redis还是内存来维护会话历史最简单的方式是存在内存的Map里conversationId - ListOpenAiMessage。但生产环境你得考虑多实例部署——用户第一次请求落在A机器第二次落在B机器A机器上的历史就丢了。所以内存方案只适合单机演示。实际项目中我推荐用Redis。ListOperations很好用以conversationId为key存储消息记录Service public class ConversationService { private final StringRedisTemplate redisTemplate; private static final String PREFIX ai:conversation:; private static final long TTL_SECONDS 1800; // 30分钟 public void saveExchange(String conversationId, String userMsg, String assistantMsg) { String key PREFIX conversationId; // 把用户消息和助手回复都存进去 redisTemplate.opsForList().rightPush(key, JSON.toJSONString(OpenAiMessage.user(userMsg))); redisTemplate.opsForList().rightPush(key, JSON.toJSONString(OpenAiMessage.assistant(assistantMsg))); redisTemplate.expire(key, Duration.ofSeconds(TTL_SECONDS)); } public ListOpenAiMessage getHistory(String conversationId) { String key PREFIX conversationId; // 只取最近20条控制请求体大小 Long size redisTemplate.opsForList().size(key); if (size null || size 0) return new ArrayList(); long start Math.max(0, size - 20); ListString rawList redisTemplate.opsForList().range(key, start, -1); return rawList.stream() .map(s - JSON.parseObject(s, OpenAiMessage.class)) .collect(Collectors.toList()); } }这里有个性能问题每条消息都存一条Redis记录取的时候要遍历转JSON。消息量小的时候没问题但如果涉及大批量应用建议改成一次存一个JSON数组或者直接用opsForList().range批量取出后统一反序列化。实际项目中这个方案能撑住常规并发量。5.2 token预算和上下文窗口的处理策略OpenAI每个模型都有上下文窗口限制。GPT-4o mini是128K token看起来很大但对着一长串历史对话反复发送不仅慢费用也会膨胀。所以我推荐两个做法按条数截断比如最多保留20轮历史超过就把最早的消息丢掉。按token估算截断累计消息体超过某个阈值时把最早的消息丢掉。方式二更精确但要先统计token数。这里有一个简单的估算公式英文一个单词约占1.3个token中文一个字约占1.5到2个token。你可以用tiktoken这个官方库精确统计但Java集成略麻烦。我用的简化方案是字符数除以2作为近似token数超过阈值比如6000 token就丢掉旧消息。肉眼对比下来偏差不大足够用了。还有一个隐藏问题如果历史里全是你和用户的对话每个回合都会越来越大最后触发OpenAI的Context length exceeded报错。我见过有同事被这个问题折磨了好久才意识到是忘了加截断逻辑。6. 生产环境部署的四个关键配置项很多人把代码写完、本地测试通过就以为完事了结果部署到服务器后问题百出。我按踩坑频率排序把生产环境必须要做的配置列一下。6.1 调用审计日志别把所有内容都打进去AI对话服务涉及用户输入和AI输出这些内容可能包含敏感信息。日志打印时有两种选择全量记录或者脱敏记录。全量记录方便排查问题但如果有用户聊天内容泄漏责任很大。我的建议是数据库里保留完整对话记录用于业务分析和投诉排查但应用日志里只记录conversationId、调用耗时、token用量、HTTP状态码这些元数据不记录消息正文。这样既满足了排查需要又降低了日志泄漏风险。6.2 限流不设限流就是在裸奔OpenAI的API有速率限制按TPM每分钟token数和RPM每分钟请求数计算。超过会被返回429。你当然可以在应用层面加个重试机制但更关键的是别让自己的服务被刷爆。我用的方案是Bucket4j一个轻量令牌桶算法库在接口层面限流Configuration public class RateLimitConfig { Bean public FilterRegistrationBeanOncePerRequestFilter rateLimitFilter() { FilterRegistrationBeanOncePerRequestFilter registration new FilterRegistrationBean(); registration.setFilter(new OncePerRequestFilter() { private final Bucket bucket Bucket.builder() .addLimit(limit - limit.capacity(10).period(Duration.ofMinutes(1))) .build(); Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (bucket.tryConsume(1)) { filterChain.doFilter(request, response); } else { response.setStatus(429); response.getWriter().write({\code\:429,\message\:\Too Many Requests\}); } } }); registration.addUrlPatterns(/api/ai/*); return registration; } }限流粒度按用户维度更合理。这里只用全局维度做演示如果你有用户体系建议根据用户ID做Key维度的限流桶。6.3 错误重试与熔断策略OpenAI接口偶尔会有5xx错误或者网络抖动导致SSE连接中断。这种情况下盲目重试只会加重问题。我的做法是针对429不要立即重试等Retry-After头部指定的时间再试。针对5xx最多重试2次间隔指数退避1秒、2秒、4秒。连续失败超过阈值触发熔断直接返回降级文案比如AI服务暂时不可用请稍后再试。Spring Boot 3里可以用Resilience4j做这些代码量也不大。具体细节这里不展开但强烈建议把这块当成和业务代码同等重要的工作来对待。6.4 网络超时和连接池的调优默认的HTTP客户端超时设置很短生产环境并发一上来连接池也会成为瓶颈。我用的是JdkClientHttpRequestFactory搭配HttpClient构建RestClientHttpClient httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .executor(Executors.newCachedThreadPool()) .build(); ClientHttpRequestFactory factory new JdkClientHttpRequestFactory(httpClient); // 设置读超时 factory.setReadTimeout(Duration.ofSeconds(120));这里readTimeout记得设置大一点因为AI模型生成内容本身就慢。我见过有人用默认的30秒结果稍微长一点的对话就超时中断。7. 实测性能和常见问题的处理7.1 一次真实压测数据我用gpt-4o-mini、max_tokens2048、普通开发机8核16G、内网调用做了一次简单压测结果供你参考场景同步响应平均耗时流式首字耗时流式总耗时短问题你好2.1秒0.9秒2.3秒长回答写800字文章13.6秒1.2秒12.8秒带10轮历史的多轮对话8.4秒1.6秒9.1秒注意几个数据体现出来的现实流式模式虽然总耗时和同步差不多但用户感知完全不同——首字只要1秒左右用户会认为系统很快。而同步模式下用户盯着页面空白十几秒基本就要开始投诉了。同时在多轮对话场景下请求体变大让耗时明显上升所以历史消息的截断策略真的不只是省token的问题还直接关系到响应速度。7.2 常见错误码和排查路径HTTP状态码含义排查重点401鉴权失败API Key是否正确、有没有过期、是不是被平台吊销了403无权访问Key是否绑定了某些受限模型404路径不对baseUrl有没有拼错/v1是不是漏了429限流触发TPM/RPM限制看看是否需要减轻请求频率500服务端问题一般是OpenAI自己的问题等一会重试有一次我排查一个401想破了脑袋Key都没问题最后发现是配置里把Authorization头拼成了Bearer${key}少了空格。这个低级错误让我学会一个习惯所有Header配置先去官网文档核对格式别凭印象写。7.3 使用国产模型时的适配经验很多团队因为支付、网络等因素会考虑替换成国内大模型。这个替换过程其实不像想象中那么复杂因为国产模型的接口很多都兼容OpenAI格式比如DeepSeek、通义千问等。它们的基础URL和API Key不同其他消息结构基本一致。我的经验是把所有调用封装在ChatService里只在OpenAiConfig这个配置类里保留模型商的差异。替换时改下配置就能切过去。前提是你在最初设计就做了这层抽象如果业务代码里到处都是裸的OpenAI调用替换成本会陡增。8. 部署上线前我最后过一遍的检查清单上线前需要过一遍的点我列成一份清单每次有类似项目我都会对着检查[ ] 配置文件里没有硬编码API Key环境变量里已设置[ ].gitignore已排除application-local.yml这类含密钥的配置[ ] 日志过滤了请求头和消息正文只保留元数据[ ] 对话历史有自动过期时间不会无限膨胀[ ] 接口层面加了限流429响应能被前端正常处理[ ] 模型商调用的超时设置大于120秒[ ] 流式接口的Nginx关闭了proxy_buffering[ ] 压测过了知道自己的服务能扛住多少并发[ ] 降级文案准备好了模型服务不可用时返回友好提示[ ] 线上环境模型没用最贵的旗舰版先跑了普通版验证链路这个清单看起来琐碎但基本每一条背后都有一个真实的事故案例。我自己曾在日志里不小心把API Key打出去过一次虽然很快改了但那种后怕不值得体验第二次。9. 一次真实的线上事故复盘从SSE断流到恢复最后分享一次我印象特别深的故障排查经过发生在上线后的第二个星期。运维突然反馈AI对话页面大面积白屏刷新也没用。我第一反应是模型服务挂了先去查了OpenAI的状态页一切正常。接着看后端日志发现很多请求都卡在socket timed out。再往前查发现前一天晚上我们的Nginx配置被人改过——运维加了一个全局proxy_read_timeout 30s;这行配置对所有/api/ai/路径也生效了。而一次完整的SSE流式对话动辄十几秒如果内容长一些超过30秒就会被Nginx掐断。前端收不到结束信号一直等着界面就白屏了。解决方式是在Nginx里单独给SSE接口关闭超时限制location /api/ai/chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_read_timeout 120s; }然后让运维把全局超时改回60秒。整个排查过程大概花了40分钟定位到根本原因后改配置只用了1分钟。这个案例也侧面验证了前面提到的生产环境的任何一层代理配置都可能成为影响用户体验的瓶颈。SSE这种长连接服务你必须从客户端、网关到后端全链路做超时设计任何一个环节的默认值都可能毁掉整条链路。还有个小插曲是当晚我们的熔断逻辑发挥了作用从OpenAI返回错误到降级文案出现在用户端只用了两秒部分用户甚至没察觉到故障。这也算是当初坚持做熔断的一个回报。
返回列表