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 费用监控是两个容易被忽视但生产环境必须具备的能力。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