04. Provider 抽象与统一消息协议
当 Agent 只有一个模型时,你可能会直接把厂商 SDK 的类型传遍整个系统。这样做起步快,但很快会把运行时绑死在某个 provider 的语义上。不同厂商对 tool call、流式 delta、错误、usage、停止原因、系统提示词和图像输入的表达都不一样。Agent 内核不应该理解这些差异。
成熟的做法是建立内部消息协议,再在 provider 边界做双向转换。模型供应商是插件,Agent 的事实源是你自己的类型。
内部协议为什么必要
没有内部协议时,问题会逐步扩散:
- UI 需要判断 assistant 是否因为 tool use 停止,于是依赖厂商字段。
- 会话日志直接保存厂商响应,换模型后无法恢复。
- 工具结果格式跟某家 API 耦合,另一家 provider 需要到处适配。
- 测试必须 mock 厂商 SDK,而不是 mock Agent 的真实边界。
内部协议把这些问题收束到 provider adapter。Agent loop 只认识 Message、ToolDefinition、AssistantMessage 和 stopReason。厂商差异只存在于“发请求前转换”和“收响应后转换”两处。
协议层最少要保存什么
Assistant 消息不要只保存文本。它至少要保存:
model: 实际响应的模型 id。provider: 可选的 provider id,方便审计和恢复。usage: 输入、输出、缓存、推理 token 等成本字段。stopReason: loop 控制流所需的归一化停止原因。content: 文本、tool call、可能的图片或其他块。
保存这些字段的原因很实际。用户可能在一个会话中途切换模型;你仍然要知道每条回答来自哪里。计费需要 usage。恢复需要 stop reason。工具调用需要结构化 content block。
Provider adapter 的形状
可以把 provider 边界写成两个方向:
type ProviderRequest = {
messages: Message[];
tools: ToolDefinition[];
systemPrompt: string;
model: string;
};
type ProviderClient = {
id: string;
complete(request: ProviderRequest, signal: AbortSignal): Promise<AssistantMessage>;
stream(request: ProviderRequest, signal: AbortSignal): AsyncIterable<ProviderEvent>;
};
complete 给非流式和测试用,stream 给产品体验用。两者返回的最终 assistant 消息必须等价。否则你会遇到“非流式测试通过,流式 UI 行为不同”的问题。
Faux provider 是一等公民
不要把 faux provider 当成临时 mock。它应该实现和真实 provider 一样的接口,能返回完整 assistant 消息、tool call、usage 和 stop reason。一个脚本化 provider 可以这样工作:
type ScriptedStep = {
expectLastRole?: Message["role"];
response: AssistantMessage;
};
class ScriptedProvider implements ModelClient {
private readonly steps: ScriptedStep[];
private index = 0;
constructor(steps: ScriptedStep[]) {
this.steps = steps;
}
async complete(input: { messages: Message[] }): Promise<AssistantMessage> {
const step = this.steps[this.index];
if (!step) {
throw new Error("No scripted response left");
}
this.index += 1;
const last = input.messages.at(-1);
if (step.expectLastRole && last?.role !== step.expectLastRole) {
throw new Error(`Expected last role ${step.expectLastRole}, got ${last?.role ?? "none"}`);
}
return step.response;
}
}
这个 provider 可以测试 loop 是否在 tool result 后再次请求模型,也可以测试未知工具、压缩、插话和恢复。它比 mock fetch 更接近真实 Agent 行为。
模型目录与能力
Agent 还需要一个模型目录。目录不是下拉框数据,而是运行时决策依据。每个模型至少要记录:
- 上下文窗口大小。
- 是否支持 tool calling。
- 是否支持流式 tool arguments。
- 是否支持图片、推理预算、缓存。
- 默认 provider 和认证方式。
压缩阈值、工具暴露、UI 提示和错误消息都依赖这些能力。不要在代码里到处写“如果模型名包含某字符串”。模型能力应该来自配置和目录。
生产化取舍
Provider adapter 是错误处理最密集的边界。你需要把厂商错误归一化为运行时能理解的类别:认证失败、限流、可重试服务端错误、上下文超长、内容安全拒绝、网络中断。可重试错误进入退避策略;不可重试错误进入会话日志并提示用户。
另外,streaming adapter 要能在流中断时产生明确状态。不要让 UI 卡在“模型正在输出”。流中断后的 assistant 消息可以标记 stopReason: "error",并保留已收到的文本片段,方便用户决定重试还是继续。
练习
实现两个 provider:
ScriptedProvider: 从数组返回固定 assistant 消息。HttpProvider: 只需要支持一个真实模型的非流式调用。
验收标准:
- Agent loop 可以在不改一行核心代码的情况下切换 provider。
- 两个 provider 都返回内部
AssistantMessage。 - usage 和 stop reason 不丢失。
- 当真实 provider 返回上下文超长时,错误能被识别为需要压缩,而不是普通异常。