第四章:Provider 架构 -- 请求如何路由到正确的服务商

对应源文件:packages/ai/src/api-registry.tspackages/ai/src/models.tspackages/ai/src/providers/register-builtins.ts

问题回顾

当你调用 stream(model, context) 时,系统需要回答一个问题:

这个 model 应该发给哪个 provider 适配器处理?

答案藏在 model.api 字段里。一个 model 的 api 字段告诉系统它使用哪种 API 协议。系统维护了一个注册表,把每种 API 协议映射到对应的处理函数。

API 注册表(API Registry)

什么是注册表模式

注册表模式是一种常见的设计模式。思路是:

  1. 维护一个"字典"(Map),key 是标识符,value 是处理逻辑
  2. 需要处理某个标识符时,从字典里查出对应的处理逻辑
  3. 新增处理逻辑只需要往字典里加一条记录

pi-ai 中的注册表:

注册表 (Map)
─────────────────────────────────────────
"anthropic-messages"    → { stream: streamAnthropic, streamSimple: ... }
"openai-completions"    → { stream: streamOpenAI, streamSimple: ... }
"openai-responses"      → { stream: streamOpenAIResponses, streamSimple: ... }
"google-generative-ai"  → { stream: streamGoogle, streamSimple: ... }
"mistral-conversations" → { stream: streamMistral, streamSimple: ... }
"bedrock-converse-stream" → { stream: streamBedrock, streamSimple: ... }
...

注册过程

每个注册项叫做一个 ApiProvider,它包含:

  • api:API 协议标识符
  • stream:处理"原生选项"流式请求的函数
  • streamSimple:处理"简化选项"流式请求的函数

注册就是往 Map 里放一条记录:

registerApiProvider({
  api: "anthropic-messages",
  stream: streamAnthropic,
  streamSimple: streamSimpleAnthropic,
})

查找过程

当你调用 stream(model, context) 时:

1. 读取 model.api,比如 "anthropic-messages"
2. 在注册表中查找:registry.get("anthropic-messages")
3. 找到对应的 provider → { stream: streamAnthropic, ... }
4. 调用 provider.stream(model, context, options)
5. 返回 AssistantMessageEventStream

就这么简单。整个路由逻辑只有几行代码。

懒加载(Lazy Loading)

为什么需要懒加载

pi-ai 支持 20+ 个 provider,每个 provider 的适配器代码都不小(通常 1-3 万行)。如果启动时全部加载,会浪费时间和内存。实际上,用户通常只会用 1-2 个 provider。

懒加载的实现

pi-ai 的做法很巧妙:

  1. 启动时只注册一个"代理函数"(proxy),不加载实际的 provider 代码
  2. 当这个代理函数第一次被调用时,才去 import 实际的 provider 模块
  3. 加载完成后,把实际模块的事件转发到调用者的事件流中
启动时注册的不是真正的 streamAnthropic,而是一个"懒代理":

调用 stream() 时:
  ┌─────────────────────────────────────────────┐
  │ 懒代理函数                                   │
  │ 1. 创建一个空的 EventStream(outer)          │
  │ 2. 异步加载 anthropic.ts 模块                │
  │ 3. 调用真正的 streamAnthropic() 得到 inner    │
  │ 4. 把 inner 的事件逐个转发到 outer            │
  │ 5. 立即返回 outer                            │
  └─────────────────────────────────────────────┘

关键点:懒代理立即返回一个空的 EventStream,不需要等模块加载完成。调用者可以马上开始 for await,事件会在模块加载完成后陆续到达。

这是一种 "先承诺再兑现" 的设计:先给你一个"容器",内容稍后到达。

加载失败怎么办

如果模块加载失败(比如某个 provider 的依赖没装),懒代理会生成一个 error 事件推到 outer stream 中。调用者会收到一个错误消息,而不是一个未捕获的异常。

这与前一章"错误不抛异常"的设计原则一致。

模型注册表(Model Registry)

API 注册表解决的是"如何路由到 provider",模型注册表解决的是"有哪些模型可用"。

模型数据从哪来

models.generated.ts 是一个 384KB 的自动生成文件,包含所有已知 provider 的所有模型信息。它的结构大致是:

export const MODELS = {
  "anthropic": {
    "claude-sonnet-4-20250514": {
      id: "claude-sonnet-4-20250514",
      name: "Claude Sonnet 4",
      api: "anthropic-messages",
      provider: "anthropic",
      baseUrl: "https://api.anthropic.com",
      reasoning: true,
      input: ["text", "image"],
      cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
      contextWindow: 200000,
      maxTokens: 16384
    },
    // ... 更多 Anthropic 模型
  },
  "openai": {
    "gpt-4o": { ... },
    "gpt-4o-mini": { ... },
    // ... 更多 OpenAI 模型
  },
  // ... 更多 provider
};

这个文件由 packages/ai/scripts/generate-models.ts 脚本生成。脚本会从各 provider 的 API 或文档中抓取最新的模型列表和定价信息。

