从零构建 Coding Agent
English

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

协议层最少要保存什么

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 返回上下文超长时,错误能被识别为需要压缩,而不是普通异常。