去年下半年我接到一个挺头疼的需求:给公司官网加一个 AI 助手,用户进来能直接问问题、要一个像样的人工智能回复。当时第一反应是"这事得上大模型训练",后来仔细一调研才发现,真正要做的其实是"把 OpenAI 的接口用 Spring Boot 包一层,做成一个可控、可监控、可计费的对话服务"。折腾了两周,从 API Key 怎么拿、到流式输出怎么推、再到线上 429 限流怎么兜底,踩了一圈坑才把服务稳定跑起来。
这篇文章把完整过程拆给你。不管你是刚接触 Spring Boot 的初级开发,还是已经写过几年业务代码、第一次接 AI 接口的老手,只要照着这个思路走,就能从零搭出一个能上生产的 AI 对话服务。我会把项目结构、请求链路的实现、SSE 流式响应打法、参数与成本控制、以及最容易翻车的那几个坑,全部摊开讲。
1. 项目拆分与依赖选型:先把"AI对话服务"拆成几块
1.1 先理清楚服务要做哪些事
很多人一上来就写代码,结果写着写着发现接口越写越乱。我的建议是先拆层次。一个 AI 对话服务,从职责上可以分成三层:
- 模型接入层:负责和 OpenAI API 通信,处理 HTTP 请求、错误码、重试逻辑。这是整个服务的地基,也是最容易被别人封装好的部分。
- 业务层:维护对话上下文、记录调用量、做参数控制,比如限制用户频率、控制每次请求的 token 数。
- 接口暴露层:把聊天能力封装成后端接口,给前端页面、小程序或者内部系统调用。
这样拆完之后,你就知道自己要写的核心其实只有两件事:一个是"怎么把请求发给 OpenAI 并安全拿回响应",另一个是"怎么把响应顺畅地吐给前端"。其余都是增补。
1.2 技术选型:RestClient、WebClient 还是现成 SDK?
Spring Boot 调外部 HTTP 接口,老项目里最常见的是 RestTemplate。但如果你用的是 Spring Boot 3.2 以上版本,我更推荐直接用内置的RestClient,它走的是 fluent API,写起来比 RestTemplate 舒服,也不需要额外引包。
流式输出那部分,我建议引入spring-boot-starter-webflux里的WebClient来解析 SSE 流。有人会担心:一个 MVC 项目里引入 WebFlux 会不会把架构搞乱?不会。Spring Boot 会优先使用 MVC 作为 Web 框架,WebClient 在这里只是作为 HTTP 客户端使用,不会抢走 Web 层控制权。
那为什么不用社区里现成的 openai-java SDK 或者 Spring AI 框架?这得分场景。如果你只想快速验证一个 Demo,用 SDK 最省事。但你要做的是公司级服务,需要精细控制接口行为(比如自定义错误映射、记录全链路日志、动态切换模型),自己包一层反而更清晰。Spring AI 目前在流式场景的封装还不算稳定,而且抽象层次较多,一旦出问题你得同时排查框架源码和 OpenAI 文档,成本更高。
1.3 工程版本与环境准备
开发环境按这个配置来:
- JDK 17+(Spring Boot 3.x 的硬性要求)
- Spring Boot 3.2 或更高版本
- Maven 或 Gradle 均可,我用的是 Maven
- 一个 OpenAI 账号(用于获取 API Key)
- 确保你的服务运行环境能够正常访问 OpenAI 的接口地址,不同网络环境下的连通性差异很大,部署前先做连通性测试
先在pom.xml里加上基础依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>2. API Key 的获取与安全配置:第一步就决定你后面要不要返工
2.1 获取 Key 的完整流程
很多新手卡在第一步:API Key 到底怎么拿。流程其实不复杂:
- 登录 OpenAI 平台,进入 API Keys 页面。
- 点击 Create new secret key,起个名字方便辨认,比如
prod-chat-service。 - 创建完成后,Key 只会完整显示这一次,之后无法再次查看,必须立刻复制保存。
- 给账户绑定支付方式,否则调用接口时会收到
insufficient_quota(额度不足)一类的错误。
这里提醒一点:新账户是否赠送免费额度、赠送多少,政策一直在变,别拿旧教程当准。你只需要记住,最终能够稳定调用接口的前提是账户有可用额度。
2.2 Spring Boot 里如何安全地持有关键配置
API Key 属于最高级别的敏感信息。我见过有人直接把 Key 写在application.yml里然后打包发到生产环境的,最离谱的是整个项目传到公开仓库、Key 跟着泄露的事故。正确的做法是:
- 用环境变量注入 Key
- 本地开发用
.env文件辅助加载,但.env必须加入.gitignore - 生产环境通过配置中心或容器环境变量注入
application.yml这样写:
openai: api-key: ${OPENAI_API_KEY} base-url: https://api.openai.com/v1 model: gpt-4o-mini max-tokens: 1024 temperature: 0.7在代码里用@ConfigurationProperties绑定:
@Component @ConfigurationProperties(prefix = "openai") public class OpenAiProperties { private String apiKey; private String baseUrl; private String model; private int maxTokens; private double temperature; // getter / setter }这样团队里任何人 clone 代码之后,只需要设置一个OPENAI_API_KEY环境变量就能跑起来,配置文件不用改一行。
2.3 一次 Key 泄露的复盘
我自己踩过一个坑:早期为了方便联调,把 Key 临时写死在代码里,然后某次 git add 的时候把所有文件一股脑提交了,Key 瞬间漏出去。不到两小时,账号里被刷掉了十几美元。处理方式是立刻在后台吊销旧 Key、生成新 Key,然后把所有提交历史里涉及该 Key 的记录抹掉,再在 CI 的密钥扫描插件里加了针对sk-前缀的检测规则。
从那以后我的习惯是:
- 所有 AI 相关项目初始化第一件事,就是配置
OPENAI_API_KEY环境变量 - 本地启动脚本只从
.env读配置,.env永不入库 - 定期轮换生产 Key,避免一个 Key 用到天荒地老
3. 核心请求链路:Spring Boot 里如何优雅地调 Chat Completions API
3.1 先在纸上画出请求与响应的结构
OpenAI 的聊天接口核心路径是POST /v1/chat/completions。请求体长这样:
{ "model": "gpt-4o-mini", "messages": [ { "role": "system", "content": "你是一个网站客服助手" }, { "role": "user", "content": "你们产品的退款政策是什么?" } ], "temperature": 0.7, "max_tokens": 1024, "stream": false }响应体里你最关心的两个字段是choices[0].message.content(回复正文)和usage(本次调用消耗的 token 数)。
在 Java 侧用 record 定义 DTO,干净又安全:
public record ChatMessage(String role, String content) {} public record ChatCompletionRequest( String model, List<ChatMessage> messages, Double temperature, Integer maxTokens, Boolean stream ) {} public record ChatCompletionResponse( String id, List<Choice> choices, Usage usage ) { public record Choice(int index, ChatMessage message) {} public record Usage(int promptTokens, int completionTokens, int totalTokens) {} }注意:max_tokens这个字段在 Java 里没法直接用驼峰名定义,因为 JSON 序列化时字段名会变成maxTokens,而 OpenAI 要求的是max_tokens。解决方式是在字段上加@JsonProperty("max_tokens"),或者统一用配置类在构建请求时手动指定。我习惯让 DTO 保持 Java 风格,在构造请求时再映射成底层 map 结构。
3.2 用 RestClient 实现第一版非流式对话
Spring Boot 3.2 的RestClient用法非常简洁。先在配置类里注册一个带默认 Header 的客户端:
@Configuration public class OpenAiClientConfig { @Bean public RestClient openAiRestClient(OpenAiProperties props) { return RestClient.builder() .baseUrl(props.getBaseUrl()) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.getApiKey()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } }在 Service 里实现同步对话:
@Service public class ChatService { private final RestClient restClient; private final OpenAiProperties props; public String chatSync(List<ChatMessage> messages) { ChatCompletionRequest request = new ChatCompletionRequest( props.getModel(), messages, props.getTemperature(), props.getMaxTokens(), false ); ChatCompletionResponse response = restClient.post() .uri("/chat/completions") .body(request) .retrieve() .body(ChatCompletionResponse.class); return response.choices().get(0).message().content(); } }这段代码跑通之后,你的服务已经有最基础的对话能力了。但我强烈建议你在继续之前,先把usage字段打日志存下来,后面算成本全靠它。
3.3 多轮对话为什么需要你自己存上下文
很多新手在这里犯迷糊:OpenAI 接口明明是无状态的,为什么 Postman 里连续发两条消息它好像"记得"上一句?真相是:每次请求发出去时,客户端把完整的历史消息都重新带上了。messages数组里有多少条,模型就能看到多少条上下文。
这意味着你的服务需要自己管理会话历史。最简单的方案是 Redis 里存一个 Key,以会话 ID 或用户 ID 为维度,Value 是消息列表。每次请求时把列表整体取出、追加新消息、再整体提交。到后期再考虑做"滑动窗口截断",这个话题我在第五章详细讲。
业务接口层用一个简单的 Controller 暴露:
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatService chatService; @PostMapping("/sync") public ChatResult chatSync(@RequestBody ChatRequestDTO request) { List<ChatMessage> history = chatService.getHistory(request.sessionId()); history.add(new ChatMessage("user", request.question())); String reply = chatService.chatSync(history); chatService.appendHistory(request.sessionId(), "user", request.question()); chatService.appendHistory(request.sessionId(), "assistant", reply); return new ChatResult(reply); } }到这里你已经有了一个"能用"的对话服务。但你会发现体验很差:请求要等 5-10 秒才全部返回,用户盯着转圈圈,心里直打鼓。接下来必须上流式。
4. 流式输出那一关:SseEmitter 实现打字机效果
4.1 为什么说流式响应是生产环境的及格线
大模型生成回复是按 token 逐个生成的。如果关掉流式,你必须等模型把所有 token 都生成完,一次性拿到全部文字;而打开流式,模型每生成一小段,就通过 SSE 推送给 HTTP 客户端。
用户体验的差别是巨大的。5 秒后一次性吐出一大段文字,和 1 秒后开始一个字一个字蹦出来,用户的耐心感受完全不同。流式除了体验好,还有一个隐藏优势:首字延迟大幅降低,后端也能提前感知到生成异常。所以从我的实践经验来看,面向 C 端的 AI 对话服务,流式是及格线,不是加分项。
4.2 用 WebClient 接 SSE,用 SseEmitter 推给前端
实现流式通常走"双流接力"的模型:Spring Boot 作为中转站,一边用 WebClient 消费 OpenAI 的 SSE 流,一边用SseEmitter把事件实时推给前端。
先注册 WebClient:
@Bean public WebClient openAiWebClient(OpenAiProperties props) { return WebClient.builder() .baseUrl(props.getBaseUrl()) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.getApiKey()) .build(); }Service 里的流式方法:
public SseEmitter chatStream(List<ChatMessage> history, String sessionId) { SseEmitter emitter = new SseEmitter(300_000L); // 5分钟超时 ChatCompletionRequest request = new ChatCompletionRequest( props.getModel(), history, props.getTemperature(), props.getMaxTokens(), true ); ParameterizedTypeReference<ServerSentEvent<String>> sseType = new ParameterizedTypeReference<>() {}; webClient.post() .uri("/chat/completions") .bodyValue(request) .accept(MediaType.TEXT_EVENT_STREAM) .retrieve() .bodyToFlux(sseType) .filter(event -> event.data() != null && !"[DONE]".equals(event.data())) .doOnNext(event -> sendChunk(emitter, event.data())) .doOnComplete(emitter::complete) .doOnError(emitter::completeWithError) .subscribe(); emitter.onTimeout(() -> { log.warn("SSE connection timeout: {}", sessionId); emitter.complete(); }); return emitter; } private void sendChunk(SseEmitter emitter, String data) { try { ChatChunk chunk = objectMapper.readValue(data, ChatChunk.class); String delta = chunk.choices().get(0).delta().content(); if (delta != null && !delta.isEmpty()) { emitter.send(SseEmitter.event().data(delta)); } } catch (Exception e) { log.error("Failed to parse chunk", e); emitter.completeWithError(e); } }ChatChunk结构需要和流式响应匹配:
public record ChatChunk(String id, List<ChunkChoice> choices) { public record ChunkChoice(Delta delta) {} public record Delta(String content) {} }Controller 里返回SseEmitter即可:
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestBody ChatRequestDTO request) { List<ChatMessage> history = chatService.getHistory(request.sessionId()); history.add(new ChatMessage("user", request.question())); return chatService.chatStream(history, request.sessionId()); }前端用原生的EventSource就能接:
const es = new EventSource('/api/chat/stream', { method: 'POST' }); es.onmessage = (event) => { const text = event.data; // 将 text 追加到页面 };前端每次调用前需要把历史消息传上来,或者后端直接根据 sessionId 从 Redis 取。我采用的是后端取历史,前端只管发问题。
4.3 SseEmitter 的超时、线程池与异常兜底
SseEmitter 有三个坑,基本每个第一次做流式的都会遇到。
第一个坑是超时。SseEmitter 默认 30 秒超时,大模型生成慢一点,连接就断了。所以我上面显式传了 300 秒。更稳妥的做法是把超时时间做成配置项,动态加载。
第二个坑是线程。发送 SSE 事件需要在独立线程里异步执行,否则会阻塞 Tomcat 的工作线程。WebClient的.subscribe()内部本来就会异步,所以这里只要别在 Controller 方法体里用同步block()就好。如果项目里大量使用排障,我建议同时配一个专门的线程池来控制并发上限。
第三个坑是异常兜底。流式推送过程中,OpenAI 那边可能中途断流,也可能返回非 JSON 片段。如果你不在doOnError里做处理,前端就会一直傻等。我的做法是收到错误后调用emitter.completeWithError(e),并在发送给前端时,把错误信息序列化成一段固定格式的对象,前端解析后能弹出友好提示。
我还遇到过一个细节问题:SseEmitter.event().data()默认传输的字段名是data,前端EventSource的onmessage能直接拿到。如果你需要传输自定义字段,可以SseEmitter.event().name("message").data(payload),此时前端要监听addEventListener('message', ...)。这些细节务必确认清楚,免得前后端对不上。
5. 参数调优与控制成本:别让 AI 把你公司聊穷了
5.1 模型与参数的默认值怎么定
OpenAI 目前常用的是gpt-4o、gpt-4o-mini以及 4.1 系列,具体以官方模型列表为准。我的建议是:线上默认用 mini 系列,智能要求高的细分场景再升到 4o。原因无他,成本差一个量级,客服机器人、文档问答这种场景 mini 完全够用。
temperature控制随机性,范围 0-2。数值越小,输出越确定;数值越大,越有发散性。知识问答类应用我习惯设 0.2,创意写作类可以设 0.8-1.0。千万别默认值拉到 1.0,知识库问答会经常冒出一本正经的胡说八道。
max_tokens是输出上限,不是"固定生成数"。它设太小,回复会被截断;设太大,遇到长回复会先等很久,成本也高。我通常先设 1024,观察线上调用的实际分布再做调整。如果经常出现截断(回复末尾有戛然而止的痕迹,或者 usage 里finish_reason为length),再往上加。
5.2 按量计费怎么算:一个真实成本账单
很多老板问"这个 AI 一天要花多少钱"。我给出一个真实账单逻辑:假设你的服务每天 1000 次对话,每次请求输入 800 token、输出 500 token,按目前公开价格粗算(实际以官方定价为准):
- 输入 token 数:800 × 1000 = 80 万 token,按
gpt-4o-mini输入价格折算 - 输出 token 数:500 × 1000 = 50 万 token,按
gpt-4o-mini输出价格折算 - 全天成本大约在几美元以内,如果换
gpt-4o,成本会直接跳到 10 倍以上
关键是把 usage 数据接进日志系统。我每次对话都会打一条带prompt_tokens、completion_tokens的结构化日志,这样月底导出账单时能看到每个 session 消耗了多少 token。
5.3 上下文截断:无状态 API 背后的内存账
每次请求把完整历史都带上去,意味着越聊越长,成本越高,而且还会触及模型的上下文窗口上限。解决思路是按"价值"保留消息:
system指令永远保留- 最近 10 轮对话(20 条消息)完整保留
- 更早的内容做摘要,或者直接把最老的 user/assistant 消息丢弃
用 Java 实现一个简单的环形队列:
public List<ChatMessage> buildContextMessages(List<ChatMessage> history) { LinkedList<ChatMessage> messages = new LinkedList<>(); for (ChatMessage m : history) { if (m.role().equals("system") || messages.size() < 20) { messages.addLast(m); } } return messages; }再深入一点,可以按 token 数做预算:比如整个上下文预算 3000 token,每次构建请求前把超过预算的资源从最老的消息开始裁。这个思路配合 Redis 存历史,基本能撑住常规业务。
6. 错误处理与重试策略:把 429、5xx、超时都挡在用户外面
6.1 先把 OpenAI 错误分类再写重试逻辑
不是所有错误都值得重试。我的习惯是先给错误归个类:
400:请求格式错误、token 超限,一般改代码,重试无意义401:API Key 无效或过期,重试同样无意义429:限流或额度不足。分两种:普通限流可以重试,额度不足重试也没用5xx:OpenAI 服务器临时故障,可以重试超时/连接中断:网络抖动,可以重试
Spring 的RestClient或WebClient会把非 2xx 响应抛成HttpClientErrorException或WebClientResponseException,异常里有getStatusCode(),先按状态码分流。
6.2 指数退避重试的小实现
我用一个非常朴素的方法做指数退避重试,不引入 Spring Retry 依赖。核心逻辑是:
public <T> T executeWithRetry(Supplier<T> action, int maxAttempts) { int attempt = 1; while (true) { try { return action.get(); } catch (WebClientResponseException e) { if (attempt >= maxAttempts || !isRetryable(e.getStatusCode())) { throw e; } long waitMs = Math.min(1000L * (1L << (attempt - 1)), 8000L); log.warn("OpenAI API retry, attempt={}, wait={}ms, status={}", attempt, waitMs, e.getStatusCode()); sleep(waitMs); attempt++; } } } private boolean isRetryable(HttpStatusCode status) { return status.is5xxServerError() || status.value() == 429 || status.value() == 408; }注意 1 秒、2 秒、4 秒、8 秒的上限必须设。有人直接把退避上限写成 60 秒,结果是用户端超时了你还在傻等,体验更差。另外,如果 429 响应头里带了Retry-After,优先按这个头等待。
6.3 断流场景的用户体验兜底
流式场景里的错误更隐蔽:可能前面已经吐了半段文字,然后流断了。SseEmitter 会触发onError或超时,但前端看到的是"话说到一半没人了"。
我的兜底方案是:
- 后端记录每次流式任务的状态,比如用日志追踪
started、completed、failed - 如果流中断并且已经产生的字符数小于某个阈值,前端展示"暂时没收到回复,请重试"
- 如果字符数足够多,前端保留已显示内容,并追加一条"生成被中断,你可以继续提问"
这个逻辑听起来简单,但线上体验真的差很多。当初我上线第一版没有兜底,用户的反馈是"回答到一半就卡死",拉了很多人回去反复排查才发现是流断了,不是接口挂了。
重试还要注意一个业务场景:多轮会话里发生的重试,必须保证消息不会重复推送。因为流式请求本质上是一次 HTTP 调用,你可以让前端用 requestId 去重,后端在重试时直接复用同一个 requestId 传给 OpenAI,响应里会返回相同的id。
这个项目做完之后的一点体会
回看整个项目,最花时间的其实不是调用 OpenAI 的那几十行代码,而是把 Key 安全、流式推送、成本控制、异常重试这些"工程琐事"一一落地。这也是为什么我一开始不建议直接套现成 SDK——只有自己把链路全部打通一遍,才知道哪一层会出问题。如果你打算做类似的项目,我的建议是先把非流式跑通、再做流式、最后补重试和监控,一步步来,每一步都验证完再进入下一步。至少我自己这样走下来,生产环境出问题的概率低了很多。