回退不是再试一次:给模型路由装上方向盘和里程表

一次 LLM 路由改动的设计笔记:失败之后往哪走、走到哪停、走过的路怎么留下来。
1. 「再试一次」为什么不够⌗
一个 Agent 系统里,除了主对话,还有大量「小调用」:给一段文档打分、从一段输出里抽结构化字段、判断一条消息该归到哪类。它们的共同点是:便宜的模型多半能做,偶尔做不好;做不好的时候,系统需要一个更强的模型兜住。
这类调用失败时,常见的处理有三种:
| 做法 | 问题 |
|---|---|
| 同一个模型重试 N 次 | 输出校验失败往往是确定性的——同一个模型、同一个 prompt,第二次大概率还是那个样子 |
| 一张扁平的备选列表 | 列表顺序表达不了「先便宜后贵」还是「先好后快」,换个调用点就得重抄一份 |
| 失败后扔进「任何能用的模型」兜底池 | 调用点设定的成本上限、质量下限、排除规则,在兜底池里全部失效;而且成功了也没人知道它其实已经降级 |
MaxClaw 原有的路由属于第二、三种的组合:按任务类型走一条路由链,健康检查把近期失败的 provider 降级跳过,全部失败后默认进入一个「终极 fallback 池」,以可见的降级卡片收场。这对主对话是对的——对话不能因为一个模型挂了就停下。
但对那些有明确质量/成本边界的小调用,它把三件事搅在了一起:往哪退、退到哪为止、退过的痕迹去哪了。这次落地的「跨档回退链」就是把这三件事拆开。
2. 方向:一条有序的档位轴⌗
系统里本来就有 palette(能力档位)的概念,每个档位是一串有内部顺序的 provider。这次只新增了一条档位之间的全序:
paletteOrder(默认): frontier ── mid ── light ── free
更强 ◀────────────────────▶ 更弱
一个调用点(代码里叫 site)的回退策略只有两个字段:
{ "start": "free", "toward": "stronger" }
展开规则全部在 palette-chain.ts 的 32 行里:
return chain.toward === 'stronger'
? paletteOrder.slice(0, start + 1).reverse()
: paletteOrder.slice(start)
也就是说,起点在 stronger 方向上是下限,在 weaker 方向上是上限,并且不绕回:
| 策略 | 访问的档位 |
|---|---|
free + stronger | free → light → mid → frontier |
mid + stronger | mid → frontier |
mid + weaker | mid → light → free |
frontier + stronger | frontier(朝外的端点,立即停) |
free + weaker | free(同上) |
两个方向对应两种真实需求:
stronger:从便宜的开始,做不好再升级。 适合「大多数时候小模型就够了」的调用。所有在代码里注册的 site 默认就是free + stronger。weaker:从好的开始,挂了再降级。 适合「质量优先,但宁可降级也不能不出结果」的调用;起点就是质量天花板,也是成本天花板。
为什么不让每个 site 写自己的模型列表?因为档位的成员会变——新模型进 mid、旧模型退出 free——这些变动只该改一处。site 只声明「从哪个档开始、朝哪个方向」,具体是哪些 provider 由档位定义实时展开。
3. 展开的三个细节⌗
把档位链展开成 provider 序列时,测试(palette-chain.test.ts)钉住了三条规则。
档内顺序保持,provider 只在第一次出现处计入。 档位定义允许互相引用,同一个 provider 可能同时属于多个档。测试里 light = [c, palette:mid]、free = [d, c],free + stronger 展开为 d, c, b, a,而不是 d, c, c, b, a, ...。
归属也按第一次出现算。 c 同时在 free 和 light 里,展开结果把它记在 free 名下。这不是记账洁癖:每一跳用哪个档位的 thinking 强度,取决于这个归属。测试里 free 档配置了 thinkingLevel: 'off',于是 d、c 两跳都以 off 调用——同一个模型,在不同档位下本来就可能是不同的用法。
类别过滤与排除对整条链生效。 带内容类别的调用只能发给打了全部类别标签的模型(取交集);excludeModels 按精确 provider id 排除。设计文档里给的例子是「审稿调用排除写稿的那个模型」——自己审自己没有意义。过滤之后如果一个候选都不剩:
PaletteChainError: no models remain after category and exclusion filters
在发出任何请求之前就失败,而且这次「零跳失败」照样落库。
4. 每一跳都有原因⌗
链上的每个候选,只会是三种结果之一:
| outcome | 何时 | 计入「尝试跳数」 |
|---|---|---|
success | 返回且通过输出校验 | 是 |
failed | provider 错误、超时、拒答、上下文超长、输出校验失败 | 是 |
skipped | provider 被禁用、缺 key、未注册 | 否,但保留跳过原因 |
和原有路由相比,有几处刻意的不同。
4.1 校验失败也是一跳⌗
这是整个设计里最值钱的一条。以前 callStructured 的做法是:拿到输出,JSON 解析或 schema 校验失败,就带着错误信息让同一个模型再来一轮。现在走链的调用把 JSON 解析和 Zod 校验塞进了链内部的 validateOutput:
d 输出 "not json" → failed (validation)
c 输出 {"ok":"yes"} → failed (validation) ← 类型不对
b 输出 {"ok":true} → success, chainHops = 3
这是 router-palette-chain.test.ts 里的原样用例。一个模型给不出合规输出,最有效的补救通常不是让它再想一遍,而是换一个更强的模型。校验失败的那一跳照样记 token——模型确实干了活,只是活不合格。
4.2 不看健康分,挨个试⌗
原路由在有健康候选时会跳过近期失败而被降级的 provider。回退链传了一个 exhaustive: true,关掉这条捷径:链上每个可用候选都按顺序试,包括有过失败记录的。
理由是边界:一条 site 链已经被方向和起点限定得很短,健康状态是全局的、带时间衰减的,不该悄悄把某个调用点的候选再砍一刀,更不该因为「前面都被跳过了」把调用推出边界。代价是可能在一个刚挂过的模型上多等一次——这笔账被认为划算,因为另一边的代价是不可见的边界破坏。
4.3 上下文超长:不再退回调用方⌗
原路由遇到 context_length 会抛 ContextCapacityError,交还给调用方去压缩 prompt——对长对话这是对的。在回退链里它变成普通的一跳失败:下一个模型可能窗口更大。测试 context errors advance rather than escape the explicit chain 钉住了这一点。
4.4 两种情况立刻停⌗
调用方取消(AbortSignal) → 立即抛出,不换模型
本地 IR 编码损坏(确定性错误) → 立即抛出,不换模型
前者不是 provider 超时,是调用方不要了;后者是请求本身坏了,换谁都一样坏。把同一个坏请求挨个发给每个模型,只是把一次失败放大成 N 次。
5. 耗尽就显式失败⌗
链走到边界还没成功,抛 PaletteChainError,消息里带全部跳和原因:
Palette chain "review" failed: d: failure-d | c: failure-c
测试 an outward endpoint exhausts its own palette without crossing the bound or entering terminal fallback 专门验证:即使调用方传了 terminalFallback: 'auto',free + weaker 也只试 d, c 两个,然后失败,不进入终极兜底池。
这是最容易被质疑的一条:明明还有别的模型能用,为什么不用?
因为 site 的策略本身就是一个承诺。free + weaker 的意思是「只用 free 档」——也许是成本约束,也许是这个调用量巨大、不值得用贵模型。兜底池会在这个承诺上悄悄开个后门,而且以「成功」收场。调用方拿到结果,看不出它其实越过了边界。对一个需要自己决定下一步的 Agent 来说,「明确失败 + 每一跳的原因」比「看起来成功」有用得多:它可以换策略、可以报告、可以放弃,而不是基于一个被静默降级(或升级)的结果继续往下做。
还有几条也是「前置失败」,同样在任何模型调用之前发生:
| 条件 | 错误 |
|---|---|
带 chain 但没有 site | require a registered site |
| site 未注册 | not registered |
同时钉住某个模型 modelRequired | cannot be combined |
| 没装链调用的记录器 | recorder is not installed |
最后一条值得多说一句:记不下来就不调用。
6. 单独记账,但不重复计费⌗
每次链调用结束后,记录会被 await 写进 PG 表 llm_chain_calls(迁移 0616):site、内容类别、生效策略、最终服务的模型、总延迟、以及全部跳(每跳的 outcome、错误类别与原因、延迟、上报的 token)。
llm_chain_calls
├── site, categories, chain(jsonb)
├── started_at, latency_ms, success, model
├── hops(jsonb) ← [{providerId, outcome, reason, errorClass, latencyMs, usage}]
└── error
几个设计选择:
- 不存 prompt 和响应正文。 这是可观测性记录,不是对话存档。
- 不是第二本账。 token 计费继续走原有的
token_usage,包括校验失败的跳和中途断开的跳;链表里的 usage 只用来回答「这个 site 在这段时间花了多少」。测试first success stops and writes one complete record without double billing断言一次成功调用只产生一条 usage 记录。 - 写库失败是响亮的。 记录器抛错时,调用以
AggregateError("Could not persist palette chain call ...")失败,并且不会再触发额外的模型调用——测试里calls精确等于['d']。宁可让调用方知道「结果有,但账没记上」,也不要一个永远对不上的监控面板。 - 一跳里的适配器重试被合并计入。 底层适配器自己的网络重试不算新的跳,但被中断那次的 token 会累加到同一跳上(测试里一跳的 input 是
10 + 10 = 20)。
7. 看板的口径⌗
Web 控制台的 LLM 配置页新增「跨档回退链」面板:管理 site 的起点和方向、按类别预览完整展开链,以及按时间范围和 site 看监控。口径在 SQL 里写得很死,也在测试里钉住了:
| 指标 | 分母 / 口径 |
|---|---|
| 成功率 | 全部调用(含过滤为空的零跳失败) |
| 平均尝试跳数 | 全部调用;skipped 不计 |
| 模型占比 | 只算成功调用——失败的调用没有「服务模型」 |
| 延迟 p50/p90/p99 | 全部调用,含失败 |
| 输入/输出 token | 全部调用的全部跳,含失败跳 |
| 最近失败 | 最多 30 条,带每一跳的原因 |
llm-chain-calls.test.ts 的一组数据:3 次调用,成功率 2/3,平均跳数 2,两个成功调用分别由 a、b 服务(各占 0.5),p50 = 500ms,p90 ≈ 820ms,p99 ≈ 892ms,input token 40、output token 12——失败调用的 token 一并算进去了。
「平均跳数」是这里最有信息量的数字。一个 free + stronger 的 site,平均跳数如果长期接近 1,说明 free 档就够用;如果爬到 3,说明起点定低了,每次都在为失败的尝试付钱和付延迟——这时该把起点往上调,而不是继续堆重试。这个指标让「起点定在哪」从拍脑袋变成可以按数据调的旋钮。
8. 配置:和档位住在一起⌗
回退策略写在 LLM 配置的 chains 字段里,和档位、provider 共享同一个带版本号的 PG 配置修订:
- 乐观锁保存(
baseRevisionId),热应用不重启,能沿历史版本回滚; - 在写入边界一次性校验。
chain-config.test.ts列了 10 种必须拒绝的输入:空顺序、重复档位、未知档位、起点不在顺序里、方向不是stronger/weaker、空 site 名、带空格的 site 名、未知字段、null……; - 档位顺序和名字都可以自定义(测试里用
large/small、fast/slow/spare跑过); - 没有
chains字段的旧配置,行为完全不变——不会凭空生成一个策略修订。
热应用的测试很直接:把顺序改成 free, mid, light, frontier、site 改成 mid + stronger,预览立刻变成 b, a, d, c;再改成 frontier + stronger,下一次调用直接由 a 服务。
9. 这次没做的⌗
坦白几处边界:
- 没有迁移任何现有调用方。 这次落地的是机制本身:router API、配置、记录、看板。没有
site的旧调用走原路径,一个都没改。一套新装的系统,回退链面板会是空的,直到有调用方注册 site。 PaletteChainError转换成通用的 provider 失败结构时,错误类别统一记成unknown。 真正的分类保留在链记录的每一跳上;上层降级卡片目前看不到它。- 记录是同步
await的。 每次链调用多一次 PG 写入的延迟。这是「记不下来就不算」的直接代价。
10. 一句话⌗
回退链把「失败后怎么办」从一个隐式的运行时行为,变成了一个可以声明、可以预览、可以度量的策略:
声明 start + toward ← 写在配置修订里,热更新、可回滚
预览 展开后的 provider 序列 ← 改之前就能看到会打到谁
执行 每跳一个原因,到边界就停 ← 不重试同一个,不越界兜底
度量 llm_chain_calls ← 平均跳数告诉你起点定得对不对
重试是希望问题自己消失;回退链是承认问题会发生,然后规定好它发生时该往哪走、走到哪为止、留下什么记录。