Skip to content

SpringBoot 对接大模型 API 的工程化实践 ​

后端对接大模型 API,看起来就是发个 HTTP 请求的事。但真到生产环境,要处理的远不止「调通接口」——超时重试、流式响应、Token 计费、多模型切换、降级兜底……这篇复盘我们在 SpringBoot 项目中把大模型 API 从「能调通」做到「能上线」的完整路径。

演进路径:从裸调到服务化 ​

整个对接过程经历了三个阶段:

阶段一:裸调阶段                 阶段二:封装阶段                  阶段三:服务化阶段
┌───────────────┐              ┌───────────────┐               ┌───────────────┐
│  Controller    │              │  Controller    │               │  Controller    │
│      │         │              │      │         │               │      │         │
│      ▼         │              │      ▼         │               │      ▼         │
│  RestClient    │    ───→     │  AiService     │     ───→     │  AiGateway     │
│  (直接调API)   │              │  (统一封装)    │               │  (路由+限流)   │
│      │         │              │      │         │               │      │         │
│      ▼         │              │      ▼         │               │      ▼         │
│  大模型 API    │              │  大模型 API    │               │  多模型池      │
└───────────────┘              └───────────────┘               │  (主备+降级)   │
                                                                └───────────────┘

问题:                          问题:                          最终形态:
- 超时没处理                     - 多模型切换硬编码               - 统一网关入口
- 没有重试                       - 流式响应支持差                 - 多模型路由策略
- 流式响应处理不了               - 没有Token计量                  - 自动降级容灾
- 散落各处难维护                 - 异常处理不统一                 - Token计费 + 监控

阶段一:裸调的三个致命问题 ​

最开始就是在 Controller 里直接用 RestClient 调 OpenAI 兼容接口:

java
@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);
    }
}

能跑,但三个问题很快暴露:

  1. 超时没设:大模型响应慢时请求挂死,线程池被耗尽
  2. 没有重试:网络抖动一下就直接 500 给用户
  3. 流式响应不支持:用户等 10 秒才看到完整回答,体验极差

阶段二:封装 AiService ​

分层架构 ​

把大模型调用从 Controller 中抽出来,做独立 Service 层:

┌──────────────────────────────────────────────────┐
│                Controller 层                      │
│         接收请求 / 参数校验 / 响应封装             │
└──────────────────────┬───────────────────────────┘
                       ▼
┌──────────────────────────────────────────────────┐
│                 AiService 层                      │
│  ┌─────────┐  ┌──────────┐  ┌────────────────┐  │
│  │Prompt   │  │ 请求构建  │  │ 响应解析        │  │
│  │模板管理 │→ │ + 超时配置│→ │ + Token 计量   │  │
│  └─────────┘  └──────────┘  └────────────────┘  │
│  ┌──────────────────────────────────────────┐   │
│  │    重试 + 熔断 (Resilience4j)             │   │
│  └──────────────────────────────────────────┘   │
└──────────────────────┬───────────────────────────┘
                       ▼
┌──────────────────────────────────────────────────┐
│              LLM Client 层                        │
│    RestClient (同步) / WebClient (流式)           │
└──────────────────────┬───────────────────────────┘
                       ▼
                 大模型 API

超时与重试配置 ​

java
@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 计费,重试一次就是一次钱):

java
@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 配置:

yaml
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 转发给前端。

java
@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  │   │ 本地模型  │
          └──────────┘   └──────────┘   └──────────┘

路由策略实现 ​

java
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 信息:

java
@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 做底层。

java
@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 路径的缓冲:

nginx
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 本质上是一个慢速、昂贵、不稳定的下游服务。用对待不稳定第三方服务的心态去做工程化封装,就不会出大问题。

📖本文阅读--次|📊全站访问--次|👥访客--人