SpringBoot 对接大模型 API 的工程化实践
后端对接大模型 API,看起来就是发个 HTTP 请求的事。但真到生产环境,要处理的远不止「调通接口」——超时重试、流式响应、Token 计费、多模型切换、降级兜底……这篇复盘我们在 SpringBoot 项目中把大模型 API 从「能调通」做到「能上线」的完整路径。
演进路径:从裸调到服务化
整个对接过程经历了三个阶段:
阶段一:裸调阶段 阶段二:封装阶段 阶段三:服务化阶段
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Controller │ │ Controller │ │ Controller │
│ │ │ │ │ │ │ │ │
│ ▼ │ │ ▼ │ │ ▼ │
│ RestClient │ ───→ │ AiService │ ───→ │ AiGateway │
│ (直接调API) │ │ (统一封装) │ │ (路由+限流) │
│ │ │ │ │ │ │ │ │
│ ▼ │ │ ▼ │ │ ▼ │
│ 大模型 API │ │ 大模型 API │ │ 多模型池 │
└───────────────┘ └───────────────┘ │ (主备+降级) │
└───────────────┘
问题: 问题: 最终形态:
- 超时没处理 - 多模型切换硬编码 - 统一网关入口
- 没有重试 - 流式响应支持差 - 多模型路由策略
- 流式响应处理不了 - 没有Token计量 - 自动降级容灾
- 散落各处难维护 - 异常处理不统一 - Token计费 + 监控阶段一:裸调的三个致命问题
最开始就是在 Controller 里直接用 RestClient 调 OpenAI 兼容接口:
@RestController
public class ChatController {
private final RestClient restClient = RestClient.create();
@PostMapping("/chat")
public String chat(@RequestBody String prompt) {
return restClient.post()
.uri("https://api.llm.com/v1/chat/completions")
.header("Authorization", "Bearer " + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of(
"model", "gpt-4",
"messages", List.of(Map.of("role", "user", "content", prompt))
))
.retrieve()
.body(String.class);
}
}能跑,但三个问题很快暴露:
- 超时没设:大模型响应慢时请求挂死,线程池被耗尽
- 没有重试:网络抖动一下就直接 500 给用户
- 流式响应不支持:用户等 10 秒才看到完整回答,体验极差
阶段二:封装 AiService
分层架构
把大模型调用从 Controller 中抽出来,做独立 Service 层:
┌──────────────────────────────────────────────────┐
│ Controller 层 │
│ 接收请求 / 参数校验 / 响应封装 │
└──────────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ AiService 层 │
│ ┌─────────┐ ┌──────────┐ ┌────────────────┐ │
│ │Prompt │ │ 请求构建 │ │ 响应解析 │ │
│ │模板管理 │→ │ + 超时配置│→ │ + Token 计量 │ │
│ └─────────┘ └──────────┘ └────────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ 重试 + 熔断 (Resilience4j) │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ LLM Client 层 │
│ RestClient (同步) / WebClient (流式) │
└──────────────────────┬───────────────────────────┘
▼
大模型 API超时与重试配置
@Configuration
public class LlmClientConfig {
@Bean
public RestClient llmRestClient() {
return RestClient.builder()
.requestFactory(clientHttpRequestFactory())
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
private ClientHttpRequestFactory clientHttpRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(5)); // 连接超时 5s
factory.setReadTimeout(Duration.ofSeconds(60)); // 读取超时 60s
return factory;
}
}重试用 Resilience4j,不能无限重试(大模型 API 按 Token 计费,重试一次就是一次钱):
@Retry(name = "llmCall", fallbackMethod = "fallback")
@CircuitBreaker(name = "llmCall", fallbackMethod = "fallback")
public String callLlm(String prompt) {
// 正常调用逻辑
}
// 降级方法:返回兜底文案
public String fallback(String prompt, Exception e) {
log.warn("LLM 调用降级, prompt={}", prompt, e);
return "AI 服务暂时繁忙,请稍后重试";
}Resilience4j 配置:
resilience4j:
retry:
instances:
llmCall:
max-attempts: 3 # 最多重试 3 次
wait-duration: 1s # 重试间隔 1s
retry-exceptions:
- org.springframework.web.client.ResourceAccessException # 网络异常才重试
circuitbreaker:
instances:
llmCall:
failure-rate-threshold: 50 # 失败率 50% 触发熔断
wait-duration-in-open-state: 30s
sliding-window-size: 20流式响应:SSE 的正确打开方式
这是后端对接大模型最容易踩坑的地方。大模型的流式响应是 SSE(Server-Sent Events)格式,前端需要逐 token 接收。
错误做法:用 RestClient 同步调用,等所有内容返回再一次性发给前端。用户体验极差。
正确做法:SpringBoot 用 WebFlux 的 Flux 处理 SSE 流,逐 token 转发给前端。
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String prompt) {
return webClient.post()
.uri("/v1/chat/completions")
.body(BodyInserters.fromValue(Map.of(
"model", "gpt-4",
"messages", List.of(Map.of("role", "user", "content", prompt)),
"stream", true // 开启流式
)))
.retrieve()
.bodyToFlux(String.class) // 按 SSE 流接收
.takeUntil("[DONE]"::equals) // 遇到 [DONE] 停止
.filter(s -> !s.equals("[DONE]"))
.map(this::extractContent) // 解析出 content
.onErrorResume(e -> {
log.error("流式调用异常", e);
return Flux.just("[ERROR] AI 服务暂时不可用");
});
}阶段三:多模型网关
业务稳定后,需求变成了:主用模型 A,A 不可用切模型 B,还想做 A/B 测试对比效果。这时候需要一层 AI 网关。
网关架构
┌─────────────────┐
│ AI Gateway │
│ │
请求 ────────────────→│ ① 路由策略 │
│ ② 限流控制 │
│ ③ Token 计量 │
│ ④ 降级容灾 │
└───────┬─────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 模型 A │ │ 模型 B │ │ 模型 C │
│ (主力) │ │ (备用) │ │ (兜底) │
│ GPT-4 │ │ Claude │ │ 本地模型 │
└──────────┘ └──────────┘ └──────────┘路由策略实现
public interface ModelRouter {
String route(ChatRequest request);
}
public class PriorityRouter implements ModelRouter {
private final ModelHealthChecker healthChecker;
@Override
public String route(ChatRequest request) {
// 优先级路由:主力 → 备用 → 兜底
if (healthChecker.isHealthy("model-a")) {
return "model-a";
}
if (healthChecker.isHealthy("model-b")) {
log.warn("模型A不可用,降级到模型B");
return "model-b";
}
log.warn("全部云端模型不可用,降级到本地模型");
return "local-model";
}
}Token 计费
大模型按 Token 计费,不做计量就是一笔糊涂账。在响应解析阶段提取 usage 信息:
@Data
public class LlmResponse {
private String content;
private TokenUsage usage; // Token 用量
@Data
public static class TokenUsage {
private int promptTokens; // 输入 Token
private int completionTokens; // 输出 Token
private int totalTokens; // 总 Token
}
}
// 计费记录
@Aspect
@Component
public class TokenBillingAspect {
@AfterReturning(pointcut = "execution(* AiService.call*(..))", returning = "result")
public void recordTokenUsage(JoinPoint joinPoint, LlmResponse result) {
if (result.getUsage() != null) {
billingService.record(
getCurrentUser(),
result.getUsage().getTotalTokens(),
joinPoint.getSignature().getName()
);
}
}
}五个生产级踩坑记录
坑 1:连接池没配,高并发下连接泄漏
RestClient 默认每次请求新建连接,高并发下 TCP 连接数暴涨,最终 Too many open files。
解决:配连接池,用 Apache HttpClient 或 OkHttp 做底层。
@Bean
public RestClient llmRestClient() {
PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager();
connManager.setMaxTotal(100); // 总连接数
connManager.setDefaultMaxPerRoute(50); // 每个路由最大连接数
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connManager)
.evictIdleConnections(30, TimeUnit.SECONDS)
.build();
return RestClient.builder()
.requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient))
.build();
}坑 2:JSON 序列化吞掉了流式响应的增量
用 Jackson 序列化 SSE 响应时,反序列化整个 JSON 对象,导致必须等完整 JSON 才能解析,流式效果全废。
解决:逐行解析 SSE,不要反序列化完整 JSON。
坑 3:重试触发了重复扣费
大模型 API 返回 500 但实际已经生成了内容(计了费),重试又调一次,双重扣费。
解决:只对网络异常重试,不对 HTTP 5xx 重试。5xx 说明是服务端问题,重试大概率还是错。
坑 4:SSE 在 Nginx 后面被缓冲
上线后发现流式响应变成了「等 5 秒一次性返回」。Nginx 默认缓冲代理响应。
解决:Nginx 关闭 SSE 路径的缓冲:
location /chat/stream {
proxy_buffering off; # 关闭缓冲
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
}坑 5:多模型切换时 Prompt 格式不兼容
从 GPT-4 切到 Claude 时,System Prompt 的传递方式不同(GPT 用 system role,Claude 用 system 参数),直接切换报错。
解决:在 Gateway 层做模型适配器,每种模型有自己的请求构建器。
复盘总结
SpringBoot 对接大模型 API,核心不是「调通接口」,而是把不稳定的 LLM 服务包装成稳定的后端服务。
关键工程化能力清单:
| 能力 | 必要性 | 实现方案 |
|---|---|---|
| 超时控制 | 必须 | 连接超时 5s + 读取超时 60s |
| 重试 + 熔断 | 必须 | Resilience4j,只重试网络异常 |
| 流式响应 | 必须 | WebFlux + SSE |
| 连接池 | 必须 | HttpClient 连接池 |
| 多模型路由 | 推荐 | 网关层路由策略 |
| Token 计费 | 推荐 | AOP 拦截 usage |
| 降级兜底 | 推荐 | 本地模型兜底 |
| Prompt 适配 | 视情况 | 模型适配器模式 |
最后一句:LLM API 本质上是一个慢速、昂贵、不稳定的下游服务。用对待不稳定第三方服务的心态去做工程化封装,就不会出大问题。