一次修复复盘:给"内容触发的拒答"配一个会过期的冷却,本质上是定时重发同一份被拒内容。

1. 拒答和 503 不是一类错误⌗

一个多模型 agent 框架,路由层对 provider 失败要做分类处理。这个框架的 src/llm/provider-health.ts 里,大部分失败都按时间熔断:

失败类型熔断时长隐含假设
认证失败30 分钟凭据过一会儿可能被修好
配额耗尽默认 15 分钟配额窗口会滚动
短暂错误60 秒服务端抖动会自愈
内容政策拒答??

前三行有个共同点:故障原因在时间轴上会变化。服务端会恢复,配额会重置,所以"等一会儿再试"是有依据的。

拒答不一样。模型说"这段内容我不处理",它判断的对象是请求里的字节。在一个 agent 线程里,每次请求都会把对话历史原样重放。只要触发拒答的那一段还在历史里,下一次请求就照样带着它。

所以表格最后一行没法照抄。时钟走了十五分钟,这件事本身说明不了内容变了,也说明不了 provider 的策略变了。

2. 段内历史只增不减⌗

框架把一个线程切成上下文段,段键是 ${sessionId}#${segmentNo}。session 是线程的一"代",压缩/滚动或 fresh_start 会开新一代;模型、模式、工具面、提示词等任何一项变了,segmentNo 都会递增。

同一个段里,历史只会往后追加:

段 S 的请求字节(每个 turn 重放)

turn 1:  [system][...历史...][X][新消息1]          ← X 触发拒答
turn 2:  [system][...历史...][X][新消息1][回复][新消息2]
turn 3:  [system][...历史...][X][...........][新消息3]
                              ▲
                              X 永远在前缀里,直到段结束

能让 X 离开请求的只有这几种边界:

边界为什么改变了结论
压缩 / 滚动出新一代历史被改写或摘要,X 不一定还在
fresh_start新一代,不带入旧历史
换 primary 模型换了 provider,判断对象变了
工具面 / 提示词 / 模式变化请求前缀变了,新段重新判定

在两次边界之间重试,等于把同一份已被拒绝的字节再发一遍。

需要说明:拒答并不是这个框架遇到拒答时的第一反应。如果触发内容是还没被接受的新增部分(外部事件、工具结果),框架会先把它隔离或重新渲染,换个中性版本再试;只有已被接受的历史本身被拒、没有可中和的增量时,才会走到本文讨论的"本段跳过这个 provider,降级到下一个"这条路。

3. 修复前:一个永不到期的十五分钟⌗

这条路最初的设计(10 月初)是纯边界失效:段内拒过就不再试,直到边界。后来有一次修改加了一个 15 分钟的时钟冷却,理由是"跳过应该是冷却,不是永久封禁"。这个直觉不算错,拒答分类器确实可能不稳定。但实现落地后,有几处地方各自偏离了设计:

// 修复前 segmentRefusals 的核心判断
if (typeof chunk.createdAt === 'number'
    && now - chunk.createdAt >= REFUSAL_REPROBE_AFTER_MS) continue

① 冷却锚在"最近一行"上,而不是"最近一次拒答"上。 被跳过的 turn 也会写一张降级起始卡,路由层把"段内跳过"也记成一次 errorClass: 'refusal' 的失败。这张卡的时间戳就是当前 turn。线程一活跃,冷却每个 turn 都被续上;线程闲置超过 15 分钟,下个 turn 就去重探,被拒后再写一行,重新计时。这个循环没有上限。

② 时间戳有两种形状。 回放行(ReplayChunk)的 createdAt 是 number,另一种行(Chunk)是 Date。typeof === 'number' 对 Date 不成立,守卫就悄悄失效了:同一段逻辑,换一种行形状,行为就从"无限重探"变成"永不重探"。

③ 通知只响一次。 dedupe 键是 provider-refusal:${segmentId},每段只推一次。之后无论重探了多少次,人都看不到。

