回合收敛与异常吸收
错误归类、step 内重试、投影降级、上下文溢出与中止路径——provider 层的失败如何被吸收成一次可完成或能明确失败的回合。
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()):
- 请求体过大(
APIRequestTooLargeError)→ 以media: 'degraded'重发:只保留最近 2 个媒体片段,更早的换成[image omitted: dropped to fit the provider request size limit; ...]这类占位文本。 - 降级后仍过大,或图片格式被拒 → 以
media: { strip }重发:把快照里记下的媒体全部换成占位文本,之后重新读入的媒体不受影响。 - 消息结构被拒(工具交换不相邻等可恢复的结构错误)→ 以
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。
收敛层次
| 层 | 上限 | 失败表达 |
|---|---|---|
| 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 抽象层。