Invalidate unavailable accounts in memory before asynchronous persistence, evict account WebSockets, and classify explicit auth, quota, ban, and workspace failures for immediate rotation. Separate account-scoped installation identity from client session semantics, preserve logical IDs across failover, clear opaque account state, remove forced HTTP/1.1 configuration, and document the lifecycle rules. Co-authored-by: Codex <noreply@openai.com>
41 KiB
Responses 请求生命周期控制器设计
状态:Request → Attempt → Stream 三层洋葱生命周期、明确账号事实的运行时秒级失效,以及单一 HTTP 自动协商路径已经实现;不存在新旧双路径或兼容层。本文“Responses WebSocket v2 与 HTTP/2 低延迟传输审计”一节中的预热、快路径预算、熔断与 HTTP/2 PING 仍是尚未落地的传输目标,不能把这些参数误认为当前生产行为。
本文回答一个具体问题:Responses 代理能否像 Koa 洋葱模型一样,让身份、历史恢复、账号恢复、会话策略、用量和遥测等模块只处理自己的入口与出口,并按固定顺序组合,而不把同一规则散落在请求过程中。
结论是可以,而且值得做。但目标不能是单层 before / await next / after。当前代理存在响应提交前后的不可逆边界,并且一次请求可能尝试多个账号,因此需要 Request、Attempt、Stream 三层嵌套生命周期。
重构结果
重构前分散在非流式请求、流式建连、已提交长流和客户端错误编码中的生命周期编排,已经收敛到统一的类型化生命周期:
| 范围 | 当前归属 | 责任 |
|---|---|---|
| Request | dispatch/lifecycle/request.rs |
统一模型、身份、history/session plan、controller enter 和候选冻结 |
| Attempt | dispatch/lifecycle/attempt.rs、contract.rs |
统一单账号尝试、typed observation、decision、重试与提交边界 |
| Stream | dispatch/lifecycle/stream.rs、finalizer.rs |
统一 canonical event、终态、取消、shutdown 和 exactly-once finalize |
| 功能规则 | dispatch/controllers/<feature>/ |
每个功能唯一拥有自己的规则、状态和 lifecycle hook |
| 协议事实 | dispatch/transport/ |
将上游响应、首帧和流事件规范化为 typed facts |
| 路由与失败 | dispatch/routing/、failure/ |
通用候选/history 路由,以及失败事实聚合 |
dispatch/service.rs 和 dispatch/stream/lifecycle.rs 是 complete 与 stream 两种入口适配器:它们选择请求模式、调用同一 enter_request 和 AttemptRunner,再把已建立的流交给统一 Stream lifecycle。两者不是两套业务生命周期,也不得拥有功能规则。dispatch/stream/live.rs 是已提交流的传输执行器,终态统一交给 lifecycle finalizer,不构成兼容路径。
设计边界
该控制器模型只属于 dispatch,不新增顶层 application 层。
- API 层继续负责鉴权提取、HTTP/WebSocket 协议解析和最终编码。
- request ID、访问日志、连接排空继续使用 Tower/Axum middleware。
fleet继续拥有账号池、quota 和账号状态。upstream继续拥有 Codex 协议、HTTP/SSE 和 WebSocket 传输。telemetry继续保存已经确定的事实,不参与调度决策。- controller 不成为运行时任意安装、任意排序的插件系统;顺序是静态架构契约。
v1/* 适用范围与单功能不越界原则
洋葱模型只应用于代理业务接口 v1/*,包括 HTTP/SSE Responses、Responses WebSocket 和 review 变体。管理端 /api/admin/*、/healthz、静态资源和连接排空继续使用现有 API 层与 Tower/Axum middleware,不挂入这套业务洋葱。
本架构确立一条强制原则:
单功能单归属:一个功能的业务语义只能由一个 controller 拥有。其他层只能传递事实、执行通用控制流、适配协议或持久化数据,不得再次识别、推断或决定该功能。
以 cyber_policy 为例,适用请求、session key、失败识别、账号排除、CAS 清理和状态转换全部属于 controllers/cyber_policy/。它可以暴露 Request、Attempt、Stream、Finalize 四类窄 hook,但不应把同一规则复制到 API、transport、lifecycle 或 store;是否还能 retry 只由 lifecycle 的通用 commit/candidate contract 决定。
边界必须满足以下规则:
controller唯一拥有本功能的规则、状态机、阈值、typed classification、decision 和 scope;不能选择具体的下一个账号、编码 HTTP/SSE/WebSocket 或直接调用其他 controller。跨 controller 的唯一固定优先级由ControllerSet声明。lifecycle只拥有固定顺序、enter/leave、短路、逆序 unwind、retry 循环、commit 边界和 exactly-once finalize;不得出现if cyber_policy或功能阈值。transport/upstream只把原始 HTTP、SSE、WebSocket 输入规范化为通用事实,例如状态码、错误码、terminal、usage;不得解释事实的业务后果。api只解析请求,并编码ResponseDispatchResponse、ResponseDispatchStream或包含ClientFailure的ResponseDispatchError;不得重新识别 cyber、quota、history 等功能。store只提供原子读写、CAS、TTL 和数据映射;业务专用 store trait 可以由 controller 定义,基础设施实现不能拥有业务阈值和状态转换。- controller 之间不得横向调用;共享内容只能是没有业务含义的协议事实和值对象,不能把共享业务规则藏进
utils、common或 helper。
允许的唯一挂载点是静态 composition root ControllerSet:它负责构造 controller、注入依赖、声明固定优先级,并通过类型化方法分发各阶段 hook。它只能调用 owner 暴露的分类、effect 与 decision,不能替 owner 构造功能专用 decision。业务代码不在各个入口或尝试循环中手工挂载功能。
已实现目录与边界
当前源码采用以下真实目录;目录本身就是实现边界,不另设抽象目标树:
backend/src/dispatch/
├── lifecycle/
│ ├── mod.rs 洋葱组合入口
│ ├── request.rs Request enter/leave
│ ├── attempt.rs AttemptRunner
│ ├── pipeline.rs 唯一 retry/commit 编排器
│ ├── contract.rs typed observation、decision 与提交契约
│ ├── stream.rs canonical stream 状态与终态
│ ├── finalizer.rs exactly-once finalizer
│ └── trace.rs 请求与尝试 trace
├── controllers/
│ ├── mod.rs
│ ├── cyber_policy/
│ │ ├── mod.rs
│ │ ├── types.rs scope 与状态值对象
│ │ └── store.rs 原子状态存取
│ ├── history.rs
│ ├── quota.rs
│ ├── account_failure.rs
│ ├── account_state.rs 运行时失效与 WS 驱逐的共享 effect 执行器
│ ├── cloudflare.rs
│ ├── affinity.rs
│ ├── usage.rs
│ └── telemetry/
│ ├── mod.rs
│ └── events.rs
├── routing/ 通用候选 ledger;不拥有功能策略
├── transport/ 账号调用、prefetch、observation、canonical event
├── failure/ 通用失败聚合与 SSE 终态事实
├── affinity/ 通用会话亲和基础设施
├── stream/
│ ├── lifecycle.rs stream 入口适配器
│ └── live.rs 已提交流传输执行器
├── errors.rs 通用 DispatchFailure 与 ClientFailure
└── service.rs v1/* complete facade 与共享依赖容器
目录规则是:功能代码向 controllers/<feature> 收敛;生命周期契约和控制流只能位于 lifecycle/;协议事实规范化只能位于 transport/;通用候选与失败聚合分别位于 routing/、failure/;API 不得反向进入 controller 的内部实现。
生产源码和测试必须物理分离:backend/src/ 不允许出现 #[cfg(test)]、mod tests 或 test-only helper;所有 contract、controller、transport 和集成测试只能放在 backend/tests/,并按生产边界组织。
本次重构没有保留兼容路径:不存在 compatibility re-export、feature flag 切换或新旧双路径。service.rs、stream/lifecycle.rs 与 stream/live.rs 仅按传输阶段承担入口或执行职责,不复制 lifecycle/controller 业务语义。
不越界验收
- 修改功能阈值、状态转换或 session 规则时,只修改该 controller 目录及其测试。
- 新增功能只允许新增一个 controller、
ControllerSet静态组合项和本模块测试,不修改 request/attempt/stream 主循环的业务判断。 - lifecycle contract test 只验证顺序和控制流;controller test 只验证功能语义;transport test 只验证事实规范化;API test 只验证统一 outcome 编码。
- 用
rg检查功能关键词:除 controller、自有 store/schema、测试和文档外,生产代码不得出现功能专用判断。 - 删除 controller 后,工程只能因静态
ControllerSet或类型引用失败;lifecycle、API、transport 和 store 中不能残留该功能的决策逻辑。 - 以下情况直接判定越界:功能名出现在 lifecycle 控制流条件分支、API 重判错误、store 根据业务阈值决策、多个 controller 互相调用、同一原始事件被多次解析。
三层生命周期
RequestLifecycle
enter: normalize/model -> local identity -> history/session plan -> candidate snapshot
AttemptLifecycle (每个候选账号一次)
enter: acquire -> quota verify -> history for account -> interval -> account boundary -> call
leave: normalize observation -> classify -> runtime invalidation -> retry/fail decision -> trace
StreamLifecycle (响应提交后)
enter: canonical decoder -> observers -> downstream encoder
leave: terminal/cancel -> Cloudflare -> usage -> affinity -> telemetry -> cyber -> lease release
start: return ResponseDispatchResponse or ResponseDispatchStream to API
leave: finalize one FinalOutcome immediately or when the stream body ends
进入顺序和有副作用的退出顺序都由静态 ControllerSet 明确声明。ControllerRequestScope 只保存 cyber、history、usage 的真实请求状态,不维护运行时 marker 栈;retry 或 rejection 不执行空 unwind,complete 与已提交 stream 才按固定逆序调用实际存在 leave 副作用的 owner。
三层的责任不同:
- Request 决定请求不变量和本次请求允许尝试的账号集合。
- Attempt 决定单账号调用是否被接受、同账号恢复、换下一个账号或立即失败。
- Stream 只处理已经越过提交边界的事件、下游交付和终态;此时不能再产生换号 decision。
流式请求有两个不同时间点,不能共用一个“最终结果”类型。API 在首帧提交边界拿到 ResponseDispatchStream;Request/Attempt 状态已经被消费并转移进后台任务持有的 StreamFinalizer,真正的 completed、failed 或 cancelled 要等 stream terminal 后才能形成 FinalOutcome 并逆序退出。
类型化上下文与结果
controller 不接收一个可随意写入的 Koa 风格字典,也不使用 HashMap<String, Any> 或无边界的 serde_json::Value 扩展包。公共 context 只保存跨模块稳定事实,每个 controller 通过窄输入或 capability view 读取所需字段。
实际核心类型:
struct RequestContext {
request: CodexResponsesRequest,
display_model: String,
controller_scope: ControllerRequestScope,
candidates: AccountAttemptLedger,
}
enum AttemptStep<'runner, 'dependencies> {
Open(OpenAttempt<'runner, 'dependencies>),
Committed(CommittedAttempt),
}
enum AttemptDecision {
Accept,
RetrySameAccount,
RetryNextCandidate { /* typed exhaustion */ },
Return(AttemptReturnKind),
}
enum StreamTerminal {
Completed { /* canonical response */ },
Incomplete { /* canonical response */ },
Failed { /* typed failure */ },
UpstreamClosed,
ProtocolError { /* detail */ },
DownstreamClosed,
Shutdown,
}
enum AttemptPipelineOutcome {
Established(EstablishedResponse),
Rejected(AttemptRejection),
}
账号状态、会话恢复和客户端错误映射只分类一次。API 消费 ResponseDispatchResponse、ResponseDispatchStream 或 ResponseDispatchError:非流式 payload 直接编码,stream body 绑定到对应 transport,typed failure 映射到 HTTP JSON、SSE 或 WebSocket。后台 lifecycle 只消费 StreamTerminal / FinalOutcome 做领域结算,不再重新识别 cyber_policy、quota、banned 或 history failure。
controller 契约
controller 分为两类:
- Decision controller:只在
OpenAttempt上返回 retry、accept 或 fail 等显式 decision;CommittedAttempt不暴露 decision API。 - Observer controller:只消费已经确定的事实。其 Redis、数据库或遥测失败默认是 best-effort,不能改写已经确定或已经发给客户端的 outcome。
错误也分为两类:
- Critical:无法建立正确请求语义,转为
DispatchFailure并触发逆序退出。 - BestEffort:记录警告并 fail-open,不阻塞正常请求。
账号可用性 effect 属于第三种更窄的 CriticalRoutingState:它不能改写已经观察到的上游 outcome,但必须在返回换号 decision 前完成内存池失效和 WebSocket 池摘除。PostgreSQL 只是该事实的持久副本,必须后台写入,不能位于换号的等待链上。遥测、Cookie 和普通 usage 持久化仍是 BestEffort,不能与这条关键路径混为一谈。
同层 controller 不直接调用另一个 controller,也不自行选择下一账号。lifecycle 是唯一解释 decision 的组件。这样模块只拥有规则,编排器只拥有顺序和控制流。
固定顺序
Request enter
- 请求规范化、模型解析和本地身份。
- history plan。
- 普通 affinity 与会话 recovery 查询;无依赖项可在同一 stage 并行。
- 合并 controller enter 结果,明确 preferred 和 excluded 账号集合。
- 冻结
AccountAttemptLedger。 - 建立请求级 trace/telemetry 外层作用域。
Attempt enter/leave
- 获取候选账号 lease。
- 必要时验证 quota。
- 为该账号准备 history;不能跨账号时返回 typed failure。
- 等待账号请求间隔。
- 从当前账号生成 token/account/Cookie 和稳定 installation ID;会话语义 ID 保持客户端原值。
- 发起上游调用。
- 统一规范化 HTTP、SSE 首帧和 WebSocket 错误为
AttemptObservation。 - 按唯一优先级分类 history、session policy、账号隔离和 transport retry。
- 产生
AttemptDecision,由唯一 pipeline 应用为AttemptApplyOutcome并记录 attempt trace。
classifier 优先级只能存在一处。complete、prefetch 与 committed stream 共同经过同一个 feature failure owner classifier;cyber_policy 必须先于通用 403、quota 或 5xx 分类。composition root 只能把不透明的分类结果交还 owner 产生 decision,其他模块不能再次调用 is_cyber_policy_* 或自行拼装 Cyber retry。
账号边界与会话 ID 边界
官方 Codex 源码将 identifier 分为三种完全不同的状态,lifecycle 不能用一个“账号作用域 identity”概念把它们混在一起:
| 类别 | 字段 | Attempt 规则 |
|---|---|---|
| 当前账号认证/指纹 | token、chatgpt-account-id、Cookie、installation ID |
每次按当前账号重建;installation ID 按账号稳定派生 |
| 上游 opaque 状态 | x-codex-turn-state、previous_response_id |
turn state 换号清除;previous ID 只留在原 owner,或在有完整 transcript 时删除并全量重放 |
| 客户端会话语义 | session/thread/conversation、turn、window、parent/forked thread、prompt_cache_key、x-client-request-id |
原样透传;不得 HMAC、随机化或随账号改写 |
官方客户端将 installation ID 持久化为安装级 UUID,所以它不是登录账号 ID;本工程按账号稳定派生是号池防关联策略。相反,官方 prompt_cache_key 默认取 session ID,x-client-request-id 使用 thread ID,证明它们属于会话连续性。代理可以把客户端原值投影到 HTTP/WS header,但不能生成第二份账号作用域值。
AccountScopedRequest 是 attempt 到 transport 的提交能力:只有当前账号认证、installation ID、Cookie 和 opaque state 已处理的请求才能调用上游。AccountScopedIdentity 被刻意收窄到 installation ID,不保存任何 session/thread/turn/window 值;换号时编译期可见的身份处理入口因此只有一个。
明确账号事实与秒切号边界
数据库中的 active 只是上一次持久化快照,不是当前账号可用性的最终真相。请求已经选中账号后,上游仍可能返回封禁、工作区停用、token 失效、额度耗尽或窗口限流。处理顺序固定为:
typed upstream fact
-> feature owner 唯一分类
-> 持有账号池 mutex 时线性化内存失效并移除现有 slots
-> 将同一状态写入有序 PostgreSQL 后台队列
-> 从 WebSocket 池摘除该账号的 idle/busy entries
-> OpenAttempt 返回 RetryNextCandidate
任何并发请求即使已经冻结了包含该账号的 candidate snapshot,在真正 acquire_candidate 时也必须重读运行时状态,因此内存失效完成后不能再租到旧账号。数据库连接池拥塞、行锁或写失败只能影响持久化收敛,不能让当前请求等待,也不能让其他请求继续看到旧 Active。账号运行状态后台写入按产生顺序消费,避免同一进程内的失败事实乱序落库。
账号事实矩阵来自当前工程、官方 Codex error/rate-limit 类型以及 /home/zyy/桌面/Codes/sub2api 的 OpenAI failover 实现交叉审计;只吸收明确的 OpenAI/Codex 信号,不复制 sub2api 的多平台、自定义状态码或连续次数产品策略:
| 上游事实 | owner 与运行时 effect | 当前 attempt |
|---|---|---|
account_deactivated、deactivated_workspace、明确 banned/suspended/organization disabled |
account_failure → Banned + WS 驱逐 |
立即下一账号 |
identity_verification_required |
account_failure → Disabled + WS 驱逐 |
立即下一账号 |
401、token_invalidated、token_revoked、refresh_token_invalidated、invalid_api_key、authentication_error |
account_failure → Expired + WS 驱逐 |
立即下一账号 |
insufficient_quota、payment_required、workspace_*_credits_depleted 或普通 402 |
quota → QuotaExhausted + WS 驱逐 |
立即下一账号 |
429、rate_limit_exceeded、usage_limit_reached、workspace_*_usage_limit_reached |
quota → 带 reset/retry 时间的运行时限流 + WS 驱逐 |
立即下一账号 |
| model unavailable/not supported,包括明确的 404 | account_failure 只记录 ModelUnsupported,不失效整账号 |
立即下一账号 |
| connect/timeout、529、5xx | 通用 transport retry;不写账号状态 | 有候选时下一账号 |
| Cyber、Cloudflare HTML challenge、previous-response failure | 各自 controller/history owner | 不得被通用 403/404 抢占 |
同一失败在 HTTP 状态响应、SSE/WS response.failed 和 error 事件中都消费相同的 code、type、status、message 与 retry typed facts。transport 只能提取这些字段,不能决定账号状态。普通 403 在 Cyber、Cloudflare 和 model classifier 都未命中后才作为账号禁止事实处理。
CommittedAttempt 类型上没有 retry API:如果失败在客户端响应已经提交后才到达,只能在其他 BestEffort finalizer I/O 之前执行同样的内存失效、后台持久化和 WS 驱逐,让后续请求立即避开该账号;绝不能把另一账号的输出拼到当前流。
Stream enter/leave
- 上游 SSE 或 WebSocket 事件转换为一次性的
CanonicalResponseChunk/CanonicalResponseEvent。 - 保留 raw bytes 供 HTTP SSE 透明转发;typed event 同时提供给 observer 和下游 WebSocket encoder。
- 在首个可转发事件处记录 commit;commit 后类型上不再允许 retry。
- 影响下一请求路由的关键状态在 terminal 向下游转发前完成有界更新。
- terminal、上游断开、协议错误、下游取消和 shutdown 都转换为
StreamTerminal。 - 统一 finalizer 逆序执行 controller,并且只执行一次。
- 最后释放账号 lease。
Drop 不能执行异步 Redis、数据库或遥测操作。response body 的 Drop 只发送取消信号;持有完整生命周期的受监督 task 在 live loop 返回唯一 StreamSummary 后,消费式调用 StreamFinalizer::finalize(...).await。Drop guard 只作为 panic/abort 的资源释放兜底。
Rust 落地选择
核心热路径不需要 Vec<Box<dyn Middleware>>:
- 非流式与流式模式用泛型或 sealed enum 做静态 dispatch,共享一个 request prelude 和
AttemptRunner。 - controller 集合使用具体
ControllerSet或 sealed controller enum,顺序由代码和 contract test 固定。 - 流 decoder 和状态机使用具体类型,不能对每个 token 走 async trait/vtable。
- 只有未来确实需要异构、可配置 controller 集合时,才在注册边界使用
Arc<dyn Controller>;每请求私有状态仍应封装在 controller scope 内。 - typestate 只用于
OpenAttempt→CommittedAttempt这一不可逆边界,不把每个 controller 小阶段都编码为泛型状态。
与 Koa 相同的是嵌套进入、逆序退出和短路语义;不同的是 Rust 通过所有权、显式 outcome 和提交 typestate 保证边界,而不是依赖一个任意可变 context 和递归 next() future。
已落地的实现约束
- complete 与 stream 入口共享
enter_request和AttemptRunner,不存在各自维护的账号尝试主循环。 ControllerSet是静态 composition root;controller 顺序和 typed hook 在编译期可见,不使用动态 middleware 链。- complete、prefetch 与 committed stream 的公共失败 owner 优先级只在
ControllerSet::classify_shared_failure声明一次;composition root 不实现 Cyber、quota 或账号失败规则。 - ban、deactivated workspace、认证失效与 quota owner 共享同一
account_stateeffect 执行器:内存状态先线性化,PostgreSQL 有序后台持久化,WS entry 随即驱逐;controller 不直接等待数据库。 - Cyber failure 由
CyberPolicyController返回不透明分类,并由该 owner 唯一构造换号或返回 decision;ControllerSet只分发 effect 与 decision。 ControllerRequestScope只保存 cyber、history、usage 的真实状态;不存在通用 entered-controller marker、容量错误或空 unwind hook。- complete 的 accepted response 归一为
FinalOutcome后由finalize_complete消费 scope;retry 继续进入下一 attempt,rejection 在 controller effect 已完成后直接返回,不经过空 finalize。 - complete 的真实退出顺序固定为 Cloudflare → Usage → Affinity → Telemetry;live stream 固定为 Cloudflare → Usage → Affinity → Telemetry → Cyber,无副作用的阶段不制造空分支。
- 上游 HTTP、SSE 首帧与 live stream 统一转换为 observation 或 canonical event,再由 controller 消费 typed facts。
- stream 的 completed、incomplete、failed、断开、协议错误、下游取消和 shutdown 均形成显式终态,并经过 exactly-once finalizer。
- WebSocket upgrade 成功后的
response.failed/error保留 code、type、显式 status 和 retry 事实进入 canonical lifecycle;transport 不得据此推导 HTTP 状态或业务后果。 - 入站 Responses WebSocket API 只采集 canonical transcript facts 并持有 service 暴露的 opaque replay snapshot/plan;previous ID 匹配、Replace/Append/Unavailable、清洗与拼接全部由
HistoryController唯一拥有。 - 上游 WebSocket pool 保持独立的连接 lease 与回收职责,dispatch lifecycle 只消费其 typed transport outcome。
- 测试全部位于
backend/tests/;backend/src/中不存在#[cfg(test)]、#[test]、mod tests或 test-only helper。
Responses WebSocket v2 与 HTTP/2 低延迟传输审计
状态:审计完成,目标方案已确定,尚未实现。
本节解决的不是“WebSocket 和 HTTP/SSE 谁在理论上更快”,而是两个直接影响体感的问题:如何让健康连接尽量在请求到来前准备好,以及当 WebSocket 建连异常时,如何避免在换账号或 HTTP fallback 之前白等 15 秒。
最终结论是双热通道:精确会话 WebSocket 负责 v2 的连接本地状态和增量续接;按 origin 共享的 HTTP/2 连接负责新链和可持久化续链的低延迟后备。两者不能互相冒充,也不能用一个通用“连接可用”布尔值抹掉 continuation 的所有权差异。
审计证据与事实边界
以下结论来自 2026-07-14 的四类证据,必须区分来源:
- 官方 Codex 源码确认:本地官方仓库当时的
main为5bed6447998c754d154dbd796517310b8f04d4ce。OpenAI provider 默认声明支持 WebSocket;旧responses_websockets/responses_websockets_v2feature 已标记 Removed,实际传输路径不再依赖这两个开关;握手固定发送OpenAI-Beta: responses_websockets=2026-02-06。 - 官方 Codex 源码确认:会话初始化后会在后台构造稳定的 instructions、tools 和空 input,发送 v2
response.create且设置generate=false,等待response.completed后把连接和 response ID 交给首个真实 turn。首个 turn 只有在请求属性仍匹配时才使用 warmup response ID;模型、service tier、tools 等属性变化时仍可复用 socket,但必须发送新的完整 create。 - OpenAI 官方文档确认:WebSocket Mode 在活跃 socket 上只保存最新一个 previous-response 的连接本地内存状态;
store=false/ ZDR 下没有持久化 fallback,ID 不在该 socket 缓存时返回previous_response_not_found;单条连接一次只允许一个 in-flight response,不支持 multiplex,连接最长 60 分钟。 - 当前工程源码确认:已经透明透传
generate=false,发送 v2 beta header,按(base_url, account_id, conversation_id)建池,并在 socket 上记录和校验最新 response ID;池内连接默认 25 秒 ping、5 秒探活、55 分钟最大寿命。 - 当前工程源码确认:reqwest Client 已按自定义 CA profile 全局缓存,HTTP/2 可以跨账号共享同一 origin 的连接池;强制 HTTP/1.1 的配置和分支已经删除,但尚未配置 HTTP/2 PING,reqwest 0.12.28 默认会在 90 秒后清理 idle pool。
- 线上与网络探针只能用于校准,不构成协议保证:此前当日生产失败样本中,59 次 WebSocket 超时有 56 次被同账号 HTTP 恢复;OCI 未鉴权探针中,HTTP/2 冷连接完成 TLS 约 32–35ms,WebSocket upgrade 响应约 70–591ms。该探针不包含真实鉴权和模型 TTFT,只说明健康握手通常不需要 15 秒。
官方依据:
- Codex
client.rs的 prewarm 契约 - Codex 会话启动预热
- Codex
generate=false请求构造与等待完成 - OpenAI Responses API WebSocket Mode
- reqwest 0.12.28 HTTP/2 keep-alive 配置
WebSocket v2 的收益边界
WebSocket v2 的主要收益不是帧格式比 HTTP/SSE 更快,而是同时消除三类重复工作:
- socket 持续存在,避免每轮重新建立 TCP/TLS/HTTP 请求通道;健康 HTTP/2 keep-alive 也能覆盖这一部分。
- 客户端只发送新增 input 和
previous_response_id,不重复上传完整 history、instructions 和 tools。 - 上游直接复用当前 socket 上的 connection-local previous-response 状态;HTTP/2 连接复用不能提供这份模型请求状态。
因此,热 HTTP/2 可以让 fallback 的网络建连接近无感,但不能替代 store=false WebSocket 链。OpenAI 官方文档给出的典型收益场景是长工具链,并报告 20 次以上工具调用的 rollout 最高约 40% 端到端加速;这不是所有单轮请求都能获得的固定收益。
当前实现缺口
当前实现已经具备 v2 透传和池化基础,但还有四个会影响正确性或尾延迟的缺口:
upstream/openai/transport/client_sse.rs只根据是否携带 previous ID 判断 fallback。首个generate=false没有 previous ID,因此上游 WebSocket 失败后仍可能回退 HTTP。controllers/affinity.rs只根据store记录 continuation scope。HTTP fallback 产生的store=falseresponse 也会被记为ConnectionLocal,但实际没有任何 WebSocket 持有该 ID。WebSocketPreferred与WebSocketRequired的冷建连都可能在前台等待完整 15 秒;池只有 Idle / Busy 等连接结果,没有复用中的单飞 Connecting 状态和短快路径预算。- HTTP Client 虽然全局缓存并默认协商 HTTP/2,但 90 秒 idle pool timeout 会在长期只走 WebSocket 时主动清掉健康后备连接;TCP keepalive 不能阻止连接池自己驱逐 socket。
第 1、2 点会形成错误链:
客户端后台发送 generate=false
-> 上游 WebSocket 失败
-> 代理降级 HTTP,得到 store=false response_id
-> 客户端下一轮携带 previous_response_id
-> 新 WebSocket 没有该 connection-local 状态
-> previous_response_not_found 或被迫全量重放
目标实现必须禁止这条链,而不是再增加一次重试掩盖它。
最终传输状态机
请求首先被规范化为显式 transport requirement,不继续靠 use_websocket、force_http_sse 和 previous_response_id.is_some() 的组合隐式推断:
| 请求事实 | 传输要求 | 允许行为 |
|---|---|---|
客户端显式 generate=false 且 store=false |
Explicit WebSocket Warmup | 必须使用 WebSocket;允许后台完整建连预算;禁止 HTTP fallback |
store=false connection-local previous ID |
Exact WebSocket Continuation | 只能租用持有同一最新 ID 的精确 socket;缺失时立即返回 typed unavailable,由 HistoryController 决定全量重放或失败 |
| 已确认 persisted previous ID | Persisted Continuation | 热 WebSocket 优先;没有热 socket 时允许使用 HTTP/2 或新 WebSocket hydration |
| 外部来源且所有权未知的 previous ID | External Unknown | 只在已选定账号原样尝试一次,不遍历账号碰运气;失败后返回观察到的协议错误 |
| 没有 previous ID 的普通新链 | New Chain | 精确热 WebSocket 优先;冷建连只等快路径预算,超时后同账号走热 HTTP/2 |
| 客户端显式要求 HTTP | HTTP Required | 直接使用 HTTP,不启动 WebSocket 冷建连 |
response 完成后的 continuation scope 必须同时由 store 和实际 transport 推导:store=true 是 Persisted;store=false + WebSocket completed 才是 ConnectionLocal;store=false + HTTP 必须是 ReplayRequired 或 Unavailable,绝不能伪装成 ConnectionLocal。只有入站 WebSocket 已持有完整 canonical transcript 时才能标记 ReplayRequired,普通 HTTP 客户端没有本地 replay snapshot 时只能标记 Unavailable。
普通新链的目标时序:
获取账号 lease
-> controller 完成账号与 route 准备
-> 生成当前账号凭据、installation ID / Cookie / handshake 输入
-> 启动精确 WebSocket 单飞建连
与 wait_for_request_interval 并行
-> 已有热 socket 或 800ms 内完成:发送 response.create
-> 800ms 未完成:取消尚未发送 payload 的冷连接,同账号立即走热 HTTP/2
-> HTTP/2 也失败或返回明确可换号失败:Attempt lifecycle 选择下一候选账号
800ms 是初始快路径预算,不是永久硬编码真理。它来自当前 OCI 探针的健康区间并为鉴权波动保留余量,必须根据 ws_connect_ms 的生产 p95/p99 调整。无论以后调成多少,该预算都只能覆盖发送 payload 之前的握手阶段。
一旦 response.create 已可能送达上游,连接断开或首事件超时就是“是否已经开始推理未知”的 ambiguous failure。没有上游幂等保证时,不能为了缩短体感而并发发送同一请求到 HTTP 或其他账号,否则可能重复推理、重复扣费或产生两条不一致输出。现有 20 秒 initial-event guard 与 5 分钟 active-stream idle timeout 必须继续和 pre-send connect budget 分开建模。
官方客户端预热与代理预热
官方 Codex 客户端已经提供标准预热信号。代理收到 generate=false 后应当:
- 正常进入 Request / Attempt 洋葱并选定账号,不绕过 quota、history 或账号隔离规则。
- 在该账号、base URL 和本地 conversation identity 下建立 WebSocket。
- 原样发送
response.create(generate=false),等待response.completed。 - 将 socket、最新 warmup response ID 和连接元数据作为一个不可拆分的池条目保存。
- 首个真实请求只有在 previous ID 和请求属性都满足续接条件时,才在该 socket 上发送增量 input。
代理不得给所有账号盲目建立“空 WebSocket”:握手携带账号凭据、session/thread 会话语义、Cookie 和路由信息,空连接不能安全跨账号或跨 conversation 借用;单 socket 不支持 multiplex,批量空连接还会提前消耗上游连接额度。对不发送标准 warmup 的客户端,只允许在候选账号和会话语义已经确定后做 handshake-only 单飞建连,不得由代理擅自补发 generate=false,因为后者可能额外处理完整上下文并引入隐藏成本、状态映射和并发竞态。
HTTP/2 热后备
HTTP/2 连接按 (scheme, host, port, proxy/TLS profile) 共享,不按账号建池;Authorization 和账号头属于单个 HTTP/2 stream,同一 origin 的多个账号可以复用一条 TLS/H2 连接。这正适合作为 WebSocket 冷建连失败后的廉价后备。
工程不再暴露 tls.force_http11,也不保留 http1_only() 兼容分支;旧 tls 配置节点会被 deny_unknown_fields 直接拒绝。上游 HTTP 只有一条自动 ALPN 协商路径,主动连接保活优化只针对 HTTP/2,不提供运行时切回“强制 HTTP/1.1”的第二套策略。目标 Client 配置为:
Client::builder()
.pool_idle_timeout(None::<Duration>)
.http2_keep_alive_interval(Duration::from_secs(30))
.http2_keep_alive_timeout(Duration::from_secs(5))
.http2_keep_alive_while_idle(true)
有可用账号时,现有启动模型目录刷新已经会在服务启动后建立同 origin HTTP 连接,不需要新增一条“为了预热而预热”的业务请求。目标配置的目的只是让该连接在 WebSocket-only 流量期间不被本地 90 秒 idle timeout 清理,并用 HTTP/2 PING 在后台发现已经失活的链路。
熔断与账号边界
WebSocket 建连失败通常是 origin、出口网络、代理或 Cloudflare 边缘链路事实,不应直接把账号标为失效。此前生产样本中绝大多数超时能被同账号 HTTP 恢复,因此顺序必须是“同账号换 transport,再由 HTTP 结果决定是否换账号”,而不是“换账号后再重复一次同样的 WebSocket 超时”。
初始熔断规则:
- key 为 origin + 出口 route/TLS profile,不按账号 key。
- 30 秒窗口内连续 3 次 WebSocket 快路径超时,打开 30 秒。
- 熔断期间已有热 WebSocket 仍可使用;普通新链直接走 HTTP/2,不再创建冷 WebSocket。
- 30 秒后只允许一个 half-open 单飞探针;成功关闭熔断,失败重新打开。
- 401、403、quota、账号停用和套餐限制仍由对应 controller 处理,不能污染 transport 熔断。
previous_response_not_found只驱逐精确 conversation socket 并交给 HistoryController,不打开 origin 熔断。
这些时间和次数属于 transport policy,只能由 upstream/openai/transport 单一 owner 定义和观测;lifecycle 只能消费 ReusedWebSocket、ConnectedWebSocket、Http2Fallback、ContinuationUnavailable、PreSendFailure、PostSendAmbiguousFailure 等 typed outcome。
洋葱边界下的代码归属
| 责任 | 唯一归属 | 禁止越界 |
|---|---|---|
| v2 payload、握手、快路径预算、单飞、连接池、origin 熔断 | upstream/openai/transport/ |
lifecycle/controller 不解析 WebSocket 错误字符串,不维护连接状态 |
store + 实际 transport 推导 continuation scope |
upstream 产出事实,controllers/history.rs 解释所有权与重放 |
affinity 不能只看 store 猜测 scope |
| 账号选择、retry next、提交边界 | dispatch/lifecycle/ |
transport 不选择下一个账号,不写账号状态 |
| history full replay、previous ID owner、跨账号可恢复性 | controllers/history.rs |
API 和 WebSocket pool 不自行拼接历史 |
| quota、封禁、Cyber 和账号状态 | 各自 classifier controller;account_state 只执行通用失效 effect |
transport connect failure 不能直接停用账号,effect 执行器不能识别错误语义 |
| HTTP/WS 入站解析和最终事件编码 | api/client/responses/ |
API 不决定 upstream transport fallback |
Attempt lifecycle 只增加一个窄的 transport preparation 调用点:在账号、route、identity 和 Cookie 已确定后启动连接准备,并与账号请求间隔并行;它接收准备结果,不拥有 800ms、30 秒或熔断阈值。这样低延迟优化不会把 WebSocket 规则扩散进 attempt.rs,也不会形成第二套 retry 主循环。
可观测性与验收
必须分别记录选择账号、连接准备、发送请求、首协议事件和首真实输出,不能只用一个总耗时掩盖慢点:
transport_decision:ws_reused、ws_connected_fast、http2_ws_slow、http2_breaker_open、ws_exact_required。ws_connect_ms、transport_decision_wait_ms、upstream_headers_ms、first_event_ms、first_output_ms。http_version、origin breaker state、WebSocket pool decision、connection reused。- pre-send failure 与 post-send ambiguous failure 必须是不同枚举和不同指标。
generate=falsewarmup 成功率、warmup 被首轮使用率、请求属性变化导致的 warmup miss。
完成实现必须同时满足:
generate=false的store=false请求在 WebSocket 失败后绝不回退 HTTP,也不记录虚假的 ConnectionLocal scope。- warmup 与首个真实 turn 在属性匹配时使用同一 socket,首轮携带 warmup response ID 和增量 input。
- 精确 connection-local socket 缺失时不冷建连碰运气,不遍历账号;只走 HistoryController 的 typed replay/failure。
- 普通
WebSocketPreferred冷建连不会在前台等待 15 秒;健康快路径预算耗尽后同账号 HTTP/2 立即接管。 - WebSocket transport failure 不更新账号失效状态;只有后续明确的账号事实才能触发账号 controller。
- 明确账号事实发生后,即使 PostgreSQL 行写入被阻塞,当前 attempt 仍立即切到下一账号,并发候选租用也不能再次获得旧账号;解除阻塞后状态最终落库。
- 账号失效必须同时驱逐该账号的 WebSocket entries;已提交流只能影响后续请求,不能构造 retry。
- origin 熔断期间没有冷 WebSocket 建连风暴,已有热连接不受影响。
- payload 发送后的 ambiguous failure 不自动并发 hedge 到第二 transport 或账号。
- HTTP/2 连接在超过 90 秒的 WebSocket-only 空闲窗口后仍可复用,并能在 PING timeout 后清理失活连接。
- 所有测试位于
backend/tests/,按 transport、history controller 和 lifecycle contract 边界组织,backend/src/不出现 test-only 代码。
性能约束
- 独立且都需要的入口查询并行执行,不能把多个 Redis RTT 串行叠加。
- best-effort controller 必须有有界超时和明确 fail-open 行为。
- 账号可用性内存失效和 WS 池摘除不是数据库 BestEffort I/O;数据库持久化只能进入后台队列,不能占用换号延迟预算。
- 逐事件 observer 只做同步、低成本状态更新;Redis、数据库和遥测 I/O 只在必要的 terminal/finalize 阶段执行。
- canonical event 同时保留 raw payload,避免为透明转发再次序列化。
- 是否引入动态分发以基准和可维护性为依据,不凭感觉优化。
验收条件
- 新增一个跨阶段恢复策略时,只新增 controller、静态注册和本模块测试,不同时修改
service.rs、stream/lifecycle.rs、stream/live.rs和 API handler。 - 非流式与流式共享同一个 request prelude 和
AttemptRunner。 - 同一上游失败在 complete、prefetch 和 live terminal 得到一致分类。
- commit 后无法构造 retry decision,不能拼接两个账号的输出。
- enter 顺序、真实 leave 副作用的固定逆序、无运行时 marker/unwind 和 controller 顺序有 contract test。
- completed、incomplete、failed、upstream disconnect、decoder error、oversize、downstream cancel 和 shutdown 都 exactly-once finalize。
- HTTP JSON、HTTP SSE 与 Responses WebSocket 对相同
ClientFailure保持语义一致。 - 并发同会话状态使用不可复用 revision token/CAS 测试,旧成功不能删除新失败,状态删除或过期后重建也不能出现 ABA。
- 生产模块不加入
#[cfg(test)]、mod tests或 test-only helper;测试只能放在backend/tests/。 - 不存在 compatibility re-export、旧入口包装器、feature flag 双路径或重复的旧失败分类。