模型查询

models.ts 提供了几个简单的查询函数:

// 获取所有 provider 列表
getProviders()  → ["openai", "anthropic", "google", ...]

// 获取某个 provider 的所有模型
getModels("anthropic")  → [model1, model2, ...]

// 获取一个具体模型
getModel("anthropic", "claude-sonnet-4-20250514")  → Model 对象

这些函数都有完整的 TypeScript 类型提示。在 IDE 中输入 getModel(" 时会自动补全 provider 名称,输入第二个参数时会自动补全该 provider 下的模型 ID。

自定义模型

除了内置模型,用户还可以创建自定义模型。只要指定正确的 api 字段,注册表就知道用哪个适配器处理:

const myLocalModel = {
  id: "llama-3.1-8b",
  name: "Llama 3.1 8B (本地)",
  api: "openai-completions",          // 使用 OpenAI 兼容协议
  provider: "ollama",
  baseUrl: "http://localhost:11434/v1", // 本地 Ollama 服务
  reasoning: false,
  input: ["text"],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  contextWindow: 128000,
  maxTokens: 32000
};

// 直接传给 stream(),不需要注册
stream(myLocalModel, context);

因为 api: "openai-completions" 对应的适配器已经在注册表中了,所以这个自定义模型可以直接使用。

完整的调用链路

把前几章的内容串起来,一次完整的 LLM 调用的路径是:

你的代码
    │
    │ stream(model, context, options)
    ▼
stream.ts
    │ 1. resolveApiProvider(model.api)  →  在注册表中查找
    │ 2. provider.stream(model, context, options)
    ▼
api-registry.ts
    │ 3. 注册表返回懒代理函数
    ▼
register-builtins.ts (懒代理)
    │ 4. 创建 outer EventStream
    │ 5. 异步 import("./anthropic.js")
    │ 6. 调用真正的 streamAnthropic(model, context, options)
    ▼
anthropic.ts (实际适配器)
    │ 7. 把 Context 转换为 Anthropic API 的请求格式
    │ 8. 发送 HTTP 请求到 api.anthropic.com
    │ 9. 解析 Anthropic 的 SSE 响应
    │ 10. 生成统一的 AssistantMessageEvent 序列
    │ 11. push 到 EventStream
    ▼
返回给你的代码
    │
    │ for await (const event of stream) { ... }

消息转换(Message Transform)

在发送给 provider 之前,消息列表会经过一次 transformMessages() 处理:

它做了什么

  1. 图片降级:如果模型不支持图片输入(model.input 不包含 "image"),自动把图片块替换为文字占位符:"(image omitted: model does not support images)"

  2. 思考块处理

    • 同模型回放:保留原始 thinking 块(因为有些 provider 需要 signature 用于多轮连续性)
    • 跨模型切换:把 thinking 块转为普通文本(其他模型不认识 thinking 格式)
    • 空思考块:直接丢弃
  3. 工具调用 ID 标准化:不同 provider 的 tool call ID 格式不同。比如 OpenAI Responses API 生成的 ID 有 450+ 字符且包含特殊字符,而 Anthropic 要求 ID 只能包含字母数字和短横线。需要跨 provider 回放对话时,就需要标准化这些 ID

  4. 孤立工具调用修复:如果对话历史中有个 assistant 调用了工具但没有对应的 toolResult(比如被中断了),自动插入一个合成的错误 toolResult,防止 API 报错

  5. 错误消息跳过:如果 assistant 消息的 stopReason 是 error 或 aborted,直接跳过不发给 LLM(因为不完整的消息可能导致 API 报错)

为什么需要消息转换

跨 provider 切换是 pi-ai 的核心卖点之一。用户在使用 pi 时可以中途切换模型(比如从 Claude 切到 GPT),之前的对话历史需要无缝衔接到新模型。消息转换层就是解决这个"翻译"问题的。


小结

┌──────────────────────────────────────────────────┐
│                   你的代码                        │
│  stream(model, context)                          │
└──────────────────┬───────────────────────────────┘
                   │ model.api = "anthropic-messages"
                   ▼
┌──────────────────────────────────────────────────┐
│              API 注册表 (Map)                     │
│  "anthropic-messages" → 懒代理                    │
│  "openai-completions" → 懒代理                    │
│  "google-generative-ai" → 懒代理                  │
│  ...                                             │
└──────────────────┬───────────────────────────────┘
                   │ 首次调用时加载模块
                   ▼
┌──────────────────────────────────────────────────┐
│           Provider 适配器 (anthropic.ts)          │
│  1. transformMessages()  消息转换                 │
│  2. 构建 Anthropic API 请求体                     │
│  3. 发送 HTTP 请求                               │
│  4. 解析响应,生成统一事件序列                      │
└──────────────────────────────────────────────────┘

下一章我们会深入看一个具体的 provider 适配器内部是怎么工作的。