Spring Boot 集成 LLM:从 API 调用到生产级封装的最佳实践
一、大模型集成的工程陷阱:远不止调一个 API
Spring Boot 集成 LLM 的第一步通常是引入一个 HTTP 客户端,调用大模型的 Chat Completion API,拿到响应返回给前端。这个"Hello World"级别的集成在 Demo 中跑通很快,但进入生产环境后会暴露一系列工程问题:API Key 如何安全管理?多模型供应商如何统一接入?流式响应如何优雅处理?Token 消耗如何精确计量?调用失败如何重试和降级?
更深层的问题是,大模型 API 的行为与传统 REST API 有本质区别。响应延迟在秒级到十秒级,流式模式下连接持续数十秒,Token 计费按输入输出分别计算,不同模型的上下文窗口和输出限制各异。直接把 LLM API 当普通 HTTP 接口封装,会在生产环境中反复踩坑。
二、LLM 集成架构:从 API 调用到可观测的模型服务层
生产级 LLM 集成需要一个模型服务层(Model Service Layer),位于业务逻辑和模型 API 之间。这个层负责:统一接口适配(屏蔽不同供应商的 API 差异)、连接管理(流式连接的生命周期控制)、Token 计量(输入输出 Token 的精确统计)、重试与降级(模型不可用时的备选方案)、可观测性(延迟、Token 消耗、错误率的监控)。
flowchart TB A[业务服务层] --> B[模型服务层] B --> C{模型路由} C -->|OpenAI| D[OpenAI Adapter] C -->|Anthropic| E[Anthropic Adapter] C -->|国产模型| F[国产模型 Adapter] D --> G[统一接口<br/>ChatCompletion] E --> G F --> G G --> H[Token 计量] H --> I[重试与降级] I --> J[可观测性埋点] subgraph 横切关注点 K[API Key 管理<br/>Vault/环境变量] L[连接池管理<br/>WebClient 复用] M[请求/响应日志<br/>脱敏处理] end B --> K B --> L B --> M模型服务层的设计目标是让业务代码只关心"我要什么回答",而不关心"这个回答来自哪个模型、怎么调用的、失败了怎么办"。
三、生产级代码实现:统一接口、Token 计量与降级
3.1 统一模型接口与适配器
// 统一接口:屏蔽不同供应商的 API 差异 // 为什么定义统一接口:业务代码不应依赖特定供应商的 // API 结构,切换供应商时只需新增适配器, // 业务代码无需修改 public interface LlmProvider { String getName(); ChatResponse chat(ChatRequest request); Flux<ChatChunk> streamChat(ChatRequest request); boolean supports(String model); } @Data @Builder public class ChatRequest { private String model; private List<Message> messages; private double temperature; private int maxTokens; } @Data @Builder public class ChatResponse { private String content; private int promptTokens; private int completionTokens; private String model; private String provider; }3.2 OpenAI 适配器实现
@Service public class OpenAiProvider implements LlmProvider { private final WebClient openAiClient; private final TokenMeter tokenMeter; @Override public String getName() { return "openai"; } @Override public ChatResponse chat(ChatRequest request) { try { OpenAiChatResponse resp = openAiClient.post() .uri("/v1/chat/completions") .header("Authorization", "Bearer " + getKeyFromVault()) .bodyValue(mapToOpenAiRequest(request)) .retrieve() .bodyToMono(OpenAiChatResponse.class) .timeout(Duration.ofSeconds(60)) .block(); // 记录 Token 消耗 // 为什么在适配器层计量而非业务层:不同供应商 // 返回 Token 统计的方式不同,适配器层是唯一 // 能统一处理的位置 tokenMeter.record(getName(), resp.getUsage().getPromptTokens(), resp.getUsage().getCompletionTokens()); return ChatResponse.builder() .content(resp.getChoices().get(0) .getMessage().getContent()) .promptTokens(resp.getUsage().getPromptTokens()) .completionTokens( resp.getUsage().getCompletionTokens()) .model(resp.getModel()) .provider(getName()) .build(); } catch (WebClientRequestException e) { throw new LlmProviderException( "OpenAI 请求失败: " + e.getMessage(), e); } catch (WebClientResponseException e) { if (e.getStatusCode().value() == 429) { throw new LlmRateLimitException("OpenAI 限流"); } throw new LlmProviderException( "OpenAI 返回错误: " + e.getStatusCode(), e); } } @Override public Flux<ChatChunk> streamChat(ChatRequest request) { return openAiClient.post() .uri("/v1/chat/completions") .header("Authorization", "Bearer " + getKeyFromVault()) .bodyValue(mapToOpenAiStreamRequest(request)) .retrieve() .bodyToFlux(String.class) .takeUntil(data -> "[DONE]".equals(data.trim())) .filter(data -> !"[DONE]".equals(data.trim())) .map(this::parseStreamChunk) .onErrorResume(ParseException.class, e -> { log.warn("流式 Chunk 解析失败: {}", e.getMessage()); return Flux.empty(); }); } private String getKeyFromVault() { // 从 Vault 或环境变量获取 API Key // 为什么不硬编码:API Key 是敏感凭证, // 硬编码在代码中会随 Git 提交泄露 String key = System.getenv("OPENAI_API_KEY"); if (key == null) { throw new LlmProviderException( "未配置 OpenAI API Key"); } return key; } }3.3 Token 计量与费用监控
@Component public class TokenMeter { private final MeterRegistry meterRegistry; private final TokenCostRepository costRepository; // 模型单价表(元/千 Token) private static final Map<String, BigDecimal> PRICING = Map.of( "gpt-4", new BigDecimal("0.06"), "gpt-4o", new BigDecimal("0.02"), "gpt-3.5-turbo", new BigDecimal("0.001") ); public void record(String provider, int promptTokens, int completionTokens) { // Micrometer 指标 meterRegistry.counter("llm.tokens.prompt", "provider", provider).increment(promptTokens); meterRegistry.counter("llm.tokens.completion", "provider", provider).increment(completionTokens); // 费用记录入库,用于月度对账 BigDecimal cost = calculateCost( provider, promptTokens, completionTokens); costRepository.save(TokenCostRecord.builder() .provider(provider) .promptTokens(promptTokens) .completionTokens(completionTokens) .cost(cost) .timestamp(Instant.now()) .build()); } private BigDecimal calculateCost(String provider, int promptTokens, int completionTokens) { BigDecimal price = PRICING.getOrDefault( provider, new BigDecimal("0.01")); int totalTokens = promptTokens + completionTokens; return price.multiply( BigDecimal.valueOf(totalTokens)) .divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP); } }3.4 模型服务层:重试、降级与路由
@Service public class LlmService { private final List<LlmProvider> providers; private final TokenMeter tokenMeter; public ChatResponse chat(ChatRequest request) { LlmProvider provider = selectProvider(request.getModel()); return executeWithRetryAndFallback(provider, request); } private LlmProvider selectProvider(String model) { return providers.stream() .filter(p -> p.supports(model)) .findFirst() .orElseThrow(() -> new LlmProviderException( "无可用模型供应商: " + model)); } private ChatResponse executeWithRetryAndFallback( LlmProvider primary, ChatRequest request) { try { // 主供应商调用,带重试 return Retry.builder() .maxAttempts(3) .waitDuration(Duration.ofSeconds(2)) .retryOnException(e -> e instanceof LlmRateLimitException || e instanceof LlmProviderException) .build() .execute(() -> primary.chat(request)); } catch (Exception e) { log.warn("主供应商 {} 调用失败,尝试降级", primary.getName(), e); return fallback(request); } } private ChatResponse fallback(ChatRequest request) { // 降级策略:切换到更便宜的模型 // 为什么降级而非直接报错:用户体验上, // 低质量回答优于无回答;成本上, // 轻量模型的费用远低于旗舰模型 ChatRequest fallbackRequest = ChatRequest.builder() .model("gpt-3.5-turbo") .messages(request.getMessages()) .temperature(request.getTemperature()) .maxTokens(request.getMaxTokens()) .build(); LlmProvider fallbackProvider = selectProvider( fallbackRequest.getModel()); return fallbackProvider.chat(fallbackRequest); } }四、LLM 集成的架构权衡:延迟、成本与质量
流式 vs 非流式的选择:流式响应改善了用户感知延迟(TTFT 通常在 1-2 秒),但增加了服务端资源占用(每个流式连接持续数十秒)。高并发场景下,流式连接数可能成为瓶颈。建议对短文本生成(< 200 Token)使用非流式调用,对长文本生成使用流式调用。
Token 计量的精度问题:部分供应商的流式响应不返回 Token 使用量,只能在流结束后通过单独 API 查询。这导致实时计量存在延迟,费用监控可能有数分钟的滞后。解决方案是在本地用 Tokenizer 预估 Token 数,流结束后用实际数据校准。
降级策略的质量风险:从 GPT-4 降级到 GPT-3.5 可能导致输出质量显著下降,特别是复杂推理和代码生成场景。降级策略应根据业务场景差异化配置——摘要、翻译等简单任务可以降级,代码审查、法律分析等高风险任务不应降级。
API Key 的轮换管理:多供应商场景下,每个供应商可能有多个 API Key(不同团队、不同环境)。Key 的轮换需要做到零停机——新 Key 生效后旧 Key 才失效,且切换过程中不能中断正在进行的请求。建议使用 Vault 的动态 Secret 功能,配合 WebClient 的请求级 Header 注入。
五、总结
Spring Boot 集成 LLM 的核心是建立一个模型服务层,将业务逻辑与模型 API 解耦。统一接口屏蔽供应商差异,适配器模式处理协议转换,Token 计量提供成本可见性,重试和降级保证可用性。落地时建议先实现单供应商的完整链路(调用 + 计量 + 重试),再逐步引入多供应商路由和降级策略。API Key 管理和 Token 费用监控是两个容易被忽视但生产环境必须具备的能力。