Zero 架构 · 第九课
第一课点过那条最重要的接缝:整个循环只跟一个 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。循环只认这两个类型。StreamEvent 的
Type 是一个极小的封闭词汇表 ——
types.go:37:
text · reasoning · tool-call-start · tool-call-delta · tool-call-end
tool-call-dropped · usage · done · error
无论后端的线上协议长什么样,适配器必须把它翻译成恰好这几种事件。上游的循环、 第六课的 TUI、第四课的护栏,看到的永远是这套统一词汇 —— 它们不知道、也不需要知道厂商是谁。
每个 internal/providers/<家>/ 目录就是一个把厂商怪癖隔离在内的防腐层。
以 Anthropic 为例,它的 StreamCompletion
provider.go:150
做两个方向的翻译:
anthropicRequest / mapMessages
provider.go:361、
provider.go:411
把统一的 Message 列表塑成 Anthropic 的 Messages API 形状:system 被单独抽出
(Anthropic 的 system 不在 messages 数组里)、thinking/redacted_thinking 块按其
content-block 模型还原。
emitPayload
provider.go:237
是一台翻译机:把 Anthropic 的 content_block_delta 里的 text_delta 译成
StreamEventText、input_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(已脱敏)
}
thoughtSignature、OpenAI 的
reasoning_effort 和 Codex 的特殊鉴权头 —— 每一样都只活在自己那个适配器里。
循环侧看到的永远是那 9 种规范化事件。加一家新 provider = 新写一个这样的双向翻译器,
其余子系统一行都不用改。
Usage
types.go:134 规定:
InputTokens 是总提示大小,CachedInputTokens/CacheWriteTokens 是它的
子集。每个适配器把自家五花八门的用量字段映射进这一个结构 —— 于是第三课的压缩阈值、预算统计
在所有厂商下算法完全一致。reasoning_effort、Anthropic/Gemini 的 thinking budget),
不支持推理的模型直接忽略。Data 是解码后的裸图字节(无 base64、无
data: 前缀),各适配器编码成自家格式;NormalizeImageMediaType 在边界把 MIME
收敛到白名单。types.go:216ReasoningBlocks(Anthropic thinking 块)、
ToolCallSignature(Gemini thoughtSignature)对循环是黑盒,但被透传保存 ——
中途换 provider 时,外来的块会被忽略。types.go:190StreamEventToolCallDropped —— 一个统一信号,而不是各家一种错法。
providers.New
factory.go:38
按解析出的 providerKind 挑适配器。有个考究的特例:Codex(ChatGPT 后端)不按 kind 分,
而按目录 catalog id 分叉 ——
factory.go:47
因为它的后端对每个缺 originator + chatgpt-account-id 头的请求都 401,
必须用 Codex 风味的 provider;而其他「OpenAI-兼容」端点走普通 openai.New 不受影响。
选择逻辑的怪癖,也被关在工厂里。
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.go 的 emitPayload 一个 switch,看它怎么把厂商 SSE
逐条译成规范事件。最后翻一眼 factory.go,确认连「选谁」都没泄漏进上游。
providerio 那层(retry.go/headers.go/auth.go)在所有适配器间共享了什么?」、
「CollectStreamWithOptions(第一课)怎么把这条事件流抽干成 Text+ToolCalls 的?」,
或者「中途 /model 换 provider 时,前一家的 ReasoningBlocks 为什么必须被忽略?」