从零构建 Coding Agent
English
本章目录

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 只认识 MessageToolDefinitionAssistantMessagestopReason。厂商差异只存在于“发请求前转换”和“收响应后转换”两处。

协议层最少要保存什么

Assistant 消息不要只保存文本。它至少要保存:

  • model: 实际响应的模型 id。响应模型可能和请求模型不同(路由类网关会自动选模),两个都值得记。
  • provider: 可选的 provider id,方便审计和恢复。
  • usage: 输入、输出 token 之外,还有缓存读、缓存写和推理 token。缓存字段直接影响成本核算——缓存读通常只有正常输入价格的十分之一。
  • stopReason: loop 控制流所需的归一化停止原因。
  • content: 文本、tool call、thinking 块、可能的图片或其他块。
  • errorMessage: stop reason 为 erroraborted 时的人类可读原因。

保存这些字段的原因很实际。用户可能在一个会话中途切换模型;你仍然要知道每条回答来自哪里。计费需要 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 返回上下文超长时,错误能被识别为需要压缩,而不是普通异常。
  • 写一个错误分类函数,把至少六种模拟错误文本正确分为可重试和不可重试。