Java 后端如何接入大语言模型

摘要

大语言模型已经从聊天工具逐渐进入企业应用:智能客服、知识库问答、代码助手、内容审核、数据分析和业务自动化,都需要后端服务以稳定、可控的方式调用模型。

对于 Java 后端开发者来说,接入大语言模型并不只是发送一个 HTTP 请求。真正进入生产环境后,还需要处理模型供应商差异、请求超时、重试、限流、流式输出、结构化结果、上下文管理、敏感信息、Token 成本和可观测性等问题。

本文以 Spring Boot 项目为背景,从一次最简单的模型调用开始,逐步介绍 Java 后端接入大语言模型的完整方法。内容包括 OpenAI 兼容接口、HTTP 客户端、SDK 封装、配置管理、Prompt 设计、结构化输出、SSE 流式响应、异常处理、重试降级、调用日志和生产级服务抽象。

本文不会绑定某一个具体模型供应商。不同厂商的请求字段、模型名称和认证方式可能变化,但后端接入时需要解决的核心问题基本一致。读完本文后,你应该能够为 Java 项目设计一个可替换、可测试、可观测的大模型调用模块。

你将掌握:

  • 大语言模型 API 的基本调用流程;
  • Java 后端如何封装模型客户端;
  • 如何使用 Spring Boot 管理模型配置;
  • 如何组织系统消息、用户消息和上下文;
  • 如何处理同步调用和流式输出;
  • 如何实现结构化 JSON 输出;
  • 如何设计超时、重试、限流和降级;
  • 如何控制成本、保护敏感数据并记录调用链路。

一、背景与问题

1. 大模型接入本质上是一个外部服务集成问题

从业务角度看,大模型像一个"会理解和生成文本的外部服务"。Java 后端需要向它发送请求,再解析响应:

text 复制代码
用户请求
  -> Java 业务服务
  -> 大模型客户端
  -> 模型服务
  -> 返回文本或结构化结果
  -> Java 业务服务
  -> 返回用户

最小调用可能只有几行代码,但生产级接入会涉及:

  • 认证信息管理;
  • 模型和参数配置;
  • 请求超时时间;
  • 失败重试;
  • 供应商切换;
  • 流式响应;
  • 结果校验;
  • 用户和会话上下文;
  • 日志与费用统计;
  • 数据安全和权限控制。

如果直接在 Controller 中拼接 JSON、发送 HTTP 请求、解析响应,代码很快会与业务逻辑混在一起,后续很难替换模型或增加治理能力。

2. 为什么不能把 API Key 写在代码里

不应该这样写:

java 复制代码
String apiKey = "sk-real-secret";

也不应该把真实密钥提交到配置文件并上传到代码仓库:

yaml 复制代码
llm:
  api-key: sk-real-secret

密钥一旦进入 Git 历史、构建日志、镜像层或异常日志,就可能长期暴露。正确做法是:

  • 使用环境变量;
  • 使用密钥管理服务;
  • 在部署平台注入 Secret;
  • 对不同环境使用不同密钥;
  • 为密钥设置额度和权限;
  • 定期轮换并支持撤销。

配置文件只保留占位符或非敏感默认值。

3. 直接使用通用 HTTP 调用的隐患

很多项目一开始会直接写:

java 复制代码
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .header("Authorization", "Bearer " + apiKey)
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

这种方式本身没有问题,但如果每个业务模块都各写一套,就会出现:

  • 请求格式不一致;
  • 超时配置不一致;
  • 错误处理不一致;
  • 日志字段不一致;
  • 供应商切换成本高;
  • 流式和非流式实现重复;
  • Token 和费用无法统一统计。

因此,建议把外部模型调用封装在独立的客户端或网关中,业务层只依赖稳定的领域接口。

4. 模型返回的不是传统确定性接口

传统业务接口通常有相对稳定的输入输出:

json 复制代码
{
  "id": 1001,
  "status": "PAID"
}

大模型输出具有不确定性:

  • 同样输入可能出现不同措辞;
  • 可能遗漏字段;
  • 可能返回额外解释;
  • 可能输出不合法 JSON;
  • 可能拒绝回答;
  • 可能受到上下文长度影响;
  • 供应商升级后行为可能变化。

所以 Java 后端不能把模型输出当成绝对可信的业务结果。模型输出必须经过校验、清洗和业务规则检查。

二、核心概念

1. Chat Completion 的基本组成

许多模型接口都采用消息列表形式:

json 复制代码
{
  "model": "model-name",
  "messages": [
    {
      "role": "system",
      "content": "你是一个严谨的技术助手。"
    },
    {
      "role": "user",
      "content": "解释什么是依赖注入。"
    }
  ],
  "temperature": 0.2
}

常见角色包括:

角色 作用
system 定义助手行为、边界和输出要求
user 用户输入或当前任务
assistant 历史模型回复
tool 工具调用后的结果

不同供应商的字段名称可能不同,但"系统规则 + 用户任务 + 历史上下文"的结构较为通用。

2. System、User 与上下文

系统消息适合放稳定规则:

