错误处理与自纠:失败回灌、退避重试、死循环检测与取消中断
把六类真实故障一次处理干净:工具失败、参数非法、网关限流、命令超时、流中断、模型打转。原则只有一条——能让模型自己改的错误就回灌给它,不能的才向用户抬头,并且任何时候用户都能按下取消。
今日目标
- 能把 Agent 运行时的错误分成回灌自纠、重试退避、直接抬头三类,并说清判据
- 能实现带抖动的退避重试,并解释为什么工具失败不该重试整轮
- 能实现取消信号从按键一路传到子进程的完整链路,并检测出模型在打转
前五天我们零散地遇到过好几类失败,今天把它们放在一起处理一遍。读完回到页面顶部把三条目标勾掉。
小白版讲解
犯错之后怎么办:把错误原样告给他,比替他改更有效
新人第一次动手一定会撞墙:改了个文件,测试红了;敲了条命令,参数写错了。
带他的人有两种做法:替他改,或者把报错原样贴给他加一句「你看这行说的是缩进对不上」。前者当天效率高,但他下次还是不会。
Agent 这边更极端:你根本没有「替它改」这个选项。 你能改的是文件和命令,改不了它下一步想干什么——那只取决于它在上下文里读到了什么。所以处理 Agent 的错误只有一种手段:把错误变成它下一轮能读到的文字。 这件事叫工具结果回灌(tool result feedback),我们从第三天就在用,今天才把它当成一条纪律。
这条纪律有个反直觉的结论:工具的失败信息不是给人看的日志,而是给模型看的行为规范。 同一个失败写成两种文字,模型的行为完全不同:
写法一:edit_file failed: no match
写法二:在 src/calc.js 里找不到那段 old_string,没有改动任何内容。
常见原因是缩进或换行不一致,或者文件已经被改过了——请先 read_file 再重试。第一种,模型会原地把同样的调用再发一次(它以为是偶发抖动)。第二种,它会先去读文件。这两行字的差别就是「这个 Agent 能不能自己走出困境」的差别,而它不在模型里,在你写的那句话里。今天要做的,就是把这条纪律推广到六类故障上,并且承认它有边界。
六类故障与三种处理:谁该回灌、谁该重试、谁该抬头
失败远不止六种,但按「你该怎么处理」归类,它们只落在三个桶里。本实验把六类各注入一次,正好填满三个桶:
| 故障 | 你这一层看到的东西 | 处理 | 为什么 |
|---|---|---|---|
| 工具执行失败 | 工具结果的 ok 是 false | 回灌 | 它改个参数、换个路径就能自己解决 |
| 参数不是合法 JSON | 分片拼完解析不了 | 回灌 | 是它写坏的,把原文给它看它就重写 |
| 模型在打转 | 同工具同参数连续三次 | 回灌 | 它自己看不出在重复,得有人告诉它 |
| 命令超时 | 子进程被杀掉 | 回灌 | 结果里带上「把命令拆小」,它才会改策略 |
| 网关限流 | 一个 429 | 重试 | 跟模型无关,等一会儿就好 |
| 流在中途断开 | 收不到结束事件 | 看情况 | 还没吐字就重试,吐过字就抬头 |
判据可以背下来:这个错误模型自己改得动吗?改得动就回灌;改不动但换个时机能好就重试;两条都不成立才抬头。
「抬头」是最贵的一档,它花的是用户的注意力。所以只留给两种情况:环境坏了(没有密钥、网关一直不通),以及硬上限触发。
export type Handling = 'feedback' | 'retry' | 'surface'
/**
* emitted 是「这次尝试已经对外吐出过几个分片」,它是整个分类里最重要的参数:
* 重试的前提是这次尝试没留下任何用户已经看见的痕迹。
*/
export function diagnoseStreamError(error: unknown, emitted: number): Diagnosis {
if (error instanceof RateLimitError) {
return { handling: 'retry', message: '网关限流', retryAfterMs: error.retryAfterMs }
}
if (error instanceof StreamTruncatedError) {
// 同一个错误,两种相反的处理,区别只在有没有吐过字
if (emitted === 0) return { handling: 'retry', message: '流在开头就断了', retryAfterMs: 500 }
return { handling: 'surface', message: '流在中途断开,重试会让同一段话说两遍' }
}
if (emitted === 0 && looksTransient(error)) {
return { handling: 'retry', message: '网络抖动', retryAfterMs: 500 }
}
return { handling: 'surface', message: describeError(error) }
}from dataclasses import dataclass
from typing import Literal
Handling = Literal["feedback", "retry", "surface"]
@dataclass(frozen=True)
class Diagnosis:
handling: Handling
message: str
retry_after_ms: int | None = None
def diagnose_stream_error(error: Exception, emitted: int) -> Diagnosis:
"""emitted 为 0 才允许重试:吐过字再重试,用户会看到同一段话说两遍。"""
match error:
case RateLimitError():
return Diagnosis("retry", "网关限流", error.retry_after_ms)
case StreamTruncatedError() if emitted == 0:
return Diagnosis("retry", "流在开头就断了", 500)
case StreamTruncatedError():
return Diagnosis("surface", "流在中途断开,重试会让同一段话说两遍")
case _ if emitted == 0 and looks_transient(error):
return Diagnosis("retry", "网络抖动", 500)
case _:
return Diagnosis("surface", str(error))两版都把「限流」和「断流」这两个都标着可重试的错误分到了不同的桶里,区别只在 emitted 这一个参数上。这是今天最容易被忽略的判据,下面还会再碰它两次。
回灌的写法:错误要具体到可操作,不能只写失败了
既然失败信息是行为规范,那就按写规范的方式写。三条要求,缺一条模型的反应就会跑偏:
- 说清有没有副作用。 「写入失败,文件没有任何改动」和「写入失败」是两句完全不同的话:前者告诉它可以安全重试,后者会让它不敢重试,或者重试之后写重了。
- 给出下一步动作,而不是原因分析。 「那段内容出现了三次」是原因,「请把上下文写长一点让它唯一」才是动作。
- 不要把技术细节漏出去。 堆栈、errno、内部路径对模型全是噪音,它会试着去「修」这些东西。
INJECT=tool_error 就是拿这条做的实验:第一次写文件失败,回灌里带着「文件没有任何改动,请原样重试一次」,模型下一轮原样重发,成功,测试变绿。如果回灌只写「写入失败」,它会转去读文件、检查目录、试着新建——每一步都合理,每一步都没用。
回灌也有极限。INJECT=bad_args 那一段是故意让它自纠失败的:网关把每次的参数都截断成非法 JSON,模型连换四个工具、四次都发坏,最后向用户说明。这是在演示——自纠是第一道,硬上限是最后一道,两道都得有。
重试只重试可重试的:限流与网络归网关层,工具失败归模型
第一个要定的不是「重试几次」,而是重试包在哪一层。只有一个正确答案:包在网关调用这一层,不包整轮。
理由很短:重试的前提是这一步幂等(idempotent),而写工具天生不幂等。重试整轮意味着「已经改了一半的文件」会被再改一遍;如果第一次的编辑其实成功了、只是响应丢了,第二次就会撞上「找不到那段原文」。
于是分工很明确:网关层的错误自己咽下去重试,工具层的失败一律交给模型。写成代码就是一层薄包装:
export async function* streamWithRetry(
provider: ChatProvider,
req: ChatRequest,
options: RetryOptions = {}
): AsyncIterable<StreamDelta> {
const maxAttempts = options.maxAttempts ?? 3
for (let attempt = 1; ; attempt += 1) {
let emitted = 0
try {
for await (const delta of provider.stream(req)) {
emitted += 1
yield delta
}
return
} catch (error) {
// 用户按了取消:这不是故障,原样抛出去让循环收尾
if (req.signal?.aborted) throw error
const diagnosis = diagnoseStreamError(error, emitted)
if (diagnosis.handling !== 'retry' || attempt >= maxAttempts) throw error
// 网关给了 retry-after 就把它当下限:它比我们的猜测准
const waitMs = Math.max(backoffDelay(attempt, options), diagnosis.retryAfterMs ?? 0)
options.onRetry?.({ attempt, waitMs, message: diagnosis.message })
await sleep(waitMs, req.signal)
}
}
}async def stream_with_retry(provider, req, options=RetryOptions()):
"""只包网关这一层:工具失败不走这里,它要回灌给模型。"""
for attempt in itertools.count(1):
emitted = 0
try:
async for delta in provider.stream(req):
emitted += 1
yield delta
return
except Exception as error:
if req.signal.is_set(): # 用户按了取消,不是故障
raise
diagnosis = diagnose_stream_error(error, emitted)
if diagnosis.handling != "retry" or attempt >= options.max_attempts:
raise
wait_ms = max(backoff_delay(attempt, options), diagnosis.retry_after_ms or 0)
options.on_retry(attempt, wait_ms, diagnosis.message)
await sleep_cancellable(wait_ms / 1000, req.signal)emitted 在这里第二次出场,而且是这段代码里唯一的状态。它守的是一条肉眼可见的底线:已经打到屏幕上的字不能被重说一遍。 起点代码故意把这条写错(断流也一律重试),跑 INJECT=truncated 就能看到同一句话打印三遍——那不是恢复,那是错乱。
还有一条容易漏的:用户按下取消导致的失败不是故障,不许重试。 少了上面那个判断,你会遇到「按了取消,它却又发了一次请求」这种让人抓狂的现象。
退避要加抖动:为什么固定间隔会把限流拖成雪崩
重试间隔最朴素的写法是「等一秒再试」。它在你一个人调试时完全正常,在线上会出事。
假设你的服务同时有五十个会话在跑,某一刻集体撞上限流。如果大家都等一秒,一秒之后五十个请求会同时打上去、同时被拒、再同时等一秒——限流没被重试缓解,而是被你们自己拖成一场周期性雪崩。
解法是给等待时间加一段随机量,术语叫抖动(jitter)。它做的事只有一件:把那些请求摊到一个时间窗里,让节拍消失。
/** 第 attempt 次失败之后该等多久(attempt 从 1 起) */
export function backoffDelay(attempt: number, options: BackoffOptions = {}): number {
const baseMs = options.baseMs ?? 400
const capMs = options.capMs ?? 8000
const random = options.random ?? Math.random
const window = Math.min(capMs, baseMs * 2 ** (attempt - 1))
// 等量抖动:一半确定、一半随机。不用「0 到窗口之间随机」,
// 那有可能抽到几毫秒,等于没退避
return Math.round(window / 2 + random() * (window / 2))
}import random as _random
def backoff_delay(attempt: int, base_ms: int = 400, cap_ms: int = 8000, rng=_random) -> int:
"""等量抖动:窗口的一半是确定的,另一半随机。attempt 从 1 起。"""
window = min(cap_ms, base_ms * 2 ** (attempt - 1))
return round(rng.uniform(window / 2, window))把随机源固定成 0.5,三次的等待就是 300、600、1200 毫秒;随机源在 0 到 1 之间变化时,第二次落在 400 到 800 毫秒之间;涨到上限之后停在 8000 毫秒。这几个数字在自检里被逐个断言过,是可复现的。
三条常被追问的细节:
- 为什么不用全抖动(0 到窗口之间随机)。 它有可能抽到几毫秒,那次重试等于没退避;等量抖动保证了下限。
- 上限要有。 指数增长四五次就到分钟级了,那时候该让用户自己决定。
- 网关回了
retry-after就把它当下限。 那是对方告诉你的真实情况,比猜的准。
各家网关的限流阈值具体是多少——不要写进代码,也不要背。 它随套餐、随模型、随时间变,可靠的做法是读响应头,读不到就按上面这套自己退。
取消链路:按键、AbortSignal、fetch、子进程,一环断了就杀不掉
你喊一声「先停一下」,新人得停下手上的键盘,也得去把刚才启动的那个构建任务停掉。只做第一件,你看到的是他抬头看你,而机器还在转。
Agent 的取消链路有四环,缺任何一环都会出现「按了没用」:
Mermaid 源码
flowchart LR
A["① 按键<br/>Esc / Ctrl+C"] --> B["② AbortController<br/>abort()"]
B --> C["③ fetch 的 signal<br/>真的断开连接"]
B --> D["④ 子进程<br/>杀掉整个进程组"]
C --> E["循环收尾<br/>保留已收到的内容"]
D --> E第三、四环最容易漏。只在循环里判断「信号是不是已经中止」,你会看到「已打断」两个字立刻出现,用起来像是停了——但那条 node --test 还在跑、那个下载还在继续。取消要落到最外层的系统资源上才算取消。
最脏的是第四环,第四天写命令工具时处理过一次:子进程用独立进程组启动,杀的时候杀整个组(负号 PID),先发终止信号、留两秒收尾、到点还没退就强杀。原因是 node --test 自己还会拉起子进程,只杀最外层那个 shell 的话,孙子进程会变成孤儿继续跑,而且还持着管道——于是你的 Promise 永远不会完成,「杀了却没停」就是这么来的。
第一环有个平台细节:按键事件只有真终端才有,管道与 CI 里根本没有 keypress 事件。所以本实验把「哪些键算取消」抽成一个函数,好让这条判断在管道里也能被断言——Esc 算,Ctrl+C 算,单独一个 c 不算,Alt 组合键不算。
模型在打转:同工具同参数连续三次,就该打断并换策略
有一类失败长得不像失败:每一步都成功,整件事在原地转圈。它读某个文件,拿到内容,下一轮再读同一个文件。
不是坏了,是它看不见自己在重复。上下文里那两次调用和结果都在,但「我已经做过一样的事了」需要跨轮比较,而它的工作方式是顺着上下文往下续写——续写最顺的下一句,往往就是刚才那一句。
所以这件事必须由循环来做,做法很朴素:把「工具名加参数原文」当成一个键,连续命中达到阈值就打断。
export const REPEAT_THRESHOLD = 3
export class RepeatDetector {
private lastKey = ''
private streak = 0
/** 喂一次调用,返回它连续出现的次数(含这一次) */
push(call: ToolCall): number {
const key = `${call.name}(${call.args.trim()})`
// 只看连续:中间插进过别的调用就重新计数
if (key === this.lastKey) this.streak += 1
else {
this.lastKey = key
this.streak = 1
}
return this.streak
}
tripped(call: ToolCall): boolean {
return this.push(call) >= REPEAT_THRESHOLD
}
}REPEAT_THRESHOLD = 3
class RepeatDetector:
"""比参数原文、只看连续。比语义太贵,不看连续会把正常的验证节奏当成打转。"""
def __init__(self) -> None:
self._last_key = ""
self._streak = 0
def push(self, call: ToolCall) -> int:
key = f"{call.name}({call.args.strip()})"
self._streak = self._streak + 1 if key == self._last_key else 1
self._last_key = key
return self._streak
def tripped(self, call: ToolCall) -> bool:
return self.push(call) >= REPEAT_THRESHOLD三条口径要一起看,少一条就会误伤:
- 比参数原文,不比语义。 参数只差一个空格就算不同的调用——它至少在尝试新东西,真正的打转是一字不差的重复。
- 只看连续。 中间插进过别的调用就重新计数:「读文件、跑测试、再读同一个文件」是正常的验证节奏。
- 打断的方式是回灌,不是抬头。 告诉它「你已经用完全相同的参数调过三次了,结果都在上面,请换个做法或者直接给结论」,本实验里它这一轮就换了。
阈值取三而不是二,因为两次很常见——改完一个文件再读一次确认是好习惯。另外这道检测排在审批门前面:一个正在打转的调用不该去打扰用户批准第三遍。
循环的硬上限:轮数、时长、花费,三条都要有
第五天的循环里有一个孤零零的常量:最多转八圈。它挡得住一种失控,挡不住另外两种,所以今天扩成三条:
| 上限 | 挡住的是 | 单独用它会漏掉什么 |
|---|---|---|
| 轮数 | 反复回灌但一直改不对 | 一轮里跑了个十分钟的测试套件,轮数还很富余 |
| 时长 | 单轮太慢、整件事拖太久 | 上下文越滚越大,轮数与时长都没超 |
| 花费 | 输入规模失控 | 卡在一个几秒就返回的调用上无限打转 |
三条要一起有,而且触发时必须报出是哪一条——用户看到「停下了」而不知道为什么,下一步只会重试同一句话。
花费这一条本课只按 token 数算,不折算成金额:单价随网关与模型变,写进代码就等于把项目绑在某一家的价目表上;而真正要限制的是「输入规模」本身,token 数就是它的单位。要看钱,在部署层把单价配成环境变量乘一下就行。
实现上有个小决定:三条上限做成一个记账对象,而不是三个散落的变量。第十天的任务清单、第十二天的压缩、第二十天的成本统计都要读同一份账。还有一条:上限触发时除了打给用户,也要在消息数组里留一条记录,否则用户接着说下一句时,模型不知道上一轮为什么断的。
源码导读
动手实验
今天挖了五个练习点,全都属于「写错了不会报错,只会在出事那天暴露」的那一类:断流也一律重试、退避没有抖动、只检查轮数一条上限、打转检测没接进循环、取消键只认 Ctrl+C。起点代码原样跑是十一项里过五项。
先说清楚一件事:INJECT 是在模块加载时读一次的,所以要切换注入的那几项自检一律起一个子进程跑,不要改成在同一个进程里改环境变量——那样你验的是「你以为的注入」。
- 实现错误分类函数,把六类注入故障映射到回灌、重试、抬头三条路径;先跑
INJECT=truncated看起点代码那个「同一句话打印三遍」的现象,再改对它。 - 给网关层加带抖动的退避重试,只对限流与网络错误生效;
INJECT=rate_limit时终端上应该出现一行「等 xxx 毫秒再试第 2 次」,随后任务照常做完。 - 把取消信号从按键接到子进程:输入「跑个长命令,不要停」并批准,然后按 Esc,那条要跑十五秒的命令应该在一秒内就停了。
- 实现重复调用检测与三条硬上限;
INJECT=loop时第三次同参数调用被挡下,模型随后自己给出结论。 - 跑自检:
MOCK=1 SELFTEST=1 pnpm start应该打印11/11 通过。分片数、重试次数、被挡下的调用次数、退避的窗口边界都是可复现的;只有取消那一项里的实际毫秒数会浮动,看的是量级不是数值。
验收看六条勾:自检 11/11 通过;限流退避之后重试成功且任务照常完成;断流不重试、已收到的内容全部保留;第三次同参数调用被挡下并回灌了换做法的建议;卡死的命令被超时杀掉且回灌里带着下一步动作;按 Esc 能真的停下一条正在跑的长命令。
面试题
今天三道题,考的是错误处理的实现判断,不是「重试要加指数退避」这种口号:
- Agent 运行时的错误怎么分类?哪些该回灌给模型,哪些该直接报给用户?
- 重试与退避怎么实现?为什么要加抖动?哪些错误绝对不该重试?
- 用户按下取消,你的取消信号要穿过哪几层才算真的停下来?
完整的中英题干、分析过程与答题要点见本课面试题库的第六天。第三题最能看出有没有真做过——没实现过的人答到「设一个标志位」就停了,实现过的人会主动讲子进程和进程组。
检查清单与明日预告
- 能把六类故障分到回灌、重试、抬头三个桶里,并说出那条判据
- 知道为什么「限流」和「断流」都标着可重试,处理却是相反的
- 能说清重试为什么只能包网关这一层,不能包整轮
- 能解释固定间隔为什么会把限流拖成周期性雪崩,以及等量抖动为什么优于全抖动
- 能一口气数出取消链路的四环,并说出第三、四环漏掉之后的现象
- 知道打转检测的三条口径:比参数原文、只看连续、打断方式是回灌
- 能说出三条硬上限各自挡住什么,以及为什么花费只按 token 数算
明天是 D7《会话持久化与恢复:只追加的事件日志、resume 与分叉,第一周复盘》。今天让它摔倒了能自己起来,但只要进程一退,整段对话就没了——包括它刚才辛苦查明的那些事实。明天用一份只追加的事件日志把对话落到磁盘上,让它能重启接着说、还能从中间任意一步分叉出一条新会话。顺序是有意的:先把一轮之内的失败处理干净,再谈跨进程的恢复,反过来做会把「这一轮出错了」和「上一次没跑完」混在同一段代码里。第一周的七层到明天就叠齐了,明天最后还有一次复盘。
面试题库
Agent 运行时的错误怎么分类?哪些该回灌给模型让它自纠,哪些该直接报给用户?How do you classify errors at agent runtime? Which ones go back to the model to self-correct, and which surface to the user?
国内高频海外高频基础#error-handling#agent-loop分析过程 · 先想清楚再作答
- 这题在看你有没有一条可执行的判据。按「网络错误 / 业务错误 / 系统错误」分类的答案听着整齐,但对写代码毫无帮助——因为它没告诉你每一类该怎么处理。
- 怎么拆:分类要按**处理方式**来分,不按错误来源来分。一句话的判据是:这个错误模型自己改得动吗?改得动就回灌;改不动但换个时机能好就重试;两条都不成立才向用户抬头。
- 然后把常见故障套进去。回灌那一档最大:工具执行失败、参数不是合法 JSON、命令超时、模型在打转,全是模型下一轮能改的。重试那一档只有网关限流与网络抖动。抬头那一档只留给环境坏了(没有密钥、网关一直不通)与硬上限触发。
- 有一个能显出深度的细节:**同一个错误可以落在不同的档里,取决于它发生的时机。** 流在中途断开这件事,如果一个分片都还没吐出来,重试是安全的;如果已经吐了半段话到屏幕上,重试会让用户看到同一段话说两遍——那时候必须抬头。所以分类函数的参数里要带上「这次尝试已经吐出去几个分片」。
- 结论还要带上一条实现纪律:回灌的文本就是模型下一步的行为规范,所以它必须写清有没有副作用、给出下一步动作。「写入失败,文件没有任何改动,请原样重试」和「写入失败」会让模型走两条完全不同的路。
- 可预期的追问:那回灌会不会永远转不出来?会,所以自纠是第一道、硬上限是最后一道。我实测过一段「参数一直发不对」的注入:模型连换四个工具、四次都失败,最后是靠轮数上限收场的。只有自纠没有上限,等于把无限循环交给运气。
How to reason about it · think before answering
- This checks whether you have an actionable rule. Splitting errors into network, business, and system sounds tidy but does not help you write code, because it says nothing about handling.
- How to break it down: classify by handling, not by origin. The one-line rule is: can the model fix this itself? If yes, feed it back. If not, but a later attempt would succeed, retry. Only if neither holds, surface it to the user.
- Then place the common failures. Feedback is the biggest bucket: tool failures, arguments that are not valid JSON, command timeouts, and the model looping — all fixable in its next turn. Retry covers only rate limits and transient network errors. Surfacing is reserved for a broken environment (no key, gateway persistently down) and for hard limits firing.
- One detail that shows depth: the same error can land in different buckets depending on when it happened. If a stream breaks before any chunk was emitted, retrying is safe; if half a sentence is already on the user's screen, retrying prints it twice, so you must surface instead. That is why the classifier takes a count of chunks already emitted.
- Close with an implementation discipline: the feedback text is the model's spec for its next move, so it must state whether there were side effects and what to do next. Write failed, file unchanged, retry as-is versus write failed sends the model down two completely different paths.
- Likely follow-up: can feedback loop forever? Yes, so self-correction is the first line and hard limits are the last. In one injected run where arguments were always malformed, the model tried four different tools, failed all four, and the round limit ended it. Self-correction without a limit hands your control flow to luck.
答题要点
- 按处理方式分三类:回灌自纠、退避重试、向用户抬头
- 判据一句话:模型自己改得动就回灌,换个时机能好就重试,都不成立才抬头
- 工具失败、参数非法、命令超时、模型打转都属于回灌;限流与网络抖动属于重试
- 同一个错误按时机分档:断流在吐字前可重试,吐字后必须抬头
- 回灌文本要写清有没有副作用与下一步动作;自纠是第一道,硬上限是最后一道
Key points
- Three buckets by handling: feed back for self-correction, retry with backoff, or surface to the user
- One rule: feed back what the model can fix, retry what a later attempt fixes, surface the rest
- Tool failures, bad arguments, command timeouts, and looping all go back to the model; rate limits and transient network errors are retried
- The same error splits by timing: a stream that breaks before any output is retryable, after output it must surface
- Feedback text must state side effects and the next action; self-correction is the first line, hard limits the last
重试与退避你会怎么实现?为什么要加抖动?哪些错误绝对不该重试?How would you implement retries and backoff? Why add jitter, and which errors must never be retried?
国内高频海外高频进阶#retry#backoff分析过程 · 先想清楚再作答
- 这题的区分度不在「指数退避」四个字——那个人人都会说。区分度在两个地方:重试包在哪一层,以及你能不能说出不该重试的那几类。
- 先答层次,这是最容易答错的一半:**重试包在网关调用这一层,不包整轮。** 理由是重试的前提是这一步幂等,而写工具天生不幂等。重试整轮意味着「已经改了一半的文件」会被再改一遍;如果第一次的编辑其实成功了、只是响应丢了,第二次会撞上「找不到那段原文」。所以工具失败一律走回灌,只有网关错误走重试。
- 再答抖动,要能算给面试官听:假设五十个会话同时撞上限流,大家都「等一秒再试」,一秒后五十个请求同时打上去、同时被拒、再同时等一秒——限流没被缓解,而是被重试拖成了一场周期性雪崩。抖动做的事只有一件:把这些请求摊到一个时间窗里,让节拍消失。
- 抖动的写法也有取舍:不要用「0 到窗口之间随机」,那有可能抽到几毫秒,等于没退避;用「窗口的一半确定、一半随机」既有下限又打散了节拍。另外窗口要有上限,指数涨四五次就到分钟级,那时候该让用户自己决定;网关回了 retry-after 就把它当下限,它比你的猜测准。
- 然后是绝对不该重试的三类,答出来才算做过:① 写操作已经产生了副作用的失败,重试会写重;② 用户按下取消导致的失败——它不是故障,重试的现象是「按了取消它却又发了一次请求」;③ 已经有内容吐到屏幕上之后的断流,重试会让同一段话说两遍。另外 4xx 里的鉴权与参数错误重试一百次也是同样的结果。
- 可预期的追问:重试几次合适?次数不是重点,重点是「总等待时间的上限」与「用户能不能中途打断」。我的实现里退避的等待也接了取消信号,否则按下取消还要干等一次退避。
How to reason about it · think before answering
- The signal is not the phrase exponential backoff, which everyone says. It is two things: which layer the retry wraps, and whether you can name the errors that must never be retried.
- Answer the layering first, the half most people get wrong: retries wrap the gateway call, not the whole turn. Retrying requires idempotence, and write tools are not idempotent. Retrying a turn means a half-edited file gets edited again, and if the first edit actually succeeded and only the response was lost, the second attempt fails with no such text. So tool failures always go back to the model and only gateway errors are retried.
- Then jitter, with the arithmetic out loud: if fifty sessions hit a rate limit at once and everyone waits one second, a second later fifty requests arrive together, are rejected together, and wait together. The limit is not relieved, it is turned into a periodic stampede. Jitter does one thing: it spreads those requests across a window so the rhythm disappears.
- The jitter shape matters too: do not sample uniformly from zero to the window, since a few milliseconds is effectively no backoff. Half fixed plus half random keeps a floor while breaking the rhythm. Cap the window, because four or five doublings reach minutes and the user should decide by then, and treat a retry-after header as a floor since it beats your guess.
- Then the three never-retry cases, which is what marks real experience: failures where a write already had an effect, because retrying writes twice; failures caused by user cancellation, which is not a fault at all and shows up as the agent firing another request after you cancelled; and a stream that breaks after output was already shown, because retrying repeats the same sentence. Auth and malformed-argument 4xx responses are equally pointless to retry.
- Likely follow-up: how many attempts? The count matters less than a cap on total wait and whether the user can interrupt mid-wait. In my implementation the backoff sleep also listens to the cancel signal, otherwise pressing cancel still waits out a full backoff.
答题要点
- 重试只包网关调用这一层,不包整轮:写工具不幂等,重试整轮会重复副作用
- 抖动的作用是打散节拍,避免同时被限流的一批请求变成周期性雪崩
- 用等量抖动而不是全抖动(保住下限),窗口要有上限,retry-after 当下限
- 绝对不重试:已产生副作用的写、用户取消引发的失败、已有输出之后的断流
- 关键指标是总等待时间上限与可中断性,退避的等待本身也要能被取消
Key points
- Retries wrap only the gateway call, never the whole turn, since write tools are not idempotent
- Jitter exists to break the rhythm so rate-limited clients do not stampede in lockstep
- Prefer equal jitter over full jitter to keep a floor, cap the window, and treat retry-after as a lower bound
- Never retry: writes that already had an effect, failures caused by cancellation, or a break after output was shown
- The metrics that matter are total wait cap and interruptibility, so the backoff sleep must be cancellable too
用户按下取消,你的取消信号要穿过哪几层才算真的停下来?When the user presses cancel, which layers must the cancellation signal reach before things have really stopped?
国内高频海外高频深入#cancellation#subprocess分析过程 · 先想清楚再作答
- 这题几乎是一道「做过没做过」的判别题。没做过的人答到「设一个标志位,循环里检查它」就停了;做过的人会立刻说到子进程。
- 怎么拆:把「停下来」翻译成「哪些资源还在占着」。一次 Agent 的轮次里占着资源的有三处——一个正在流的 HTTP 连接、一个正在跑的子进程、还有循环自己。取消要落到前两处上,最后一处只是收尾。
- 于是链路是四环:① 按键,终端的 keypress 事件;② 一个 AbortController,进程内广播;③ 把 signal 交给 fetch,连接才会真的断开;④ 把同一个 signal 交给子进程的执行器,杀掉整个进程组。缺任何一环的现象都是「按了没用」,但表现不同:缺 ③ 是流量还在跑,缺 ④ 是命令还在跑。
- 第四环最脏,值得主动展开:子进程要用独立进程组启动,杀的时候杀整个组(负号 PID),先发终止信号、留两秒收尾、到点还没退就强杀。原因是被调的命令自己还会拉起子进程,只杀最外层的 shell,孙子进程会变成孤儿继续跑,而且还持着管道——于是你的 Promise 永远不会完成,这就是「杀了却没停」。
- 还有三个容易漏的细节:取消的粒度是「这一轮」而不是整个会话,所以每轮一个新的控制器;取消导致的失败不是故障,不许触发重试;已经收到的内容要保留下来落进历史,用户看过的东西不能凭空消失。
- 可预期的追问:无交互环境怎么办?管道与 CI 里没有 keypress 事件,第一环不存在,取消只能来自信号或程序自己。所以「哪些键算取消」要抽成一个可单测的纯函数,后面三环则用直接触发控制器的方式来验。
How to reason about it · think before answering
- This is almost a binary test of hands-on experience. People who have not built it stop at set a flag and check it in the loop; people who have go straight to child processes.
- How to break it down: translate stopping into which resources are still held. A turn holds three: an in-flight streaming HTTP connection, a running child process, and the loop itself. Cancellation must reach the first two; the loop only cleans up.
- So the chain has four links: the keypress event from the terminal, an AbortController broadcasting in-process, the signal handed to fetch so the connection actually closes, and the same signal handed to the command runner so it kills the whole process group. Missing any link looks like cancel did nothing, but differently: without the third the traffic keeps flowing, without the fourth the command keeps running.
- The fourth link is the messy one and worth volunteering: spawn the child in its own process group and kill the group (negative PID), send a terminate signal first, allow a couple of seconds to wind down, then force kill. The command being run spawns its own children, so killing only the outer shell leaves orphans running while they still hold the pipes, which means your promise never settles. That is what cancelled but not stopped actually is.
- Three more details people miss: cancellation is scoped to one turn, not the session, so each turn gets a fresh controller; a failure caused by cancellation is not a fault and must not trigger a retry; and content already received must be kept in history, because what the user has seen cannot vanish.
- Likely follow-up: what about non-interactive environments? Pipes and CI have no keypress events, so the first link does not exist and cancellation can only come from a signal or the program itself. That is why which keys count as cancel belongs in a small pure function you can unit test, while the other three links are verified by triggering the controller directly.
答题要点
- 把「停下来」翻译成「哪些资源还占着」:流式连接、子进程、循环本身
- 四环:按键 → AbortController → 传给 fetch 的 signal → 传给子进程执行器并杀整个进程组
- 杀子进程要用独立进程组加负号 PID,先终止后强杀,否则孙子进程持着管道让调用永不返回
- 取消的粒度是一轮而不是会话,每轮一个新控制器;取消引发的失败不许重试
- 已收到的内容要保留;无交互环境没有按键这一环,把按键判定抽成纯函数来单测
Key points
- Translate stopping into which resources remain held: the streaming connection, the child process, and the loop
- Four links: keypress, AbortController, the signal passed to fetch, and the signal passed to the command runner which kills the whole process group
- Kill children via their own process group and a negative PID, terminate then force kill, or orphans holding the pipes make the call never settle
- Cancellation is scoped to one turn, not the session, so use a fresh controller per turn, and never retry a cancellation-induced failure
- Keep whatever was already received; non-interactive environments have no keypress link, so make the key check a testable pure function