首页
/ OmniRoute Combo 终结 503 的诊断一致性:11462 修复剖析——round-robin 与嵌套 runtime-unit 全链路附加 poolSize/attemptOrder 追踪

OmniRoute Combo 终结 503 的诊断一致性:11462 修复剖析——round-robin 与嵌套 runtime-unit 全链路附加 poolSize/attemptOrder 追踪

2026-09-07 13:31:05作者:侯霆垣

导读:在 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(流水线/融合)、randomweighted 等策略逐个尝试候选,遇到失败再回退到下一个,直至命中一个合格响应。

为了防止"无限回退"拖垮后端,每个组合请求都设有全局尝试预算(在 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_exceededcombo_timeout
recovery ComboRecoveryHint 可选的行动建议

recovery 提示采用白名单约束(见 utils/error.ts):action 只能是闭集 try-auto/wait/retry/switch-combo 之一,next_step 文案被裁剪并剥离换行,retry_after_seconds 被钳制在 0..3600 区间。

2.2 双重透出:响应体 + 自定义头

errorResponseWithComboDiagnosticsutils/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 记录 providerexhaustedConnectionsformatExhaustedConnectionKey 归一为 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.tsruntimeUnits.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.tsunitDisplayName),因此调用方能区分"直接模型尝试"与"嵌套子 Combo 尝试";
  • excluded = [](该路径目前不做预排除,只有真实尝试的轨迹);
  • terminalReason = "max_attempts_exceeded"

4.4 嵌套治理:深度、环检测与质量门

修复的同时,嵌套循环本身具备多层护栏,这些共同决定了诊断中 attemptOrder 的真实形态:

  • 嵌套深度超限返回 Max combo nesting depth exceeded,环引用返回 Circular combo reference detectedruntimeUnits.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 路径额外做了两件事:

  1. 终局原因细分:若最后一次失败匹配 reasoning consumed X/Y tokens,判定为推理模型把 max_tokens 预算耗尽(reasoning_budget_exhausted),并给出"请调大 max_tokens"的可操作文案——此时换别的模型重试也无济于事,应让调用方调整输入而非盲目重发;
  2. 故障计数联动:调用 recordComboFailure 累计连续失败,使会话-Combo 组合的固定(pin)在第三次失败时被解除——诊断里的 recovery.next_step 会明确告知客户端该怎么做(这正是 utils/error.tsrecovery 白名单动作 retry/wait/switch-combo/try-auto 等被消费的场景之一)。

顺带一提,errorResponseWithComboDiagnostics 在引擎其他终结点也已广泛采用,例如 dispatchPrelude.tsresolveAutoStrategy.tstargetResolution.ts 以及 combo.ts 内的超时终结(combo_timeout,返回 504)等——这表明"终结必有诊断"已成为该引擎的通用规范。

六、回归测试契约:#11462 的验证方式

修复配套的确定性回归测试位于 combo-runtimeunits-diagnostics-11462.test.ts。测试构建两个 model 单元(openai/ru-aanthropic/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");
});

测试锁定的契约要点:

  1. 终结状态码必须是 503
  2. error.message 保持统一文案 Maximum combo retry limit reached(不破坏既有调用方匹配);
  3. diagnostics 字段必须存在,且 poolSize 为数字、attemptOrder 为数组、terminalReason === "max_attempts_exceeded"

同一诊断契约的相关回归覆盖还散落在 combo-diagnostics-trace.test.tscombo-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,调用方拿到的错误体与响应头形态完全一致。建议的消费姿势:

  1. 先看 error.code/error.messageMaximum combo retry limit reached 是终局信号,无需重发原请求的"无限重试版",但可按 recovery_hint 的建议动作处理;
  2. 回放 diagnostics.attemptOrder:按序回放每个 { provider, model },即可判断失败是否集中在特定供应商/模型(例如全部卡在 openai/*,说明问题在供应商侧);
  3. 解析 diagnostics.excluded:找出哪些连接因 exhausted 被排除——这对判断"是不是账号额度/并发配额耗尽"非常关键;
  4. 检查 recovery_hint/x-omniroute-recovery-*:若为 retry,可结合 retry_after_seconds 做指数退避后重发;若为 switch-combo,则应切换组合而不是重发同一组合;
  5. 服务端日志侧:若无法读取响应体(如代理层只留头),x-omniroute-combo-pool-sizex-omniroute-combo-attemptedx-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 为契约锚点。

登录后查看全文
热门项目推荐
相关项目推荐