# 回合收敛与异常吸收

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

- 作者：David（道雾轩）
- 专栏：Kimi Code 源码解读（https://daiw.org/manual/kimi-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/kimi-code/turn-convergence
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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`）：

```ts
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`）：

```ts
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`）：

```ts
    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()` 交给错误处理器。[压缩](https://daiw.org/manual/kimi-code/compaction)服务注册了其中一个（`fullCompactionService.ts:199`）：

```ts
      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` 安全点换成了钩子加错误处理器。

## 中止路径

```ts
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`）：

```ts
    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](https://daiw.org/manual/kimi-code/goal-plan) 就这样续跑，而且续跑前会先看 `maxStepsPerTurn` 还剩没剩 step。

## 收敛层次

| 层 | 上限 | 失败表达 |
| --- | --- | --- |
| Goal | token、墙钟时间、回合数预算 | goal 状态机 |
| Turn | `max_steps_per_turn` | `LoopRunResult` 的 `failed`（`loop.max_steps_exceeded`）|
| Step | `max_attempts_per_step`（默认 10 次）| 降级重发、recovery、退避重试，耗尽后上抛 |
| Tool | 工具自身超时 | `isError: true` 的结果 |

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

下一篇：[kosong：LLM 抽象层](https://daiw.org/manual/kimi-code/kosong)。
