Appearance
🐣 小a的拆解任务
入职第一天,老z给小a派了第一个任务:"pi-ai 这个包,是整个工坊的地基。你给我把它拆明白——它怎么用一套接口,盖住 近 40 家厂商的?"
小a打开
packages/ai/src/providers/一数,近 40 家厂商。又打开types.ts,好家伙,32KB。"这……从哪下手?"
老z拍拍它肩膀:"别被吓到。这套抽象的核心,其实就一个函数 + 一个接口。 抓住这俩,其他的都是它们的展开。"
从这一章起,我们大量贴真实源码。每段代码都来自 pi 仓库,标了文件路径和行号,你可以对照着看。
17.1 先抓核心:一个工厂 + 一个接口
老z带着小a直奔要害。pi-ai 的统一抽象,核心就两样东西:
Provider接口:定义"一个厂商长什么样"createProvider工厂:把"配置"变成符合接口的对象
抓住这俩,近 40 家厂商的秘密就解开了。
17.2 Provider 接口:一个厂商的"标准画像"
我们先看 Provider 接口——它定义了"一个厂商必须提供什么"。
📄 源码证据
packages/ai/src/models.ts:75(真实完整接口)typescriptexport interface Provider<TApi extends Api = Api> { readonly id: string; // 厂商唯一标识,如 "anthropic" readonly name: string; // 显示名,如 "Anthropic" readonly baseUrl?: string; // API 地址,如 "https://api.anthropic.com" readonly headers?: ProviderHeaders; // 额外的请求头 readonly auth: ProviderAuth; // 鉴权方式(怎么证明身份) // 当前已知的模型列表(同步返回) getModels(): readonly Model<TApi>[]; // 动态厂商专用:刷新模型列表(从厂商 API 拉最新的) refreshModels?(context: RefreshModelsContext): Promise<void>; // 可选:过滤模型(如按凭据过滤掉不可用的) filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[]; // 完整流式:暴露厂商所有细节(思考过程、原始事件) stream<T extends TApi>(model: Model<T>, context: Context, options?: ApiStreamOptions<T>): AssistantMessageEventStream; // 简化流式:抹平思考等厂商差异,统一事件流 streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; }
🧙 老z拆解这个接口:
一个 Provider 要回答这几个问题:
- 你是谁?(
id/name)- 上哪找你?(
baseUrl)- 怎么证明身份?(
auth)- 你有哪些模型?(
getModels())- 怎么调用你?(通过
api提供的 stream 方法)只要一个厂商能回答这五个问题,它就能被纳入 pi 的统一体系。 这就是"标准画像"的威力——不管你多大牌(OpenAI)还是多小众,都套进同一个模子。
注释里的重要细节
注意 auth 字段的注释:
📄 注释原文:
Required: at least one of apiKey/oauth. Every provider has auth semantics — even providers with only ambient credentials...
翻译:每个 provider 都必须有 auth——哪怕它只用环境变量、不显式登录,也要提供 auth(其 resolve() 会报告"我有没有配置好")。pi 不允许"不知道自己鉴权状态"的 provider。 这是 fail loud 原则的体现(附录 E.6)。
17.3 createProvider 工厂:把配置变成对象
光有接口不够,得有个东西"造"出符合接口的对象。这就是 createProvider。
📄 源码证据
packages/ai/src/models.ts:556(真实签名)typescriptexport function createProvider<TApi extends Api = Api>( input: CreateProviderOptions<TApi> // 一份"配置单" ): Provider<TApi> // 返回符合接口的 Provider
CreateProviderOptions 就是"配置单"的类型定义(真实字段):
📄 源码证据
models.ts:533typescriptinterface CreateProviderOptions<TApi extends Api = Api> { id: string; // 厂商唯一标识 name?: string; // 显示名 baseUrl?: string; // API 地址 headers?: ProviderHeaders; // 额外请求头 auth: ProviderAuth; // 鉴权方式 models: readonly Model<TApi>[]; // 模型列表 fetchModels?: ...; // 可选:动态拉取模型 filterModels?: ...; // 可选:按凭据过滤 api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>; // 说话方式 }
这段代码是本章最重要的一段,老z带小a分三步读——每步只看一个职责,别一锅炖。
第一步:维护两张模型表(静态 + 动态)
意图:模型会变。pi 维护两张表——静态的(构建期抓的快照)和动态的(运行期可刷新)。
📄 源码证据
packages/ai/src/models.ts——createProvider内部(精简)typescriptconst baselineModels = input.models; // 静态表(构建期生成) let dynamicModels: readonly Model<TApi>[] = []; // 动态表(运行期可刷新) const currentModels = (): readonly Model<TApi>[] => { const merged = [...baselineModels]; for (const model of dynamicModels) { const index = merged.findIndex((entry) => entry.id === model.id); if (index >= 0) merged[index] = model; // 同 id → 覆盖 else merged.push(model); // 新 id → 追加 } return merged; };
🧙
currentModels()的合并逻辑——动态表覆盖同 id 的静态项。好处:既能离线用(只看静态),又能在线更新(动态覆盖)。一个厂商刚上线了新模型,运行时刷新
dynamicModels,currentModels()就能返回最新列表,静态表不用改。
第二步:按协议派发到正确的"说话方式"
意图:不同模型用不同 API 协议(OpenAI 用 openai-completions,Anthropic 用 anthropic-messages)。要根据 model.api 找到对应的流实现。
📄 源码证据(接上)
typescript// 根据 model.api 找到对应的流实现 const single = typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined; const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>); const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api]; // 派发:把调用转到正确的流实现 const dispatch = (model, run) => { const streams = apiFor(model); if (!streams) { return lazyStream(model, async () => { throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`); }); } return run(streams); };
🧙
apiFor(model)干什么——给一个模型,返回它该用的流实现。
- DeepSeek 的模型
api是openai-completions→ 用openAICompletionsApi()那套- Anthropic 的模型
api是anthropic-messages→ 用anthropicMessagesApi()那套找不到怎么办?
dispatch会抛ModelsError—— fail loud,不静默失败(附录 E 的原则)。这避免了"模型配错了却偷偷跑出诡异结果"。
第三步:打包成 Provider 对象
意图:把上面两步封装成一个符合 Provider 接口的对象返回。外部只看到统一接口,内部复杂性(合并、派发)全被闭包吃掉。
📄 源码证据(接上)
typescriptreturn { id: input.id, name: input.name ?? input.id, baseUrl: input.baseUrl, headers: input.headers, auth: input.auth, getModels: currentModels, // 对外只暴露"当前模型表" refreshModels: /* 动态刷新逻辑 */, // stream/streamSimple 通过 dispatch 实现 };
🐣 小a恍然大悟:"所以
createProvider是个闭包工厂——它返回的对象,内部状态(baselineModels、dynamicModels)被闭包封住,外部只能通过 getModels() 访问?"🧙 "对!这就是闭包的威力——封装。 不用 class,用闭包照样实现『私有状态 + 公开方法』。pi 这里选了函数式风格,不是 OOP。三步合起来:维护模型表 → 按协议派发 → 打包成统一对象,这就是把『一个厂商的配置』变成『一个可用的 Provider』的全部秘密。"
17.4 对比两个真实 provider:最简 vs 最复杂
抽象讲完了,看真实的 provider 长什么样。老z挑了两个极端对比——最简单的 DeepSeek,和最复杂的 Anthropic。
📄 先看 Anthropic 的真实"配置单"(
packages/ai/src/providers/anthropic.ts:38):typescriptexport function anthropicProvider(): Provider<"anthropic-messages"> { return createProvider({ id: "anthropic", name: "Anthropic", baseUrl: "https://api.anthropic.com", auth: { apiKey: anthropicApiKeyAuth(), // API Key 责任链 oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)", load: loadAnthropicOAuth }), }, models: Object.values(ANTHROPIC_MODELS), // 内置模型清单 api: anthropicMessagesApi(), // 自有协议实现 }); }对照配置单读一遍:
id(anthropic)、name、baseUrl、auth(apiKey + oauth 两种鉴权)、models(模型列表)、api(它自己的anthropic-messages协议)。"一个厂商 = 一份配置单"在真实源码里就是字面意思。
最简的:DeepSeek(15 行)
📄 源码证据
packages/ai/src/providers/deepseek.ts(全 15 行)typescriptimport { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { envApiKeyAuth } from "../auth/helpers.ts"; import { createProvider, type Provider } from "../models.ts"; import { DEEPSEEK_MODELS } from "./deepseek.models.ts"; export function deepseekProvider(): Provider<"openai-completions"> { return createProvider({ id: "deepseek", name: "DeepSeek", baseUrl: "https://api.deepseek.com", auth: { apiKey: envApiKeyAuth("DeepSeek API key", ["DEEPSEEK_API_KEY"]) }, models: Object.values(DEEPSEEK_MODELS), api: openAICompletionsApi(), // 复用 OpenAI 协议! }); }
🧙 为什么 DeepSeek 这么短?
因为它兼容 OpenAI 协议——
api: openAICompletionsApi()直接复用了 OpenAI 的实现。DeepSeek 不用自己造一套 API 适配,白嫖 OpenAI 的。15 行里,真正"DeepSeek 特有"的只有:
id、name、baseUrl、auth(环境变量名)、models(它的模型列表)。差异被抽象吃掉了,剩下的是纯配置。
最复杂的:Anthropic(自带 OAuth 责任链)
再看 Anthropic——它有自己的协议、自己的 OAuth、复杂得多的鉴权:
📄 源码证据
packages/ai/src/providers/anthropic.ts(节选鉴权部分)typescriptfunction anthropicApiKeyAuth(): ApiKeyAuth { return { resolve: async ({ ctx, credential }) => { // 1️⃣ 先用存档凭据 if (credential?.key) { return { auth: { apiKey: credential.key }, source: "stored credential" }; } // 2️⃣ 找 AUTH_TOKEN(Bearer 方式) const authToken = await ctx.env(ANTHROPIC_AUTH_TOKEN_ENV); if (authToken) { return { auth: { headers: { Authorization: `Bearer ${authToken}` } }, source: ANTHROPIC_AUTH_TOKEN_ENV, }; } // 3️⃣ 找 OAuth token / API key(两个环境变量都试) for (const envVar of [ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV]) { const apiKey = await ctx.env(envVar); if (apiKey) return { auth: { apiKey }, source: envVar }; } // 4️⃣ 都没有 → 显式返回 undefined(不偷偷降级) return undefined; }, }; }
🧙 这段代码展示了"责任链模式":
Anthropic 找一个 key,要依次试四条路:
- 存档凭据(用户之前登录存的)
AUTH_TOKEN环境变量(Bearer 方式)OAUTH_TOKEN环境变量API_KEY环境变量一路往下试,任一条成功就返回,全失败就显式返回 undefined(告诉调用方"我没找到",而不是偷偷用空值继续)。
对比 DeepSeek:DeepSeek 的 auth 是
envApiKeyAuth("...", ["DEEPSEEK_API_KEY"])——一行搞定,只找一个环境变量。Anthropic 复杂 4 倍,因为它支持多种登录方式。
对比表:抽象吃掉了什么
| 维度 | DeepSeek | Anthropic |
|---|---|---|
| 协议 | 复用 OpenAI(openAICompletionsApi) | 自有(anthropic-messages) |
| 鉴权 | 单环境变量 | OAuth + 4 路 env 责任链 |
| 行数 | 15 行 | 约 50 行 |
| 用到的模式 | Factory | Factory + Chain of Responsibility |
🐣 小a顿悟:"所以加一个新厂商,我只要写一个
xxxProvider()函数,填好配置,根本不用动 createProvider?"🧙 "对! 这就是开闭原则——对扩展开放(加新 provider),对修改封闭(不动 createProvider)。pi 能支持 近 40 家厂商,核心就靠这个。"
17.5 统一调用入口:上层怎么用
有了 provider,上层怎么调?老z带小a看统一调用入口。
ProviderStreams:每个 provider 必须实现的两个方法
📄 源码证据
packages/ai/src/types.ts:236typescriptexport interface ProviderStreams { // 完整流式(暴露所有细节) stream(model, context, options?: StreamOptions): AssistantMessageEventStream; // 简化流式(屏蔽思考等差异,第 2 章讲的"统一接口") streamSimple(model, context, options?: SimpleStreamOptions): AssistantMessageEventStream; }
🧙 两个方法的区别:
stream:完整版,暴露厂商的所有细节(包括思考过程、原始事件)streamSimple:简化版,把"思考"等厂商差异抹平,给你统一的事件流回忆第 2 章——"思考强度"各厂商格式不同(10 种 thinkingFormat)。
streamSimple就是那个"抹平差异"的层:你用 streamSimple,就不用管底层是哪家、思考格式是啥。
上层的调用流程
上层代码:
models.streamSimple(model, context, {
messages, // 统一消息格式
tools, // 统一工具格式
reasoning, // 思考强度(第 2 章)
})
↓
models 找到 model 对应的 provider
↓
provider.streamSimple(...) ← 翻译成厂商格式,发请求
↓
收到厂商的流式响应
↓
翻译回统一格式,吐给上层🧙 关键:上层只调
streamSimple,不用知道 provider 是谁。 这就是第 3 章讲的"统一接口"——管你哪家,都是一套调用。
17.6 消息格式互转:最难的"翻译"
统一接口最大的难点,是消息格式互转。各家的消息结构天差地别(第 3 章的巴别塔表),pi 怎么把它们统一?
这部分代码在 api/ 目录下,核心是 transform-messages.ts。老z不展开全部细节(那是几千行),只讲清它在干什么:
🧙 transform-messages 的职责:
上层的统一消息 厂商的原生消息 (user/assistant/toolResult) → (OpenAI 的 messages / Google 的 contents / ...) ← (反向:厂商响应翻译回统一格式)
- 正向:你给统一格式 → 翻译成厂商要的(比如把 toolResult 转成 OpenAI 的 tool 角色消息)
- 反向:厂商返回 → 翻译回统一格式(比如把 Anthropic 的 content_block 翻译成统一的 assistant 事件)
这是统一抽象里代码量最大的部分——因为每家方言都得写翻译规则。但好在,这些规则是声明式的、可维护的,加新厂商只加新规则,不改核心。
🐣 小a感叹:"所以统一接口的『干净』,是靠底下大量翻译代码换来的?"
🧙 "对。这就是抽象的代价——上层越简单,下层越复杂。 pi-ai 让你一行 streamSimple 调任何厂商,背后是几千行翻译代码。抽象把复杂度从『调用方』搬到了『抽象内部』。"
17.7 鉴权解耦:provider 不直接读环境变量
回头看 Anthropic 那段 auth 代码,有个精妙的设计值得专门讲——provider 不直接读环境变量,而是声明式描述"我需要什么"。
🧙 传统做法 vs pi 的做法:
传统(直接读):
typescriptconst apiKey = process.env.ANTHROPIC_API_KEY; // 直接读问题:测试时难 mock,且 provider 耦合到环境。
pi 的做法(声明式 + context):
typescriptconst apiKey = await ctx.env(ANTHROPIC_API_KEY_ENV); // 通过 context 读
ctx.env()是个抽象层——它可能读环境变量,也可能读配置文件,也可能在测试时返回假值。provider 不关心"环境变量从哪来",只声明"我需要这个变量"。好处:可测试(测试时注入假 context)、可配置(来源可换)、解耦。
17.8 代价:抽象不是免费的
老z不让小a只看好处。pi-ai 的这套抽象,代价是什么?
🧙 代价一:差异黑洞
看
types.ts里AnthropicMessagesCompat、OpenAIResponsesCompat、BedrockCompat一堆子接口——光thinkingFormat就有 10 种格式。每家电厂的怪癖都被塞进配置。抽象的上层干净,但抽象内部是个不断膨胀的"差异黑洞"。📄 源码证据
types.ts:540的 thinkingFormat 枚举typescriptthinkingFormat?: | "openai" // 用 reasoning_effort | "openrouter" // 用 reasoning: { effort } | "deepseek" // 用 thinking: { type } + reasoning_effort | "together" // 用 reasoning: { enabled } | "zai" // 用 thinking: { type } | "qwen" // 用 enable_thinking: boolean | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
🧙 代价二:性能损耗
每次调用都要经过"翻译"——统一格式 → 厂商格式 → 发请求 → 响应 → 翻译回统一格式。这有 CPU 开销(虽然相比网络 IO 可忽略)。
🧙 代价三:学习曲线
新开发者看 pi-ai,要理解 Provider、Api、Compat、Stream、SimpleStream 一堆概念,门槛不低。
🐣 小a记下:"所以统一接口的收益(可替换、可复用)是用复杂度换的。如果你只用一家厂商,这套抽象其实是过度设计——直接用官方 SDK 更简单。"
🧙 "完全正确!这就是 Part III 要讲的——你的需求决定该不该上这套抽象。"
本章小结
🐣 小a的第十七课
┌──────── pi-ai 的统一抽象 ────────┐ │ │ │ • 核心:一个工厂 + 一个接口 │ │ createProvider + Provider │ │ │ │ • Provider = 厂商的标准画像 │ │ id/name/baseUrl/auth/models │ │ + stream/streamSimple │ │ │ │ • createProvider = 闭包工厂 │ │ 合并静态+动态模型 │ │ 按 model.api 派发到流实现 │ │ │ │ • 加新厂商 = 写配置单 │ │ (DeepSeek 15 行, │ │ Anthropic 约 50 行带责任链) │ │ → 开闭原则 │ │ │ │ • 统一入口:streamSimple │ │ 抹平思考等厂商差异 │ │ │ │ • 代价:差异黑洞 + 学习曲线 │ └──────────────────────────────────┘
关键认知:pi-ai 的统一抽象,本质是"用工厂模式 + 配置化"把厂商差异吃掉。上层干净,下层复杂。加厂商容易,但维护这套抽象要持续投入。
课后实验
- 读一个 provider:打开
packages/ai/src/providers/下任意一个厂商(比如zai.ts或moonshotai.ts),对照 DeepSeek 的 15 行,看它的配置单有什么不同。 - 找责任链:在
anthropic.ts里,数数 auth 的 resolve 函数试了几条路。画一张"找 key 责任链"的流程图。 - 感受差异黑洞:打开
types.ts,搜Compat,数数有多少个厂商特定的兼容接口。体会"抽象内部有多复杂"。
下一章:pi-ai 解决了"怎么调 LLM"。但光会调还不够——怎么让模型会调工具、会循环?这是 pi-agent-core 干的事。 → 第 18 章 · pi-agent-core 源码