text 复制代码
你是企业内部技术助手。
回答必须基于提供的资料。
如果资料不足,请明确说明,不要编造。
不要输出用户的敏感信息。

用户消息放当前任务:

text 复制代码
请根据下面的接口日志分析最可能的错误原因。

业务上下文可以作为结构化片段注入:

text 复制代码
项目名称:订单服务
环境:测试环境
时间范围:2026-09-01 至 2026-09-07
日志摘要:...

不要把所有数据库记录无差别地拼接到 Prompt 中。上下文需要经过筛选、脱敏和长度控制。

3. Temperature 与生成参数

常见生成参数包括:

参数 含义 使用建议
temperature 输出随机性 事实问答使用较低值,创意写作可适当提高
max_tokens 最大输出长度 根据场景限制,避免异常长输出
top_p 采样范围 一般不要和 temperature 同时频繁调节
stop 停止序列 结构化输出或特殊格式时使用
stream 是否流式返回 对话和长文本通常开启

参数不是越多越好。生产系统应该为不同用例定义配置模板,而不是让前端任意传入所有参数。

4. Token 与上下文窗口

模型调用成本和上下文长度通常与 Token 数量有关。一次请求的 Token 可能包括:

text 复制代码
系统消息 Token
  + 用户输入 Token
  + 历史消息 Token
  + 检索上下文 Token
  + 工具结果 Token
  + 模型输出 Token

上下文过长会带来:

  • 成本增加;
  • 延迟升高;
  • 超过模型上下文限制;
  • 重要信息被淹没;
  • 历史内容与当前任务混淆。

后端应该对上下文做预算:

text 复制代码
系统规则:固定预算
当前问题:固定预算
历史消息:滑动窗口
检索资料:按相关性截断
输出结果:设置上限

5. 同步调用与流式调用

同步调用的流程:

text 复制代码
发送完整请求
  -> 等待模型生成完成
  -> 一次性返回完整响应

优点是简单,适合:

  • 分类;
  • 抽取;
  • 短文本问答;
  • 后台任务。

流式调用的流程:

text 复制代码
建立连接
  -> 返回第一个片段
  -> 返回更多片段
  -> 返回结束事件

适合:

  • 聊天界面;
  • 长文本生成;
  • 需要快速显示首字的场景;
  • 用户希望看到实时进度的任务。

流式输出不能简单地把多个字符串拼起来就结束,还需要处理断线、异常、客户端取消和结束标记。

6. 结构化输出

如果业务需要模型返回 JSON,不能只在 Prompt 中说"请返回 JSON",还应该在服务端校验。

例如订单分类结果:

json 复制代码
{
  "category": "REFUND_REQUEST",
  "priority": "HIGH",
  "reason": "用户明确要求退款"
}

Java 侧可以使用 Jackson 映射到 DTO,再配合 Bean Validation 检查字段:

java 复制代码
public record TicketClassification(
        @NotBlank String category,
        @NotBlank String priority,
        @NotBlank String reason
) {
}

解析失败时,应该进入修复、重试或人工处理流程,而不是继续执行业务动作。

7. 模型抽象层

业务代码不应该直接依赖某个供应商的 SDK 类型:

java 复制代码
public interface LlmClient {

    LlmResponse complete(LlmRequest request);

    Flux<LlmChunk> stream(LlmRequest request);
}

不同供应商可以有不同实现:

text 复制代码
LlmClient
  ├── OpenAiCompatibleLlmClient
  ├── CloudProviderLlmClient
  └── LocalModelLlmClient

业务服务只依赖 LlmClient,切换供应商时不需要修改业务流程。

三、工作原理

1. Java 后端接入模型的标准链路

生产级调用链可以设计为:

text 复制代码
Controller
  -> Application Service
  -> Prompt Builder
  -> LlmClient
  -> HTTP Client / SDK
  -> Model Provider
  -> Response Parser
  -> Business Validator
  -> Application Result

每一层职责如下:

主要职责
Controller 接收 HTTP 请求和返回响应
Application Service 编排业务流程
Prompt Builder 组装系统指令和上下文
LlmClient 统一调用模型
Response Parser 解析文本或结构化结果
Business Validator 校验业务规则
Observability 记录日志、指标和 Trace

2. Spring Boot 配置

使用 application.yml 保存非敏感配置:

yaml 复制代码
app:
  llm:
    base-url: ${LLM_BASE_URL:https://api.example.com/v1}
    model: ${LLM_MODEL:default-model}
    api-key: ${LLM_API_KEY:}
    connect-timeout: 3s
    read-timeout: 60s
    max-output-tokens: 2048
    temperature: 0.2

使用配置类绑定:

java 复制代码
import java.time.Duration;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.llm")
public record LlmProperties(
        String baseUrl,
        String model,
        String apiKey,
        Duration connectTimeout,
        Duration readTimeout,
        Integer maxOutputTokens,
        Double temperature
) {
}

注册配置类:

java 复制代码
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

不要在业务代码中直接读取 EnvironmentSystem.getenv。集中配置可以统一校验、测试和审计。

3. 使用 WebClient 调用接口

Spring WebFlux 的 WebClient 适合实现同步结果和流式结果。

创建客户端:

java 复制代码
import java.time.Duration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class LlmClientConfig {

    @Bean
    WebClient llmWebClient(
            WebClient.Builder builder,
            LlmProperties properties
    ) {
        return builder
                .baseUrl(properties.baseUrl())
                .defaultHeader(
                        HttpHeaders.AUTHORIZATION,
                        "Bearer " + properties.apiKey()
                )
                .defaultHeader(
                        HttpHeaders.CONTENT_TYPE,
                        "application/json"
                )
                .build();
    }
}

如果项目使用 Spring MVC,也可以使用 RestClient 或 Java 11 HttpClient。重点不是具体 HTTP 客户端,而是把调用细节封装在 LlmClient 内部。

4. 定义领域请求和响应

不要把供应商 SDK 的请求类型直接暴露给业务层。可以先定义自己的类型:

java 复制代码
public record LlmMessage(
        String role,
        String content
) {
}

public record LlmRequest(
        String model,
        List<LlmMessage> messages,
        Double temperature,
        Integer maxOutputTokens,
        Boolean stream
) {
}

public record LlmUsage(
        Integer inputTokens,
        Integer outputTokens,
        Integer totalTokens
) {
}

public record LlmResponse(
        String text,
        String finishReason,
        LlmUsage usage,
        String providerRequestId
) {
}

这样可以在不同供应商之间统一抽象。

5. 实现同步调用

下面以常见的 OpenAI 兼容消息格式为例:

java 复制代码
import org.springframework.http.MediaType;
import reactor.core.publisher.Mono;

@Component
public class OpenAiCompatibleLlmClient implements LlmClient {

    private final WebClient webClient;
    private final LlmProperties properties;

    public OpenAiCompatibleLlmClient(
            WebClient llmWebClient,
            LlmProperties properties
    ) {
        this.webClient = llmWebClient;
        this.properties = properties;
    }

    @Override
    public LlmResponse complete(LlmRequest request) {
        ProviderChatRequest providerRequest =
                ProviderChatRequest.from(request);

        ProviderChatResponse response = webClient
                .post()
                .uri("/chat/completions")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(providerRequest)
                .retrieve()
                .bodyToMono(ProviderChatResponse.class)
                .timeout(properties.readTimeout())
                .block();

        if (response == null) {
            throw new LlmClientException("模型返回为空");
        }

        return response.toDomainResponse();
    }
}

在响应映射类中处理供应商字段:

java 复制代码
public record ProviderChatResponse(
        String id,
        List<Choice> choices,
        ProviderUsage usage
) {

    public LlmResponse toDomainResponse() {
        if (choices == null || choices.isEmpty()) {
            throw new LlmClientException("模型没有返回候选内容");
        }

        Choice choice = choices.get(0);
        return new LlmResponse(
                choice.message().content(),
                choice.finishReason(),
                usage == null ? null : usage.toDomainUsage(),
                id
        );
    }
}

6. Prompt Builder

Prompt 不应该在 Controller 中用字符串拼接完成。可以单独封装:

java 复制代码
@Component
public class TicketPromptBuilder {

    public LlmRequest build(Ticket ticket) {
        String system = """
                你是企业客服工单分类助手。
                只能根据工单内容进行分类。
                如果信息不足,请将 priority 设置为 UNKNOWN。
                不要编造工单中没有出现的事实。
                """;

        String user = """
                请分类下面的客服工单。

                工单标题:%s
                工单内容:%s

                返回 JSON,字段包括 category、priority、reason。
                """.formatted(
                ticket.title(),
                ticket.content()
        );

        return new LlmRequest(
                "default-model",
                List.of(
                        new LlmMessage("system", system),
                        new LlmMessage("user", user)
                ),
                0.1,
                512,
                false
        );
    }
}

更复杂的项目可以使用模板文件、Prompt 注册表或数据库版本化管理 Prompt。关键是让 Prompt 具备版本和可测试性。

7. 结构化 JSON 解析

定义 DTO:

java 复制代码
public record TicketClassification(
        String category,
        String priority,
        String reason
) {
}

解析服务:

java 复制代码
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

@Component
public class LlmJsonParser {

    private final ObjectMapper objectMapper;

    public LlmJsonParser(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public TicketClassification parseClassification(String text) {
        String json = extractJsonObject(text);

        try {
            TicketClassification result = objectMapper.readValue(
                    json,
                    TicketClassification.class
            );
            validate(result);
            return result;
        } catch (JsonProcessingException e) {
            throw new LlmOutputException(
                    "模型输出不是合法 JSON",
                    e
            );
        }
    }

    private void validate(TicketClassification result) {
        if (result.category() == null || result.category().isBlank()) {
            throw new LlmOutputException("分类字段为空");
        }
        if (result.priority() == null || result.priority().isBlank()) {
            throw new LlmOutputException("优先级字段为空");
        }
    }
}

不要为了提高解析成功率而无条件截取大括号。更稳妥的方式是使用供应商提供的 JSON Schema 或结构化输出能力,并对最终结果做白名单校验。

8. 在业务服务中调用

java 复制代码
@Service
public class TicketClassificationService {

    private final LlmClient llmClient;
    private final TicketPromptBuilder promptBuilder;
    private final LlmJsonParser jsonParser;

    public TicketClassificationService(
            LlmClient llmClient,
            TicketPromptBuilder promptBuilder,
            LlmJsonParser jsonParser
    ) {
        this.llmClient = llmClient;
        this.promptBuilder = promptBuilder;
        this.jsonParser = jsonParser;
    }

    public TicketClassification classify(Ticket ticket) {
        LlmRequest request = promptBuilder.build(ticket);
        LlmResponse response = llmClient.complete(request);
        return jsonParser.parseClassification(response.text());
    }
}

业务服务只关心:

  • 如何构造任务;
  • 如何调用统一客户端;
  • 如何解析和校验结果。

它不需要知道 HTTP Header、供应商 JSON 结构和底层连接细节。

9. 流式输出与 SSE

Spring WebFlux 可以通过 Flux 返回流式内容:

java 复制代码
@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final ChatService chatService;

    @GetMapping(
            value = "/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE
    )
    public Flux<ServerSentEvent<String>> stream(
            @RequestParam String message
    ) {
        return chatService.stream(message)
                .map(chunk -> ServerSentEvent.<String>builder()
                        .event("token")
                        .data(chunk.text())
                        .build())
                .concatWithValues(
                        ServerSentEvent.<String>builder()
                                .event("done")
                                .data("[DONE]")
                                .build()
                );
    }
}

流式客户端:

java 复制代码
public Flux<LlmChunk> stream(LlmRequest request) {
    ProviderChatRequest providerRequest =
            ProviderChatRequest.from(request.withStream(true));

    return webClient
            .post()
            .uri("/chat/completions")
            .accept(MediaType.TEXT_EVENT_STREAM)
            .bodyValue(providerRequest)
            .retrieve()
            .bodyToFlux(String.class)
            .map(this::parseChunk)
            .filter(Objects::nonNull)
            .timeout(properties.readTimeout());
}

生产环境还需要处理:

  • 客户端断开后的取消信号;
  • 模型服务中途返回错误;
  • SSE 心跳;
  • 代理服务器缓冲;
  • 连接超时;
  • 结束事件;
  • 已生成内容的保存。

10. 超时、重试与降级

模型调用必须设置超时:

java 复制代码
Mono<LlmResponse> response = callModel(request)
        .timeout(Duration.ofSeconds(60));

重试不能对所有错误一视同仁。适合重试的错误:

  • 临时网络失败;
  • 连接重置;
  • 服务端 429;
  • 服务端 5xx;
  • 临时网关错误。

不应该重试的错误:

  • API Key 无效;
  • 请求格式错误;
  • 上下文超过限制;
  • 内容安全拒绝;
  • 业务参数错误。

使用指数退避:

text 复制代码
第 1 次失败 -> 等待 200ms
第 2 次失败 -> 等待 500ms
第 3 次失败 -> 等待 1s
仍失败 -> 降级或返回错误

降级方案可以是:

  • 切换备用模型;
  • 使用缓存结果;
  • 返回人工处理任务;
  • 使用规则引擎完成简单分类;
  • 告知用户稍后重试。

不要在没有预算控制的情况下无限重试。

11. 日志与调用指标

每次模型调用建议记录:

text 复制代码
request_id
trace_id
业务场景
模型名称
供应商
Prompt 版本
输入 Token
输出 Token
总耗时
首 Token 延迟
状态码
错误类型
是否重试
估算费用

敏感 Prompt 和完整用户内容不要直接写普通日志。可以记录摘要、哈希或受控存储引用。

12. 成本估算

可以根据 Token 用量和价格配置估算成本:

java 复制代码
public record LlmPricing(
        BigDecimal inputPricePerMillion,
        BigDecimal outputPricePerMillion
) {
}

public BigDecimal estimateCost(
        LlmUsage usage,
        LlmPricing pricing
) {
    BigDecimal input = BigDecimal.valueOf(usage.inputTokens())
            .multiply(pricing.inputPricePerMillion())
            .divide(BigDecimal.valueOf(1_000_000));

    BigDecimal output = BigDecimal.valueOf(usage.outputTokens())
            .multiply(pricing.outputPricePerMillion())
            .divide(BigDecimal.valueOf(1_000_000));

    return input.add(output);
}

价格会变化,因此价格配置应该外置,不要硬编码在业务逻辑中。统计时还要区分:

  • 用户可见请求;
  • 自动重试请求;
  • 评估请求;
  • 后台任务请求;
  • 失败请求。

四、实战示例

下面实现一个简单的"技术问题回答服务"。它接收用户问题,调用模型生成回答,并记录调用信息。

1. 定义请求和响应

java 复制代码
public record ChatRequest(
        @NotBlank
        @Size(max = 4000)
        String message,
        String conversationId
) {
}

public record ChatResponse(
        String conversationId,
        String answer,
        String model,
        String requestId
) {
}

请求长度应该限制在合理范围内。超长输入可以先拒绝、摘要或异步处理。

2. 定义会话上下文

java 复制代码
public record ConversationContext(
        String conversationId,
        List<LlmMessage> history,
        int estimatedTokens
) {
}

上下文服务负责加载和裁剪历史消息:

java 复制代码
@Service
public class ConversationContextService {

    public ConversationContext load(String conversationId) {
        List<LlmMessage> history = repository
                .findRecentMessages(conversationId, 20)
                .stream()
                .map(this::toLlmMessage)
                .toList();

        return new ConversationContext(
                conversationId,
                trimToBudget(history, 6000),
                estimateTokens(history)
        );
    }
}

历史消息不应无限增长。可以使用滑动窗口、摘要记忆或按重要性保留。

3. 构造 Prompt

java 复制代码
@Component
public class ChatPromptBuilder {

    public LlmRequest build(
            ChatRequest input,
            ConversationContext context
    ) {
        LlmMessage system = new LlmMessage(
                "system",
                """
                你是一个 Java 后端技术助手。
                解释问题时优先给出准确、可验证的方案。
                如果信息不足,请明确说明假设。
                不要编造不存在的 API、版本或测试结果。
                """
        );

        List<LlmMessage> messages = new ArrayList<>();
        messages.add(system);
        messages.addAll(context.history());
        messages.add(new LlmMessage("user", input.message()));

        return new LlmRequest(
                "default-model",
                messages,
                0.2,
                1200,
                false
        );
    }
}

系统消息需要稳定,动态用户内容应该与系统规则分离。不要把用户输入拼接到看起来像系统命令的位置。

4. 实现聊天服务

java 复制代码
@Service
public class ChatService {

    private final LlmClient llmClient;
    private final ConversationContextService contextService;
    private final ChatPromptBuilder promptBuilder;
    private final LlmCallRecorder callRecorder;

    public ChatService(
            LlmClient llmClient,
            ConversationContextService contextService,
            ChatPromptBuilder promptBuilder,
            LlmCallRecorder callRecorder
    ) {
        this.llmClient = llmClient;
        this.contextService = contextService;
        this.promptBuilder = promptBuilder;
        this.callRecorder = callRecorder;
    }

    public ChatResponse chat(
            ChatRequest input,
            String requestId
    ) {
        ConversationContext context = contextService.load(
                input.conversationId()
        );

        LlmRequest request = promptBuilder.build(input, context);
        long started = System.nanoTime();

        try {
            LlmResponse response = llmClient.complete(request);
            callRecorder.recordSuccess(
                    requestId,
                    request,
                    response,
                    elapsedMillis(started)
            );

            return new ChatResponse(
                    input.conversationId(),
                    response.text(),
                    request.model(),
                    requestId
            );
        } catch (LlmClientException ex) {
            callRecorder.recordFailure(
                    requestId,
                    request,
                    ex,
                    elapsedMillis(started)
            );
            throw new ChatUnavailableException(
                    "当前模型服务暂时不可用",
                    ex
            );
        }
    }
}

5. 定义 Controller

java 复制代码
@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final ChatService chatService;

    @PostMapping
    public ChatResponse chat(
            @Valid @RequestBody ChatRequest request,
            @RequestHeader("X-Request-ID") String requestId
    ) {
        return chatService.chat(request, requestId);
    }
}