④ 文案和代码说的相反。 模块头注释写"不按时间失效",推给人的通知写"这一段里不再尝试",给模型看的降级卡写"本上下文段内不再尝试它",可代码其实每 15 分钟就去试一次。

四条叠在一起,结果就是一个线程卡在拒答上出不来:每隔一段时间重发一次被拒内容,每次都多写一行记录,而所有说明文字都声称它早就停了。

4. 修复后:三档递进,用尽即冻结⌗

新规则写在 src/runtime/provider-refusal.ts:

export const REFUSAL_REPROBE_DELAYS_MS: readonly number[] = [
  15 * 60_000,          // 第 1 次重探:距首次拒答 15 分钟
  60 * 60_000,          // 第 2 次:距上次拒答 1 小时
  REFUSAL_SCOPE_TTL_MS, // 第 3 次:6 小时,与 provider-health 的 turn 级拒答记忆同一口径
]
export const MAX_REFUSAL_REPROBES = REFUSAL_REPROBE_DELAYS_MS.length

状态机只看两个数,本段被拒次数和最近一次被拒时刻:

const reprobes = Math.max(0, refusals - 1)
if (reprobes >= MAX_REFUSAL_REPROBES) → frozen
else nextProbeAt = refusedAt + REFUSAL_REPROBE_DELAYS_MS[reprobes]

一个段的完整生命周期:

t=0        首次拒答 (refusals=1)          → 通知 #1(normal)
  │ 15m 内所有 turn:跳过,降级
t≈15m      重探 1 → 仍拒 (refusals=2)       → 静默
  │ 1h
t≈1h15m    重探 2 → 仍拒 (refusals=3)       → 静默
  │ 6h
t≈7h15m    重探 3 → 仍拒 (refusals=4)       → 冻结,通知 #2(critical)
  │
  ∞        本段不再发送给该 provider,直到边界

最坏情况下,一份被拒内容在一个段里总共只发给该 provider 4 次(首次 + 3 次重探),而且前一次拒得越久,下一次等得越久。重探次数只在"不幸的分类器抖动"这类情况上留了余地,不再在无限的时间轴上期待内容自己消失。

为什么要设上限,而不是做一个无上限的指数退避?指数退避管的是"多久试一次",管不了"值不值得试"。退避的前提是失败原因会随时间衰减,而内容触发的拒答,原因就固定在前缀里。退避再长,也只是把同一份请求发得慢一点,次数照样没有尽头。真正能改变结论的事件(压缩、换代、换模型)都是离散的,恢复也应该挂在这些事件上,不该挂在时钟上。

5. 状态记在哪:锚定拒答,不锚定 turn⌗

修复没有新建事件类型,状态仍然放在已有的 llm-fallback 起始卡(sourceMeta.refusal)上,只给每个 provider 条目加了两个字段:

interface ProviderRefusal {
  providerId: string
  model: string | null
  category: string | null
  refusals?: number   // 本段内实际被拒次数(含这一条)
  refusedAt?: number  // 最近一次实际被拒的时刻
}

关键在于谁负责写这两个值:

行的来源refusalsrefusedAt
真实拒答(首次或重探失败)上一次值 + 1这次拒答的时刻
冷却内被跳过的 turn照抄历史里的值照抄,不刷新

turn-runner 只在真的收到拒答时调用 segmentRefusalState 递增计数;direct-loop-host 写降级卡时只读不增。这样一来,跳过行再多也推不动时钟,冷却始终从最近一次真实拒答算起。这正好修掉了第 3 节的 ①。

读的一侧,epochMs 同时接受 number 和 Date,修掉 ②:

function epochMs(value: number | Date | undefined): number | null {
  const ms = value instanceof Date ? value.getTime() : value
  return typeof ms === 'number' && Number.isFinite(ms) ? ms : null
}

