Skip to content

fix(openai): 非高级调度补齐选号决策并按 previous_response 路由 - #7427

Open
xlplbo wants to merge 2 commits into
Wei-Shaw:mainfrom
xlplbo:fix/openai-legacy-scheduler-decision
Open

xlplbo wants to merge 2 commits into
Wei-Shaw:mainfrom
xlplbo:fix/openai-legacy-scheduler-decision

Conversation

@xlplbo

@xlplbo xlplbo commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

目的

未开启“高级调度”(openai_advanced_scheduler_enabled,默认关闭)时,让选号决策如实反映粘性命中,并让带 previous_response_id 的请求回到持有该响应的账号——与高级调度的行为对齐。

解决的问题

selectAccountWithSchedulerOnce 在调度器为 nil(即未开高级调度)时走的是旧选号路径,这条路径有两处缺口:

1. 决策标签恒为 load_balance 进入旧路径时 decision.Layer 被固定成 load_balance,返回前不回填 SelectedAccountID / SelectedAccountTypeStickySessionHit 恒为 false——即使账号其实来自会话粘性命中。后果是 openai.account_schedule_decisionschedule_layer 这些日志字段在默认配置下不可信,排查“会话为什么换了账号”时看不出粘性是否生效。

2. previous_response_id 不参与选号。 旧路径从不按 previous_response_id 查持有响应的账号,StickyPreviousHit 恒为 false。而 WS 入口(openai_gateway_handler.go 的“切组/会话失配防护”)见到 previousResponseID != "" && !StickyPreviousHit && previousResponseCanMove 就会剥掉首包里的 previous_response_id。于是在默认配置下,只要客户端在选号时带了 previous_response_id(HTTP 请求,或 WS 连接的首帧):

  • WS 首帧里的 previous_response_id 总会被剥掉、退化为用 input 重建上下文,即使会话粘性恰好选回了原账号也一样;
  • 会话哈希绑定过期或漂移后,请求会落到不持有该响应的账号;带 function_call_output 的工具续链无法重建、previous_response_id 原样保留,按该处注释的说法,原样转发到别的账号会触发上游会话链鉴权失败。

这一条是对照高级调度读代码发现的缺口。我们自己的 Codex 流量在选号时不带 previous_response_id(8 天约 2.3 万次选号里出现 0 次),走不到这条路径;受影响的是用 previous_response_id 串联多轮的 Responses API 客户端。因为没有线上流量数据,这一条改用专门的端到端探针在真实上游上验证,见“效果”。

效果

线上数据:决策标签

我们自建实例未开高级调度(openai_advanced_scheduler_enabled=false),运行日志为 debug 级。按部署本修复的时刻把 openai.account_schedule_decision 日志切成两段统计:

选号次数 layer=load_balance layer=session_hashsticky_session_hit=true
改前(09-12 08:59 → 09-16 17:38) 18,014 18,012(100.0%) 0
改后(09-16 17:38 → 09-20 20:21) 5,105 833(16.3%) 4,272(83.7%)

改前另有 2 条 guardian_parent。WS 入口的 openai.websocket_account_selected 同样:改前 5,290 条 schedule_layer 全是 load_balance,改后 4,110 条里 2,387 条(58.1%)是 session_hash

也就是说,改前日志显示“粘性从不命中”,而实际上八成以上的选号来自会话粘性。改后窗口内实例还带有我们 fork 的其它调度改动,比例本身不宜直接归因;能归因到本 PR 的是“粘性命中从完全不可见变为如实记录”。

真实上游:按响应路由

环境:未开高级调度,账号为 ctx_pool 模式,请求 store=false。每条 WS 连接使用不同的 session_id,排除靠会话粘性碰巧回到原账号的可能。

步骤 客户端结果 网关日志
连接 1:让模型记住一个随机暗号,随后断开 ok schedule_layer=load_balance,账号 A,上游连接 C
连接 2:全新连接、全新会话,首帧只带 previous_response_id 和一句“暗号是什么”,不带任何历史 答对暗号;input_tokens 59,上游接上了前一轮上下文 schedule_layer=previous_response_id,仍是账号 A;ingress_ws_continuation_probepreferred_conn_id 指向 C 并复用了它
对照:同样的问题,不带 previous_response_id unknown schedule_layer=load_balance

改前的行为没有在旧二进制上实跑。按代码,改前连接 2 首帧的 previous_response_id 会被剥掉,请求内容就等同于上面的对照行。

