Java 程序员的 AI 进化论 | Spring Boot 接入 OpenAI 的六个坑,全帮你踩了

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 这东西看不见摸不着,不留神就烧钱。调试用便宜模型,生产再切大模型,每天看一眼用量报表,这是基本功。

这套方案我跑了两周,目前稳定。建议你接的时候把这六个点都考虑到,别等线上出事再补,那时候损失已经造成了。

坑踩完了,路就平了。祝你接入顺利。

相关推荐
疯狂成瘾者1 小时前
Java 常见集合方法
java·windows·python
从此以后自律1 小时前
ConcurrentHashMap 超详细详解
java
兰令水1 小时前
hot100【acm版】【2026.7.14打卡-java版本】
java·数据结构·算法·leetcode·面试
thefool1122662 小时前
Java 数组是引用类型
java
霸道流氓气质2 小时前
SpringBoot中设计模式组合使用示例-策略、模板、观察者、门面、工厂、单例。
spring boot·后端·设计模式
cfm_29143 小时前
Spring监听器
java·spring boot·后端
cfm_29143 小时前
SpringBoot 数据库全栈整合
数据库·spring boot·后端
SamDeepThinking3 小时前
Java面向对象在JVM里怎么实现
java·后端·面试
咸鱼翻身小阿橙4 小时前
多线程解析
java·开发语言