还有一种情况:修复之前写下的旧行没有 refusals,既不知道被拒过几次,时刻也不可信。代码没有去猜,直接把它当作冻结,按边界失效,也就是回到最初的设计。对于不知道的状态,选最保守、又能被边界解开的那个解释。

6. 通知:首次说规则,冻结时升级一次⌗

dedupe 键从"每段一个"改成"每种状态一个":

`provider-refusal:${segmentId}:${reprobes}:${sortedProviderIds}`

发不发由 direct-loop-host 决定:reprobes === 0 || frozen 才推。

时刻级别内容
首次拒答normal原因(历史里有它不处理的内容)、重探计划(15 分钟、1 小时、6 小时各一次)、fresh_start 选项
中间两次重探失败不推计划里已经写了,重复只是打扰
冻结critical真实拒答次数(“首次 + 3 次重探”)、“本段已冻结,不再自动尝试”、两条恢复路径:换 primary 模型、fresh_start

冻结那条用 critical,代码注释给的理由是"不受每日非关键推送预算削减,真的到达"。这是整个流程里唯一需要人做决定的时刻:系统自己能试的都试过了,剩下的只有人能决定的边界动作。

文案也一并改了,修掉 ④。降级卡改成"本上下文段内先跳过它(有界重探,用尽即在本段冻结)",IM 文本同理。模型、人、代码三方对规则的描述终于一致了。

7. 测试证明了什么⌗

规格主要写在两个测试文件里。

provider-refusal.test.ts(单元):

用例断言
首档冷却未到跳过,nextProbeAt = refusedAt + 15m
首档到期不再跳过(本回合重探),但仍在 history 里
递进第 2 次拒答后,下一档按 1 小时计
用尽第 4 次拒答后 frozen: true, nextProbeAt: null,now = 1e15 也不解冻
旧行无次数、无时刻 → 冻结
Date 形状冷却到期前后的判定与 number 形状一致
通知冻结时 level: 'critical',正文含真实次数与 fresh_start,dedupe 键与首次不同

refusal-quarantine.integration.test.ts(真实 turn 路径,替换 Date.now 推进时钟):

首个被拒 turn 之后         被拒 provider 命中数 = 4   (原始请求 + 两次重渲染尝试)
推进 15m / 1h / 6h 各跑一轮 → 每档 +1,共 4 + 3
再推进 100 小时,跑 3 轮    → 仍是 4 + 3,一字节都没再发
通知 dedupe 键             → 恰好两条:(段, 0) 和 (段, 3)
工具面变化(新段)          → +1,边界后重新尝试

中间那一行最关键:时钟推进 100 小时之后,被拒的历史一次都没再发出去。修复前的代码正好在这一步出问题。

这些测试证明的是调度与状态是确定的。它们没有证明:

  • 3 档、15 分钟 / 1 小时 / 6 小时是最优参数。材料里没有拒答随时间翻转概率的统计,这组数字是"给分类器抖动留余地"和"重发成本"之间的工程取舍,末档复用了已有的 6 小时口径,没有另造一个常量。
  • 冻结不会误伤。如果 provider 在冻结之后真的调整了策略,本段内框架不会知道。它要等下一个边界才会重新尝试。这是有意的:用"偶尔晚一点发现策略变化"换"不再无限重发被拒内容"。

8. 一条可以带走的规则⌗

给失败配恢复策略之前,先问清楚:导致失败的那个东西,会随什么而改变?

  • 随时间改变(服务恢复、配额滚动),就按时钟退避。
  • 随离散事件改变(内容被改写、请求方换人),就把恢复挂在事件上;时钟最多用来覆盖一点噪声,而且次数要有尽头。

内容拒答属于后者。等十五分钟,它还是会拒绝,因为请求里的东西一个字节都没少。真正的恢复要么让那些字节离开请求,要么换一个判断它的 provider。在此之前,系统能做的是试过几次之后停下,然后把真实情况告诉人。