Controller 不应该关心供应商地址、API Key、请求 JSON 字段和 Token 价格。

6. 使用 Resilience4j 保护调用

可以通过 Resilience4j 增加超时、重试和熔断:

java 复制代码
@Service
public class ProtectedLlmClient implements LlmClient {

    private final LlmClient delegate;

    @Retry(name = "llm")
    @CircuitBreaker(name = "llm")
    @TimeLimiter(name = "llm")
    public CompletableFuture<LlmResponse> completeAsync(
            LlmRequest request
    ) {
        return CompletableFuture.supplyAsync(
                () -> delegate.complete(request)
        );
    }
}

配置示例:

yaml 复制代码
resilience4j:
  retry:
    instances:
      llm:
        max-attempts: 3
        wait-duration: 500ms
  circuitbreaker:
    instances:
      llm:
        failure-rate-threshold: 50
        sliding-window-size: 20
        wait-duration-in-open-state: 30s

需要注意:如果方法使用阻塞式 .block(),不要盲目把它放进响应式线程中。应根据项目使用的编程模型选择同步或响应式实现,并明确线程池边界。

7. 模型切换

定义路由策略:

java 复制代码
public interface LlmRouter {

    LlmClient route(LlmScenario scenario);
}

public enum LlmScenario {
    CHAT,
    CLASSIFICATION,
    LONG_DOCUMENT,
    HIGH_RELIABILITY
}

