回合收敛与异常吸收

错误归类、step 内重试、投影降级、上下文溢出与中止路径——provider 层的失败如何被吸收成一次可完成或能明确失败的回合。

作者 David更新于 第 6 篇(共 33 篇)

v0.29 里这件事集中在 agent-core 最大的单文件 agent/turn/index.ts(1633 行)。v2 把它拆成几层,每层只吸收自己看得懂的失败:

层位置吸收什么
错误归类src/human/llm/errors.ts把 SDK 异常与 HTTP 状态归成 LlmErrorKind
step 内重试src/human/agent/turn.ts、llm/requester/retry.ts退避重试、recovery、空响应
请求内降级src/agent/llmRequester/llmRequesterService.ts媒体降级与剥离、strict 投影重发
回合级恢复src/agent/loop/loopService.ts 与错误处理器上下文溢出交给压缩、max-step、取消

错误归类

v2 的 LLM 层对外只有两类失败:llm.failed.syntax(本地消息语法错误,不重试)与 llm.failed.remote。远程错误再细分(human/llm/errors.ts:29):

export type LlmErrorKind =
  | 'syntax'
  | 'abort'
  | 'connection'
  | 'timeout'
  | 'status'
  | 'rate_limit'
  | 'quota_exhausted'
  | 'overloaded'
  | 'context_overflow'
  | 'request_too_large'
  | 'request_structure'
  | 'image_format'
  | 'empty_response'
  | 'provider'
  | 'unknown';

HTTP 错误靠「状态码 + 消息文本」联合判定。比如上下文溢出只在 400、413、422 上成立,再用一组正则嗅探 context length、maximum context 之类的措辞——有些 provider 不给专门的错误码,只在消息里说上下文太长,这一点与 v1 相同。

llm-adapter/contract/errors.ts 里另有一套基于类的错误(APIContextOverflowError、APIRequestTooLargeError 等),供请求服务和压缩用 instanceof 判断;进入 turn 机前由 llmMessageFromError() 转成上面的 kind。

step 内重试

turn 机收到 llm.failed.remote 后先分诊:先问 recovery 策略链,再问能否重试,都不行才失败。重试策略是纯函数(human/llm/requester/retry.ts:3):

export const DEFAULT_MAX_RETRY_ATTEMPTS = 10;

const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 32_000;
const RETRY_FACTOR = 2;
const JITTER_FACTOR = 0.25;

const RETRYABLE_STATUS_CODES: readonly number[] = [408, 409, 429, 500, 502, 503, 504, 529];
  • 预算按 step 计:loop_control.max_attempts_per_step(环境变量 KIMI_LOOP_MAX_ATTEMPTS_PER_STEP)是单个 step 的总尝试次数,默认 10;旧键 max_retries_per_step 已弃用。
  • 退避从 500ms 起翻倍、封顶 32 秒,另加最多 25% 的抖动;provider 给了 Retry-After 就听它的。
  • connection、timeout、rate_limit、overloaded 可重试;status 只在上面那组状态码上重试;empty_response 除 filtered 外都重试。context_overflow、request_too_large、image_format、request_structure、quota_exhausted 一律不在这里重试——要么得改请求,要么重试也没用。
  • recovery 生效后 attempt 重置为 1。现役引擎只接了凭证刷新这一种:可恢复的 401 触发 invalidate() 后重发。

每次重试发出 llm.retrying,最终变成持久化的 turn.step.retrying 事件,UI 因此能显示「正在重试第几次」。

媒体降级:两级退让

需要改请求的错误,由请求服务在同一次 generate 里就地重发,不占 turn 机的重试预算(nextProjectionPolicyForError()):

  1. 请求体过大(APIRequestTooLargeError)→ 以 media: 'degraded' 重发:只保留最近 2 个媒体片段,更早的换成 [image omitted: dropped to fit the provider request size limit; ...] 这类占位文本。
  2. 降级后仍过大,或图片格式被拒 → 以 media: { strip } 重发:把快照里记下的媒体全部换成占位文本,之后重新读入的媒体不受影响。
  3. 消息结构被拒(工具交换不相邻等可恢复的结构错误)→ 以 structure: 'strict' 重发,走更严格的投影。

