Cat Blog
HomeBlogTools
Search
Language
Choose site style
Choose accent color
Click Effect
Theme

Cat Blog · Updated regularly. Source code is available on GitHub.

Index 11: Error Recovery — 重试 / fallback / token 升级

August 13th, 2026
AI智能体Kotlin后端

Series

使用Kotlin从0开发一个ClaudeCode

Series

使用Kotlin从0开发一个ClaudeCode

Progress 11 / 21

使用Kotlin从0开发一个ClaudeCode

Previous in series

Index 10: System Prompt — 运行时分段拼接

Next in series

Index 12: Task System — TaskRecord / blockedBy / 磁盘持久化

目标

s01 到 s10 让智能体具备了完整的对话、工具、权限、Hook、子智能体、技能、压缩、记忆、系统提示能力。但有一个"最后一公里"的问题没解决:LLM 调用出错时,整个对话回合会直接失败。

AnthropicProvider.chat() 把所有异常吞掉,返回 ChatResponse(stopReason = ERROR)。AgentLoop 把 ERROR 当终态处理——追加错误内容并返回。后果是:

  • 一次 429 限流就终止整个回合,用户看到 "Error: Rate limit reached",必须手动重试
  • 网络瞬时抖动同样终止对话,尽管下一秒就恢复了
  • 模型配额/权限问题没有 fallback,只能放弃
  • 输出被 MAX_TOKENS 截断时,如果问题是输出预算不够(而非上下文太长),s08 压缩历史也救不了

Index 11 引入错误恢复层,让智能体在 LLM 出错时自愈:

  1. 瞬时错误重试(RetryStrategy)—— 网络抖动 / 429 / 5xx 按指数退避自动重试
  2. 模型 fallback(ModelFallback)—— 永久错误(auth/非法请求)自动切换备用模型
  3. token 升级(TokenEscalation)—— MAX_TOKENS 截断时提升输出预算重试

核心是 ErrorRecoveryManager——一个实现 LLMProvider 接口的透明包装器。在 ReplLoop 组装点包一层,主循环、子智能体、压缩器、记忆提取器全部自动获得恢复能力,AgentLoop 一行不改。

完成后的效果——一次限流不再打断对话:

TEXT
[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 调用链长这样:

TEXT
AgentLoop.run()
  → llmProvider.chat(messages, options, tools)     ← 唯一的外部依赖
      → AnthropicProvider
          → HTTP POST api.anthropic.com/v1/messages

AnthropicProvider 的错误处理是吞掉一切:

KOTLIN
} catch (e: Exception) {
    logger.error(e) { "API call failed" }
    ChatResponse(
        content = "Error: ${e.message}",
        stopReason = StopReason.ERROR
    )
}

AgentLoop 收到 ERROR 时:

KOTLIN
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 兜底按瞬时处理(一次多余重试的代价远低于一次错误终止)

核心设计与实现

架构全景

TEXT
                    ┌─────────────────────────────────────────┐
                    │           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,向后兼容):

KOTLIN
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 非空"的不变式):

KOTLIN
// 网络兜底
} 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 的关键字匹配分类:

KOTLIN
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恢复动作
TRANSIENTnetwork / rate_limit / api / overloaded / timeout指数退避重试
PERMANENTauthentication / invalid_request / not_found / permission切换 fallback 模型
未知兜底按瞬时处理(重试)

RetryStrategy:指数退避

KOTLIN
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:模型降级链

KOTLIN
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:输出预算升级

KOTLIN
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 的心脏:

KOTLIN
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 的两个细节:

  1. CancellationException 重抛(最终审查 C1 修复)——协程取消必须传播,不能被归一成 network_error 然后重试:
KOTLIN
    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")
        )
    }
  1. escalateIfNeeded 回注统一错误处理(最终审查 I1 修复)——升级后的 ERROR 不再直接返回,而是回注主循环走重试/fallback;升级耗尽返回**最后(最完整)**的截断响应(I2 修复):
KOTLIN
    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 处理两个不同的根因:

TEXT
s11 token 升级:maxTokens 1000 → 2000(输出预算不足)
  ├─ 升级后成功 → 完成
  └─ 升级耗尽仍 MAX_TOKENS → 返回截断响应
      → AgentLoop 触发 s08 压缩(上下文过长)
      → 压缩后重试

s11 解决"输出预算不够",s08 解决"上下文太长"——两条路径互不干扰,层叠兜底。

ReplLoop 集成:一处包装,处处受益