路由策略可以根据场景、成本和可用性选择客户端:

java 复制代码
@Component
public class DefaultLlmRouter implements LlmRouter {

    private final LlmClient primary;
    private final LlmClient fallback;

    @Override
    public LlmClient route(LlmScenario scenario) {
        if (scenario == LlmScenario.HIGH_RELIABILITY) {
            return primary;
        }
        return primary;
    }
}

实际项目可以基于健康检查、模型能力、成本和上下文窗口动态路由。

8. 编写单元测试

业务服务可以 Mock LlmClient

java 复制代码
@ExtendWith(MockitoExtension.class)
class ChatServiceTest {

    @Mock
    private LlmClient llmClient;

    @Mock
    private ConversationContextService contextService;

    @Mock
    private ChatPromptBuilder promptBuilder;

    @Mock
    private LlmCallRecorder callRecorder;

    @Test
    void shouldReturnModelAnswer() {
        ChatRequest input = new ChatRequest(
                "什么是依赖注入?",
                "conversation-1"
        );
        ConversationContext context = new ConversationContext(
                "conversation-1",
                List.of(),
                0
        );
        LlmRequest llmRequest = new LlmRequest(
                "test-model",
                List.of(new LlmMessage("user", input.message())),
                0.2,
                500,
                false
        );
        LlmResponse llmResponse = new LlmResponse(
                "依赖注入是将对象依赖交给外部容器管理。",
                "stop",
                new LlmUsage(10, 20, 30),
                "provider-request-1"
        );

        when(contextService.load("conversation-1"))
                .thenReturn(context);
        when(promptBuilder.build(input, context))
                .thenReturn(llmRequest);
        when(llmClient.complete(llmRequest))
                .thenReturn(llmResponse);

        ChatService service = new ChatService(
                llmClient,
                contextService,
                promptBuilder,
                callRecorder
        );

        ChatResponse response = service.chat(input, "request-1");

        assertThat(response.answer())
                .contains("依赖注入");
        verify(llmClient).complete(llmRequest);
    }
}

