Retry、Abort 与 turn 结账
一个 turn 可以失败多种方式——指数退避重试、abort 信号传播、max-tokens 粘滞、工具调用 drain——但无论如何退出,turn/end 事件一定在 finally 块中写入。本章把这三层机制的交互拆开,看它们怎样一起守住 turn 结账边界。
失败处理不只是 catch 和 retry
模型调用失败,不只是“catch 住报错”或“重试几次成功”两种结局;取消也不是简单地把进程杀掉、一切归零。Harness 在一个 turn 里处理失败,靠的是三层独立但相互交织的机制:
- llm-retry 插件——指数退避 + 抖动的请求级重试,分两种模式
- turn 状态管理——
max-tokens粘滞性,决定一个多 step 的 turn 最终以什么姿态结束 - Abort 与结账保证——信号传播、工具调用 drain、
finally块中无条件写入turn/end
这三层的交互,决定了“一次失败”最后会被系统记成什么。下面逐层拆开。
第一层:llm-retry 的指数退避
退避计算:localDelay
重试间隔不是随便等个固定秒数。localDelay 函数实现了标准的有界指数退避加对称抖动:
function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number {
const exponent = Math.min(retry - 1, 1024)
const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs)
const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random()
return Math.min(exponential * jitter, config.maxDelayMs)
}
默认配置值来自 retry-policy 解析层:
initialDelayMs: 500msmaxDelayMs: 10000msjitterRatio: 0.1(即 jitter 系数在 0.9 到 1.1 之间)maxRetries: 2(normal 模式)
所以第一次重试大约等 500ms(加减 10% 抖动),第二次大约 1000ms,之后就被 10s 上限封顶。抖动的目的是避免多个并发 agent 在同一时刻涌向同一个 provider——经典的雷群效应(thundering herd)防护。
两种模式:normal vs always
RetryPolicyConfig 是一个联合类型,provider 注册时选择:
normal 模式:只重试特定 error code,有次数上限。默认可重试的 code 是 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT。超过 maxRetries 就放弃,交给 waterfall 下游。
always 模式:对所有失败都重试,没有次数上限,直到成功、被 abort、或 downstream waterfall 返回 retry 决策为止。这个模式为什么存在?因为有些场景——比如 context window 溢出后做 compaction 再重试——需要一个”无论失败几次都继续尝试”的姿态。
两种模式的关键区别在 recover 函数的分支:
if (policy.mode === 'always') {
// ...delegate to downstream first, then always retry if downstream didn't
} else if (!policy.retryableCodes.includes(failure.code)) {
return next() // code 不在白名单,直接交给下游
}
normal 模式会检查 failure.code 是否在 retryableCodes 里;always 模式跳过 code 检查,先让 downstream waterfall 有机会做决策(比如 compact context),如果 downstream 没有返回 retry 动作,always 模式自己补上。
Durable-before-wait:先持久化再等待
这是一个容易被忽略但至关重要的设计决策。每次 retry 前,先把 llm/retry 事件写入 session log,然后才开始等待 backoff 延迟:
agent.session.append('llm/retry', eventData)
if (!await cancellableDelay(delayMs, fusedSignal)) return
agent.session.append('llm/retry-started', { retryId, turn, step, retry })
return { kind: 'retry' }
为什么这个顺序很重要?因为如果进程在等待期间崩溃(或被 abort),恢复时 session log 里有 llm/retry 但没有配对的 llm/retry-started——replay 系统知道”这次重试被调度了但没完成”,不会重复计入 retry 计数。如果你先等后写,崩溃就意味着丢失重试记录,恢复后可能重试超过 maxRetries。
cancellableDelay:可中断等待
等待退避期间如果收到 abort 信号,不用等满:
function cancellableDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return Promise.resolve(false)
return new Promise((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
}, delayMs)
function onAbort(): void {
clearTimeout(timer)
resolve(false)
}
signal.addEventListener('abort', onAbort, { once: true })
})
}
返回 false 表示被 abort 中断了。注意它用的是 AbortSignal.any([signal, lifetime.signal])——融合了 step 级信号和插件生命周期信号。插件被 dispose 时,所有活跃的 retry 等待也会被中断。
Provider Retry-After 的优先级
如果 provider 在 HTTP 响应里返回了 Retry-After header(映射为 failure.providerRetryAfterMs),retry 逻辑会优先使用它:
if (failure.providerRetryAfterMs !== undefined
&& Number.isFinite(failure.providerRetryAfterMs)
&& failure.providerRetryAfterMs > 0) {
if (failure.providerRetryAfterMs > policy.maxDelayMs) {
if (policy.mode === 'normal') return next() // 超过上限就放弃
delayMs = localDelay(policy, retry, random) // always 模式用本地计算
} else {
delayMs = failure.providerRetryAfterMs // 使用 provider 建议
}
}
这是一个精心设计的优先级链:provider 说”等 3 秒”就等 3 秒;provider 说”等 60 秒”但你的 maxDelayMs 只有 10 秒——normal 模式选择放弃(因为 provider 明确说”现在别来”),always 模式坚持使用自己的退避计算(因为它不能放弃)。
retryId 的连续性
同一个 step 内同一个 provider-policy 链上的所有重试共享一个 retryId(UUID)。第一次 retry 生成新 ID,后续 retry 从历史事件中恢复。invariant 层严格验证这一点——如果你用了一个已被其他链占用的 retryId,invariant 会拒绝:
if (priorPolicyRetry === undefined && history.some(prior =>
(prior.type === 'llm/retry' || prior.type === 'llm/retry-started')
&& prior.data.retryId === retryId)) {
fail(`llm/retry retryId ${JSON.stringify(retryId)} is already owned by another chain`)
}
第二层:max-tokens 粘滞性
最简场景
模型生成到一半碰到了 token 上限。BlockAssembler 的 finish 返回 { kind: 'max-tokens' }。step 函数返回 { kind: 'max-tokens' } 给 turn 循环。
如果这个 turn 只有一个 step,那很简单——turn 以 max-tokens 结束。
复杂场景:多 step 下的粘滞
但一个 turn 可以有多个 step(通过 next-step inbox 注入消息)。考虑这个序列:
- Step 1 返回
max-tokens - 用户通过 steer 注入了新消息
- Step 2 正常完成,返回
completed
你以为 turn 结果应该是 completed?不。看代码:
// max-tokens is sticky: once any step hits the ceiling, later steps
// that complete normally must not downgrade the turn outcome.
const stepEnd = await this.step(decision.assembly)
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
这就是”粘滞性”:turnEnds 一旦被设为 max-tokens,就不会被后续更”好”的结果覆盖。逻辑是:如果模型在某个 step 里生成被截断了,即使后续 step 表面上完成了,这个 turn 的整体质量已经受损——downstream 消费者(UI、orchestrator)需要知道这一点。
为什么不是所有 kind 都粘滞?
注意条件是 turnEnds.kind !== 'max-tokens'——只有 max-tokens 是粘滞的。completed(正常完成)和 null(还没结束)可以被后续 step 的结果覆盖。这是因为 completed 不代表”出过问题”,而 max-tokens 代表”生成质量可能受损”。
第三层:Abort 与保证结账
AbortController per phase
Agent 的生命周期分为几个 phase:idle、maintenance、running。每个非 idle phase 有独立的 AbortController:
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
cancel 操作直接 abort 当前 phase 的 controller:
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
if (!options.keepInbox) {
this.inbox.clear()
if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
}
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}
注意 keepInbox 选项——cancel 不一定清空 inbox。这是为了支持”取消当前请求但保留后续排队消息”的场景。
signal.throwIfAborted 的分布
翻遍 turn() 和 step() 方法,你会看到 signal.throwIfAborted() 散布在关键节点:
- step 循环入口
- preStep 调用后
- step/start append 前
- stream 迭代前和每个 chunk 后
- buildRequest 中多个 await 点后
这不是随意放置的。每个 throwIfAborted() 都是一个”检查点”——确保 abort 信号在下一个副作用(网络请求、session append)之前被检测到。如果你把 throwIfAborted 放在 append 之后,就可能在已经写入日志后才发现应该取消了。
turn 结束的 catch/finally 双保证
turn() 方法的错误处理结构是这章最精密的部分:
try {
while (true) {
// ...step 循环...
}
} catch (error: unknown) {
if (signal.aborted) {
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
throw error
}
turnEnds = {
kind: 'error',
error: error instanceof LlmError
? error.failure
: { message: errorChain(error), code: 'UNKNOWN' },
}
this.throwError(error)
} finally {
try {
this.session.append('turn/end', { turn, reason: turnEnds! })
} catch (error: unknown) {
this.throwError(error)
}
}
逐行分析:
- catch 中的 abort 分支:如果 signal 已 aborted,把
turnEnds设为aborted并 re-throw。re-throw 是因为 abort 要传播到 driver 的kick()方法让整个循环停下。 - catch 中的 error 分支:非 abort 错误。如果是
LlmError(harness 自己的结构化错误),保留其failure对象;否则用errorChain把任意 thrown value 展平为文本,code 标记为UNKNOWN。然后通过this.throwError(error)先 emitagent/error事件再 re-throw。 - finally 块:无论如何(正常 break、throw、abort),都 append
turn/end。这是铁律——没有任何退出路径能跳过结账。
errorChain:从 cause 链中提取可读信息
errorChain 递归遍历 Error 的 cause 链和 AggregateError 的 errors 数组,生成人类可读的单行文本:
export function errorChain(value: unknown): string {
const path = new Set<unknown>()
const render = (current: unknown): string => {
if (path.has(current)) return '<circular cause>'
// ...递归渲染 message + cause + AggregateError members
}
return render(value)
}
为什么要这么做?因为 Node.js 生态里的错误经常层层包裹——fetch failed 里面是 ECONNREFUSED,外面又被 TypeError 包了一层。如果你只看最外层 message,信息量为零。errorChain 确保 turn/end 事件里记录的错误文本包含完整因果链。
Abort 后的 tool call drain
这是最反直觉的部分。你 abort 了一个 turn,但如果有 tool calls 已经在执行中呢?它们不会被立刻杀死。
executeToolCalls 的 abort 处理分两部分:
已启动的调用(started):等它们自然完成。结果正常记录到 session log。这就是”drain to quiescence”——排干正在执行的操作,让它们安静地结束。
未启动的调用(skipped):写入 synthetic error result。
function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {
const callSeq = appendToolCall(session, turn, step, block)
appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: {
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
},
}, callSeq)
}
为什么要为跳过的调用写入假结果?因为 session log 的一致性不变量要求:每个 tool/call 事件必须有配对的 tool/result。如果你跳过了但不记录,replay 时就会遇到一个”开始了但没结果”的悬空调用——日志损坏。
drain 的详细流程
在 runGroup 函数中,abort 的处理机制嵌入到调度池逻辑里:
const fillPool = async (): Promise<void> => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
// ...启动新调用...
if (signal.aborted) aborted = true
}
}
// 主循环
try {
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
// ...commit results...
if (signal.aborted) aborted = true
await fillPool()
}
} catch (error: unknown) {
schedulerFailure ??= { error }
await Promise.allSettled(inFlight.values()) // drain
throw schedulerFailure.error
}
关键点:aborted 标志一旦为 true,fillPool 停止启动新调用。但已经在 inFlight 里的 Promise 继续通过 Promise.race 逐个结算。scheduler failure(代码级 bug 而非 tool 失败)则用 Promise.allSettled 等所有 in-flight 完成后才 throw——确保不会有悬空的 tool 执行在后台继续运行却没人等它的结果。
scheduler failure vs tool failure 的区别
这里有一个微妙的差异:
- tool failure(工具自身执行出错):结果正常记录为
isError: true,不中断其他 tool calls - scheduler failure(调度器内部错误):停止新调度,drain 已启动的,但不为已启动的调用写入 synthetic result——因为它们的
tool/call事件已经 append 了,结果正常录入
模块注释明确说明了这一点:“A terminal scheduler failure preserves already-recorded tool/call events without fabricating results.”
复杂场景:当三层同时发生
设想这个序列:
- Step 1 的模型请求遇到
RATE_LIMIT错误 - llm-retry 开始第一次退避(append
llm/retry,等 500ms) - 等待期间用户发送 cancel
cancellableDelay被 abort 信号中断,返回false- retry 的
backoff函数返回undefined(表示不重试了) step()的 stream 循环 throw(因为 abort)- catch 分支检测到
signal.aborted,设turnEnds = { kind: 'aborted', reason: ... } - re-throw 传播到
turn()的 catch - finally 块 append
turn/endwith reasonaborted - 传播到
kick()的 catch(被吞掉) - finally 中 setPhase 回 idle
再设想 retry + max-tokens 的交叉:
- Step 1 请求 RATE_LIMIT,retry 成功
- 重试后模型正常生成但 hit max-tokens
turnEnds设为max-tokens- next-step inbox 有消息,进入 Step 2
- Step 2 请求也 RATE_LIMIT,retry 成功
- 重试后模型正常完成
stepEnd是completed,但turnEnds.kind === 'max-tokens',所以不更新- turn 最终以
max-tokens结束
cancel 的四种原因
AgentCancelCause 是一个区分联合类型。abort controller 的 reason 携带取消原因:
- user:用户主动取消
- parent:父 agent/orchestrator 取消子 agent
- hook:某个 lifecycle hook 决定中止
- disposed:agent 实例被销毁
disposed 有特殊行为:它不会 latch wakeRequested。也就是说,如果 agent 正在被销毁,即使有新消息进来也不会在 finally 里重新唤醒 driver:
const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined
if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {
this.phase.wakeRequested = true
}
跨 turn 的 AbortController 刷新
当一个 turn 正常完成后还有 pending inbox 消息时,driver 不会退出而是继续下一个 turn。此时会换一个新的 AbortController:
if (!this.inbox.hasPending) return false
phase.abort = new AbortController()
phase.wakeRequested = false
phase.step = 0
return true
为什么要换新的?因为旧 controller 上可能有人 latch 了 wakeRequested。如果不换新的,下一个 turn 一开始就会检测到旧信号的状态,产生错误行为。
失败边界:什么情况下三层机制保护不了你
llm-retry 无法处理的:CONTEXT_WINDOW_EXCEEDED 不在默认 retryableCodes 里——它不是 transient error,重试相同请求毫无意义。处理它需要上层做 context compaction 后再通过 waterfall 返回 retry action。
粘滞性无法表达的:如果两个 step 都 hit max-tokens,你只能知道”这个 turn 被截断了”,无法区分”被截断了一次”和”被截断了两次”。turn/end 事件不携带这个粒度。
drain 无法中断的:如果某个 tool call 执行时间极长(比如一个卡住的 HTTP 请求),drain 会一直等。agent 的 whenIdle() Promise 不会 resolve,直到所有 in-flight tools settle。这是设计选择——宁可等久一点,也不能让 session log 出现不一致。
finally 唯一的风险:如果 session.append('turn/end', ...) 本身 throw(比如 session 已被 dispose),this.throwError 会 emit 错误事件并 re-throw。这个 throw 从 finally 中逃逸,意味着 turn/end 确实没写成——但此时 session 本身已经不可用了,这属于”infrastructure failure”而非应用层失败。
最简可观察场景
想验证这些机制?最小实验:
-
验证 durable-before-wait:监听 session 事件,在 step 中故意返回 RATE_LIMIT error。观察
llm/retry事件的 append 时间点——它在 delay 等待之前就出现了。 -
验证粘滞性:构造一个两 step 的 turn,第一个 step 返回 max-tokens finish,第二个正常 completed。检查
turn/end事件的 reason——应该是max-tokens。 -
验证 drain:在 tool 执行中加一个 1 秒延迟,then abort。观察 tool/result 事件——已启动的 tool 有正常结果,后续的有 synthetic error。
-
验证 finally:让 step 中的 stream throw 一个非 LlmError。检查 session log——应该同时有
agent/error事件和turn/end(reason.kind 为 error)。
连接下一章
这一章剖析了”一个 turn 怎样失败和结账”。但 retry 逻辑的 waterfall 扩展点——agent/request-error——暗示了一个更大的图景:context overflow 后的 compaction retry 不是 llm-retry 自己做的,而是通过 waterfall 让上层插件介入。下一章会展开 context window 管理和 compaction 策略——那是 retry 的”另一半故事”:不是简单地重发相同请求,而是改变请求内容后重试。
Turn settlement 的保证(finally 中的 turn/end)是整个 harness 事件日志一致性的基石。上层系统——无论是 UI 展示、token 计费、还是 session replay——都依赖”每个 turn/start 必有配对的 turn/end”这个不变量。打破它意味着打破一切。