KOTLIN
// s11: 错误恢复层——透明包装,主循环/子智能体/压缩器/记忆提取全部自动获得
// 瞬时重试 + 模型 fallback + MAX_TOKENS token 升级能力
val resilientProvider = ErrorRecoveryManager(
    delegate = llmProvider,
    retryStrategy = RetryStrategy(),
    modelFallback = ModelFallback(),   // MVP 默认空 fallback,机制就绪
    tokenEscalation = TokenEscalation()
)

然后替换 ReplLoop 内全部 5 处 llmProvider 引用:

消费点说明
AgentLoop主循环,获得完整恢复能力
SubagentFactory子智能体隔离但仍需恢复
ContextCompactors08 摘要 LLM 调用
MemoryExtractors09 记忆提取
MemoryConsolidators09 记忆整合

一处包装,全部下游透明受益——这是"透明包装器"设计的核心价值。


端到端流程

瞬时错误场景(429 限流):

TEXT
用户:"重构 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 场景:

TEXT
  → 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 model

MAX_TOKENS + token 升级场景:

TEXT
  → 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):

TEXT
  → delegate.chat → ERROR(api_error)  × 3
  → 重试耗尽 → 返回最后的 ERROR
  → AgentLoop 按既有逻辑兜底(追加错误内容并返回)
[ERROR] All models failed, last error: api_error: Internal server error

测试策略

测试类覆盖点
ErrorClassifierTestrate_limit/api/overloaded/network → TRANSIENT;authentication/invalid_request → PERMANENT;未知 type 兜底 TRANSIENT
RetryStrategyTestshouldRetry 边界(attempt 0/1 true、2 false);退避延迟 100/200/400
ModelFallbackTestchainFrom 主模型在前 + fallback 去重(含内部重复项)+ 空 fallback
TokenEscalationTest升级计算(1000→2000→4000→8000)、超 maxEscalations 返回 null、超 ceiling 返回 null
ErrorRecoveryManagerTest10 用例:瞬时重试成功、永久不重试、重试耗尽、fallback 模型、全模型失败、MAX_TOKENS 升级成功、升级耗尽返回最后截断、升级后 ERROR 走重试、delegate 抛异常归一重试、CancellationException 传播
AnthropicProviderTest4 个 ERROR 分支都携带结构化 error 字段

测试设计要点:

  1. ScriptedProvider 记录每次调用的 options —— 用 callOptions 断言重试次数、模型切换序列、maxTokens 升级序列,验证真实行为而非实现细节:
KOTLIN
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")
    }
}
  1. baseDelayMs = 0 避免真实等待 —— 测试用 RetryStrategy(baseDelayMs = 0) 让重试测试瞬时完成
  2. 升级耗尽断言"最后"而非"原始"响应 —— I2 修复后,升级耗尽返回最完整的截断响应("partial-b" 而非 "partial-a"),测试锁住这个语义

开发过程:设计取舍与踩坑记录

计划阶段的三个自主决策:

  • 透明包装器(而非侵入 AgentLoop)—— 实现 LLMProvider,一处组装、处处受益,AgentLoop 无感知
  • 结构化错误(而非解析错误文本)—— ChatResponse.error 携带机器可读 type,恢复层可靠分类
  • 未知 type 兜底 TRANSIENT —— 重试比放弃安全(最坏 ~3.5 秒浪费)

实现中发现并处理的计划偏差:

  • StopReason 包路径错误 —— 计划 import llm.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 / 磁盘持久化,让任务离开会话也能存活。

Table of Contents

Current section:目标

  • 1. 目标
  • 2. 为什么需要
  • 3. 现实问题
  • 4. 现实场景
  • 5. 设计原则
  • 6. 核心设计与实现
  • 7. 架构全景
  • 8. 结构化错误:LlmError + ChatResponse.error
  • 9. ErrorClassifier:瞬时 vs 永久
  • 10. RetryStrategy:指数退避
  • 11. ModelFallback:模型降级链
  • 12. TokenEscalation:输出预算升级
  • 13. ErrorRecoveryManager:编排器
  • 14. 与 s08 的互补
  • 15. ReplLoop 集成:一处包装,处处受益
  • 16. 端到端流程
  • 17. 测试策略
  • 18. 开发过程:设计取舍与踩坑记录
  • 19. 下一站
Back to top

Related Posts

View all posts
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

August 21st, 2026

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

August 13th, 2026

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

August 13th, 2026

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。