单元测试不应该真的调用外部模型,否则测试慢、贵、结果不稳定。

9. 编写集成测试

集成测试可以使用 WireMock 或 MockWebServer 模拟供应商响应:

java 复制代码
stubFor(post(urlEqualTo("/chat/completions"))
        .willReturn(okJson("""
        {
          "id": "req-1",
          "choices": [
            {
              "message": {
                "role": "assistant",
                "content": "测试回答"
              },
              "finish_reason": "stop"
            }
          ],
          "usage": {
            "prompt_tokens": 10,
            "completion_tokens": 5,
            "total_tokens": 15
          }
        }
        """)));

集成测试验证:

  • URL 是否正确;
  • Header 是否正确;
  • 请求 JSON 是否正确;
  • 响应映射是否正确;
  • 超时和错误是否转换;
  • Token 是否记录。

10. 生产部署清单

上线前至少确认:

text 复制代码
API Key 不在代码和镜像中
模型配置可外部注入
请求和读取超时已设置
重试次数有上限
熔断和降级已验证
输入长度已限制
模型输出已校验
敏感数据已脱敏
调用日志可追踪
Token 和费用可统计
健康检查可用
供应商异常可告警

五、常见问题与实践建议

1. 是否应该直接使用供应商 SDK

SDK 可以减少请求对象和响应对象的编写,适合快速开始。但业务层不建议直接依赖 SDK 类型。

推荐结构:

text 复制代码
业务服务
  -> 自定义 LlmClient 接口
      -> 供应商 SDK 适配器

这样既可以使用 SDK 的便利,也保留供应商替换能力。

2. 是否应该只使用 OpenAI 兼容接口

OpenAI 兼容接口有利于快速切换供应商,但兼容并不意味着完全相同:

  • 支持的参数可能不同;
  • 工具调用字段可能不同;
  • 流式事件格式可能不同;
  • 结构化输出能力可能不同;
  • 错误码和限流策略可能不同;
  • 多模态输入可能不同。

建议在客户端适配层中保留供应商差异,不要把兼容接口当成永远稳定的统一标准。

3. 为什么模型返回了 Markdown 而不是 JSON

常见原因:

  • Prompt 要求不够明确;
  • 没有使用结构化输出能力;
  • 输出中混入了解释文字;
  • 模型版本或参数不适合;
  • 上下文中出现了冲突格式。

改进方式:

  • 明确 Schema;
  • 给出合法示例;
  • 使用 JSON Object 或 Schema 模式;
  • 服务端校验;
  • 失败后有限重试;
  • 不要直接执行业务动作。

4. 为什么模型调用偶尔超时

可能原因:

  • 输入上下文太长;
  • 输出上限太大;
  • 供应商拥塞;
  • 网络链路不稳定;
  • 读取超时设置过短;
  • 流式响应没有正确消费;
  • Java 线程池或连接池不足。

排查时应记录:

  • 首 Token 延迟;
  • 总生成耗时;
  • 输入和输出 Token;
  • 请求是否重试;
  • HTTP 状态码;
  • 供应商请求 ID。

5. 应不应该把历史对话全部传给模型

通常不应该。历史消息越多,成本和延迟越高,也可能让模型混淆当前任务。

可以采用:

  • 最近 N 轮窗口;
  • 旧对话摘要;
  • 用户偏好单独存储;
  • 重要事实结构化保存;
  • 相关历史检索;
  • 不同场景使用不同上下文预算。

6. 如何处理模型拒答

拒答可能是正常的安全行为,也可能是输入被错误判断。

业务上需要区分:

  • 内容安全拒绝;
  • 权限不足;
  • 上下文不足;
  • 供应商策略限制;
  • 请求格式错误;
  • 服务暂时不可用。

