OmniRoute Combo 终结 503 的诊断一致性:11462 修复剖析——round-robin 与嵌套 runtime-unit 全链路附加 poolSize/attemptOrder 追踪
导读:在 OmniRoute 的多目标(Combo)请求执行引擎中,当所有模型/连接候选的重试总预算耗尽时,各策略路径会返回 503 "Maximum combo retry limit reached" 终结响应。修复 #11462 让 round-robin 策略与 pipeline/fusion 所使用的嵌套 runtime-unit 循环,和 priority 策略一样,在终结 503 上附带完整的 Combo 诊断追踪(
poolSize/attempted/excluded/attemptOrder/terminalReason)及x-omniroute-combo-*响应头,从而消除"同一终结条件、不同诊断形态"的不一致。读完本文你将掌握:诊断载荷的标准结构、三条策略路径各自的实现位置与数据来源、客户端消费诊断的方法,以及对应的回归测试契约。
一、背景:Combo 回退的"重试预算"与无上下文 503 问题
Combo 是 OmniRoute 中把多个模型目标组织成一次请求执行序列的机制:按 priority(优先级)、round-robin(轮询)、pipeline/fusion(流水线/融合)、random、weighted 等策略逐个尝试候选,遇到失败再回退到下一个,直至命中一个合格响应。
为了防止"无限回退"拖垮后端,每个组合请求都设有全局尝试预算(在 combo.ts 中体现为 maxGlobalAttempts 与递增的 globalAttempts;在嵌套 runtime-unit 中体现为 attemptBudget)。当预算耗尽,执行引擎必须终止循环并返回 503 Maximum combo retry limit reached。
问题恰恰出在这条"终结路径"上:优先级策略早在修复前就通过 errorResponseWithComboDiagnostics 把完整的诊断追踪挂到了该 503 上,而 round-robin 策略路径和嵌套的 runtime-unit 循环路径此前返回的是不带任何追踪信息的"裸 503"——调用方只能看到一句笼统文案,无法判断到底尝试过哪些模型、哪些被排除、为何终止。变更 11462-roundrobin-combo-diagnostics.md 正是为后两条路径补齐了这一诊断契约。
二、诊断载荷标准:ComboDiagnostics 与 errorResponseWithComboDiagnostics
所有路径最终共享同一份诊断序列化实现,位于 utils/error.ts。
2.1 ComboDiagnostics 结构
核心接口定义在 utils/error.ts:
export interface ComboExclusion {
provider: string;
model?: string;
reason: string;
}
export interface ComboDiagnostics {
poolSize: number; // 本次可尝试的候选池大小
attempted: number; // 全局累计尝试次数(已计入本次)
excluded: ComboExclusion[]; // 运行期被排除的目标(如已耗尽连接)
attemptOrder: Array<{ provider: string; model: string }>; // 真实尝试顺序
terminalReason: string; // 终止原因枚举,见下
recovery?: ComboRecoveryHint; // 可选:给调用方的下一步建议
}
字段语义:
| 字段 | 类型 | 含义 |
|---|---|---|
poolSize |
number | 本轮候选目标总数(round-robin 中即模型计数) |
attempted |
number | 达到预算上限时已累计的尝试次数 |
excluded |
ComboExclusion[] | 被排除的连接/供应商及其原因,如 { provider: "...", reason: "exhausted" } |
attemptOrder |
{provider, model}[] | 从第一个到最后一个的真实尝试顺序,用于回放"失败轨迹" |
terminalReason |
string | 终结原因,如 max_attempts_exceeded、combo_timeout |
recovery |
ComboRecoveryHint | 可选的行动建议 |
recovery 提示采用白名单约束(见 utils/error.ts):action 只能是闭集 try-auto/wait/retry/switch-combo 之一,next_step 文案被裁剪并剥离换行,retry_after_seconds 被钳制在 0..3600 区间。
2.2 双重透出:响应体 + 自定义头
errorResponseWithComboDiagnostics(utils/error.ts)把诊断同时挂到 OpenAI 风格错误体的扩展字段和 HTTP 头:
{
"error": { "message": "Maximum combo retry limit reached" },
"diagnostics": {
"poolSize": 3,
"attempted": 9,
"excluded": [{ "provider": "openai", "reason": "exhausted" }],
"attemptOrder": [{ "provider": "openai", "model": "openai/gpt-4o" }],
"terminalReason": "max_attempts_exceeded"
},
"recovery_hint": { "action": "retry", "next_step": "..." }
}
HTTP/1.1 503 Service Unavailable
x-omniroute-combo-pool-size: 3
x-omniroute-combo-attempted: 9
x-omniroute-combo-excluded: openai:exhausted
x-omniroute-combo-terminal-reason: max_attempts_exceeded
x-omniroute-recovery-action: retry
x-omniroute-recovery-next-step: ...
x-omniroute-retry-after-seconds: 30
diagnostics 作为 OpenAI 风格错误体的扩展字段存在,标准错误解析器可原样忽略,因此完全向后兼容;头部则供 curl、网关层、负载均衡器这类不解析 JSON 的组件直接读取。当携带 recovery 时,还会以 x-omniroute-recovery-action/x-omniroute-recovery-next-step/x-omniroute-retry-after-seconds 头与顶层 recovery_hint 字段镜像透出,方便非头部感知的客户端(curl、MCP 工具、日志抓取器)同样能拿到建议。
2.3 消毒即安全边界
由于诊断信息可能携带外部供应商返回的错误细节,序列化前经过白名单投影 sanitizeComboDiagnostics:仅允许 id/reason 字符串原语与整型计数透出,excluded/attemptOrder 各截断到 64 条并钳制单条长度。头部值经过 toHeaderSafeAscii 处理——HTTP 头值必须是 Latin1/ByteString,超出 0-255 的码点会被替换为 ?,确保头部构造永不抛错;JSON 体中则保留原始可读文本。这正是"诊断对外可控、不泄露敏感细节"的边界所在。
三、round-robin 策略终结路径的修复
round-robin 分支位于 combo.ts。在该分支内每次候选尝试都会递增 globalAttempts:
globalAttempts++;
if (globalAttempts > maxGlobalAttempts) {
log.warn("COMBO-RR", `Maximum combo attempts (${maxGlobalAttempts}) exceeded. ...`);
return errorResponseWithComboDiagnostics(503, "Maximum combo retry limit reached", {
poolSize: modelCount,
attempted: globalAttempts,
excluded: [
...[...exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })),
...[...exhaustedConnections].map((c) => formatExhaustedConnectionKey(String(c))),
],
attemptOrder: rrOutcomes.map((o) => ({
provider: o.model.split("/")[0] || "unknown",
model: o.model,
})),
terminalReason: "max_attempts_exceeded",
recovery: buildRecoveryHint("max_attempts_exceeded"),
});
}
数据来源清晰可辨:
poolSize= 参与轮询的模型总数modelCount;attempted= 全局累计globalAttempts(包含刚刚超限的这一次);excluded= 运行时被淘汰的供应商与连接,exhaustedProviders记录provider,exhaustedConnections经formatExhaustedConnectionKey归一为provider/model:reason形态的排他键;attemptOrder= 从rrOutcomes(本轮的轮询结果序列)投影出每个目标的provider(取model首段)与完整model;recovery= 通过buildRecoveryHint("max_attempts_exceeded")给出下一步建议。
在 round-robin 循环内部,每一跳还会受多个前置闸门约束:admission lane 是否满(#9654)、按连接的 maxConcurrent 或 combo 级并发获取信号量槽位(超时/队列满则尝试下一模型)、推理模型 max_tokens 缓冲复制(#3587/#7847)等。无论被哪个闸门拦下,只要累计尝试数冲破 maxGlobalAttempts,都会汇入上述带诊断的终结 503,而不是悄悄中止。
四、嵌套 runtime-unit 循环的修复(#11462 核心)
4.1 runtime-unit 是什么
嵌套的 runtime-unit 执行循环位于 runtimeUnits.ts,供 pipeline/fusion 组合策略通过 dispatchPrelude.ts 驱动,用于在"一个 Combo 内再嵌套子 Combo/子目标"的场景下逐个执行运行时单元(model 单元或 combo-ref 子引用单元)。它支持对单元列表按策略排序(random 洗牌、weighted 按权重抽样,见 runtimeUnits.ts),并继承 combo 的并发容量检查、配额耗尽分类、响应质量校验等治理。
4.2 修复前:裸 503
与 round-robin 一样,该循环在 attemptBudget 超限时此前只调用普通的 errorResponse(503, "Maximum combo retry limit reached"),不含任何追踪。调用方无从得知循环到底尝试过哪些单元。
4.3 修复后的诊断装配
修复后(runtimeUnits.ts 与 runtimeUnits.ts),循环先维护一个 attemptedUnits 数组记录每一次真实尝试:
// #11462: attempts already made this loop, tracked for the attempt-budget-exceeded
// diagnostics trace below (mirrors the poolSize/attemptOrder shape combo.ts already
// attaches for the priority/round-robin strategies).
const attemptedUnits: Array<{ provider: string; model: string }> = [];
const buildAttemptBudgetDiag = (): ComboDiagnostics => ({
poolSize: orderedUnits.length,
attempted: args.nesting.attemptBudget.count,
excluded: [],
attemptOrder: attemptedUnits,
terminalReason: "max_attempts_exceeded",
});
在每次尝试前(runtimeUnits.ts)递增预算计数并判定超限:
args.nesting.attemptBudget.count += 1;
if (args.nesting.attemptBudget.count > args.nesting.attemptBudget.limit) {
lastResponse = errorResponseWithComboDiagnostics(
503,
"Maximum combo retry limit reached",
buildAttemptBudgetDiag()
);
await observeFailure(lastResponse, unit);
return { response: finalFailure(lastResponse), unit };
}
值得注意的差异点:
poolSize=orderedUnits.length,即按策略排序后的单元总数(嵌套子 Combo 引用也计为一个单元);attempted=nesting.attemptBudget.count,与上层共享嵌套上下文中的预算计数——嵌套层级越多,父级与子级共享同一预算池,防止递归放大请求量;attemptOrder= 仅包含真实发出的尝试;每次发往上游前把{ provider, model }push 进attemptedUnits,其中combo-ref单元的provider记为"combo-ref"、model记为combo:名称(见 runtimeUnits.ts 的unitDisplayName),因此调用方能区分"直接模型尝试"与"嵌套子 Combo 尝试";excluded=[](该路径目前不做预排除,只有真实尝试的轨迹);terminalReason="max_attempts_exceeded"。
4.4 嵌套治理:深度、环检测与质量门
修复的同时,嵌套循环本身具备多层护栏,这些共同决定了诊断中 attemptOrder 的真实形态:
- 嵌套深度超限返回
Max combo nesting depth exceeded,环引用返回Circular combo reference detected(runtimeUnits.ts); - 模型单元先经并发容量闸门(runtimeUnits.ts),命中即被标记失败并回退下一个;
- 上游响应会做配额耗尽分类与响应质量校验(
validateResponseQuality),不合格响应视为失败;仅408/429/500/502/503/504触发单元内重试(runtimeUnits.ts)。
所有这些失败累积,最终在预算超限的那一刻,随诊断 trace 一并回传。
五、作为参照系的 priority 路径:更强的终结语义
优先级策略路径(combo.ts)是本次修复的"参照实现"——它早已用 errorResponseWithComboDiagnostics 装配终结 503,并且语义更丰富:
globalAttempts++;
if (globalAttempts > maxGlobalAttempts) {
const reasoningExhausted = /reasoning consumed \d+\/\d+ tokens/.test(lastError || "");
const failureReason = reasoningExhausted ? "reasoning_budget_exhausted" : "max_attempts_exceeded";
recordComboFailure(effectiveSessionId, combo.name);
return {
ok: false,
response: errorResponseWithComboDiagnostics(
503,
reasoningExhausted
? "All combo candidates exhausted their token budget on reasoning without producing content. ..."
: "Maximum combo retry limit reached",
buildComboDiag(failureReason)
),
};
}
priority 路径额外做了两件事:
- 终局原因细分:若最后一次失败匹配
reasoning consumed X/Y tokens,判定为推理模型把 max_tokens 预算耗尽(reasoning_budget_exhausted),并给出"请调大 max_tokens"的可操作文案——此时换别的模型重试也无济于事,应让调用方调整输入而非盲目重发; - 故障计数联动:调用
recordComboFailure累计连续失败,使会话-Combo 组合的固定(pin)在第三次失败时被解除——诊断里的recovery.next_step会明确告知客户端该怎么做(这正是 utils/error.ts 中recovery白名单动作retry/wait/switch-combo/try-auto等被消费的场景之一)。
顺带一提,errorResponseWithComboDiagnostics 在引擎其他终结点也已广泛采用,例如 dispatchPrelude.ts、resolveAutoStrategy.ts、targetResolution.ts 以及 combo.ts 内的超时终结(combo_timeout,返回 504)等——这表明"终结必有诊断"已成为该引擎的通用规范。
六、回归测试契约:#11462 的验证方式
修复配套的确定性回归测试位于 combo-runtimeunits-diagnostics-11462.test.ts。测试构建两个 model 单元(openai/ru-a、anthropic/ru-b),并把嵌套上下文的 attemptBudget 设为 { count: 0, limit: 1 }——由于 executeRuntimeUnitCombo 在每单元派发前先递增计数,预算为 1 时第二个单元在派发前即命中超限分支,从而无需让每个上游真实失败即可稳定触发被测的终结路径:
test("#11462: nested runtime-unit combo's attempt-budget-exceeded 503 must carry the combo diagnostics trace", async () => {
const result = await executeRuntimeUnitCombo({ /* ... budget: { count: 0, limit: 1 } ... */ });
assert.equal(result.response.status, 503);
const body = await result.response.json();
assert.equal(body.error.message, "Maximum combo retry limit reached");
assert.ok(body.diagnostics, "runtime-unit 503 should carry a diagnostics field");
assert.ok(typeof body.diagnostics?.poolSize === "number");
assert.ok(Array.isArray(body.diagnostics?.attemptOrder));
assert.equal(body.diagnostics?.terminalReason, "max_attempts_exceeded");
});
测试锁定的契约要点:
- 终结状态码必须是
503; error.message保持统一文案Maximum combo retry limit reached(不破坏既有调用方匹配);diagnostics字段必须存在,且poolSize为数字、attemptOrder为数组、terminalReason === "max_attempts_exceeded"。
同一诊断契约的相关回归覆盖还散落在 combo-diagnostics-trace.test.ts、combo-rr-sticky-9router.test.ts(round-robin 与 9 路由稳定性)及 combo-round-robin-streaming-lock-3811.test.ts 等文件中,感兴趣的读者可在 tests/unit 下继续追溯。
七、客户端消费指南:把诊断变成可行动的排障信息
修复的直接收益是:当一次 Combo 请求以"预算耗尽"失败时,无论走 priority、round-robin 还是 pipeline/fusion 嵌套 runtime-unit,调用方拿到的错误体与响应头形态完全一致。建议的消费姿势:
- 先看
error.code/error.message:Maximum combo retry limit reached是终局信号,无需重发原请求的"无限重试版",但可按recovery_hint的建议动作处理; - 回放
diagnostics.attemptOrder:按序回放每个{ provider, model },即可判断失败是否集中在特定供应商/模型(例如全部卡在openai/*,说明问题在供应商侧); - 解析
diagnostics.excluded:找出哪些连接因exhausted被排除——这对判断"是不是账号额度/并发配额耗尽"非常关键; - 检查
recovery_hint/x-omniroute-recovery-*头:若为retry,可结合retry_after_seconds做指数退避后重发;若为switch-combo,则应切换组合而不是重发同一组合; - 服务端日志侧:若无法读取响应体(如代理层只留头),
x-omniroute-combo-pool-size、x-omniroute-combo-attempted、x-omniroute-combo-terminal-reason三个头足以完成基本的失败归因与监控聚合。
需要注意的是:头部值经过 Latin1 安全化(非 Latin1 字符替换为 ?)且 excluded 头部总长上限 900 字符,因此以 JSON 体为准可拿到未经转义的完整可读文本;头部适合粗粒度监控,JSON 体适合精确排障。
八、小结
#11462 是一个典型的"诊断契约归一化"修复:它不改变终结 503 的状态码与文案(向后兼容),而是把既有诊断 trace 从 priority 一条路径推广到 round-robin 与嵌套 runtime-unit 两条路径,使得 OmniRoute 的 Combo 执行引擎在"尝试预算耗尽"这一终局条件下,无论由哪种策略、哪一层循环触发,都能交付结构一致、字段可回放、含恢复建议的诊断信息——对上层 Agent、网关与排障工具而言,这比一个笼统的 503 有价值得多。核心实现可依次查看 runtimeUnits.ts(嵌套路径)、combo.ts(priority/round-robin 路径)与 utils/error.ts(统一序列化与消毒),回归测试则以 combo-runtimeunits-diagnostics-11462.test.ts 为契约锚点。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00