Java 程序员的 AI 进化论 | Spring Boot 接入 OpenAI 的六个坑,全帮你踩了
上周产品甩来一个需求,要在咱们的 Spring Boot 项目里接 OpenAI 的接口,做一个智能问答的功能。我心想这能有多难,调一个 HTTP 接口嘛,RestTemplate 凑几行代码的事,撑死半小时。
结果一下午过去了,踩出来六个坑。一个比一个隐蔽,好几个还是上线跑了两天才暴露的。今天把这些坑挨个整理出来,如果你也在接大模型 API,照着排查一遍,能少走不少弯路。
一、超时没设,请求把线程池堵死
第一个坑最基础,但也最坑人。
OpenAI 的接口慢的时候是真慢,动不动十几秒,赶上高峰期更夸张。我一开始图省事,直接 new RestTemplate(),没设超时。本地调试一切正常,上了测试环境也没问题。直到有一天流量稍微上来点,监控里看到 Tomcat 的工作线程全被占满,新请求全在排队,整个服务基本卡死。
问题就出在 RestTemplate 默认不设超时,等于无限等待。一个慢请求挂住一个线程,并发一高,200 个工作线程很快就被占光。
java
// 错误写法:默认超时是无限,千万别这么用
@Bean
public RestTemplate restTemplate() {
return new RestTemplate(); // 没设超时,迟早出事
}
正确的做法是用 RestTemplateBuilder 显式设上连接和读取超时:
java
// 注意:超时时间必须设,不然默认无限等待会把请求队列堵死
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(30))
.build();
}
这里有个细节我纠结过:readTimeout 设 30 秒还是 60 秒?我后来定 30 秒。GPT-4 这种慢模型正常也就十来秒返回,超过 30 秒大概率是出问题了,与其干等不如直接 fail fast,给用户一个降级提示,比转圈一分钟强。
二、Token 限制,长文本被悄悄截断
第二个坑是上下文超长。
每个模型都有最大 Token 数限制,比如 gpt-4o 是 128K。但实际跑起来,把整个对话历史往 messages 里一塞,很容易就超了。超了之后 OpenAI 不报错,而是悄悄截断最早的消息。
这就很坑了。用户连续问了十几轮,前面输入的关键背景信息没了,AI 答非所问,用户还以为是 AI 傻了,其实是上下文被截了。
我踩这个坑是因为做了个多轮对话功能,用户反馈"聊着聊着 AI 就忘了前面说啥"。查了半天才发现是 Token 超限被截断了。OpenAI 不会主动告诉你截了,只能自己算。
java
// 粗略估算 Token 数:中文约 1.5 token/字,英文约 0.25 token/字符
// 不精确,但够用于截断判断
public int estimateTokens(String text) {
int chineseCount = 0;
int otherCount = 0;
for (char c : text.toCharArray()) {
if (String.valueOf(c).getBytes(StandardCharsets.UTF_8).length > 2) {
chineseCount++;
} else {
otherCount++;
}
}
return (int) (chineseCount * 1.5 + otherCount * 0.25);
}
算出来之后做一个滑动窗口,超过阈值就从最早的消息开始砍:
java
// 滑动窗口截断:保留最近的对话,超了就从前面砍
private List<ChatMessage> trimHistory(List<ChatMessage> history, int maxTokens) {
int total = estimateTokens(systemPrompt);
List<ChatMessage> result = new ArrayList<>();
// 从后往前加,超了就停
for (int i = history.size() - 1; i >= 0; i--) {
int t = estimateTokens(history.get(i).getContent());
if (total + t > maxTokens) {
break;
}
result.add(0, history.get(i));
total += t;
}
return result;
}
留多少预算给历史对话?我的做法是留模型上限的一半给历史,剩下一半留给 system prompt 和回复。这样既不会超限,又能保留足够上下文。
三、流式响应,SSE 解析一堆坑
第三个坑是流式输出。
为了让前端有打字机效果,一般用 stream: true,返回的是 SSE 格式,一行一个 data: {...},末尾用 data: [DONE] 标记结束。解析这个格式我踩了两个坑。
第一个坑:用 RestTemplate 调流式接口根本拿不到逐字效果,它会把整个响应缓冲完才返回。必须换 WebClient。
java
// 流式必须用 WebClient,RestTemplate 拿不到逐字效果
public Flux<String> streamChat(String prompt) {
return webClient.post()
.uri("/v1/chat/completions")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.bodyValue(Map.of(
"model", "gpt-4o-mini",
"messages", List.of(Map.of("role", "user", "content", prompt)),
"stream", true
))
.retrieve()
.bodyToFlux(String.class)
.takeUntil("[DONE]"::equals)
.filter(line -> !line.equals("[DONE]"))
.mapNotNull(this::extractContent);
}
private String extractContent(String data) {
try {
JSONObject json = JSON.parseObject(data);
return json.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("delta")
.getString("content");
} catch (Exception e) {
return null; // 不完整的 JSON 片段,跳过等下一个
}
}
第二个坑更隐蔽:data: [DONE] 这个结束标记。我一开始忘了处理,结果流结束不了,WebClient 连接一直挂着不释放,连接池慢慢就被耗光了。后来看日志才发现,每次请求结束后连接都没正常关闭。
解决办法就是上面代码里的 takeUntil("[DONE]"::equals),遇到结束标记主动结束 Flux。还有一点,SSE 返回的数据里偶尔会有空行或者不完整的 JSON 片段,extractContent 里 try-catch 兜一下,别让解析失败把整个流搞崩。
四、并发控制,连接池被打满
第四个坑也是上线才暴露的。
高并发场景下,如果不限制并发请求数,HTTP 连接池会被打满,新请求拿不到连接直接超时。压测的时候并发上到 100,错误率突然飙升,日志全是连接超时。一看连接池状态,全是 active,一个空闲的都没有。
更麻烦的是,OpenAI 那边也有并发限制,短时间请求太多会直接返回 429。你不限流,OpenAI 替你限,但代价是请求失败、用户报错。
java
// 用信号量限流,控制对 OpenAI 的并发请求数
@Component
public class OpenAiRateLimiter {
// 同时最多 5 个请求打到 OpenAI,多了直接排队
private final Semaphore semaphore = new Semaphore(5);
public <T> T executeWithLimit(Supplier<T> task) {
try {
if (!semaphore.tryAcquire(10, TimeUnit.SECONDS)) {
throw new ServiceException("当前请求较多,请稍后再试");
}
return task.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("请求被中断");
} finally {
semaphore.release();
}
}
}
调用的时候包一层:
java
public String chat(String prompt) {
return rateLimiter.executeWithLimit(() -> {
// 实际调用 OpenAI 的逻辑
return callOpenAi(prompt);
});
}
信号量限制的是你的服务到 OpenAI 的并发数。但光限并发还不够,还得配合连接池配置,把 HttpClient 的 maxConnections 设成和信号量许可数一致,不然信号量放行了,连接池却不够用,照样超时。
还有个容易忽略的点:超时时间要和信号量的等待时间匹配。我一开始信号量等待设了 10 秒,但 OpenAI 请求超时设了 30 秒,结果信号量等不到释放就先超时了。两个值得一起调,等待时间至少要大于最长请求时间。
五、费用失控,Token 在烧钱
第五个坑是钱。
OpenAI 按 Token 收费,不控制的话账单涨得飞快。我有一次测试,忘了切回测试环境,用 gpt-4 跑了一晚上批量处理数据,第二天看账单,小一千块没了。肉疼。
坑在哪?一是调试的时候用贵模型,gpt-4 比 gpt-4o-mini 贵几十倍,一个不小心就烧钱;二是没有用量监控,花了多少心里没数;三是长 prompt 反复测,每次都消耗大量 Token。
java
// 调用后记录 Token 用量,心里有本账
public ChatResult chat(String prompt) {
// ... 调用 OpenAI ...
Usage usage = response.getUsage();
log.info("本次调用 - 输入Token:{} 输出Token:{} 总计:{} 预估费用:${}",
usage.getPromptTokens(),
usage.getCompletionTokens(),
usage.getTotalTokens(),
calculateCost(usage));
// 落库,每天汇总,超预算告警
usageRepository.save(toEntity(usage));
return result;
}
private BigDecimal calculateCost(Usage usage) {
// gpt-4o-mini:输入 0.15 美元/百万Token,输出 0.6 美元/百万Token
BigDecimal inputCost = BigDecimal.valueOf(usage.getPromptTokens())
.multiply(BigDecimal.valueOf(0.15))
.divide(BigDecimal.valueOf(1_000_000));
BigDecimal outputCost = BigDecimal.valueOf(usage.getCompletionTokens())
.multiply(BigDecimal.valueOf(0.6))
.divide(BigDecimal.valueOf(1_000_000));
return inputCost.add(outputCost);
}
我的做法是分三步控制:调试用便宜模型,生产再切大模型;每次调用记录 Token 用量,每天汇总;设个日预算上限,超了发告警邮件。这三步做了之后,再没出现过账单失控的情况。
六、错误码处理,别无脑重试
第六个坑是重试策略。
OpenAI 的错误码分几类:429 是限流,可以重试;500 系列是服务器错误,也可以重试;但 400 是参数错误,重试一百次还是错,纯属浪费请求。很多人不区分,遇到错误就无脑重试,结果限流的时候越重试越被限,到头来被封 IP。
我踩这个坑就是早期代码里写了个通用的重试拦截器,所有异常都重试三次。上线第一天就遇到 429,重试三次反而触发了更严格的限流,被 OpenAI 封了半小时。
java
// 区分错误类型,该重试的重试,不该重试的快速失败
public String callWithRetry(String prompt) {
int maxRetry = 3;
long backoff = 1000;
for (int i = 0; i <= maxRetry; i++) {
try {
return callOpenAi(prompt);
} catch (OpenAiException e) {
if (!shouldRetry(e.getStatusCode()) || i == maxRetry) {
throw e;
}
// 退避时间翻倍,加随机抖动,避免惊群
long sleep = backoff + ThreadLocalRandom.current().nextLong(500);
try {
Thread.sleep(sleep);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new ServiceException("请求被中断");
}
backoff *= 2;
}
}
throw new ServiceException("AI 服务暂时不可用,请稍后重试");
}
private boolean shouldRetry(int code) {
return code == 429 || code == 500 || code == 502 || code == 503;
}
退避重试有个细节别忽略:要加随机抖动。不然所有超时请求会在同一时刻一起重试,又把服务打挂了。backoff + random 里的 random 就是干这个用的,让重试错开,别扎堆。
七、总结
六个坑搞完,系统才算真正稳下来。回头看看,这些坑其实不复杂,但每个都是真金白银买来的教训。
三个实在的建议给你:
**一是接 API 之前先把官方文档的 Limits 和 Errors 章节看一遍。**超时、Token 限制、错误码这些,文档里都写得明明白白。花十分钟看完,能省你一下午的排查时间。
**二是上线前必须压测。**这几个坑里有一半是并发上来才暴露的,本地调试根本发现不了。压测一下,超时、连接池、限流这些问题提前就能揪出来。
**三是费用监控要提前做。**别等账单来了才心疼。Token 这东西看不见摸不着,不留神就烧钱。调试用便宜模型,生产再切大模型,每天看一眼用量报表,这是基本功。
这套方案我跑了两周,目前稳定。建议你接的时候把这六个点都考虑到,别等线上出事再补,那时候损失已经造成了。
坑踩完了,路就平了。祝你接入顺利。