不要把所有拒答都转换成"系统异常"。不同原因应该有不同的用户提示和内部指标。

7. 重试是否会增加费用

会。每次重试可能都产生 Token 消耗,即使最终失败也可能计费。

重试需要:

  • 设置最大次数;
  • 只重试暂时性错误;
  • 记录重试次数;
  • 计算重试成本;
  • 对高成本任务设置预算;
  • 达到预算后快速失败或降级。

8. 是否可以把模型结果直接写入数据库

不建议直接写入关键业务表。模型结果应该先经过:

text 复制代码
解析
  -> 字段校验
  -> 枚举白名单
  -> 业务规则校验
  -> 人工确认或审核
  -> 写入数据库

尤其是涉及订单、退款、权限、合同和公开发布的结果,必须有额外保护。

9. 如何防止 Prompt 注入

不能只靠一句系统提示词。应该同时使用:

  • 用户输入与系统指令分层;
  • 外部文档标记为不可信数据;
  • 工具白名单;
  • 参数校验;
  • 业务权限校验;
  • 输出过滤;
  • 高风险操作人工确认;
  • 审计日志。

模型应该看到不可信内容,但不应该因为内容中的指令就自动获得更高权限。

10. 如何保护用户隐私

在发送给模型前,考虑:

  • 是否真的需要该字段;
  • 是否可以脱敏;
  • 是否可以只发送摘要;
  • 是否需要用户授权;
  • 供应商是否满足数据处理要求;
  • 日志是否会保存原文;
  • 数据保留多久。

最安全的敏感数据,是不发送出去的数据。

11. Java 线程模型要注意什么

如果使用阻塞式 HTTP 客户端,应该避免阻塞 WebFlux 事件循环线程。可以选择:

  • 使用响应式 WebClient 全链路;
  • 使用 Spring MVC 配合同步客户端;
  • 把阻塞调用放到专用线程池;
  • 为模型调用配置独立连接池和线程池。

不要只因为方法名是 async 就认为代码不会阻塞。

12. 如何定义接口超时

建议分别配置:

text 复制代码
连接超时:建立网络连接最多等待多久
读取超时:连接建立后等待数据最多多久
整体超时:一次模型调用总共允许多久
流式空闲超时:两段数据之间最多等待多久

不同场景使用不同预算:

场景 建议特点
在线分类 短超时、低输出上限
对话 中等超时、支持流式
长文生成 较长超时、异步化优先
批量处理 后台任务、可重试

13. 如何避免重复调用

可以为业务请求生成幂等键:

text 复制代码
业务请求 ID
  -> 查询是否已有模型结果
  -> 已完成则直接返回
  -> 未完成才调用模型

适合幂等的场景:

  • 文档摘要;
  • 工单分类;
  • 图片描述;
  • 批量数据处理;
  • 失败重试后的恢复。

六、进阶思考

1. 用模型网关统一治理

当项目中有多个业务模块调用模型,可以增加模型网关:

text 复制代码
Java 业务服务
  -> LLM Gateway
      -> 模型供应商 A
      -> 模型供应商 B
      -> 本地模型

网关可以统一处理:

  • API Key;
  • 模型路由;
  • 限流;
  • 重试;
  • 费用统计;
  • 敏感信息过滤;
  • Prompt 版本;
  • 调用审计;
  • 供应商故障切换。

对于多个团队共用模型能力的企业,网关通常比每个服务各自接入更容易治理。

2. 模型能力路由

不同任务不需要使用同一模型:

text 复制代码
简单分类 -> 低成本模型
复杂分析 -> 推理能力更强的模型
长文总结 -> 长上下文模型
高风险场景 -> 经过审核的稳定模型
离线批处理 -> 成本优先模型

路由依据可以包括:

  • 任务类型;
  • 输入长度;
  • 所需输出格式;
  • 延迟预算;
  • 成本预算;
  • 数据敏感等级;
  • 供应商健康状态。

3. 本地模型和云模型组合

企业可以采用混合架构:

text 复制代码
非敏感、复杂任务 -> 云模型
敏感数据、简单任务 -> 本地模型
高峰期请求 -> 多模型路由
离线任务 -> 成本优先模型

混合架构需要处理:

  • 模型能力差异;
  • Prompt 兼容;
  • 输出格式一致性;
  • 推理速度;
  • 版本管理;
  • 资源运维。

4. 结构化输出优先于文本解析

如果下游程序要消费模型结果,优先使用:

  • JSON Schema;
  • 工具调用;
  • 枚举字段;
  • 供应商原生结构化输出;
  • DTO 校验。

尽量避免通过正则从自然语言中提取关键业务字段。自然语言解析可以作为兜底,但不应该成为关键链路的唯一方式。

5. 业务规则和模型判断分离

模型适合处理:

  • 文本理解;
  • 信息抽取;
  • 分类建议;
  • 摘要和改写;
  • 非结构化内容分析。

代码和规则适合处理:

  • 金额计算;
  • 权限判断;
  • 状态迁移;
  • 库存扣减;
  • 交易一致性;
  • 合规门禁;
  • 最终发布。

