目标
s01 到 s10 让智能体具备了完整的对话、工具、权限、Hook、子智能体、技能、压缩、记忆、系统提示能力。但有一个"最后一公里"的问题没解决:LLM 调用出错时,整个对话回合会直接失败。
AnthropicProvider.chat() 把所有异常吞掉,返回 ChatResponse(stopReason = ERROR)。AgentLoop 把 ERROR 当终态处理——追加错误内容并返回。后果是:
- 一次 429 限流就终止整个回合,用户看到 "Error: Rate limit reached",必须手动重试
- 网络瞬时抖动同样终止对话,尽管下一秒就恢复了
- 模型配额/权限问题没有 fallback,只能放弃
- 输出被 MAX_TOKENS 截断时,如果问题是输出预算不够(而非上下文太长),s08 压缩历史也救不了
Index 11 引入错误恢复层,让智能体在 LLM 出错时自愈:
- 瞬时错误重试(RetryStrategy)—— 网络抖动 / 429 / 5xx 按指数退避自动重试
- 模型 fallback(ModelFallback)—— 永久错误(auth/非法请求)自动切换备用模型
- token 升级(TokenEscalation)—— MAX_TOKENS 截断时提升输出预算重试
核心是 ErrorRecoveryManager——一个实现 LLMProvider 接口的透明包装器。在 ReplLoop 组装点包一层,主循环、子智能体、压缩器、记忆提取器全部自动获得恢复能力,AgentLoop 一行不改。
完成后的效果——一次限流不再打断对话:
[INFO] Retrying chat after rate_limit_error (attempt 1/3, delay=500ms)
[INFO] Retrying chat after rate_limit_error (attempt 2/3, delay=1000ms)
[Agent] 好的,我来看看这个文件...为什么需要
现实问题
s10 完成时,LLM 调用链长这样:
AgentLoop.run()
→ llmProvider.chat(messages, options, tools) ← 唯一的外部依赖
→ AnthropicProvider
→ HTTP POST api.anthropic.com/v1/messagesAnthropicProvider 的错误处理是吞掉一切:
} catch (e: Exception) {
logger.error(e) { "API call failed" }
ChatResponse(
content = "Error: ${e.message}",
stopReason = StopReason.ERROR
)
}AgentLoop 收到 ERROR 时:
StopReason.ERROR, StopReason.STOP_SEQUENCE -> {
if (response.content.isNotBlank()) {
messagesHistory.add(Message(Role.ASSISTANT, response.content))
}
return response.content // ← 终止整个 turn,返回错误文本
}问题本质:错误信息被压成一行文本("Error: ..."),恢复层无从判断"为什么失败"——是网络抖动(应该重试)还是 key 无效(重试无用)?AgentLoop 也无法区分,只能一刀切终止。
现实场景
| 场景 | 现在的行为 | 应该的行为 |
|---|---|---|
| 429 限流 | turn 终止,用户手动重试 | 退避几秒后自动重试 |
| 网络闪断 | turn 终止 | 立即重试 |
| key 过期(401) | turn 终止 | 换 fallback 模型(或明确失败) |
| 输出被截断 | s08 压缩历史重试 | 先升级输出预算,仍截断再压缩 |
设计原则
- 透明包装 ——
ErrorRecoveryManager实现LLMProvider,在组装点包一层,所有下游自动受益,AgentLoop无感知 - 瞬时重试,永久 fallback —— 错误分类驱动策略:瞬时 → 重试;永久 → fallback;都失败 → 返回 ERROR(AgentLoop 按现状兜底)
- token 升级与 s08 互补 —— MAX_TOKENS 先升级输出预算,仍截断交还 s08 压缩历史;两条路径解决两个不同的截断原因
- 结构化错误 ——
ChatResponse.error携带机器可读的错误 type,让恢复层能分类,而非解析文本 - 重试比放弃安全 —— 未知错误 type 兜底按瞬时处理(一次多余重试的代价远低于一次错误终止)
核心设计与实现
架构全景
┌─────────────────────────────────────────┐
│ AnthropicProvider │
│ (吞异常 → ERROR + 结构化 LlmError) │
└──────────────────┬──────────────────────┘
│ delegate
┌──────────────────▼──────────────────────┐
│ ErrorRecoveryManager │
│ (实现 LLMProvider 的透明包装器) │
│ │
│ ┌────────────────────────────────────┐ │
│ │ MAX_TOKENS → TokenEscalation │ │
│ │ ERROR 瞬时 → RetryStrategy 退避重试 │ │
│ │ ERROR 永久 → ModelFallback 换模型 │ │
│ │ delegate 抛异常 → CancellationException│
│ │ 重抛 / 其他归一 network_error │ │
│ └────────────────────────────────────┘ │
└──────────────────┬──────────────────────┘
│ 实现 LLMProvider
┌────────────────────────┼────────────────────────┐
│ │ │
AgentLoop SubagentFactory Memory/Compactor
(主循环,无感知) (子智能体隔离) (摘要/提取)
ErrorClassifier ← LlmError(type, message)
(瞬时 / 永久 分类)结构化错误:LlmError + ChatResponse.error
恢复层要判断"为什么失败",第一步是让错误信息结构化。ChatResponse 增加 error 字段(默认 null,向后兼容):
data class LlmError(
val type: String, // "rate_limit_error" / "api_error" / "authentication_error" / "network_error" ...
val message: String
)
data class ChatResponse(
val content: String,
val stopReason: StopReason,
val toolCalls: List<ToolCall> = emptyList(),
val error: LlmError? = null // s11: 非空表示本次调用失败
)AnthropicProvider 在全部 4 个 ERROR 返回点设置 error 字段(最终审查发现第 4 个分支——"Unrecognized API response format"——最初漏了,违反"ERROR 时 error 非空"的不变式):
// 网络兜底
} catch (e: Exception) {
ChatResponse(
content = "Error: ${e.message}",
stopReason = StopReason.ERROR,
error = LlmError(type = ERROR_TYPE_NETWORK, message = e.message ?: "Unknown error")
)
}
// API 错误响应——透传服务端 type
if (anthropicError != null) {
return ChatResponse(
content = "API Error: ${anthropicError.message}",
stopReason = StopReason.ERROR,
error = LlmError(type = anthropicError.type, message = anthropicError.message)
)
}关键设计:透传 API 自己的错误 type(rate_limit_error、authentication_error 等),而不是发明自己的分类——ErrorClassifier 直接对服务端词汇表工作。
ErrorClassifier:瞬时 vs 永久
恢复策略的分水岭。按错误 type 的关键字匹配分类:
enum class ErrorCategory { TRANSIENT, PERMANENT }
object ErrorClassifier {
fun classify(error: LlmError): ErrorCategory {
val type = error.type.lowercase()
if (TRANSIENT_TYPES.any { type.contains(it) }) return ErrorCategory.TRANSIENT
if (PERMANENT_TYPES.any { type.contains(it) }) return ErrorCategory.PERMANENT
return ErrorCategory.TRANSIENT // 未知 type 兜底按瞬时——重试比放弃安全
}
private val TRANSIENT_TYPES = listOf(
"network_error", "rate_limit_error", "api_error", "overloaded_error", "timeout"
)
private val PERMANENT_TYPES = listOf(
"authentication_error", "invalid_request_error", "not_found_error", "permission_error"
)
}| 分类 | 错误 type | 恢复动作 |
|---|---|---|
| TRANSIENT | network / rate_limit / api / overloaded / timeout | 指数退避重试 |
| PERMANENT | authentication / invalid_request / not_found / permission | 切换 fallback 模型 |
| 未知 | 兜底 | 按瞬时处理(重试) |
RetryStrategy:指数退避
class RetryStrategy(
val maxAttempts: Int = 3, // 总尝试次数(含首次)
val baseDelayMs: Long = 500, // 首次失败后的退避基准
val backoffFactor: Double = 2.0 // 每次失败延迟翻倍
) {
fun shouldRetry(attempt: Int): Boolean = attempt + 1 < maxAttempts
fun delayMs(attempt: Int): Long =
(baseDelayMs * Math.pow(backoffFactor, attempt.toDouble())).toLong().coerceAtLeast(0)
}退避序列:500ms → 1000ms → 2000ms。shouldRetry 的 attempt + 1 < maxAttempts 精确保证"恰好 maxAttempts 次总尝试",无 off-by-one。
ModelFallback:模型降级链
class ModelFallback(val fallbackModels: List<String> = emptyList()) {
fun chainFrom(primaryModel: String): List<String> =
(listOf(primaryModel) + fallbackModels).distinct() // 主模型在前 + 去重
}chainFrom 返回完整尝试链(而非"下一个"),让编排器用 for (model in models) 简单迭代,无可变状态。distinct() 去重(最终审查 I3:最初只去重了与主模型重复的,没去重 fallback 内部重复项)。
TokenEscalation:输出预算升级
class TokenEscalation(
val maxEscalations: Int = 1, // 最多升级几次
val factor: Double = 2.0, // 每次 maxTokens * factor
val ceiling: Int = 32_768 // 硬上限
) {
fun escalatedMaxTokens(current: Int, escalation: Int): Int? {
if (escalation > maxEscalations) return null
val escalated = (current * Math.pow(factor, escalation.toDouble())).toInt()
return if (escalated > ceiling) null else escalated // 超 ceiling 放弃升级
}
}ErrorRecoveryManager:编排器
核心——实现 LLMProvider 的透明包装器。这是 s11 的心脏:
class ErrorRecoveryManager(
private val delegate: LLMProvider,
private val retryStrategy: RetryStrategy = RetryStrategy(),
private val modelFallback: ModelFallback = ModelFallback(),
private val tokenEscalation: TokenEscalation = TokenEscalation()
) : LLMProvider {
private val logger = KotlinLogging.logger {}
override suspend fun chat(
messages: List<Message>,
options: ChatOptions,
tools: List<JsonObject>,
toolResults: List<ToolResult>
): ChatResponse {
val models = modelFallback.chainFrom(options.model)
for (model in models) {
val attemptOptions = options.copy(model = model)
var attempt = 0
while (true) {
var response = safeChat(messages, attemptOptions, tools, toolResults)
// MAX_TOKENS:先尝试 token 升级;升级耗尽返回最后(最完整)的截断响应
if (response.stopReason == StopReason.MAX_TOKENS) {
val escalated = escalateIfNeeded(messages, attemptOptions, tools, toolResults, response)
if (escalated.stopReason == StopReason.MAX_TOKENS) {
return escalated // 升级耗尽 → 交还 AgentLoop 走 s08 压缩
}
response = escalated // 升级后成功或 ERROR,走下方统一处理
}
// 成功(END_TURN / TOOL_USE / STOP_SEQUENCE)直接返回
if (response.stopReason != StopReason.ERROR) {
return response
}
// ERROR:判断瞬时 / 永久
val error = response.error ?: LlmError(UNKNOWN_ERROR_TYPE, response.content)
val category = ErrorClassifier.classify(error)
if (category == ErrorCategory.TRANSIENT && retryStrategy.shouldRetry(attempt)) {
val delayMs = retryStrategy.delayMs(attempt)
logger.info {
"Retrying chat after ${error.type} (attempt ${attempt + 1}/${retryStrategy.maxAttempts}, delay=${delayMs}ms)"
}
delay(delayMs)
attempt++
continue
}
// 永久错误或重试耗尽:尝试下一个模型
if (model != models.last()) {
logger.warn { "Chat failed with ${error.type}, falling back to next model" }
break
}
// 全部模型失败,返回最后的 ERROR
logger.error { "All models failed, last error: ${error.type}: ${error.message}" }
return response
}
}
error("unreachable: model chain is never empty")
}
}safeChat 的两个细节:
- CancellationException 重抛(最终审查 C1 修复)——协程取消必须传播,不能被归一成 network_error 然后重试:
private suspend fun safeChat(...): ChatResponse = try {
delegate.chat(...)
} catch (e: CancellationException) {
throw e // 结构化并发:取消必须传播
} catch (e: Exception) {
logger.warn(e) { "LLM chat threw, treating as network error" }
ChatResponse(
content = "Error: ${e.message}",
stopReason = StopReason.ERROR,
error = LlmError(NETWORK_ERROR_TYPE, e.message ?: "Unknown error")
)
}escalateIfNeeded回注统一错误处理(最终审查 I1 修复)——升级后的 ERROR 不再直接返回,而是回注主循环走重试/fallback;升级耗尽返回**最后(最完整)**的截断响应(I2 修复):
private suspend fun escalateIfNeeded(...): ChatResponse {
var lastMaxTokens = original
var escalation = 1
while (true) {
val newMaxTokens = tokenEscalation.escalatedMaxTokens(options.maxTokens, escalation)
?: return lastMaxTokens // 升级不可用/耗尽 → 返回最后(最完整)的截断响应
logger.info { "MAX_TOKENS, escalating maxTokens ${options.maxTokens} → $newMaxTokens (escalation $escalation)" }
val response = safeChat(messages, options.copy(maxTokens = newMaxTokens), tools, toolResults)
if (response.stopReason == StopReason.MAX_TOKENS) {
lastMaxTokens = response
escalation++
continue
}
return response // 升级后成功或 ERROR(ERROR 由主循环处理)
}
}与 s08 的互补
MAX_TOKENS 时 s11 与 s08 处理两个不同的根因:
s11 token 升级:maxTokens 1000 → 2000(输出预算不足)
├─ 升级后成功 → 完成
└─ 升级耗尽仍 MAX_TOKENS → 返回截断响应
→ AgentLoop 触发 s08 压缩(上下文过长)
→ 压缩后重试s11 解决"输出预算不够",s08 解决"上下文太长"——两条路径互不干扰,层叠兜底。
ReplLoop 集成:一处包装,处处受益
// s11: 错误恢复层——透明包装,主循环/子智能体/压缩器/记忆提取全部自动获得
// 瞬时重试 + 模型 fallback + MAX_TOKENS token 升级能力
val resilientProvider = ErrorRecoveryManager(
delegate = llmProvider,
retryStrategy = RetryStrategy(),
modelFallback = ModelFallback(), // MVP 默认空 fallback,机制就绪
tokenEscalation = TokenEscalation()
)然后替换 ReplLoop 内全部 5 处 llmProvider 引用:
| 消费点 | 说明 |
|---|---|
AgentLoop | 主循环,获得完整恢复能力 |
SubagentFactory | 子智能体隔离但仍需恢复 |
ContextCompactor | s08 摘要 LLM 调用 |
MemoryExtractor | s09 记忆提取 |
MemoryConsolidator | s09 记忆整合 |
一处包装,全部下游透明受益——这是"透明包装器"设计的核心价值。
端到端流程
瞬时错误场景(429 限流):
用户:"重构 UserService"
AgentLoop.run()
→ resilientProvider.chat(...)
→ delegate.chat → ERROR(rate_limit_error)
→ ErrorClassifier: TRANSIENT
→ RetryStrategy.shouldRetry(0) = true → delay(500ms)
→ delegate.chat → ERROR(rate_limit_error)
→ RetryStrategy.shouldRetry(1) = true → delay(1000ms)
→ delegate.chat → END_TURN("好的,让我先看一下...")
→ AgentLoop 正常继续
[INFO] Retrying chat after rate_limit_error (attempt 1/3, delay=500ms)
[INFO] Retrying chat after rate_limit_error (attempt 2/3, delay=1000ms)永久错误 + fallback 场景:
→ delegate.chat → ERROR(authentication_error)
→ ErrorClassifier: PERMANENT
→ 不重试 → ModelFallback: 下一个模型
→ delegate.chat(模型=claude-opus-4-8) → END_TURN
[WARN] Chat failed with authentication_error, falling back to next modelMAX_TOKENS + token 升级场景:
→ delegate.chat → MAX_TOKENS("partial")
→ TokenEscalation: maxTokens 1000 → 2000
→ delegate.chat(maxTokens=2000) → END_TURN("full")
[INFO] MAX_TOKENS, escalating maxTokens 1000 → 2000 (escalation 1)全部失败(重试耗尽 + 无 fallback):
→ delegate.chat → ERROR(api_error) × 3
→ 重试耗尽 → 返回最后的 ERROR
→ AgentLoop 按既有逻辑兜底(追加错误内容并返回)
[ERROR] All models failed, last error: api_error: Internal server error测试策略
| 测试类 | 覆盖点 |
|---|---|
ErrorClassifierTest | rate_limit/api/overloaded/network → TRANSIENT;authentication/invalid_request → PERMANENT;未知 type 兜底 TRANSIENT |
RetryStrategyTest | shouldRetry 边界(attempt 0/1 true、2 false);退避延迟 100/200/400 |
ModelFallbackTest | chainFrom 主模型在前 + fallback 去重(含内部重复项)+ 空 fallback |
TokenEscalationTest | 升级计算(1000→2000→4000→8000)、超 maxEscalations 返回 null、超 ceiling 返回 null |
ErrorRecoveryManagerTest | 10 用例:瞬时重试成功、永久不重试、重试耗尽、fallback 模型、全模型失败、MAX_TOKENS 升级成功、升级耗尽返回最后截断、升级后 ERROR 走重试、delegate 抛异常归一重试、CancellationException 传播 |
AnthropicProviderTest | 4 个 ERROR 分支都携带结构化 error 字段 |
测试设计要点:
ScriptedProvider记录每次调用的 options —— 用callOptions断言重试次数、模型切换序列、maxTokens 升级序列,验证真实行为而非实现细节:
class ScriptedProvider(private val responses: MutableList<ChatResponse>) : LLMProvider {
val callOptions = mutableListOf<ChatOptions>()
override suspend fun chat(messages, options, tools, toolResults): ChatResponse {
callOptions.add(options)
return if (responses.isNotEmpty()) responses.removeAt(0) else error("No more scripted responses")
}
}baseDelayMs = 0避免真实等待 —— 测试用RetryStrategy(baseDelayMs = 0)让重试测试瞬时完成- 升级耗尽断言"最后"而非"原始"响应 —— I2 修复后,升级耗尽返回最完整的截断响应("partial-b" 而非 "partial-a"),测试锁住这个语义
开发过程:设计取舍与踩坑记录
计划阶段的三个自主决策:
- 透明包装器(而非侵入 AgentLoop)—— 实现
LLMProvider,一处组装、处处受益,AgentLoop 无感知 - 结构化错误(而非解析错误文本)——
ChatResponse.error携带机器可读 type,恢复层可靠分类 - 未知 type 兜底 TRANSIENT —— 重试比放弃安全(最坏 ~3.5 秒浪费)
实现中发现并处理的计划偏差:
StopReason包路径错误 —— 计划 importllm.StopReason,实际在agent包,编译修正- 测试 helper 参数类型 ——
manager()从ScriptedProvider放宽为LLMProvider接口,让抛异常 provider 可传入 - 第 4 个 ERROR 分支 —— "Unrecognized API response format" 分支最初未设 error 字段,违反"ERROR 时 error 非空"不变式(Task 1 审查发现并修复)
最终全分支审查发现并修复(1 Critical + 3 Important):
- C1 CancellationException 吞掉(Critical) —— safeChat 的 catch(Exception) 会把协程取消归一成 network_error 并重试,破坏结构化并发(REPL 关闭时子智能体取消不干净)。修复:
catch (CancellationException) { throw e }提前重抛 - I1 升级后 ERROR 绕过恢复链 —— 升级调用返回 ERROR 时直接跳出,不进重试/fallback。修复:
response = escalated回注统一错误处理 - I2 升级耗尽返回原始(较小)响应 —— 多级升级时应返回最后(最完整)的截断响应。修复:
lastMaxTokens跟踪 - I3 chainFrom 不去重内部重复 ——
distinct()修复
几个被采纳的关键设计决策:
- 透传 API 错误 type —— 不发明自己的错误分类,直接对服务端词汇表工作
- token 升级先于 s08 压缩 —— 输出预算不足 vs 上下文过长,两个根因两条路径
- 重试耗尽后 fallback —— 瞬时错误重试 3 次仍失败,再尝试 fallback 模型(双保险)
- 日志分级 —— 重试 info、fallback warn、全失败 error
下一站
s11 让智能体在出错时自愈,这是阶段三(系统可靠性)的第一块基石。它把"LLM 调用会失败"这个现实彻底封装掉了:
- s12 Task System —— 任务持久化后,重试/恢复可以在任务粒度上编排(任务失败自动重试,而非整个会话)
- s13 Background Tasks —— 后台任务的 LLM 调用同样经 resilientProvider,错误恢复自动生效
- s20 Comprehensive Agent —— 全机制集成的错误处理统一由 ErrorRecoveryManager 承担
s11 留给后续最大的礼物是一个可复用的错误恢复范式:结构化错误 → 分类 → 策略驱动恢复(重试/降级/升级)→ 全部失败兜底。这套模式在 s12 任务系统、s13 后台执行里会反复出现。
下一篇:Index 12: Task System —— TaskRecord / blockedBy / 磁盘持久化,让任务离开会话也能存活。


