04. Provider 抽象与统一消息协议
当 Agent 只有一个模型时,你可能会直接把厂商 SDK 的类型传遍整个系统。这样做起步快,但很快会把运行时绑死在某个 provider 的语义上。不同厂商对 tool call、流式 delta、错误、usage、停止原因、system prompt 和图像输入的表达都不一样。Agent 内核不应该理解这些差异。
成熟的做法是建立内部消息协议,再在 provider 边界做双向转换。模型供应商是插件,Agent 的事实源是你自己的类型。一个真实系统可能同时对接十来种 API 形态、四十多个网关厂商,而内核对此一无所知——这就是协议层的价值。
内部协议为什么必要
没有内部协议时,问题会逐步扩散:
- 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 之外,还有缓存读、缓存写和推理 token。缓存字段直接影响成本核算——缓存读通常只有正常输入价格的十分之一。stopReason: loop 控制流所需的归一化停止原因。content: 文本、tool call、thinking 块、可能的图片或其他块。errorMessage: stop reason 为error或aborted时的人类可读原因。
保存这些字段的原因很实际。用户可能在一个会话中途切换模型;你仍然要知道每条回答来自哪里。计费需要 usage 和模型目录里的单价相乘。恢复需要 stop reason。工具调用需要结构化 content block。
Adapter 要吸收的厂商差异
值得列举一些真实存在的差异,让你对“adapter 在做什么”有具体感受:
- system prompt:有的 API 用
system角色,有的要求developer角色。 - 输出上限:字段名可能是
max_tokens,也可能是max_completion_tokens。 - 消息顺序:多数 API 要求 tool result 紧跟对应的 assistant 消息,连续多条 tool result 要合并进同一条消息。
- 空内容:有的 API 拒绝空文本块,adapter 要在转换时剔除。
- 工具 schema:有的 API 提供严格 JSON schema 模式,有的代理网关会拒绝带未知字段的 schema。
- 推理内容:支持思维链的模型会返回 thinking 块,往往附带一个只对“同一个模型”有效的加密签名,用于下次请求时原样回放。
- 参数编码:tool call 的 id 格式、参数是对象还是字符串,各家不同。
每一条差异单独看都是小事,合在一起就是几千行 adapter 代码。关键是这些代码全部集中在边界上,内核和产品层一行都不用改。
跨模型切换是差异最集中的场景。会话进行到一半从模型 A 切到模型 B 时,adapter 需要:把 A 专属的 thinking 签名丢弃或把思维链降级为普通文本;把 A 生成的 tool call id 归一化成 B 能接受的格式并同步更新 tool result 的引用;把 B 不支持的图片替换成占位说明。没有这层转换,“切换模型”这个看似简单的功能会直接把会话变成非法请求。
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 行为不同”的问题。
发请求前还有一步容易忽略的清洗:把文本里未配对的 UTF-16 代理对替换掉。用户粘贴的内容、文件读出来的字节都可能包含它们,多数 API 会直接拒绝这样的 JSON。正常的 emoji 是成对代理,不受影响。这类“协议边界卫生”工作都属于 adapter。
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 还需要一个模型目录。目录不是下拉框数据,而是运行时决策依据。每个模型至少要记录:
- 上下文窗口大小和最大输出 token。
- 是否支持 tool calling、图片输入、推理模式。
- 每百万 token 的输入、输出、缓存读、缓存写单价。
- 推理强度档位如何映射到该厂商的参数(同一个“high”在不同 API 里是完全不同的字段)。
- 默认 provider、endpoint 和认证方式。
压缩阈值、工具暴露、UI 提示、成本核算和错误消息都依赖这些能力。不要在代码里到处写“如果模型名包含某字符串”。模型能力应该来自配置和目录。
生产化取舍
Provider adapter 是错误处理最密集的边界。第一步是归一化:把厂商各异的异常统一成“认证失败、限流、可重试服务端错误、上下文超长、内容安全拒绝、网络中断”几类。第二步是分类重试。真实系统的可重试判断大致是:
- 可重试:429 限流、5xx、网关超时(包括 CDN 的 524)、overloaded、连接被拒、socket 挂断、流在收到结束标记前中断,以及厂商明确写着“please retry”的错误。
- 不可重试:配额耗尽、余额不足、账单问题、认证失败、请求本身非法。
重试要有退避和上限,并且尊重服务端要求的等待时间——但如果服务端要求等待超过某个上限(比如 60 秒),应该把错误连同等待时长交给上层,让用户决定,而不是让界面无声地卡一分钟。
另外,streaming adapter 要能在流中断时产生明确状态。不要让 UI 卡在“模型正在输出”。流中断后的 assistant 消息可以标记 stopReason: "error",并保留已收到的文本片段,方便用户决定重试还是继续。
练习
实现两个 provider:
ScriptedProvider: 从数组返回固定 assistant 消息。HttpProvider: 只需要支持一个真实模型的非流式调用。
验收标准:
- Agent loop 可以在不改一行核心代码的情况下切换 provider。
- 两个 provider 都返回内部
AssistantMessage。 - usage(含缓存字段)和 stop reason 不丢失。
- 当真实 provider 返回上下文超长时,错误能被识别为需要压缩,而不是普通异常。
- 写一个错误分类函数,把至少六种模拟错误文本正确分为可重试和不可重试。