不要让模型承担可以用确定性代码完成的关键判断。

6. 评估与回归

模型接入后,需要建立评估样本:

text 复制代码
输入问题
期望行为
必须包含的字段
禁止出现的内容
允许调用的工具
最大耗时
最大成本

每次修改以下内容,都应运行回归测试:

  • 模型版本;
  • Prompt;
  • 工具描述;
  • 上下文策略;
  • JSON Schema;
  • 路由策略;
  • 重试和降级配置。

7. 可观测性设计

建议将模型调用纳入统一 Trace:

text 复制代码
HTTP Request
  -> Business Span
  -> Prompt Build Span
  -> LLM Request Span
  -> Tool Span
  -> Response Parse Span

模型 Span 可以记录:

  • 模型名;
  • 供应商请求 ID;
  • 输入输出 Token;
  • 首 Token 延迟;
  • 总延迟;
  • 是否命中重试;
  • 是否发生降级;
  • 结果状态。

不要在 Trace 中无保护地保存完整敏感 Prompt。

8. 访问控制

不同业务场景应该使用不同权限:

text 复制代码
普通客服 Agent
  -> 只能读取脱敏资料

内部分析 Agent
  -> 可以读取授权数据

发布 Agent
  -> 可以执行发布,但需要审批

权限控制应该在工具和业务服务层强制执行,不能只写在 Prompt 中。

9. 从调用服务到 AI 平台

当模型能力逐渐增多,系统可以演进为平台:

text 复制代码
模型客户端
  -> Prompt 管理
  -> 模型路由
  -> 知识库
  -> 工具注册
  -> Agent 编排
  -> 评估平台
  -> 成本中心
  -> 安全审计

但平台化需要真实需求驱动。只有当多个团队、多个业务和多个模型共享能力时,才值得引入更多平台抽象。

10. 生产级接入检查表

上线前可以用下面的清单检查:

text 复制代码
接口和密钥
  [ ] API Key 通过 Secret 注入
  [ ] 不同环境使用不同密钥
  [ ] 供应商请求 ID 可记录

可靠性
  [ ] 连接、读取和整体超时已设置
  [ ] 重试只针对临时错误
  [ ] 重试有最大次数
  [ ] 熔断和降级经过验证

数据安全
  [ ] 敏感字段已脱敏
  [ ] 用户输入与系统指令分离
  [ ] 输出结果经过校验
  [ ] 高风险操作有审批

成本与性能
  [ ] 上下文有长度预算
  [ ] 输出有最大 Token 限制
  [ ] Token 和费用可统计
  [ ] 模型调用可取消

工程质量
  [ ] 客户端有统一抽象
  [ ] Prompt 有版本
  [ ] 有单元测试和集成测试
  [ ] 有评估集和回归测试
  [ ] 有日志、指标和 Trace

结论

Java 后端接入大语言模型,最初可能只是一次 HTTP 调用,但生产级系统需要把模型调用当作一个需要治理的外部能力。

比较稳妥的架构是:

text 复制代码
Controller
  -> Application Service
  -> Prompt Builder
  -> 自定义 LlmClient
  -> 供应商 SDK 或 HTTP Client
  -> 输出解析和业务校验

在此基础上,再加入:

  • 配置集中管理;
  • 超时、重试、熔断和降级;
  • 同步与流式输出;
  • 结构化响应;
  • Token 和费用统计;
  • 日志、指标和分布式追踪;
  • 数据脱敏和权限控制;
  • 评估集与回归测试;
  • 多供应商和模型路由。

最重要的原则是:模型负责理解和生成,业务系统负责校验和执行。不要把模型输出直接当成可信业务指令,也不要让具体供应商 SDK 渗透到整个业务代码中。

下一篇将继续介绍 Spring AI,讨论如何使用 Spring 生态中的统一抽象构建第一个 Java AI 应用,并比较直接使用 HTTP、供应商 SDK 与 Spring AI 的适用场景。

相关推荐
xieliyu.1 小时前
JVM 垃圾回收机制详解:从标记过程、回收算法到垃圾收集器
java·jvm·笔记·java-ee
JavacKaka1 小时前
Redis大Key导致生产事故复盘博客-2026-09-18
java
逃逸线LOF1 小时前
Spring Boot4 整合 Shiro3 & Thymeleaf
spring boot·spring
右耳朵猫AI1 小时前
Java周刊2026W38 | Micronaut 修补三漏洞、JDK 27 提速 54%、Jetty 修复抖动测试
java·后端·spring
事已至此先睡覺吧1 小时前
第二篇:Java 基础语法:变量、数据类型、运算符与类型转换
java
liulilittle2 小时前
mock 框架架构
ai·架构·自动化·llm·mock·测试·tools
Wang's Blog2 小时前
Java 项目实战: 外卖平台-后台退出功能与首页iframe架构
java·服务器·redis
Wang's Blog2 小时前
Java 项目实战: 外卖平台-软件开发流程与项目整体介绍
java·服务器·redis
她说..2 小时前
常见设计模式-模板方法模式
java·spring·设计模式·springboot