第 5 章

错误处理与恢复

第 5 章:错误处理与恢复策略

Agent 总会失败。关键在于它是优雅地应对,还是毁灭性地崩溃。

在一个周二的凌晨三点,我被报警电话叫醒了:我们的一个 Agent 已经连续六个小时疯狂请求 Claude API。起因只是一个简单的网络波动导致请求超时,而我们那幼稚的重试逻辑忠实地执行了指令:立即重试。一遍又一遍。每分钟上千次,持续了整整六个小时。结果是我们耗尽了速率限制(rate limit),触发了滥用检测,却一事无成。

修复代码只花了 15 分钟,但这个教训我记了很久。

究竟哪里会出错?

Agent 系统的故障通常可以归为几大类,每一类都需要不同的应对策略。

基础设施故障(如网络超时、连接重置、DNS 抖动)通常是瞬时的。请求本身没问题,只是“管道”断了。这种情况下,简单的重试通常奏效。

速率限制(Rate Limiting) 严格来说并不是传统意义上的错误。它是 API 在告诉你:“请慢一点。”如果把它当作普通故障并立即重试,只会适得其反。

无效的 LLM 响应 源于语言模型的概率本质。你可能会遇到带有多余逗号的 JSON、调用了不存在的工具、或者输出被截断。非确定性输出是语言模型的固有特性。

工具调用失败 发生在 Agent 的动作碰撞到现实世界时。磁盘空间不足、权限被拒绝、API 返回了非预期的响应。Agent 的请求很合理,但现实说“不”。

上下文溢出(Context Overflow) 是 LLM 系统特有的。与传统软件不同,Agent 的工作内存有硬上限。一旦超过这个上限,就没有优雅的退路——请求根本无法照原样继续。

核心洞见在于:适用于网络错误的重试策略会加剧速率限制问题,且对上下文溢出毫无帮助。在尝试修复之前,你必须先搞清楚为什么失败。

正确的重试姿势

最简单的恢复手段是重试,但“简单”不代表“幼稚”。

退避算法(Backoff Algorithm)

想象一台在负载下挣扎的服务器。现在再想象一百个客户端同时失败,并以最高频率重试。你刚刚对一个本就脆弱的系统发起了一场 DDoS 攻击。

function retryWithBackoff(operation, maxAttempts) {
    let baseDelay = 1000; // 1秒
    const maxDelay = 60000; // 60秒
    
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            return operation();
        } catch (error) {
            if (!isRetryable(error)) throw error;
            
            let delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
            sleep(delay);
        }
    }
    throw new Error('超过最大重试次数');
}

第一次重试:2 秒后;第二次:4 秒后;第三次:8 秒后。你在为故障系统留出喘息空间的同时,依然在推动进度。

惊群效应(The Thundering Herd)

但还有一个问题。如果一千个客户端在同一时刻遇到了网络分区,它们都会在 2 秒后同时重试。又是一个峰值。然后它们又会在 4 秒后同步重试。

你需要引入抖动(Jitter)

let delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
let jitter = Math.random() * (delay * 0.25);
sleep(delay + jitter);

这种随机偏移将重试请求在时间轴上打散,避免了在预测的时间点形成流量洪峰。

识别可重试的场景

并非所有错误都值得重试:

状态码含义建议方案
400 Bad Request请求格式错误修复代码,不要重试
401 Unauthorized认证失败刷新 Token 后重试
404 Not Found资源不存在不要重试
429 Too Many Requests频率受限等待后重试
500 Server Error服务端故障使用退避策略重试
503 Unavailable服务不可用使用退避策略重试
529 Overloaded (Anthropic 特有)负载过高用户操作可重试;后台任务建议放弃

连接重置(Connection Resets)比较特殊——通常是由过期的长连接(keep-alive)引起的。建议禁用连接复用并重试一次,若仍失败再向上抛出。

上下文溢出不能原样重试。你必须先对上下文进行转换。

升级处理阶梯

当简单重试无果时,你需要采取更激进的干预措施。

第一级:原样重试。 等待一会再试,寄希望于瞬时问题已解决。

第二级:修改请求。 Token 太多?截断输入。模型过载?切换到另一个备选模型。请求太复杂?拆分成更小的步骤。

第三级:转换上下文。 当累积的状态(而非单次请求)成为瓶颈时,重塑状态。压缩对话历史,总结旧的工具执行结果,丢弃低优先级的背景信息。

第四级:升级给用户。 有些问题需要人类的判断:无法自动解决的身份认证、需要澄清的歧义、需要确认的高风险操作。这不叫失败,这叫正确的委托。

第五级:带状态保存的优雅退出。 当所有手段都失效,也要优雅地倒下。保存当前状态,确保工作不丢失,清晰说明原因,并保留系统可恢复的线索。

