等十五分钟,它还是会拒绝:内容拒答的重探为什么必须有尽头

一次修复复盘:给"内容触发的拒答"配一个会过期的冷却,本质上是定时重发同一份被拒内容。
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 // 最近一次实际被拒的时刻
}
关键在于谁负责写这两个值:
| 行的来源 | refusals | refusedAt |
|---|---|---|
| 真实拒答(首次或重探失败) | 上一次值 + 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。在此之前,系统能做的是试过几次之后停下,然后把真实情况告诉人。