ARTICLE DETAIL

资讯详情

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

Spring Boot集成OpenAI:从API Key到SSE流式AI对话服务实战

Spring Boot集成OpenAI:从API Key到SSE流式AI对话服务实战 去年下半年我接到一个挺头疼的需求给公司官网加一个 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 还是现成 SDKSpring 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 17Spring Boot 3.x 的硬性要求Spring Boot 3.2 或更高版本Maven 或 Gradle 均可我用的是 Maven一个 OpenAI 账号用于获取 API Key确保你的服务运行环境能够正常访问 OpenAI 的接口地址不同网络环境下的连通性差异很大部署前先做连通性测试先在pom.xml里加上基础依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency2. 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 API3.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, ListChatMessage messages, Double temperature, Integer maxTokens, Boolean stream ) {} public record ChatCompletionResponse( String id, ListChoice 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(ListChatMessage 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) { ListChatMessage 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把事件实时推给前端。先注册 WebClientBean public WebClient openAiWebClient(OpenAiProperties props) { return WebClient.builder() .baseUrl(props.getBaseUrl()) .defaultHeader(HttpHeaders.AUTHORIZATION, Bearer props.getApiKey()) .build(); }Service 里的流式方法public SseEmitter chatStream(ListChatMessage history, String sessionId) { SseEmitter emitter new SseEmitter(300_000L); // 5分钟超时 ChatCompletionRequest request new ChatCompletionRequest( props.getModel(), history, props.getTemperature(), props.getMaxTokens(), true ); ParameterizedTypeReferenceServerSentEventString 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, ListChunkChoice 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) { ListChatMessage 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 ListChatMessage buildContextMessages(ListChatMessage history) { LinkedListChatMessage 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 超限一般改代码重试无意义401API Key 无效或过期重试同样无意义429限流或额度不足。分两种普通限流可以重试额度不足重试也没用5xxOpenAI 服务器临时故障可以重试超时/连接中断网络抖动可以重试Spring 的RestClient或WebClient会把非 2xx 响应抛成HttpClientErrorException或WebClientResponseException异常里有getStatusCode()先按状态码分流。6.2 指数退避重试的小实现我用一个非常朴素的方法做指数退避重试不引入 Spring Retry 依赖。核心逻辑是public T T executeWithRetry(SupplierT 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——只有自己把链路全部打通一遍才知道哪一层会出问题。如果你打算做类似的项目我的建议是先把非流式跑通、再做流式、最后补重试和监控一步步来每一步都验证完再进入下一步。至少我自己这样走下来生产环境出问题的概率低了很多。
返回列表