async function executeWithRecovery(request, context) {
    // 第一级:简单重试
    for (let attempt = 1; attempt <= 3; attempt++) {
        try {
            return await execute(request);
        } catch (error) {
            if (!isRetryable(error)) break;
            await sleep(exponentialBackoffWithJitter(attempt));
        }
    }
    
    // 第二级:修改请求
    if (error.type === 'ContextOverflow') {
        const reducedRequest = truncateInput(request, 0.75);
        try { return await execute(reducedRequest); } catch {}
    }
    
    if (error.type === 'ModelOverloaded') {
        try { return await execute(request, { model: fallbackModel }); } catch {}
    }
    
    // 第三级:转换上下文
    if (error.type === 'ContextOverflow') {
        const compactedContext = await compactHistory(context);
        try { return await execute(request, { context: compactedContext }); } catch {}
    }
    
    // 第四级:请用户定夺
    if (isUserPresent()) {
        const userDecision = await promptUser(
            "遇到无法自动解决的错误:" + formatUserFriendlyError(error) +
            "\n是否尝试其他方案?"
        );
        return handleUserDecision(userDecision);
    }
    
    // 第五级:优雅失败
    await saveState(context, request, error);
    throw new GracefulFailure({
        message: "多次尝试后仍无法完成任务",
        stateId: savedStateId,
        suggestion: `使用此命令恢复: /resume ${savedStateId}`
    });
}

工具调用的错误隔离

当 Bash 命令执行失败时,你有两个选择:让整个 Agent 循环崩溃,或者将错误作为“信息”反馈给 Agent。

工具: bash
输入: apt-get install nodejs
结果: {
    "success": false,
    "exit_code": 1,
    "stderr": "E: Could not open lock file - permission denied"
}

脆弱的系统会抛出异常。稳健的系统将其包装成工具执行结果,让 LLM 自己去推理:“权限被拒绝——我应该尝试使用 sudo,或者询问用户其他安装方式。”

实现起来非常简单:

async function executeTool(tool, input) {
    try {
        const result = await tool.execute(input);
        return { role: 'tool_result', content: result, is_error: false };
    } catch (error) {
        return { role: 'tool_result', content: formatToolError(error), is_error: true };
    }
}

错误不会终结循环,它们只是对话的一部分。

任务执行中的断点恢复

Agent 会被中断。进程被杀掉、连接断开、用户关上了笔记本。当它们重启时,需要回答:“我刚才在干什么?”

如果你在每一轮对话后都将内容持久化到磁盘,恢复就变得可能。关键检查点包括:

消息链完整性。 如果记录以 Assistant 发起的工具调用结束,但没有对应的工具结果,说明这一轮被中断了。

文件状态验证。 当时是否正在编辑文件?文件是否还存在?是否被外部修改过?

计划状态恢复。 多步骤计划进行到哪一环了?哪些已完成?哪些需要重试?

function resumeSession(transcriptPath) {
    const transcript = loadTranscript(transcriptPath);
    const lastMessage = transcript.messages.at(-1);

    if (lastMessage.role === 'assistant' && hasToolCalls(lastMessage)) {
        const pendingTools = lastMessage.toolCalls;
        if (!hasMatchingToolResults(transcript, pendingTools)) {
            for (const tool of pendingTools) {
                const result = checkToolStateAndRecover(tool);
                transcript.append(result);
            }
        }
    }
    // ... 其他状态检查
    return transcript;
}

恢复后的 Agent 可能无法百分之百还原现场,但它会理解发生了什么,并能基于现状做出明智的后续决定。

优雅降级(Graceful Degradation)

有时候,选择不在于“成功”还是“失败”,而在于“部分成功”还是“全盘皆输”。

一个 Agent 在重构 50 个文件,完成了 47 个后遇到了 3 个边缘情况。是该回滚所有修改,还是完成能做到的并报告遗留问题?

模型回退。 主模型不可用?尝试 Haiku 等轻量模型。低质量的回复总比没有回复好。

功能降级。 缓存坏了?禁用它。并行执行有问题?回退到串行。

部分结果。 尽力而为。分别报告成功和失败的任务,让用户决定是针对失败的部分重试,还是见好就收。

错误消息的两个受众

每一条错误消息都有两个需求完全不同的受众。

用户需要清晰且可操作的信息:

糟糕的示范 "Error: ECONNRESET at TCP.onStreamRead (node:internal/stream:333:27)"

更好的示范 "我与 Claude 服务器失去了连接。这通常很快就能恢复。 请稍后重试,如果问题持续,请检查您的网络连接。"

开发者则需要调试上下文:堆栈信息、请求 ID、时间戳、系统状态。

function handleError(error, context) {
    log.error({
        message: error.message,
        stack: error.stack,
        requestId: context.requestId,
        systemState: captureSystemState()
    });
    
    return {
        userMessage: translateToUserFriendly(error),
        suggestion: getSuggestion(error),
        canRetry: isRetryable(error)
    };
}

将技术错误映射为人类能听懂的话:

韧性思维(Resilience Mindset)

在构建 Agent 时,要不断问自己:“如果这里挂了会发生什么?”

每一个问题都需要一个答案。重试、升级、保存状态后优雅退出——这些都是有效的。唯独“崩溃并丢失所有进度”不是。

那些赢得信任的 Agent,往往是那些能妥善处理逆境的 Agent。它们会说:“我遇到了一个问题,这是我目前已经完成的工作,建议我们接下来这样处理。”它们保留成果而非丢失进度,解释问题而非掩盖矛盾,平滑降级而非毁灭性崩溃。

这并不是什么光鲜亮丽的工作。但正是这些工作将演示与产品区分开来。可靠性,才是将精巧的原型转化为生产力工具的基石。