Zero 架构 · 第十课
第九课讲每个 internal/providers/<家>/ 是一个防腐层 —— 装的是各家不一样的怪癖。
这一课翻到硬币另一面:internal/providers/providerio/ 装的是所有适配器完全一样的那部分 ——
重试、鉴权、HTTP 客户端、SSE 解析、脱敏。Anthropic / Gemini / OpenAI / Codex 的 StreamCompletion
里,凡是「发一个 POST、读一条 SSE 流、把密钥擦掉」的动作,走的都是这一个包。
对齐 mission:防腐层保证「加一家 provider 只写一个翻译器」,但如果重试、超时、脱敏也让每家各写一遍, 就会出现各家行为不一致的 bug(注释里点名:以前只有 OpenAI 会重试,Anthropic/Gemini 遇到 429 直接 把首个失败抛出去)。把这些横切策略抽进一个共享层,是「一致性」的来源。
这一个原则解释了这个包里几乎每一个决定。一次 completion 请求是非幂等的:请求一旦到达服务器, 就可能已经在生成一段要计费的补全。所以「失败了要不要重试」永远先问一句:这次失败,能证明服务器 根本没接活吗?
SendWithRetry
retry.go:43
是所有 provider 共享的唯一重试策略。它只重试三个状态 ——
ShouldRetryStatus
retry.go:108:
429 Too Many Requests // 被限流:服务器明确拒收
503 Service Unavailable // 服务不可用:明确拒收
529 (Anthropic overloaded) // 过载:明确拒收
退避 Backoff
retry.go:115 默认 attempt×400ms,
但服务器给了 Retry-After 就听它的(RetryAfter 解析秒数或 HTTP 日期,
retry.go:136),
且整体封顶 30s(maxBackoff)—— 防止一个恶意/离谱的 Retry-After 把 agent 卡上几分钟。
退避途中 ctx 被取消能立刻脱身。
SendWithAuthRetry
auth.go:27
包在 SendWithRetry 外面,多管一件事:OAuth 令牌可能过期。它在收到 401 时强制刷新令牌、
重试恰好一次
auth.go:63。
为什么 401 可重?因为 401 意味着服务器拒绝了请求、没有处理补全 —— 和 429/503 是同一个「没接活 → 重放安全」的保证。
SendWithRetry 可能已经把一个没带鉴权头的请求发出去了(泄漏了路径/请求体)。所以令牌在
发请求之前拿到,错误就直接返回。② 两种鉴权绝不同时发:withBearer
auth.go:75 在用 OAuth 时把
APIKey 清空,保证 Authorization 头里不会既有 bearer 又有 key。
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、某端点的自定义头 —— 全靠填这张表表达,
而不是各写一段拼头逻辑。
sharedHTTPClient
providerio.go:161
是所有没自带 client 的 provider 共用的那一个,专门调优来治一类挂死:Go 的连接池会复用 keep-alive 连接,
而复用到一条服务器/NAT 已经悄悄丢弃的连接时,因为是 POST(非幂等),Go 不会自动换新连接重发 ——
于是永久等一个永远不来的响应。三个旋钮:
ResponseHeaderTimeout = 120s:限定「发完请求 → 收到首个响应头」的间隔,让复用到的死连接快速失败然后重拨。它不限制 200 之后的流式,所以慢首字/长推理不受影响。IdleConnTimeout = 30s:让跨越停顿而变空闲的连接被关掉重拨,而非陈旧后复用。DisableKeepAlives = (GOOS == "darwin"):只在 macOS 上彻底关池化。因为还有一类「没死但严重降速」的复用连接,从流内部无法与「后端本来就慢」区分,唯一可靠的修法就是在这个唯一复现过该 bug 的平台上把池化整个拿掉。每请求一次 TCP+TLS 握手只多几十毫秒,远比几分钟的挂死划算。
ScanSSEDataWithContext
providerio.go:256
在阻塞式扫 SSE 的同时,用两个计时器守着流。区别很微妙:
DefaultStreamIdleTimeout
providerio.go:56):只在真正沉默时触发。SSE 的 keep-alive 注释行(如 OpenRouter 的 : OPENROUTER PROCESSING)会重置它 —— 一个还在心跳的上游不算「死」。ContentStallTimeout
providerio.go:68):只被真实数据行重置,keep-alive 不重置它。治的是「一直心跳、却永不产出」的上游(chatgpt/gpt-5.x、ollama 推理模型上观察到)—— 光靠 idle 看门狗永远不会触发,agent 就无限挂着。
两个看门狗任一触发,都调 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。
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.go 的 ShouldRetryStatus 那条注释背下来 —— 「非幂等 POST,只重放证明没接活的失败」是理解整个包的钥匙。
再看 auth.go 怎么把 401 归进同一类安全重放。最后跳到 ScanSSEDataWithContext,对读两个看门狗的重置规则 ——
idle 被心跳重置、content 只被真数据重置,这一处不对称就是治「心跳不产出」挂死的全部智慧。
CollectStreamWithOptions(第一课)怎么把 StreamEvent 流抽干成 Text + ToolCalls 的?」、
「沙箱引擎怎么在 executeToolCall(第二课)里真正隔离命令执行?」,
或者「会话怎么 /fork、specialist 子会话(EventSessionChild)怎么挂到父会话上?」