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)支持两种响应方式:一次性返回完整结果,或者通过SSE(Server-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("*")。
响应结构统一用Result<T>包装,{ 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 类似 }请求体DTO:
public class OpenAiChatRequest { private String model; private List<OpenAiMessage> 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. 从会话服务里拿历史消息 List<OpenAiMessage> history = conversationService.getHistory(request.getConversationId()); // 2. 拼装完整消息列表 List<OpenAiMessage> 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 Result<ChatResponse> 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秒超时 // 组装请求,跟同步版完全一样 List<OpenAiMessage> 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 -> List<OpenAiMessage>。但生产环境你得考虑多实例部署——用户第一次请求落在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 List<OpenAiMessage> 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); List<String> 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 FilterRegistrationBean<OncePerRequestFilter> rateLimitFilter() { FilterRegistrationBean<OncePerRequestFilter> 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构建RestClient:
HttpClient 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_tokens=2048、普通开发机(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返回错误到降级文案出现在用户端只用了两秒,部分用户甚至没察觉到故障。这也算是当初坚持做熔断的一个回报。