「换个模型就好了」:上下文超限为什么不能交给路由回退

一篇设计复盘:一个被归错类的 400,怎样让「回退成功」变成了最隐蔽的失败。
证据口径:出处写成 文件 或 commit 短 SHA(源码仓库不公开,用于自查)。数字只来自代码和测试;标「推断」的是我的解读。
1. 一条看起来很健康的线程⌗
设想一个长期运行的对话线程,钉在一个 1M token 上下文窗口的模型上。它跑了很久,上下文一点点涨。某一回合,发出去的 prompt 是 1,019,089 token——超了。
上游的回答是一个 HTTP 400:
{"type":"error","error":{"type":"invalid_request_error",
"message":"prompt is too long: 1019089 tokens > 1000000 maximum"}}
(这正是测试里的夹具,src/llm/provider-error.test.ts;代码注释记录了它在 2026-10-21 实际出现过。)
接下来发生的事,从每一层单独看都没毛病:
- 路由器看到一个 400,分类为
invalid_request。这类错误不是瞬时的,不在本 provider 上重试,于是换候选链里的下一个模型。 - 候选链里恰好有一个窗口 1,050,000 的模型。1,019,089 < 1,050,000,它接住了,回答正常返回。
- 回合执行器拿到了一个成功的结果。没有错误,自然也没有理由压缩。
下一回合,同样的事再来一次。再下一回合也是。
线程从来没有失败过。它只是再也不会被压缩了。
2. 两个视角,两个结论⌗
问题的根在于:上下文超限这件事,路由器和线程看到的是两种完全不同的东西。
| 视角 | 它看到的 | 它的「正确」反应 |
|---|---|---|
| 路由器(provider 层) | 某个 provider 拒绝了这个请求 | 换一个 provider |
| 回合执行器(线程层) | 这个线程的状态已经超出它所选模型能装下的范围 | 压缩历史,在同一模型上续写 |
路由器的回退逻辑建立在一个隐含前提上:失败属于 provider——网络断了、服务过载、限流、额度用完。换一家,问题就不在了。
上下文超限不满足这个前提。超的不是 provider,是 prompt;prompt 是线程的状态。换一个窗口更大的模型,并没有让状态变小,只是暂时找到了一个还装得下的容器。差距是 1,050,000 − 1,000,000 = 50,000 token,线程每回合都在继续长,这点余量迟早用完——而在那之前,压缩机制一直是瞎的。
用图画出来:
修复前
──────────────────────────────────────────────────────────
turn N ─► router ─► 钉选模型 (1M) ── 400 invalid_request ─┐
│ 记一笔健康失败
下一个候选 (1.05M) ◄──────────────────┘
│ 成功
turn-runner ◄────────────┘ 「一切正常」,不压缩
turn N+1:同上,再来一遍
修复后
──────────────────────────────────────────────────────────
turn N ─► router ─► 钉选模型 (1M) ── 400, 文本命中超限短语
│ 分类为 context_length
│ 不记健康、不试下一个、不进终极池
turn-runner ◄── ContextCapacityError
│
└─► rollover(trigger = context-limit):摘要旧历史、开新一代
└─► 在同一个钉选模型上重发本回合
3. 错误该分给谁:按「谁能修」分类⌗
MaxClaw 的路由器其实早就承认「不是所有错误都该换模型」。看它在回退之前直接 rethrow 的几类(src/llm/router.ts 的 tryTerminalFallbackOrRethrow 与候选链循环):
| 错误 | 属于谁 | 换模型有用吗 | 正确的响应者 |
|---|---|---|---|
| 网络、5xx、过载、限流、额度窗口 | provider | 有用 | 路由器:重试 / 下一个候选 / 终极池 |
| 内容政策拒答(refusal) | 内容 | 不该用:拒答随内容走,不升级到更强的模型重发 | 调用方 |
invalid_ir(本地历史在编码前就被拒) | 本地数据 | 必败:同一份 IR 换谁都过不了 | 调用方 |
| 意图性中止(用户 stop、steering 重建) | 调用方自己 | 无意义 | 调用方 |
| 上下文超限 | 线程状态 | 能「成功」,但是错的 | 回合执行器:压缩后在原模型续写 |
前四行在这次改动之前就已经是这样。最后一行是这次补上的,而它比其它几行都更阴险:拒答换模型也许会被另一个模型接受,但至少那是一个可以讨论的策略选择;invalid_ir 换模型必败,失败会大声地冒出来。上下文超限换到大窗口模型是会成功的——成功把问题吞掉了,没人报警。
一个错误分类器真正要回答的问题不是「这是什么错」,而是「这是谁的错,谁能修它」。路由器能修 provider 的错;状态的错只有持有状态的那一层能修。
4. 为什么之前没接住:两份清单的漂移⌗
MaxClaw 里早就有「API 报上下文超限 → 自动 rollover」这条兜底路径。回合执行器有一个 isContextLimitError,直连循环(src/runtime/direct-loop-host.ts)在模型调用抛错时问它,命中就抛 DirectLoopRolloverRequiredError('context-limit', …),外层据此压缩、重建消息、继续。
那它为什么没生效?两个原因叠在一起。
第一,错误根本没走到它面前。 分类在路由器里发生,Anthropic 协议的这个 400 被归为 invalid_request,路由器按 provider 故障处理,换了模型、成功了。回合执行器收到的是一个正常结果。
第二,就算走到了,它也不认识这句话。 修复前代码里有两份「什么算超限」的文本清单:
// src/memory/compaction.ts(压缩摘要器自己的判定)
msg.includes('context limit') || msg.includes('context_length_exceeded') ||
msg.includes('maximum context length') || msg.includes('prompt is too long') ||
msg.includes('token limit') || (msg.includes('too many') && msg.includes('token'))
// src/runtime/turn-runner.ts(回合执行器的判定)
msg.includes('context limit') || msg.includes('context_length_exceeded') ||
msg.includes('maximum context length') || msg.includes('token limit') ||
(msg.includes('too many') && msg.includes('token'))
对一下就看出来:回合执行器那份少了 prompt is too long——正好是这次上游用的措辞。同一个概念写了两遍,其中一遍跟上了新文案,另一遍没有。这是复制粘贴代码最典型的死法,不需要任何人犯大错,只需要时间。
5. 改了什么⌗
三处,都不大(diff 统计:7 个文件,+170 / −38,其中测试占了大头)。
5.1 分类:超限短语只定义一次(src/llm/provider-error.ts)
const CONTEXT_OVERFLOW_PHRASES = ['prompt is too long', 'context limit',
'context_length_exceeded', 'maximum context length', 'token limit']
classifyHttpFailure 在已经判定为确定性客户端错误(4xx 或确定性错误体)的分支里,先看错误体的 message 字段是否命中这些短语,命中就归为 context_length。只看 4xx 的 message 字段,不在任意自由文本里搜——同一个文件里还有一条既有测试专门守着「不从自由文本里的状态码样子的子串推断」。
ContextCapacityError 从 router.ts 搬到这里,和唯一的判定函数放在一起:
export function isContextLimitError(err: unknown): boolean {
if (err instanceof ContextCapacityError) return true
if (err instanceof ProviderError) return err.failure.errorClass === 'context_length'
return isContextOverflowText(err instanceof Error ? err.message : String(err))
}
顺序是有意的:有类型先信类型。一个已经被分类为 invalid_request 的 ProviderError 不会再被文本「救」成超限——测试里 'Anthropic API 400: bad field' 返回 false。文本匹配只留给没被分类过的裸错误(比如外部 CLI 抛出来的字符串)。
回合执行器和压缩摘要器各自那份清单删掉,都改用这一个函数。
5.2 路由:超限立刻交回,不进任何回退(src/llm/router.ts)
候选链循环里,分类结果是 context_length 时:
if (failure.errorClass === 'context_length') {
throw new ContextCapacityError(`[LLMRouter] ${provider.id} rejected the prompt as over its context window: ${reason}`)
}
注意它在 this.health.recordFailure(...) 之前。这个 provider 没有做错任何事,不该给它的健康状态记一笔,更不该因此触发熔断或降级。终极回退入口也加了一行:ContextCapacityError 原样 rethrow,不进终极池。
5.3 回合执行器:什么都没改,只是终于收到了
ContextCapacityError 一直就在回合执行器的识别范围里——它原本是路由器容量预检(「链上没有任何 provider 装得下」)抛的。现在上游的超限拒绝也走同一个类型、同一条 rollover 路径,rollover 事件的 trigger 记为 context-limit,和水位触发的 threshold 区分开。
6. 证据:测试就是规格⌗
| 测试 | 断言 | 说明了什么 |
|---|---|---|
provider-error.test.ts:prompt-too-long 分类 | 400 + 上述错误体 → { status: 400, code: 'invalid_request_error', errorClass: 'context_length' },且非瞬时 | 上游码保留,但类别以语义为准 |
provider-error.test.ts:isContextLimitError | 两种类型化错误为真;已分类为 invalid_request 的为假;两条裸文本为真;socket hang up 为假 | 先类型后文本,文本不覆盖类型 |
router.test.ts:钉选单模型 / 钉选 palette 两种 | 抛 ContextCapacityError;上游只被请求 1 次;1.05M 的备选从未被调用;熔断列表为空 | 无下一个候选、无终极回退、无健康惩罚 |
router.test.ts:钉选模型容量预检 | knownPromptTokens = 990_000 时上游请求 0 次,备选不被调用 | 预检失败同样不偷偷换大模型 |
turn-runner.compaction.test.ts:端到端 | 第一次请求被拒、第二次成功;两次请求都发往同一个端点;重发的 messages 含摘要和本回合原问题;会话 id 变了(新一代);压缩事件 trigger = 'context-limit';新一代里唯一的助手回答是 rollover 之后那条 | 线程在原模型上压缩续写,回合不丢 |
最后一条用的是真的 LLMRouter,不是桩:路由配置里的默认 chat 链就是那个更大的备选模型,它被 mock 成随时可以成功回答——测试的意义正在于:一个现成的、能成功的退路摆在那里,系统也不去走它。
这里 990,000 这个数也值得算一下:预检用 knownPromptTokens / 0.95 求所需窗口,990,000 / 0.95 ≈ 1,042,106 > 1,000,000,所以钉选的 1M 模型被判装不下。0.95 和回合执行器主动 rollover 的阈值对齐(ROLLOVER_THRESHOLD_PERCENT)。
7. 为什么水位预判不够,还需要这条兜底⌗
一个自然的问题:既然有按水位主动压缩(每次模型调用前 shouldRolloverBeforeCall),为什么还会撞上上游的硬墙?
从代码里能看到几种预判本身就不覆盖的情况:
- 没有读数。 预判只用上游上一次返回的真实 input token 数,禁止用字符长度估算(
router.ts容量预检注释原话的意思)。新一代的第一次调用、没有用量历史时,预判 fail-open。 - 读数不可信。 有的 provider 配置了
usageDrivenRollover = false:它的 input_tokens 把服务端会话状态也算进去,实测虚高约 3.3 倍(src/shared/provider-types.ts注释)。这类 provider 的主动压缩和容量预检全部关闭,上游的超限错误是唯一的触发器。 - 单步跳涨。 水位是上一次调用的读数,这一次调用可能带上了一个巨大的工具结果。(推断:这是长工具链里最常见的越线方式。)
所以「错误驱动的 rollover」不是锦上添花的保险层,而是对一部分 provider 来说唯一的压缩入口。它的输入——「这是不是一次超限」——如果被上游路由层截胡,整条机制就失效,而且失效得悄无声息。
8. 代价与没解决的⌗
放弃了一种「能救」的情况。 如果用户就是想要「这一回合别压缩、换个大窗口模型先答了」,现在做不到了:超限永远走压缩。我认为这是对的——在钉选模型的线程里静默换模型,本身就违背了「钉选」的意思;而且这次救了,下回合还是同一道墙。但这是一个取舍,不是免费的。
文本匹配仍然是文本匹配。 上游用 invalid_request_error 这种通用码报超限,只在 message 里写清楚,分类只能看文字。短语表现在只有一份,漂移风险小了,但上游哪天换一种措辞,这里照样会漏——漏的后果会回到第 1 节:回退成功、不压缩。比较好的一点是,现在只需要改一处。
容量预检对 palette 的行为没变。 预检的本意是「不降级到小窗口模型」,实现上是在候选链里过滤掉装不下的;如果一个 palette 里同时有小窗口和大窗口模型,且有可信读数,预检会留下大窗口的那个——这和本文的论点方向相反。(推断:实际运行中主动 rollover 在同一阈值上先触发,这条路径很少走到;但它是一处尚未统一的语义,测试只覆盖了钉选单模型的情况。)
rollover 有上限。 一个回合内最多 3 次(MAX_TOOL_LOOP_ROLLOVERS),再超就停下,提示开新对话;压缩摘要器自己也会撞窗口,最多把输入预算对半砍 4 次(SUMMARY_INPUT_SHRINK_STEPS)。这两个上限防的是「压完还是装不下」的死循环,不是这次改动引入的,但新的触发源会让它们更常被用到。
9. 一句话⌗
回退是给「别人坏了」准备的。当失败的原因是你自己——你的 prompt、你的线程、你攒下的状态——退到别人那里成功一次,只是把账往后挪了一回合。分类器要问的不是「这是什么错误码」,而是「换一个人来,问题还在不在」。