行为对照

场景(未开高级调度) 改前 本 PR
会话粘性命中 layer=load_balancesticky_session_hit=false,未回填选中账号 layer=session_hashsticky_session_hit=true,回填选中账号
请求带 previous_response_id,持有账号可用 不按响应选号,sticky_previous_hit=false,WS 首包被剥 previous_response_id 路由到持有该响应的账号,layer=previous_response_idsticky_previous_hit=true
持有账号被排除 / 传输不兼容 / 上游模型受渠道限制 / 代理被隔离 释放已占槽位,回退到原有选号
请求模型受渠道定价限制 报无可用账号 同样在路由前报无可用账号

改动

  • 决策回填AccountSelectionResult 增加未导出字段 stickySessionHit,旧路径两个分支(未开批量负载、负载感知的粘性分支)命中粘性时置位;新增 applyLegacySelectionDecision 在返回前回填选中账号,粘性命中时标 session_hash 层与 StickySessionHitselectAccountForModelWithExclusions 保持原签名,内部委托给多返回一个“是否粘性命中”的 selectAccountForModelWithExclusionsStickyHit
  • 按响应路由:新增 selectLegacyAccountByPreviousResponse,旧路径选号前先用 selectAccountByPreviousResponseIDForCapability 找持有该响应的账号,校验直接复用高级调度器 previous_response 层的 isAccountRequestCompatibleReason(分组、隐私、运行期封禁、代理隔离、上游模型渠道限制、传输与能力),另在路由前做请求模型的渠道定价限制检查。不兼容就释放槽位、回退到原有选号;命中后标 previous_response_id 层与 StickyPreviousHit,并顺带写会话粘性绑定。仅对 OpenAI 平台生效。

开启高级调度的部署不受影响:两处改动都在 scheduler == nil 分支内。

测试

新增 openai_legacy_scheduler_decision_test.go

  • TestLegacySchedulerDecision_StickySessionLayer:批量负载开/关两种配置下,粘性命中标 session_hash、未命中保持 load_balance,两种情况都回填选中账号。
  • TestLegacySchedulerDecision_PreviousResponseRouting:路由到持有账号;持有账号被排除时回退;传输不兼容、上游模型受渠道限制、代理被隔离时释放槽位并回退;请求模型受渠道限制时在路由前失败;全部代理被隔离时放行到持有账号(与高级调度的 fail-open 一致)。

用例在开头复位包级的高级调度开关缓存(5 秒 TTL),避免同包其它用例写入的开关串扰。

cd backend
go test -tags=unit ./internal/service -run 'TestLegacySchedulerDecision' -count=1
  • 改前对照(upstream/main d2e319b,只把三个非测试文件还原成上游版本):9 个子用例失败 7 个,通过的 2 个(持有账号被排除、请求模型受渠道限制)是改前改后行为本就一致的场景;本 PR 下 9 个全部通过。
  • ./internal/service 全量 -tags=unit:除 TestOllamaProbeCallback_StaleLongDoesNotOverrideNewShort 外全部通过。该用例是上游既有用例,本机 Windows 上因时钟步进固定失败,本 PR 未触及相关限流代码。
  • golangci-lint run ./internal/service/:0 issues。

代价与保留项

  • 续链依赖上游连接级状态。 store=false 时响应状态保存在上游那条 WS 连接里,而 ctx_pool 的上游连接是共享的。探针第一次运行时,我们在连接 1 与连接 2 之间插了另一个会话的请求,它复用了同一条上游连接并开了新链;随后带 previous_response_id 的请求虽然被正确路由到同一账号、同一条上游连接,上游仍返回 previous_response_not_found。这是 ctx_pool 共享连接的固有性质,高级调度走的是同一套逻辑,不是本 PR 引入的。本 PR 只保证“路由到持有账号并保留 previous_response_id”,不保证上游一定还认得它。
  • previous_response_id 的请求在旧路径上多一次响应→账号的缓存查询与一次兼容校验;未带时直接跳过,无额外开销。
  • 持有账号满槽时,沿用 selectAccountByPreviousResponseIDForCapability 的现有行为:返回该账号的等待计划(粘性等待的超时与人数上限),不改等待/排队语义。
  • 只补 OpenAI 平台;其它 OpenAI 兼容平台的旧路径保持原样。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant