Zero 架构 · 第九课

Provider 适配层:把厂商的怪癖挡在门外

第一课点过那条最重要的接缝:整个循环只跟一个 Provider 接口对话,从不点名任何厂商。 这一课把它彻底拆开 —— 看 Zero 怎么用一个只有一个方法的接口 + 一小撮规范化词汇, 让循环、护栏、压缩、TUI、会话全都对「后面接的是 Anthropic 还是 Gemini 还是某个自建 OpenAI-兼容端点」一无所知。这就是支持 25+ provider 的全部秘密。

对齐 mission:如果你要自己写一个 coding agent,这一课是最该抄的架构决策。选对这一条边界, 后面每加一家模型都是「写一个适配器」;选错了,厂商的怪癖会渗进你代码的每个角落。

接缝:一个方法,进出都是规范化的值

type Provider interface {                                    // types.go:209
    StreamCompletion(ctx, request CompletionRequest) (<-chan StreamEvent, error)
}

进去的是规范化的 CompletionRequest{Messages, Tools, ReasoningEffort} types.go:197, 出来的是一条 StreamEvent 的 channel。循环只认这两个类型。StreamEventType 是一个极小的封闭词汇表 —— types.go:37:

text · reasoning · tool-call-start · tool-call-delta · tool-call-end
tool-call-dropped · usage · done · error

无论后端的线上协议长什么样,适配器必须把它翻译成恰好这几种事件。上游的循环、 第六课的 TUI、第四课的护栏,看到的永远是这套统一词汇 —— 它们不知道、也不需要知道厂商是谁。

适配器 = 防腐层(anti-corruption layer)

每个 internal/providers/<家>/ 目录就是一个把厂商怪癖隔离在内的防腐层。 以 Anthropic 为例,它的 StreamCompletion provider.go:150 做两个方向的翻译:

入向:规范化请求 → 厂商线上格式

anthropicRequest / mapMessages provider.go:361provider.go:411 把统一的 Message 列表塑成 Anthropic 的 Messages API 形状:system 被单独抽出 (Anthropic 的 system 不在 messages 数组里)、thinking/redacted_thinking 块按其 content-block 模型还原。

出向:厂商 SSE 事件 → 规范化 StreamEvent

emitPayload provider.go:237 是一台翻译机:把 Anthropic 的 content_block_delta 里的 text_delta 译成 StreamEventTextinput_json_delta 译成 tool-call-delta、thinking_delta 译成 reasoning、message_stop 译成 done……

switch payload.Type {
case "content_block_delta":
    switch payload.Delta.Type {
    case "text_delta":       // → StreamEventText
    case "input_json_delta": // → tool-call-delta
    case "thinking_delta":   // → reasoning
    }
case "message_stop":         // → StreamEventDone
case "error":                // → StreamEventError(已脱敏)
}
怪癖被关在哪 Anthropic 的 content-block 流模型、Gemini 的 thoughtSignature、OpenAI 的 reasoning_effort 和 Codex 的特殊鉴权头 —— 每一样都只活在自己那个适配器里。 循环侧看到的永远是那 9 种规范化事件。加一家新 provider = 新写一个这样的双向翻译器, 其余子系统一行都不用改

规范化的几处硬功夫

工厂:连「选哪个适配器」也隔离掉

providers.New factory.go:38 按解析出的 providerKind 挑适配器。有个考究的特例:Codex(ChatGPT 后端)不按 kind 分, 而按目录 catalog id 分叉 —— factory.go:47 因为它的后端对每个缺 originator + chatgpt-account-id 头的请求都 401, 必须用 Codex 风味的 provider;而其他「OpenAI-兼容」端点走普通 openai.New 不受影响。 选择逻辑的怪癖,也被关在工厂里。

为什么这是全库最重要的边界 循环(1)、护栏(4)、压缩(3)、TUI(6)、会话(7/8)—— 全部写在这一个小接口 + 9 种事件之上。 它们从不 import 任何具体 provider 包。这就是依赖倒置的教科书落地: 高层策略依赖抽象,厂商细节依赖抽象去实现自己。抄 agent 架构,先抄这条缝。

动手回忆

Provider 接口的规范化「出向词汇」是什么?

要新增支持一家新模型厂商,需要动哪些代码?

为什么各家不同的 token 用量要映射进统一的 Usage 结构?

接下来该读的一手源码

less +175 ../zero/internal/zeroruntime/types.go        # StreamEvent + StreamEventType 封闭词汇
less +134 ../zero/internal/zeroruntime/types.go        # Usage 的规范化不变量
less +38  ../zero/internal/providers/factory.go        # New:按 kind 选适配器 + Codex 特例
less +237 ../zero/internal/providers/anthropic/provider.go # emitPayload:SSE→规范事件翻译机
less +361 ../zero/internal/providers/anthropic/provider.go # anthropicRequest:规范请求→线上格式

读的顺序:先把 StreamEventType 那 9 个常量背下来 —— 那是循环眼里世界的全部。再挑 anthropic/provider.goemitPayload 一个 switch,看它怎么把厂商 SSE 逐条译成规范事件。最后翻一眼 factory.go,确认连「选谁」都没泄漏进上游。

我是你的老师 —— 随时问我。 适合现在追问: 「providerio 那层(retry.go/headers.go/auth.go)在所有适配器间共享了什么?」、 「CollectStreamWithOptions(第一课)怎么把这条事件流抽干成 Text+ToolCalls 的?」, 或者「中途 /model 换 provider 时,前一家的 ReasoningBlocks 为什么必须被忽略?」