一次 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 + strongerfree → light → mid → frontier
mid + strongermid → frontier
mid + weakermid → light → free
frontier + strongerfrontier(朝外的端点,立即停)
free + weakerfree(同上)

两个方向对应两种真实需求:

  • 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返回且通过输出校验是
failedprovider 错误、超时、拒答、上下文超长、输出校验失败是
skippedprovider 被禁用、缺 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 但没有 siterequire a registered site
site 未注册not registered
同时钉住某个模型 modelRequiredcannot 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          ← 平均跳数告诉你起点定得对不对

重试是希望问题自己消失;回退链是承认问题会发生,然后规定好它发生时该往哪走、走到哪为止、留下什么记录。