摘要
大语言模型已经从聊天工具逐渐进入企业应用:智能客服、知识库问答、代码助手、内容审核、数据分析和业务自动化,都需要后端服务以稳定、可控的方式调用模型。
对于 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);
}
}
不要在业务代码中直接读取 Environment 或 System.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 的适用场景。