1. 为什么要在 Spring Boot 里自己接 OpenAI,而不是直接调现成 SDK
很多 Java 后端同学第一次接触大模型集成,第一反应是去找一个封装好的 starter,加个依赖、配个 key 就完事。我一开始也这么干,结果踩了两个坑:一是某些封装库版本迭代太快,Spring Boot 2.6.x 和 2.3.x 的自动装配行为不一致,升级一次就报NoSuchMethodError;二是业务方要求对请求做细粒度的超时控制、重试策略和 token 统计,封装层把RestTemplate或WebClient藏得太深,改起来反而更费劲。
所以后来我倾向于一个更朴素的做法:用 Spring Boot 原生的 HTTP 客户端能力,直接对接 OpenAI 的 Chat Completions 接口。这样做的好处很实在——依赖少、可控性强、出问题能一眼定位到是哪一层。你不需要引入spring-ai那一整套抽象,也不用担心它和你的 Spring Boot 版本打架。当然,如果你的项目已经在用 Spring AI 或者 Spring AI Alibaba,那另说,本文的重点是"从零搭建",把底层链路讲透。
这篇文章适合谁看?如果你会写 Spring Boot 的 Controller 和 Service,知道@RestController和@ConfigurationProperties怎么用,但对"怎么把大模型对话能力接进自己的 Java 服务"还没头绪,那这篇就是给你准备的。我会从依赖选型、配置管理、请求封装、流式响应、异常处理一路讲到上线前要注意的坑,代码都能直接抄。
先明确一个核心概念:OpenAI 的对话接口本质就是一个HTTPS POST 请求,请求体是 JSON,响应体也是 JSON。所谓"集成",无非是把 HTTP 调用包装成一个 Spring 的 Service,把 API Key 管好,把异常兜住,把并发和超时控制住。想通这一点,后面所有事情都顺了。
2. 环境准备与依赖选型:别一上来就堆框架
2.1 Spring Boot 版本与 JDK 的取舍
我实测下来,Spring Boot 2.7.x 配 JDK 17是目前最稳的组合。为什么不是 3.x?因为 Spring Boot 3.x 强制要求 JDK 17 起步,而且把javax.*换成了jakarta.*,如果你项目里还有老版本的第三方库没适配,迁移成本不小。而 2.7.x 是 2.x 的最后一个大版本,社区支持成熟,JDK 8 到 17 都能跑。
如果你是新项目、没有历史包袱,直接上 Spring Boot 3.2.x + JDK 21 也没问题,本文的代码在两者上都能跑,唯一要注意的是WebClient的依赖坐标在 3.x 里没变,但spring-boot-starter-webflux的版本要跟着父 POM 走。
至于热词里提到的spring boot 2.3.x 2.6.x,我的建议是:2.3.x 太老了,WebClient的很多便利方法还没有;2.6.x 可以用,但要注意spring.mvc.pathmatch.matching-strategy默认值变了,如果你同时用了 Swagger,可能会遇到路径匹配报错,加一行配置改成ant_path_matcher就行。
2.2 用 RestTemplate 还是 WebClient
这是第一个要做的技术决策,我列个表对比一下:
| 维度 | RestTemplate | WebClient |
|---|---|---|
| 编程模型 | 同步阻塞 | 同步/异步/流式 |
| 流式响应支持 | 不支持 | 原生支持 |
| 依赖 | spring-boot-starter-web | spring-boot-starter-webflux |
| 学习成本 | 低 | 中等 |
| 适用场景 | 简单问答、后台任务 | 打字机效果、高并发 |
结论很明确:只要你的对话服务需要"打字机"式的流式输出,就必须用 WebClient。因为 OpenAI 的流式接口返回的是text/event-stream,RestTemplate 拿到的是完整响应,做不了逐字推送。而 WebClient 的bodyToFlux(String.class)可以一行行消费。
如果你只是做后台批处理、不需要实时推给前端,那 RestTemplate 更简单。但考虑到"AI 对话服务"这个场景,流式几乎是标配,所以我下面以 WebClient 为主线,RestTemplate 的写法在需要的地方会补充。
依赖就两个:
<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>注意,同时引入 web 和 webflux 时,Spring Boot 默认还是以 Servlet 容器(Tomcat)启动,WebClient 可以正常用,不会冲突。这一点很多人担心,实测没问题。
2.3 API Key 的获取与配置管理
API Key 的获取流程这里不展开,简单说就是登录 OpenAI 平台,在 API Keys 页面创建一个,格式是sk-开头的一长串。创建后只显示一次,务必立刻保存,这是新手最容易犯的错。
配置管理上,绝对不要把 key 硬编码在代码里,也不建议直接写在application.yml里提交到 Git。我的做法是分三层:
- 本地开发:用环境变量
OPENAI_API_KEY,在 IDE 的运行配置里设置。 - 测试/生产:用配置中心或容器编排的 Secret 注入。
- 代码里通过
@Value("${openai.api-key}")或@ConfigurationProperties读取。
openai: api-key: ${OPENAI_API_KEY:} base-url: https://api.openai.com/v1 model: gpt-4o-mini connect-timeout: 5000 read-timeout: 60000这里base-url单独抽出来是有讲究的——方便你切换到兼容 OpenAI 协议的其他服务端点,或者做本地 mock 测试。read-timeout给到 60 秒,是因为大模型生成一段长回复确实可能超过 30 秒,设太短会频繁超时。
3. 请求封装:把 Chat Completions 接口吃透
3.1 请求体结构逐字段拆解
OpenAI 的/v1/chat/completions接口,请求体核心就几个字段,我用一个 Java 的 record(JDK 17)或普通类来映射:
public record ChatRequest( String model, List<Message> messages, Double temperature, Integer max_tokens, Boolean stream ) { public record Message(String role, String content) {} }逐个说清楚:
- model:模型名,比如
gpt-4o-mini、gpt-4o。选哪个?我的经验是,日常对话、成本敏感的场景用gpt-4o-mini足够,它的响应速度和价格都很友好;需要复杂推理、代码生成再上gpt-4o。 - messages:消息数组,每条有
role和content。role有三个值:system(设定人设和规则)、user(用户输入)、assistant(模型的历史回复)。多轮对话的关键就是把历史消息按顺序拼进去,模型本身是无状态的,它不记得上一句说了什么,全靠你把上下文带过去。 - temperature:0 到 2 之间,控制随机性。写代码、做客服问答建议 0.2 到 0.5,创意写作可以到 0.8 以上。默认 1.0 有时候会"太放飞"。
- max_tokens:限制回复长度。注意这是输出的 token 上限,不是输入。设太小会导致回复被截断,设太大浪费额度。一般对话 1024 够用。
- stream:是否流式返回,布尔值。
这里有个容易忽略的点:messages 的总 token 数是有上限的,不同模型上限不同。如果你做多轮对话,历史消息越堆越长,迟早会超。所以必须做上下文裁剪,这个后面第 5 节细讲。
3.2 用 WebClient 发起非流式请求
先看最简单的非流式调用,适合后台任务:
@Service public class ChatService { private final WebClient webClient; private final OpenAiProperties props; public ChatService(WebClient.Builder builder, OpenAiProperties props) { this.props = props; this.webClient = builder .baseUrl(props.getBaseUrl()) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.getApiKey()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } public String chat(String userInput) { ChatRequest request = new ChatRequest( props.getModel(), List.of(new ChatRequest.Message("user", userInput)), 0.7, 1024, false ); ChatResponse response = webClient.post() .uri("/chat/completions") .bodyValue(request) .retrieve() .bodyToMono(ChatResponse.class) .block(Duration.ofSeconds(60)); return response.choices().get(0).message().content(); } }响应体的结构也要映射好,核心是choices[0].message.content,另外usage字段里有prompt_tokens、completion_tokens、total_tokens,做成本统计时非常有用,建议一并解析出来存日志。
3.3 流式响应:SSE 逐字推送的实现
流式才是对话服务的灵魂。OpenAI 的流式返回是一行行data: {...}的 SSE 格式,最后以data: [DONE]结束。WebClient 的处理方式:
public Flux<String> chatStream(String userInput) { ChatRequest request = new ChatRequest( props.getModel(), List.of(new ChatRequest.Message("user", userInput)), 0.7, 1024, true ); return webClient.post() .uri("/chat/completions") .bodyValue(request) .retrieve() .bodyToFlux(String.class) .filter(line -> line.startsWith("data: ")) .map(line -> line.substring(6)) .takeUntil("[DONE]"::equals) .filter(json -> !"[DONE]".equals(json)) .map(this::extractContent) .filter(s -> !s.isEmpty()); }extractContent就是把每个 chunk 的 JSON 解析出choices[0].delta.content。注意流式返回里字段叫delta而不是message,这是新手最容易搞混的地方。
然后在 Controller 里用text/event-stream推给前端:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String q) { return chatService.chatStream(q); }前端用EventSource或fetch的流式读取就能实现打字机效果。这里有个坑:如果你前面挂了 Nginx,必须关闭该路径的缓冲,加proxy_buffering off;,否则前端会等所有内容生成完才一次性收到,流式就白做了。
4. 多轮对话与上下文管理:模型没有记忆,你得替它记
4.1 会话状态存哪里
模型是无状态的,多轮对话的本质是每次请求都把历史消息带上。那历史消息存哪?三种方案:
- 前端存:每次请求把完整历史发给后端。优点是后端无状态、易扩展;缺点是请求体越来越大,且容易被篡改。
- 后端内存存:用
ConcurrentHashMap<String, List<Message>>按 sessionId 存。简单,但重启就丢,多实例部署不共享。 - Redis 存:生产环境推荐。按 sessionId 存一个 List,设置过期时间(比如 30 分钟),天然支持多实例。
我一般用 Redis,key 设计成chat:session:{sessionId},value 用 JSON 序列化的消息列表。每次请求先读历史,追加用户消息,调用模型,再把模型回复追加进去写回。
4.2 上下文裁剪的三种策略
历史越堆越长,token 迟早爆。裁剪策略我试过三种:
- 滑动窗口:只保留最近 N 轮。简单粗暴,但会丢失早期的重要设定。
- 保留 system + 最近 N 轮:system 消息永远保留(人设不能丢),user/assistant 只留最近几轮。这是我最常用的。
- 摘要压缩:把早期对话让模型总结成一段话,替换掉原始消息。效果好但多一次调用,成本和延迟都上去了。
实际项目里,我通常用策略 2,N 取 10 轮左右。同时用一个粗略的估算:中文大约 1 个字 1 到 2 个 token,英文大约 4 个字符 1 个 token,据此判断是否要裁剪。精确计算可以用对应的 tokenizer,但引入额外依赖,粗略估算对大多数场景够用。
4.3 system 提示词的设计心得
system 消息决定了模型的"人设",写得好不好直接决定服务质量。我的经验是:
- 明确角色:"你是一个专业的电商客服助手"比"你是一个助手"效果好得多。
- 给出边界:"如果用户问的问题超出你的知识范围,请如实说明并建议联系人工客服",能有效减少胡编。
- 规定格式:如果需要结构化输出,在 system 里写清楚"请以 JSON 格式返回,包含 field1 和 field2 两个字段"。
- 别写太长:system 提示词也占 token,而且过长的规则模型未必都记得住,抓重点。
一个实测有效的模板:
你是{产品名}的智能助手,负责回答用户关于产品功能和使用方法的问题。 回答要求: 1. 简洁准确,单次回复不超过 200 字 2. 不确定的信息不要编造,引导用户联系人工客服 3. 语气友好专业,不使用夸张表达5. 异常处理与稳定性:上线前必须堵住的窟窿
5.1 OpenAI 常见错误码与应对
调用外部接口,异常处理是重头戏。我把常见的错误码和应对策略整理成表:
| HTTP 状态码 | 含义 | 应对策略 |
|---|---|---|
| 401 | API Key 无效 | 检查配置,不重试 |
| 429 | 请求频率超限 | 指数退避重试 |
| 500/502/503 | 服务端错误 | 有限次重试 |
| 400 | 请求体有问题 | 检查参数,不重试 |
| 超时 | 网络或生成过慢 | 重试或降级 |
关键原则:4xx 类错误重试没意义,5xx 和超时才值得重试。重试要用指数退避,比如第一次等 1 秒,第二次 2 秒,第三次 4 秒,避免雪崩。
5.2 超时与重试的代码落地
WebClient 的超时配置要分连接超时和读取超时:
HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, props.getConnectTimeout()) .responseTimeout(Duration.ofMillis(props.getReadTimeout())); this.webClient = builder .baseUrl(props.getBaseUrl()) .clientConnector(new ReactorClientHttpConnector(httpClient)) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.getApiKey()) .build();重试可以用 Reactor 的retryWhen:
.retrieve() .bodyToMono(ChatResponse.class) .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) .filter(e -> e instanceof WebClientResponseException.TooManyRequests || e instanceof WebClientResponseException.ServiceUnavailable))注意filter里只对 429 和 503 重试,其他异常直接抛出。流式请求不要轻易重试,因为已经推给前端的内容没法撤回,重试会导致内容重复。
5.3 降级方案:模型挂了怎么办
生产环境一定要有降级。我的做法是:主模型调用失败超过阈值后,切到备用模型(比如从gpt-4o降到gpt-4o-mini),再失败就返回一个预设的兜底话术,比如"当前服务繁忙,请稍后再试"。用 Resilience4j 的 CircuitBreaker 可以很优雅地实现,但即使手写一个计数器也能应付。
另外,API Key 要支持热更新。如果 key 泄露需要紧急更换,总不能重启服务。可以把 key 放在配置中心,监听变更事件重建 WebClient。
6. 实测中的性能与成本优化
6.1 连接池与并发控制
WebClient 底层用的是 Reactor Netty,默认连接池大小是 CPU 核数乘以 2。如果你的服务并发量高,这个值可能不够,会出现请求排队。可以通过ConnectionProvider调整:
ConnectionProvider provider = ConnectionProvider.builder("openai-pool") .maxConnections(200) .pendingAcquireTimeout(Duration.ofSeconds(10)) .build();但要注意,连接数不是越大越好,OpenAI 那边对你的账号有速率限制(RPM 和 TPM),连接开太多反而更容易触发 429。合理做法是配合本地限流,用RateLimiter控制每秒请求数。
6.2 token 成本的可观测性
成本控制的前提是能看见。我建议在每次调用后记录一条日志,包含:sessionId、模型名、prompt_tokens、completion_tokens、耗时、是否成功。这些数据攒起来,用 Grafana 或简单的报表就能看出哪个功能最烧钱。
一个实测数据供参考:gpt-4o-mini处理一次普通问答(输入 200 token、输出 300 token),成本在千分之几美分级别,一天一万次调用也就几美元。但如果用gpt-4o,成本会高一个数量级。所以能用小模型解决的场景,坚决不用大模型。
6.3 缓存能省下的钱
有些问题是重复的,比如"你们的退货政策是什么"。这类高频问题完全可以做缓存:把用户问题做归一化(去空格、转小写)后作为 key,模型回复作为 value,存 Redis,设置合理过期时间。命中缓存直接返回,既省钱又快。
但要注意,多轮对话场景下缓存要谨慎,因为同样的用户输入在不同上下文里答案可能不同。我的做法是只对"单轮、无历史"的请求启用缓存。
7. 几个我踩过的坑和对应解法
第一个坑:流式响应中文乱码。原因是 WebClient 默认按字节流处理,如果没指定字符集,中文可能被拆成半个字符。解法是在bodyToFlux(String.class)之前确保响应头Content-Type带charset=utf-8,或者手动用DataBufferUtils按行切分并指定 UTF-8 解码。
第二个坑:@ConfigurationProperties不生效。检查两点:类上有没有加@Component或通过@EnableConfigurationProperties注册;setter 方法是否齐全(用 record 的话要确认 Spring Boot 版本支持构造绑定)。我遇到过因为字段名是apiKey而配置写的是api-key,relaxed binding 本该处理,但因为少了 setter 导致绑定失败。
第三个坑:Nginx 缓冲导致流式失效。前面提过,再强调一次,proxy_buffering off和proxy_cache off都要加,X-Accel-Buffering: no响应头也建议带上。
第四个坑:多实例部署时内存存会话导致串话。用户第一次请求打到实例 A,第二次打到实例 B,历史就丢了。所以会话状态必须外置到 Redis,这是多实例部署的硬性要求。
第五个坑:忘记处理[DONE]标记。流式返回的最后一行是data: [DONE],如果不过滤掉,解析 JSON 时会抛异常。用takeUntil提前终止流是最干净的做法。
8. 从能跑到好用,还差哪些工程化细节
把对话跑通只是第一步,真正上线还要补几块:
接口鉴权:你的对话接口不能裸奔,必须校验用户身份,否则会被刷。用 Spring Security 加个 JWT 过滤器是标配。
输入长度限制:用户可能粘贴一篇长文进来,直接超 token 上限。在 Controller 层就要限制输入字符数,超了直接返回友好提示。
敏感内容过滤:用户输入和模型输出都要过一遍敏感词过滤,这是合规底线。可以用现成的词库,也可以接内容审核接口。
日志脱敏:对话内容可能包含用户隐私,日志里不要全量打印,或者做脱敏处理。
灰度与开关:新模型上线先灰度一部分用户,出问题能一键切回。用一个配置开关控制走哪个模型,比改代码重新发布快得多。
监控告警:错误率、平均耗时、token 消耗量都要有监控,超过阈值告警。特别是 429 错误率,一旦飙升说明要么该扩容要么该限流。
我个人在实际操作中的体会是,集成大模型这件事,技术难度不在调用本身,而在工程细节的把控。接口就那一个,参数就那几个,但要把超时、重试、降级、缓存、限流、监控、安全这些周边都做扎实,才敢说这是一个能上生产的服务。很多团队 demo 跑得飞快,一上量就各种问题,根子都在这些"不起眼"的地方。
最后分享一个小技巧:本地开发时,如果不想每次都真实调用消耗额度,可以写一个 mock 的ChatService实现,用@Profile("local")激活,返回固定话术。这样调试前端和联调时既快又省钱,等真正需要验证模型效果时再切回真实实现。这个模式在团队协作里特别有用,前端同学不用等后端配好 key 就能开工。