Zero 架构 · 第十课

providerio:适配器各写一遍的那些事

第九课讲每个 internal/providers/<家>/ 是一个防腐层 —— 装的是各家不一样的怪癖。 这一课翻到硬币另一面:internal/providers/providerio/ 装的是所有适配器完全一样的那部分 —— 重试、鉴权、HTTP 客户端、SSE 解析、脱敏。Anthropic / Gemini / OpenAI / Codex 的 StreamCompletion 里,凡是「发一个 POST、读一条 SSE 流、把密钥擦掉」的动作,走的都是这一个包。

对齐 mission:防腐层保证「加一家 provider 只写一个翻译器」,但如果重试、超时、脱敏也让每家各写一遍, 就会出现各家行为不一致的 bug(注释里点名:以前只有 OpenAI 会重试,Anthropic/Gemini 遇到 429 直接 把首个失败抛出去)。把这些横切策略抽进一个共享层,是「一致性」的来源。

贯穿全课的一条铁律:completion POST 不可重放

这一个原则解释了这个包里几乎每一个决定。一次 completion 请求是非幂等的:请求一旦到达服务器, 就可能已经在生成一段要计费的补全。所以「失败了要不要重试」永远先问一句:这次失败,能证明服务器 根本没接活吗?

retry.go:只重试「服务器明确没接活」的状态

SendWithRetry retry.go:43 是所有 provider 共享的唯一重试策略。它重试三个状态 —— ShouldRetryStatus retry.go:108:

429 Too Many Requests   // 被限流:服务器明确拒收
503 Service Unavailable  // 服务不可用:明确拒收
529 (Anthropic overloaded) // 过载:明确拒收
为什么别的失败一律不重 其他 5xx(500/502/504)不重:一个 504 网关超时可能发生在上游已经产出计费补全之后, 重放就是重复计费。传输层错误(网络断/超时)也不重 retry.go:65:POST 的连接失败代表 服务器没收到 —— 可能请求已到、补全正在生成,只是响应回不来了。唯有 429/503/529 能证明请求没被受理, 才安全可重。流一旦开始(200 之后)也永不重发。

退避 Backoff retry.go:115 默认 attempt×400ms, 但服务器给了 Retry-After听它的(RetryAfter 解析秒数或 HTTP 日期, retry.go:136), 且整体封顶 30s(maxBackoff)—— 防止一个恶意/离谱的 Retry-After 把 agent 卡上几分钟。 退避途中 ctx 被取消能立刻脱身。

auth.go:401 也能安全重试一次 —— 同一条铁律

SendWithAuthRetry auth.go:27 包在 SendWithRetry 外面,多管一件事:OAuth 令牌可能过期。它在收到 401强制刷新令牌、 重试恰好一次 auth.go:63。 为什么 401 可重?因为 401 意味着服务器拒绝了请求、没有处理补全 —— 和 429/503 是同一个「没接活 → 重放安全」的保证。

两处考究的安全细节先解析鉴权、再派发 auth.go:40:如果把 resolver 放进请求回调里, 一旦它报错,SendWithRetry 可能已经把一个没带鉴权头的请求发出去了(泄漏了路径/请求体)。所以令牌在 发请求之前拿到,错误就直接返回。② 两种鉴权绝不同时发:withBearer auth.go:75 在用 OAuth 时把 APIKey 清空,保证 Authorization 头里不会既有 bearer 又有 key。

headers.go:一张表统一「鉴权头怎么拼」

AuthHeaders headers.go:8 把各家五花八门的鉴权方式收进一个结构,ApplyAuthHeaders headers.go:18 按统一优先级拼头: 自定义头覆盖默认头;有现成 AuthHeaderValue(OAuth)就直接用,否则用 API key + scheme 拼 (Bearer <key>);scheme 为 none/raw 时裸发 key(有些自建端点要的就是裸 key)。 Anthropic 的 x-api-key、OpenAI 的 Authorization: Bearer、某端点的自定义头 —— 全靠填这张表表达, 而不是各写一段拼头逻辑。

providerio.go:共享的 HTTP 客户端与 SSE 解析

① 一个「抗僵尸连接」的进程级客户端

