青雲的博客
深入浅出 DeepSeek Harness 第二部:一句话的旅程——输入怎样变成模型请求 第 12 章

Retry、Abort 与 turn 结账

一个 turn 可以失败多种方式——指数退避重试、abort 信号传播、max-tokens 粘滞、工具调用 drain——但无论如何退出,turn/end 事件一定在 finally 块中写入。本章把这三层机制的交互拆开,看它们怎样一起守住 turn 结账边界。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

失败处理不只是 catch 和 retry

模型调用失败,不只是“catch 住报错”或“重试几次成功”两种结局;取消也不是简单地把进程杀掉、一切归零。Harness 在一个 turn 里处理失败,靠的是三层独立但相互交织的机制:

  1. llm-retry 插件——指数退避 + 抖动的请求级重试,分两种模式
  2. turn 状态管理——max-tokens 粘滞性,决定一个多 step 的 turn 最终以什么姿态结束
  3. 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: 500ms
  • maxDelayMs: 10000ms
  • jitterRatio: 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_RESPONSERATE_LIMITSERVERTIMEOUTTRANSPORT。超过 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 上限。BlockAssemblerfinish 返回 { kind: 'max-tokens' }。step 函数返回 { kind: 'max-tokens' } 给 turn 循环。

如果这个 turn 只有一个 step,那很简单——turn 以 max-tokens 结束。

复杂场景:多 step 下的粘滞

但一个 turn 可以有多个 step(通过 next-step inbox 注入消息)。考虑这个序列:

  1. Step 1 返回 max-tokens
  2. 用户通过 steer 注入了新消息
  3. 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:idlemaintenancerunning。每个非 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)
  }
}

逐行分析:

  1. catch 中的 abort 分支:如果 signal 已 aborted,把 turnEnds 设为 aborted 并 re-throw。re-throw 是因为 abort 要传播到 driver 的 kick() 方法让整个循环停下。
  2. catch 中的 error 分支:非 abort 错误。如果是 LlmError(harness 自己的结构化错误),保留其 failure 对象;否则用 errorChain 把任意 thrown value 展平为文本,code 标记为 UNKNOWN。然后通过 this.throwError(error) 先 emit agent/error 事件再 re-throw。
  3. 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.”

复杂场景:当三层同时发生

设想这个序列:

  1. Step 1 的模型请求遇到 RATE_LIMIT 错误
  2. llm-retry 开始第一次退避(append llm/retry,等 500ms)
  3. 等待期间用户发送 cancel
  4. cancellableDelay 被 abort 信号中断,返回 false
  5. retry 的 backoff 函数返回 undefined(表示不重试了)
  6. step() 的 stream 循环 throw(因为 abort)
  7. catch 分支检测到 signal.aborted,设 turnEnds = { kind: 'aborted', reason: ... }
  8. re-throw 传播到 turn() 的 catch
  9. finally 块 append turn/end with reason aborted
  10. 传播到 kick() 的 catch(被吞掉)
  11. finally 中 setPhase 回 idle

再设想 retry + max-tokens 的交叉:

  1. Step 1 请求 RATE_LIMIT,retry 成功
  2. 重试后模型正常生成但 hit max-tokens
  3. turnEnds 设为 max-tokens
  4. next-step inbox 有消息,进入 Step 2
  5. Step 2 请求也 RATE_LIMIT,retry 成功
  6. 重试后模型正常完成
  7. stepEndcompleted,但 turnEnds.kind === 'max-tokens',所以不更新
  8. 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”而非应用层失败。

最简可观察场景

想验证这些机制?最小实验:

  1. 验证 durable-before-wait:监听 session 事件,在 step 中故意返回 RATE_LIMIT error。观察 llm/retry 事件的 append 时间点——它在 delay 等待之前就出现了。

  2. 验证粘滞性:构造一个两 step 的 turn,第一个 step 返回 max-tokens finish,第二个正常 completed。检查 turn/end 事件的 reason——应该是 max-tokens

  3. 验证 drain:在 tool 执行中加一个 1 秒延迟,then abort。观察 tool/result 事件——已启动的 tool 有正常结果,后续的有 synthetic error。

  4. 验证 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”这个不变量。打破它意味着打破一切。