Chat同步调用与模板方法
上一篇讲了 executeWithFallback 如何遍历候选列表、逐个尝试、失败后切换到下一个候选。每个候选的实际调用就是一句 client.chat(request, target)。
那这一行代码背后到底发生了什么?一个 ChatRequest 对象是怎么变成一个发往百炼的 HTTP POST 请求的?百炼返回的 JSON 响应又是怎么变成一个 String 返回给业务层的?HTTP 500 错误是怎么触发故障转移的?
这一篇打开 Chat 子系统的黑盒,看看从业务层调用到供应商 HTTP 请求之间的完整链路。
接口分层:LLMService 与 ChatClient
1. 两层接口的设计
项目里和 Chat 相关的接口有两层。第一层是业务层使用的 LLMService:
public interface LLMService {
default String chat(String prompt) {
ChatRequest req = ChatRequest.builder()
.messages(List.of(ChatMessage.user(prompt)))
.build();
return chat(req);
}
String chat(ChatRequest request);
default String chat(ChatRequest request, String modelId) {
return chat(request);
}
default StreamCancellationHandle streamChat(String prompt, StreamCallback callback) {
ChatRequest req = ChatRequest.builder()
.messages(List.of(ChatMessage.user(prompt)))
.build();
return streamChat(req, callback);
}
StreamCancellationHandle streamChat(ChatRequest request, StreamCallback callback);
}
LLMService 面向业务层,接口里看不到任何 infra 概念——没有 ModelTarget,没有 ProviderConfig,没有供应商名称。业务代码只需要传一个 ChatRequest,就能拿到模型的回答。至于背后调的是百炼还是硅基流动,哪个模型先试哪个后试,业务层不需要关心。
第二层是供应商级别的 ChatClient:
public interface ChatClient {
String provider();
String chat(ChatRequest request, ModelTarget target);
StreamCancellationHandle streamChat(ChatRequest request, StreamCallback callback, ModelTarget target);
}
ChatClient 面向供应商层,多了两个东西:provider() 返回供应商标识(比如 "bailian"、"siliconflow"、"ollama"),chat 方法多了一个 ModelTarget 参数——它包含了这次调用需要的所有运行时信息:模型名称、供应商 URL、API Key。
两层之间的桥梁是 RoutingLLMService。它实现了 LLMService 接口,内部使用 ModelSelector + ModelRoutingExecutor + ChatClient 完成路由和调用:
@Override
public String chat(ChatRequest request) {
return executor.executeWithFallback(
ModelCapability.CHAT,
selector.selectChatCandidates(Boolean.TRUE.equals(request.getThinking())),
target -> clientsByProvider.get(target.candidate().getProvider()),
(client, target) -> client.chat(request, target)
);
}
业务层调 LLMService.chat(request),RoutingLLMService 通过 executeWithFallback 遍历候选列表,对每个候选查找对应的 ChatClient 实例,然后调 client.chat(request, target)。业务层完全不知道 ChatClient 的存在。
2. 调用链路图
从业务代码到供应商 HTTP 请求的完整路径:
整条链路分三段:路由段(RoutingLLMService → executeWithFallback)负责选谁、怎么切换;协议段(AbstractOpenAIStyleChatClient.doChat)负责构建 HTTP 请求和解析响应;传输段(OkHttp → 供应商 API)负责发送和接收。
请求与响应数 据结构
1. ChatRequest 与 ChatMessage
在看 doChat 的实现之前,先了解它的输入。ChatRequest 定义在 framework 模块,是一个统一的大模型请求对象:
@Data
@Builder
public class ChatRequest {
@Default
private List<ChatMessage> messages = new ArrayList<>();
private Double temperature;
private Double topP;
private Integer topK;
private Integer maxTokens;
private Boolean thinking;
private Boolean enableTools;
}
messages 是消息列表,每条消息由 ChatMessage 表示:
@Data
public class ChatMessage {
public enum Role {
SYSTEM,
USER,
ASSISTANT;
}
private Role role;
private String content;
private String thinkingContent;
private Integer thinkingDuration;
public static ChatMessage system(String content) {
return new ChatMessage(Role.SYSTEM, content);
}
public static ChatMessage user(String content) {
return new ChatMessage(Role.USER, content);
}
public static ChatMessage assistant(String content) {
return new ChatMessage(Role.ASSISTANT, content);
}
}
三个静态工厂方法让构建消息很简洁。比如一次带 System Prompt 的问答:
ChatRequest request = ChatRequest.builder()
.messages(List.of(
ChatMessage.system("你是一个电商客服助手,请根据提供的知识回答用户问题。"),
ChatMessage.user("AirPods Pro 2 的保修期是多久?")
))
.temperature(0.1)
.build();
2. 到 OpenAI 格式的映射
上面的 ChatRequest 会被 buildRequestBody 方法转换成 OpenAI Chat Completions API 的 JSON 格式。映射关系是直接的:
请求体:
{
"model": "qwen3-max",
"messages": [
{"role": "system", "content": "你是一个电商客服助手,请根据提供的知识回答用户问题。"},
{"role": "user", "content": "AirPods Pro 2 的保修期是多久?"}
],
"temperature": 0.1
}
model 不是从 ChatRequest 里来的——它来自 ModelTarget.candidate().getModel(),也就是 YAML 配置中的模型名。topP、topK、maxTokens 为 null 时不加进请求体,由供应商使用默认值。
响 应体:
{
"choices": [
{
"message": {
"role": "assistant",
"content": "AirPods Pro 2 的保修期为 1 年有限保修..."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 42,
"completion_tokens": 56,
"total_tokens": 98
}
}
extractChatContent 方法从响应体中提取 choices[0].message.content——就是模型的回答文本。
模板方法:AbstractOpenAIStyleChatClient
1. 为什么用模板方法
项目目前支持三个供应商:百炼(阿里云)、硅基流动、Ollama。这三家都兼容 OpenAI Chat Completions API——请求格式一样(model + messages + 参数),响应格式一样(choices[0].message.content),端点路径也类似(/v1/chat/completions 或变体)。
如果每个供应商都从头实现一遍 HTTP 请求构建、JSON 序列化、响应解析、错误处理,90% 的代码是重复的。差异就那么一点:Ollama 不需要 API Key,百炼和硅基流动需要;个别供应商可能有特殊的请求字段;URL 端点不一样。
模板方法模式解决这个问题:把相同的 90%(请求构建、HTTP 调用、响应解析、错误处理)放在抽象基类 AbstractOpenAIStyleChatClient 里,把不同的 10%(认证方式、特殊字段)留给子类通过钩子方法覆写。
2. 类继承结构
三个子类的代码量极少(各约 30 行),所有协议处理逻辑都在基类里。