Spring Boot 集成 LLM:从 API 调用到生产级封装的最佳实践
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 费用监控是两个容易被忽视但生产环境必须具备的能力。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)