v1 用 mediaDegradedActive、mediaStrippedActive 两个状态位,避免同一回合的每个 step 都重吃一次必然的 413。v2 保留了这个思路,改成按 turnId 记账(llmRequesterService.ts:344):

    let policy: ProjectionPolicy | undefined =
      recoveredStrip !== undefined
        ? { media: { strip: recoveredStrip } }
        : this.isRecoveryTurn(this.mediaDegradedTurns, request.source)
          ? { media: 'degraded' }
          : undefined;

llmRequester.mediaDegradedTurns 与 llmRequester.mediaStrippedTurns 记住「哪个回合已经降过级」,同一回合后续 step 直接从降级投影起步;登记新回合时顺手清掉更早的记录。每次降级还会上报 llm_request_projection_fallback 遥测。

上下文溢出

context_overflow 在 turn 机里不重试。turn 机这一轮以失败收场,但 loopService 不马上结束回合,而是由 recoverOrFailMachineRun() 交给错误处理器。压缩服务注册了其中一个(fullCompactionService.ts:199):

      this.loopService.registerLoopErrorHandler({
        id: 'full-compaction',
        match: (context) => this.shouldRecoverFromContextOverflow(context.error),
        handle: (context) => this.recoverFromContextOverflow(context),
      }),

处理器启动一次自动压缩、等它完成,再调用 retry():循环用压缩后的上下文重建历史,接着跑。

  • 413 也可能算作溢出,前提是估算的请求 token 已达窗口的 50%(OVERFLOW_STATUS_RECOVERY_RATIO)。
  • 每次溢出都把「估算 token × 0.85」记为该模型的实测窗口上限,此后按它与配置值中较小的那个判断阈值——引擎会从错误里学到真实窗口。
  • 连续「溢出 → 压缩 → 又溢出」超过 3 次就放弃,报 Compaction failed to bring the context under the model window after 3 attempts.

「压缩不是停止原因」这条 v1 的边界依然成立,只是载体从 beforeStep 安全点换成了钩子加错误处理器。

中止路径

function cancelReasonFor(cancellation: unknown): 'user_cancelled' | 'aborted' {
  return isUserCancellation(cancellation) ? 'user_cancelled' : 'aborted';
}

cancel() 不带原因时默认填入 userCancellationReason(),上报时据此区分用户主动取消与程序性中止(loopService.ts:2237)。turn 机按场景处理:

  • LLM 流式中:中止请求,把已流出的半条消息打捞进历史(salvageInterruptedMessage),而不是丢掉。
  • 工具执行中:进入 aborting 子状态,给工具 2.5 秒宽限(abortGraceMs),到期把没结果的调用记为 aborted;agent 机另有 10 秒总超时,到点直接停掉工具 actor。

结果是 LoopRunResult 的 cancelled 分支,照样带着 steps。

max-step 与续跑

max-step 在 step 门控处检查(loopService.ts:978):

    const maxSteps = this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.maxStepsPerTurn;
    if (
      maxSteps !== undefined &&
      maxSteps > 0 &&
      stepOrdinal > maxSteps &&
      !consumed.bypass
    ) {
      turn.maxStepsError = createMaxStepsExceededError(maxSteps);
      return { type: 'fail' };
    }

错误信息直接告诉用户去调 loop_control.max_steps_per_turn,或运行 /update-config。human/agent/errors.ts 的 isMaxStepsExceededError 既认 instanceof,也认 code 字段:错误对象跨边界传递后丢了原型,照样认得出。

「非工具停止后的续跑」由钩子完成:onDidFinishStep 的处理器可以置 stopTurn 让回合停下,也可以调用 notify() 塞一条输入让回合继续。Goal 就这样续跑,而且续跑前会先看 maxStepsPerTurn 还剩没剩 step。

收敛层次

层上限失败表达
Goaltoken、墙钟时间、回合数预算goal 状态机
Turnmax_steps_per_turnLoopRunResult 的 failed(loop.max_steps_exceeded)
Stepmax_attempts_per_step(默认 10 次)降级重发、recovery、退避重试,耗尽后上抛
Tool工具自身超时isError: true 的结果

工具失败不是异常:它返回 isError: true 的结果进入历史,让模型自己决定怎么办;只有基础设施级失败才让回合失败。provider 过滤另算:finishReason 为 filtered 时,回合以 ProviderFilteredError(「Provider safety policy blocked the response.」)结束。

下一篇:kosong:LLM 抽象层。

本页目录