sharedHTTPClient providerio.go:161 是所有没自带 client 的 provider 共用的那一个,专门调优来治一类挂死:Go 的连接池会复用 keep-alive 连接, 而复用到一条服务器/NAT 已经悄悄丢弃的连接时,因为是 POST(非幂等),Go 不会自动换新连接重发 —— 于是永久等一个永远不来的响应。三个旋钮:

这是一个 provider 无关的 bug 注释点明:因为大家共享同一个默认 transport,这个挂死在 chatgpt 和 ollama 上复现了。 把修法放在共享层 = 一次修好、全家受益。这正是共享层存在的意义。

② 双看门狗:idle vs content-stall

ScanSSEDataWithContext providerio.go:256 在阻塞式扫 SSE 的同时,用两个计时器守着流。区别很微妙:

两个看门狗任一触发,都调 cancel() 掐断在途请求(唤醒被 read 阻塞的读 goroutine),返回 ErrStreamIdle / ErrStreamStalled 两种可区分的错误 providerio.go:340。 即便 idleTimeout≤0(看门狗关掉),扫描 goroutine + select 循环照样跑,好让 ctx 取消永远被尊重。

③ 脱敏是共享层的边界职责

ClassifiedError providerio.go:382 把上游 HTTP 错误归一化成可操作的话术(401/403 → 「跑 zero auth」),每一条都先过 Redact providerio.go:423 擦掉密钥 —— 已知的 API key 直接替换,Bearer 后面形似令牌的词才擦(looksLikeToken providerio.go:411), 免得把上游帮助文本里的「Bearer authentication」也误伤。这和第二课工具结果在 registry 边界擦密钥、 第八课摘要 prompt 过 RedactString同一条纪律:密钥绝不流进错误消息、日志或发回 provider。

还有一个体贴的归一化:UpstreamUnreachable providerio.go:463 识别「本地 Ollama 代理云模型但连不上云端」这类连通性失败(502 + TLS handshake timeout + 具体 host), 把晦涩的 Go 传输错误重写成「请求根本没到模型 —— 是模型服务器到它上游的网络故障」,并指明去查 DNS/代理/VPN。

第九、十课合起来才是完整的 provider 层 第九课:防腐层 —— 每家不同的翻译逻辑,关在各自目录里。本课:共享层 —— 每家相同的横切策略(重试/鉴权/HTTP/SSE/脱敏),抽进 providerio。加一家新 provider: 你写那个「不同」的翻译器,然后调用这些「相同」的共享函数 —— 于是新 provider 天生就有了一致的 重试语义、抗挂死客户端、超时看门狗和密钥脱敏,一行都不用重写

动手回忆

为什么 SendWithRetry 重试 429/503 却不重试 504?

收到 401 时,SendWithAuthRetry 为什么敢重试一次?

SSE keep-alive 心跳行对两个看门狗分别有什么作用?

接下来该读的一手源码

less +43  ../zero/internal/providers/providerio/retry.go       # SendWithRetry + ShouldRetryStatus:哪些状态可重
less +27  ../zero/internal/providers/providerio/auth.go        # SendWithAuthRetry:401 强制刷新重试一次
less +18  ../zero/internal/providers/providerio/headers.go     # ApplyAuthHeaders:统一鉴权头优先级
less +161 ../zero/internal/providers/providerio/providerio.go  # sharedHTTPClient:抗僵尸连接的三个旋钮
less +256 ../zero/internal/providers/providerio/providerio.go  # ScanSSEDataWithContext:idle + content 双看门狗
less +382 ../zero/internal/providers/providerio/providerio.go  # ClassifiedError + Redact:边界脱敏

读的顺序:先把 retry.goShouldRetryStatus 那条注释背下来 —— 「非幂等 POST,只重放证明没接活的失败」是理解整个包的钥匙。 再看 auth.go 怎么把 401 归进同一类安全重放。最后跳到 ScanSSEDataWithContext,对读两个看门狗的重置规则 —— idle 被心跳重置、content 只被真数据重置,这一处不对称就是治「心跳不产出」挂死的全部智慧。

我是你的老师 —— 随时问我。 适合现在追问: 「CollectStreamWithOptions(第一课)怎么把 StreamEvent抽干成 Text + ToolCalls 的?」、 「沙箱引擎怎么在 executeToolCall(第二课)里真正隔离命令执行?」, 或者「会话怎么 /fork、specialist 子会话(EventSessionChild)怎么挂到父会话上?」