Agent 办事失败兜底技术实现方案:从失败模型到生产级容错体系

49 阅读48分钟

1. 引言:为什么 Agent 失败兜底是生产化的第一道门槛

在 Demo 阶段,一个基于大模型的 Agent 只要能“跑通”一个任务,就足以赢得掌声。但在生产环境中,Agent 真正要面对的是不确定的模型输出、不稳定的外部依赖、超时、配额耗尽、网络抖动、数据漂移、权限失效、下游服务异常等一系列现实问题。一个没有兜底设计的 Agent,上线后最可能的结局不是“偶尔出错”,而是“错误像雪崩一样叠加”:一次工具调用失败引发重试风暴,重试风暴打垮下游,下游雪崩又让更多 Agent 实例失败,最终整个链路不可用。

本文的目标,不是再讲一遍“如何用 LangChain 搭一个 ReAct Agent”,而是系统性地回答一个更硬核的问题:

当 Agent 办事失败时,我们如何在架构层面、代码层面、运维层面设计一套可落地的兜底方案,让系统在失败发生时依然可控、可恢复、可观测、可追责。

本文假设读者已经具备基本的 Agent 开发经验,熟悉 LLM 调用、工具调用(Function Calling)、提示词工程等基础概念。全文围绕“失败”这个核心命题展开,内容覆盖:

  • 失败模式的系统化分类与建模;
  • 从检测、重试、降级、熔断、超时、幂等、Saga 补偿到人工介入的完整兜底技术栈;
  • 状态机与持久化驱动的可恢复执行引擎;
  • 可观测性、监控告警、灰度发布与故障演练;
  • 可直接落地的代码、配置、架构图与生产实践案例。

这是一份面向生产实践的技术指南,而非概念科普。每一节都力求给出“能抄作业”的设计,同时解释清楚“为什么这样设计”。


2. Agent 失败的本质:先定义清楚“失败”是什么

兜底设计的起点,不是“出错后怎么办”,而是“什么叫失败”。很多团队之所以兜底做得一团糟,根本原因是没有对失败进行分类,把所有异常一视同仁,结果要么过度重试,要么错误降级,要么在错误的时间点把问题甩给人工。

2.1 失败的三层模型

从系统论的视角,Agent 执行任务时发生的失败可以分为三个层次:

mermaid diagram

第一层:决策失败(Planning / Reasoning Failure)

这类失败发生在模型“想”的阶段,包括:

  • 幻觉输出:模型编造了不存在的工具名、参数或事实;
  • 规划错误:把一个多步任务拆解成错误或低效的子任务序列;
  • 意图误判:把用户“取消订单”误判为“查询订单”;
  • 违反约束:输出不符合安全策略、格式协议或业务规则;
  • 循环决策:在多步推理中反复进入相同的无效状态。

第二层:执行失败(Execution Failure)

决策正确,但“做”失败了:

  • 工具调用参数错误:JSON 格式非法、必填字段缺失、类型不匹配;
  • 工具返回异常:下游 API 返回 4xx/5xx、超时、限流;
  • 外部系统状态冲突:数据库唯一键冲突、乐观锁失败、资源不存在;
  • 权限与鉴权失败:Token 过期、角色权限不足;
  • 资源配额耗尽:LLM 配额、第三方 API 配额、数据库连接池耗尽。

第三层:编排失败(Orchestration Failure)

单步都对,但整体流程出了问题:

  • 流程卡死:某个节点永远等待一个永远不会到来的事件;
  • 状态丢失:进程重启后任务状态无法恢复,导致重复执行或永久挂起;
  • 超时失控:整体任务超过 SLA 仍未完成;
  • 一致性问题:多步操作部分成功部分失败,导致数据不一致;
  • 并发冲突:同一任务被多个实例重复执行。

2.2 失败的可重试性分类

不是所有失败都值得重试。在设计兜底策略前,必须把失败按“可重试性”分类:

失败类型典型例子是否可重试兜底策略
瞬时失败网络抖动、服务暂时不可用、限流✅ 可重试指数退避重试
确定性输入错误参数类型错误、必填缺字段❌ 直接重试无效参数修复后重试,或降级
确定性业务拒绝库存不足、余额不足、无权限❌ 不可重试业务降级 / 人工介入
状态冲突唯一键冲突、版本冲突⚠️ 部分可重试先读当前状态再决策
配额/预算耗尽LLM 额度用尽、日调用量超限⚠️ 延迟可重试排队等待 / 降级模型
模型质量失败幻觉、格式错误⚠️ 可有限重试重试 + 校验 + 降级模板

这个分类是后续所有兜底策略的地基。一条黄金原则:先判定失败类型,再决定兜底动作。 盲目重试是所有生产事故中最常见、破坏力最大的错误之一。

2.3 失败的成本模型

兜底方案不是越“重”越好。每一次重试、每一次降级、每一次人工介入都有成本。我们需要建立一个简单的失败成本模型来指导决策:

总成本 = 失败概率 × (重试成本 × 重试次数 + 降级成本 + 人工介入成本 + 用户等待成本)

一个优秀的兜底体系,目标是让这个总成本最小化,而不是“永不失败”。这意味着:

  • 对高频、低成本的瞬时失败,自动化重试是划算的;
  • 对低频、高成本的确定性失败,快速失败(Fail Fast)并降级或转人工更划算;
  • 对可能造成数据不一致的失败,宁可停下来等待人工,也不要盲目“自动修复”。

3. 兜底体系的总体架构

3.1 核心设计原则

在展开具体技术之前,先确立六条贯穿全文的原则:

  1. 可观测优先于自动化:在你能看清失败之前,任何自动兜底都可能是灾难。先有日志、指标、追踪,再有重试和降级。
  2. 显式状态优先于隐式状态:所有任务状态必须持久化,禁止依赖进程内存。这样重启、扩容、故障转移才不会丢失任务。
  3. 幂等优先于重试:任何重试的前提是操作幂等。没有幂等保障的重试等于制造重复数据。
  4. 快速失败优先于盲目等待:对确定性失败,快速失败并把问题暴露出来,远好于长时间占着资源。
  5. 降级优先于失败:能返回一个次优结果,就不要返回一个错误。用户体验的底线是“有结果”,而不是“完美结果”。
  6. 人工介入是兜底的兜底:自动化兜底链条的最后一环,永远是清晰、高效的人工介入通道。

3.2 分层兜底架构

生产级 Agent 系统的兜底能力不应该散落在业务代码里,而应该是一个横切的分层体系:

mermaid diagram

  • L1 请求入口层:在流量进入 Agent 之前做第一道防线——参数验证、幂等键、限流。这一层解决的是“非法请求”和“重复请求”。
  • L2 Agent 执行层:Agent 实际运行的地方,重点解决“输出质量”和“执行正确性”。这一层需要持久化状态机来驱动。
  • L3 容错策略层:可复用的横切组件,包括重试器、熔断器、超时控制器、降级器、补偿器。这些组件应该做成 SDK / 中间件,而不是散落在每个工具调用里。
  • L4 人工与运维层:当自动化兜底全部失效时,人工介入是最后的防线。同时,监控告警和故障演练让团队在失败发生前就做好准备。

3.3 兜底决策树

当一次 Agent 执行失败时,系统按如下决策树选择兜底动作:

mermaid diagram

3.4 失败预算:用 SLO 量化兜底目标

兜底策略应该被一个 SRE 指标约束,否则团队无法回答“现在这个失败率到底可不可以接受”。为 Agent 系统定义 SLO 时,建议把“任务成功率”和“可用性”区分开:

指标示例 SLO说明
任务最终成功率99.5% / 月允许少量任务失败,但不允许大面积不可用
兜底动作覆盖率100%每次失败都必须落入某个明确的兜底动作
可恢复任务比例99.9%进入 WAITING_HUMAN / 补偿后能最终闭环的比例
用户可感知错误率< 0.1%只有转人工或快速失败且无解释才算“用户可感知错误”

把 SLO 与 100% 之间的差值定义为失败预算(Error Budget)。失败预算是一笔“可消耗的战略储备”:

  • 失败预算还剩很多时,可以激进地发布新模型、改提示词、调整重试策略;
  • 失败预算快要耗尽时,冻结所有高风险变更,优先回滚与加固兜底能力;
  • 失败预算一旦耗尽,进入“稳定压倒一切”模式:关闭非关键降级路径,强制切换保守策略。

失败预算把“错误是可接受的,但不可接受失控”这一理念转成了可执行指标。它也是可观测性章节中所有告警阈值的统一锚点。

这张决策树是整个兜底逻辑的“宪法”。实际实现时,可以把决策逻辑编码成一个由配置驱动的策略引擎,而不是硬编码 if-else。


4. 失败检测:你必须在失败发生的瞬间就知道它失败了

兜底的第一前提是检测。很多 Agent 系统的问题不是没有兜底逻辑,而是失败已经发生,系统却毫无感知,直到用户投诉或数据错乱才后知后觉。

4.1 结构化错误模型

不要在代码里用裸字符串传递错误。为整个 Agent 系统定义一个统一的错误模型:

// error_model.go
package agent

import (
    "fmt"
    "time"
)

// ErrorCategory 错误分类
type ErrorCategory string

const (
    CategoryTransient     ErrorCategory = "transient"      // 瞬时失败,可重试
    CategoryInputInvalid  ErrorCategory = "input_invalid"  // 输入错误,可修复
    CategoryBusinessReject ErrorCategory = "business_reject" // 业务拒绝,需降级/人工
    CategoryStateConflict ErrorCategory = "state_conflict"  // 状态冲突,需重新读取
    CategoryQuotaExceeded ErrorCategory = "quota_exceeded" // 配额耗尽
    CategoryModelQuality  ErrorCategory = "model_quality"   // 模型输出质量问题
    CategoryUnknown       ErrorCategory = "unknown"         // 未知错误
)

// AgentError 统一的 Agent 错误结构
type AgentError struct {
    Category    ErrorCategory `json:"category"`
    Code        string        `json:"code"`         // 稳定错误码,如 TOOL_TIMEOUT
    Message     string        `json:"message"`      // 人类可读信息
    Retryable   bool          `json:"retryable"`    // 是否可重试
    RetryAfter  time.Duration `json:"retry_after"`  // 建议重试等待时间
    Cause       error         `json:"-"`            // 原始错误
    Context     map[string]any `json:"context"`     // 结构化上下文
    StepID      string        `json:"step_id"`      // 失败步骤 ID
    ToolName    string        `json:"tool_name"`    // 失败工具名
    Latency     time.Duration `json:"latency"`      // 该步骤耗时
    Timestamp   time.Time     `json:"timestamp"`    // 发生时间
}

func (e *AgentError) Error() string {
    return fmt.Sprintf("[%s] %s: %s", e.Category, e.Code, e.Message)
}

func (e *AgentError) Unwrap() error { return e.Cause }

关键点:

  • Retryable 与 Category 分离:可重试性是一个“决策字段”,由错误生产者根据对业务的理解显式设置,而不是由兜底层去猜测。这避免了“重试策略层需要理解所有业务语义”的反模式。
  • Context 携带结构化上下文:失败时的工具入参、目标资源 ID、用户 ID 等都应该放在 Context 里,方便后续自动修复或人工介入时无需重新分析。
  • StepID 与 ToolName:让错误可以被定位到具体执行步骤和工具,这是可观测性的基础。

4.2 输出校验器:在模型输出进入执行前拦截错误

模型输出是 Agent 失败的最大来源之一。永远不要信任模型的原始输出,在工具调用前必须经过一层严格的输出校验器。

4.2.1 工具调用的 Schema 校验

以 Python 为例,使用 Pydantic 对模型的工具调用参数做强制校验:

# output_validator.py
from pydantic import BaseModel, Field, ValidationError
from typing import Literal, Optional
from agent_errors import AgentError, ErrorCategory

class TransferToolArgs(BaseModel):
    """转账工具的入参 Schema"""
    from_account: str = Field(..., min_length=5, max_length=32,
                               pattern=r"^[A-Z0-9]+$")
    to_account: str = Field(..., min_length=5, max_length=32,
                             pattern=r"^[A-Z0-9]+$")
    amount: float = Field(..., gt=0, le=100_000)
    currency: Literal["CNY", "USD", "EUR"] = "CNY"
    remark: Optional[str] = Field(None, max_length=200)

def validate_tool_args(raw_args: dict, schema_cls: type[BaseModel]) -> BaseModel:
    """校验模型输出的工具调用参数,失败时抛出结构化错误"""
    try:
        return schema_cls.model_validate(raw_args)
    except ValidationError as e:
        raise AgentError(
            category=ErrorCategory.INPUT_INVALID,
            code="TOOL_ARGS_INVALID",
            message=f"工具参数校验失败: {e.errors()}",
            retryable=True,
            retry_after=0,
            context={"raw_args": raw_args},
        )

4.2.2 语义校验:参数格式对还不够

Schema 校验只能保证“格式对”,不能保证“语义对”。还需要业务层的语义校验器:

# semantic_validator.py
def validate_transfer_semantics(args: TransferToolArgs, user: User) -> None:
    """业务语义校验:例如不能自己转自己、冷账户限制等"""
    if args.from_account == args.to_account:
        raise AgentError(
            category=ErrorCategory.BUSINESS_REJECT,
            code="SELF_TRANSFER_FORBIDDEN",
            message="不能向自身账户转账",
            retryable=False,
            context={"from": args.from_account, "to": args.to_account},
        )
    if user.risk_level == "high" and args.amount > 10_000:
        raise AgentError(
            category=ErrorCategory.BUSINESS_REJECT,
            code="HIGH_RISK_AMOUNT_LIMIT",
            message="高风险用户单笔转账限额为 10000",
            retryable=False,
            context={"user_id": user.id, "amount": args.amount},
        )

4.2.3 结构化输出的自动修复

对于 JSON 格式错误这种高频问题,一个有效的兜底是输出修复器:解析失败时,把错误信息反馈给模型,让模型自我修复。但要限制修复次数,避免陷入无限循环:

# output_repairer.py
import json
import re
from llm_client import LLMClient

class OutputRepairer:
    def __init__(self, llm: LLMClient, max_repair_attempts: int = 2):
        self.llm = llm
        self.max_repair_attempts = max_repair_attempts

    def parse_json_with_repair(self, raw_output: str) -> dict:
        """解析 JSON,失败时让模型修复,最多修复 max_repair_attempts 次"""
        for attempt in range(self.max_repair_attempts + 1):
            try:
                # 先尝试直接解析
                return json.loads(raw_output)
            except json.JSONDecodeError as e:
                if attempt >= self.max_repair_attempts:
                    raise AgentError(
                        category=ErrorCategory.MODEL_QUALITY,
                        code="JSON_REPAIR_EXHAUSTED",
                        message=f"JSON 修复达到上限: {e}",
                        retryable=False,
                        context={"raw_output": raw_output[:500]},
                    )
                # 提取错误位置,让模型修复
                raw_output = self._ask_llm_to_fix(raw_output, str(e))
        raise AssertionError("unreachable")

    def _ask_llm_to_fix(self, raw: str, error: str) -> str:
        prompt = f"""以下 JSON 解析失败:
错误:{error}
原始内容:
{raw}

请修复 JSON 格式错误,只返回修复后的 JSON,不要添加任何解释。"""
        return self.llm.complete(prompt, max_tokens=2000)

关键设计:修复是有预算的。 如果 2 次修复后仍失败,说明可能是模型能力边界或提示词问题,继续修复只是浪费 Token 和时间。

4.2.4 置信度与不确定性量化:给“格式对”再加一道概率防线

Schema 与语义校验解决的是“输出是否符合规则”,但它们无法回答一个更隐蔽的问题:模型这次输出有多不确定。当模型在幻觉、记忆模糊或任务超纲时,输出可能依然合法,却完全错误。对高风险动作,应当在执行前量化不确定性:

# uncertainty_gate.py
import math

def top_logprob_entropy(logprobs: list[float]) -> float:
    """把 top token 的概率分布近似为熵,衡量模型对该决策的集中度。"""
    if not logprobs:
        return float("inf")
    return -sum(p * math.log(p) for p in logprobs if p > 0)

def gate_by_uncertainty(decision: str, logprobs: list[float], threshold: float = 0.7):
    entropy = top_logprob_entropy(logprobs)
    if entropy > threshold:
        raise AgentError(
            category=ErrorCategory.MODEL_QUALITY,
            code="MODEL_UNCERTAINTY_HIGH",
            message=f"模型决策熵 {entropy:.3f} 超过阈值 {threshold}",
            retryable=False,
            context={"decision": decision, "entropy": entropy},
        )
    return decision

落地要点:

  • 对金额、权限、删除、对外发送等不可逆动作,先过不确定性闸门,再进入 Schema/语义校验;熵过高时直接抛 MODEL_UNCERTAINTY_HIGH,由兜底决策树转入人工或降级模板。
  • 对低成本只读动作,可不做熵闸门,避免额外的 logprobs 开销和延迟。
  • logprobs 反映的是模型自身的输出分布,不代表校准后的真实概率。在金融、医疗等强约束领域,应另行用校准数据集评估模型的置信度质量,不要把它当成绝对概率直接用于审批。

4.3 执行层错误包装:把第三方异常翻译成 AgentError

工具执行时捕获的所有第三方异常,都必须被翻译成统一的 AgentError。这层“翻译”就是错误分类的核心:

# tool_invoker.py
import requests
from agent_errors import AgentError, ErrorCategory

HTTP_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}

def classify_http_error(status: int, body: str) -> AgentError:
    """把 HTTP 错误翻译为 AgentError"""
    if status in HTTP_RETRYABLE_STATUS:
        category = ErrorCategory.TRANSIENT
        retryable = True
        code = f"HTTP_{status}_TRANSIENT"
    elif status == 401 or status == 403:
        category = ErrorCategory.BUSINESS_REJECT
        retryable = False
        code = f"HTTP_{status}_AUTH"
    elif status == 404:
        category = ErrorCategory.BUSINESS_REJECT
        retryable = False
        code = "RESOURCE_NOT_FOUND"
    elif status == 422:
        category = ErrorCategory.INPUT_INVALID
        retryable = True
        code = "HTTP_422_UNPROCESSABLE"
    else:
        category = ErrorCategory.UNKNOWN
        retryable = False
        code = f"HTTP_{status}_UNKNOWN"
    return AgentError(
        category=category, code=code,
        message=f"下游服务返回 {status}",
        retryable=retryable, context={"status": status, "body": body[:500]},
    )

def invoke_http_tool(url: str, payload: dict) -> dict:
    try:
        resp = requests.post(url, json=payload, timeout=10)
    except requests.Timeout:
        raise AgentError(
            category=ErrorCategory.TRANSIENT, code="HTTP_TIMEOUT",
            message="下游服务超时", retryable=True, retry_after=2,
        )
    except requests.ConnectionError as e:
        raise AgentError(
            category=ErrorCategory.TRANSIENT, code="HTTP_CONN_ERROR",
            message=f"连接失败: {e}", retryable=True, retry_after=1,
        )
    if resp.status_code >= 400:
        raise classify_http_error(resp.status_code, resp.text)
    return resp.json()

5. 重试策略:最常用也最容易被滥用的兜底手段

重试是兜底的第一道自动化防线,但它也是一把双刃剑。这一节给出生产级的重试设计。

5.1 重试的前提条件清单

在启用重试前,逐项确认以下条件:

  1. 操作是幂等的(详见第 8 节)。非幂等操作禁止重试,除非有专门的幂等协调机制。
  2. 错误被标记为可重试。retryable=false 的错误无论重试多少次都不会成功。
  3. 有重试预算。包括最大次数、总时长、总 Token 消耗三层预算,任一耗尽即停。
  4. 不会加剧过载。对 429 限流和服务雪崩场景,重试必须配合退避和抖动,否则会形成重试风暴。

5.2 指数退避 + 全抖动

固定间隔重试在生产中几乎必然导致“惊群效应”。标准做法是指数退避 + 全抖动(Full Jitter):

// retry.go
package resilience

import (
    "math"
    "math/rand"
    "time"
)

type RetryPolicy struct {
    MaxAttempts     int           // 最大尝试次数
    InitialInterval time.Duration // 初始退避间隔
    MaxInterval     time.Duration // 最大退避间隔上限
    Multiplier      float64       // 指数因子,通常 2.0
}

// Backoff 计算第 attempt 次重试前的等待时间(全抖动)
func (p RetryPolicy) Backoff(attempt int) time.Duration {
    if attempt <= 0 {
        return 0
    }
    interval := float64(p.InitialInterval) * math.Pow(p.Multiplier, float64(attempt-1))
    if interval > float64(p.MaxInterval) {
        interval = float64(p.MaxInterval)
    }
    // 全抖动:在 [0, interval] 范围内均匀随机
    jittered := rand.Float64() * interval
    return time.Duration(jittered)
}

// Do 执行带重试的操作
func (p RetryPolicy) Do(fn func(attempt int) error) error {
    var lastErr error
    for attempt := 0; attempt < p.MaxAttempts; attempt++ {
        if attempt > 0 {
            wait := p.Backoff(attempt)
            time.Sleep(wait)
        }
        if err := fn(attempt); err != nil {
            lastErr = err
            if !isRetryable(err) {
                return err // 非可重试错误,立即返回
            }
            continue
        }
        return nil
    }
    return fmt.Errorf("重试耗尽 (%d 次): %w", p.MaxAttempts, lastErr)
}

为什么不建议用固定抖动(等长间隔 ± 抖动)? 全抖动在分布式环境下能更好地把重试请求“摊平”到整个时间窗口,降低再次同时打爆下游的概率。AWS 架构博客对此有经典论述。

5.3 限流感知重试:尊重 Retry-After 与 429

对 429 响应,正确的做法不是机械地按退避策略等待,而是优先尊重服务端返回的 Retry-After:

# throttle_aware_retry.py
import time
from agent_errors import AgentError

def retry_aware_of_throttle(fn, max_attempts: int, base_delay: float):
    for attempt in range(max_attempts):
        try:
            return fn()
        except AgentError as e:
            if not e.retryable:
                raise
            # 如果下游明确给了 Retry-After,优先使用
            retry_after = e.retry_after if e.retry_after and e.retry_after > 0 else None
            if attempt < max_attempts - 1:
                delay = retry_after or (base_delay * (2 ** attempt))
                delay = min(delay, 60.0)  # 上限保护
                time.sleep(delay)
            else:
                raise

5.4 重试预算的三维控制

单纯的“最大次数”不足以保护系统。生产级重试必须同时控制三个维度:

retry_budget:
  max_attempts: 4            # 单工具调用最多尝试 4 次
  max_total_time_ms: 30000   # 单工具调用累计重试时间不超过 30 秒
  max_total_token: 12000     # 单工具调用(含重试反馈)消耗 Token 不超过 1.2 万

任何一维超限,立即停止重试并转入降级/人工流程。这一点在 LLM 场景尤其重要,因为每次重试都可能消耗宝贵的 Token 和用户耐心。

5.5 重试风暴的治理:上游限流 + 重试隔离

重试最危险的地方在于“连锁放大”。假设服务 A 调用服务 B,B 调用 C,每一层的失败都有 3 次重试,那么一次 C 的故障会在 A 层放大为 9 倍请求。治理手段包括:

  1. 下游限流:每个工具调用方配置独立限流器,重试请求同样受限于限流器。
  2. 重试配额按租户隔离:一个恶意或故障租户的重试不能耗尽全系统的重试预算。
  3. 断路器优先于重试:先看熔断器是否打开,打开则直接短路,不进入重试(见第 6 节)。

5.6 重试与熔断、超时、幂等的组合策略

单独配置重试参数是错误的开始。生产级重试必须在四个约束同时成立时才执行:

允许重试 = 错误可重试
        ∧ 重试预算未耗尽
        ∧ 熔断器未打开
        ∧ 父级超时剩余窗口足够
        ∧ 幂等保护已生效

对应到代码结构,推荐把重试器做成一个“策略门”,而不是一个独立的循环:

// retry_gate.go
func (g *RetryGate) ShouldRetry(ctx context.Context, err *AgentError, attempt int) bool {
    if err == nil || !err.Retryable {
        return false
    }
    if attempt >= g.budget.MaxAttempts {
        return false
    }
    if g.breaker != nil && !g.breaker.Allow() {
        return false
    }
    if deadline, ok := ctx.Deadline(); ok {
        reserve := g.policy.Backoff(attempt + 1)
        if time.Until(deadline) < reserve+g.minSafetyWindow {
            return false // 父级超时不够,再重试只会制造半截请求
        }
    }
    if g.idempotencyKey == "" {
        return false // 没有幂等键,禁止对有副作用的操作重试
    }
    return true
}

组合策略的三条铁律:

  1. 先问熔断器,再决定重试。下游已经熔断时,重试请求只会被立刻拒绝,浪费预算并放大噪声。
  2. 重试必须纳入父级超时窗口。任何脱离 context 截止时间的重试,最终都会演变成超时雪崩。
  3. 没有幂等键就不重试副作用操作。重试次数越多,重复写入风险越高;这条约束应固化在 RetryGate 内部,而不是依赖调用方自觉。

这样每个重试决策点都同时受控于“错误类别 + 重试预算 + 熔断状态 + 超时剩余 + 幂等保障”五道闸门。

6. 熔断与隔离:防止失败雪崩的最后闸门

当某个下游服务或某个工具持续失败时,继续发送请求只会加速它的崩溃。熔断器(Circuit Breaker)的作用是在失败达到阈值时主动“断开”,快速失败,给下游喘息空间。

6.1 熔断器状态机

mermaid diagram

  • Closed(闭合):正常放行请求,同时统计成功/失败次数。
  • Open(断开):直接拒绝请求,不实际调用下游,抛出快速失败错误。
  • Half-Open(半开):只放行少量探测请求,探测成功则转 Closed,失败则回到 Open。

6.2 生产级熔断器实现

// circuit_breaker.go
package resilience

import (
    "sync"
    "time"
)

type CircuitState int

const (
    StateClosed CircuitState = iota
    StateOpen
    StateHalfOpen
)

type CircuitBreaker struct {
    mu sync.Mutex

    state CircuitState

    // 阈值配置
    failureThreshold float64       // 失败率阈值,如 0.5
    minRequests      int           // 最小样本量,避免小样本误判
    openDuration     time.Duration // 开启持续时间
    halfOpenMax      int           // 半开状态允许的最大探测请求数

    // 统计
    requests   int
    failures   int
    halfOpenCount int
    openedAt   time.Time
    lastFailure error
}

func NewCircuitBreaker(failureThreshold float64, minRequests int,
    openDuration time.Duration, halfOpenMax int) *CircuitBreaker {
    return &CircuitBreaker{
        state:           StateClosed,
        failureThreshold: failureThreshold,
        minRequests:      minRequests,
        openDuration:     openDuration,
        halfOpenMax:      halfOpenMax,
    }
}

// Allow 判断请求是否被放行
func (cb *CircuitBreaker) Allow() (bool, error) {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    switch cb.state {
    case StateOpen:
        if time.Since(cb.openedAt) >= cb.openDuration {
            // 冷却结束,转半开
            cb.state = StateHalfOpen
            cb.halfOpenCount = 0
            return true, nil
        }
        return false, fmt.Errorf("熔断器开启中,最后失败: %v", cb.lastFailure)
    case StateHalfOpen:
        if cb.halfOpenCount >= cb.halfOpenMax {
            return false, fmt.Errorf("熔断器半开探测额度已用完")
        }
        cb.halfOpenCount++
        return true, nil
    default: // Closed
        return true, nil
    }
}

// RecordResult 记录请求结果
func (cb *CircuitBreaker) RecordResult(success bool, err error) {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    cb.requests++
    if !success {
        cb.failures++
        cb.lastFailure = err
    }

    switch cb.state {
    case StateClosed:
        if cb.requests >= cb.minRequests &&
            float64(cb.failures)/float64(cb.requests) >= cb.failureThreshold {
            cb.transitionToOpen()
        }
    case StateHalfOpen:
        if !success {
            cb.transitionToOpen()
        } else if cb.halfOpenCount >= cb.halfOpenMax {
            cb.reset()
        }
    }
}

func (cb *CircuitBreaker) transitionToOpen() {
    cb.state = StateOpen
    cb.openedAt = time.Now()
}

func (cb *CircuitBreaker) reset() {
    cb.state = StateClosed
    cb.requests = 0
    cb.failures = 0
    cb.halfOpenCount = 0
    cb.lastFailure = nil
}

6.3 熔断器的粒度选择

熔断器的粒度直接影响其效果:

  • 按工具粒度:每个工具(如 search_web、query_db)一个熔断器。适合下游依赖明确的场景。
  • 按下游服务粒度:一个下游服务一个熔断器。适合多个工具共用同一后端的情况。
  • 按租户粒度:每个租户一个熔断器。适合多租户 SaaS,防止单租户故障拖垮全局。

推荐组合:工具级 + 服务级双层熔断。工具级快速隔离单点故障,服务级兜底防止跨工具级联失败。

6.4 熔断与 Agent 的特殊交互

Agent 场景下熔断有个特殊点:失败可能是模型的错,而不是下游的错。例如模型连续生成错误的查询参数,导致下游返回 422,此时熔断器会把下游判为故障,但实际“罪犯”是模型。因此:

  • 对 INPUT_INVALID 类错误,不计入熔断器的失败统计(因为那不是下游的错);
  • 对 TRANSIENT 类错误(超时、5xx),才计入失败统计;
  • 熔断器打开时,Agent 不应该“傻等”,而应该把错误信息反馈给模型,让模型换一种方式完成意图,或直接降级。

7. 超时控制:任何没有超时的调用都是事故隐患

超时是 Agent 系统中最容易被忽视、却最能造成资源泄漏和用户体验雪崩的问题。

7.1 为什么超时如此关键

一个没有超时的 LLM 调用,可能在用户已经离开页面一个小时后才返回。一个没有超时的工具调用,会占用线程池、数据库连接、下游连接,最终耗尽资源。Agent 的多步执行特性让超时问题被放大:每一步都可能超时,整体任务也超时,层层叠加。

7.2 四级超时体系

生产级 Agent 需要分层设置超时:

timeout_policy:
  # 第一级:单次 LLM 调用超时
  llm_call_timeout_ms: 30000        # 单次 LLM 推理不超过 30 秒
  llm_total_timeout_ms: 120000      # 一个大任务内所有 LLM 调用合计不超过 2 分钟

  # 第二级:单工具调用超时
  tool_call_timeout_ms: 15000       # 单个工具调用不超过 15 秒
  tool_retry_total_ms: 60000        # 单个工具含重试不超过 60 秒

  # 第三级:单步骤超时(一步 = 一次 LLM 推理 + 若干工具调用)
  step_timeout_ms: 90000            # 单个 Agent 步骤不超过 90 秒

  # 第四级:整个任务超时
  task_timeout_ms: 600000           # 整个任务不超过 10 分钟
  task_deadline: "2026-08-22T09:07:07Z"  # 绝对截止时间,可选

7.3 Go 中的超时传播:Context 是超时治理的基石

在 Go 里,超时必须通过 context.Context 传播,而不是依赖裸的 time.Sleep 或独立的定时器:

// timeout_propagation.go
package agent

import (
    "context"
    "time"
)

func (a *Agent) RunTask(ctx context.Context, task Task) error {
    // 第四级:整个任务超时
    ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
    defer cancel()

    for step := 0; step < a.maxSteps; step++ {
        // 第三级:单步骤超时
        stepCtx, stepCancel := context.WithTimeout(ctx, 90*time.Second)

        llmResult, err := a.callLLMWithTimeout(stepCtx)
        if err != nil {
            stepCancel()
            return a.handleStepError(step, err)
        }

        for _, toolCall := range llmResult.ToolCalls {
            // 第二级:单工具调用超时(继承 stepCtx,所以也受限于步骤超时)
            toolCtx, toolCancel := context.WithTimeout(stepCtx, 15*time.Second)
            _, err := a.invokeTool(toolCtx, toolCall)
            toolCancel()
            if err != nil {
                stepCancel()
                return a.handleToolError(step, toolCall, err)
            }
        }
        stepCancel()

        if llmResult.FinalAnswer != "" {
            return nil
        }
    }
    return ErrMaxStepsExceeded
}

func (a *Agent) callLLMWithTimeout(ctx context.Context) (*LLMResult, error) {
    // 第一级:单次 LLM 调用超时
    llmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()
    return a.llm.Generate(llmCtx)
}

func (a *Agent) invokeTool(ctx context.Context, call ToolCall) (any, error) {
    // 把 ctx 传给下游工具,让超时能穿透到 HTTP/DB 层
    return a.toolRegistry.Invoke(ctx, call)
}

关键设计:子超时继承父超时。 context.WithTimeout 的嵌套让每一层超时都被强制约束在父级超时之内,避免了“子任务超时设定比父任务还长”的配置错误。

7.4 超时后的处理:不是简单返回错误

超时后要区分两种本质不同的情况:

  1. 客户端超时,但服务端可能继续执行。例如 HTTP 客户端超时了,但下游可能已经收到并正在处理请求。此时直接重试会导致重复执行。
  2. 任务确实被废弃。服务端已经确认停止。

对于第 1 种情况,超时后禁止盲目重试,应先通过查询接口确认下游实际状态。这引出了下一节的核心问题:幂等性。


8. 幂等性设计:重试与补偿的绝对前提

幂等性是整个兜底体系的基石。没有幂等保障,任何重试和补偿都可能造成重复扣款、重复下单、重复发送等灾难。

8.1 幂等的定义与层次

定义:一个操作执行一次和执行多次,效果相同。

在 Agent 系统中,幂等性需要在三个层次保证:

  1. 请求层:同一个用户请求不会被重复提交执行。
  2. 工具层:同一个工具调用(含参数)重试不会产生副作用。
  3. 任务层:整个 Agent 任务恢复执行时,已完成的步骤不会重复执行。

8.2 幂等键(Idempotency Key)机制

最通用的做法是幂等键:客户端为每个“有副作用”的操作生成一个唯一键,服务端用该键去重。

// idempotency.go
package idempotency

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "time"
)

type IdempotencyStore interface {
    // GetAndLock 获取幂等记录并加锁,不存在则创建
    GetAndLock(ctx context.Context, key string, ttl time.Duration) (*Record, bool, error)
    // Complete 标记完成并存储结果
    Complete(ctx context.Context, key string, result any) error
    // Release 释放锁(执行失败时)
    Release(ctx context.Context, key string) error
}

type Record struct {
    Key       string
    Status    string // "in_progress" | "completed" | "failed"
    Result    any
    CreatedAt time.Time
    ExpiresAt time.Time
}

// GenerateKey 根据操作类型 + 业务标识生成幂等键
func GenerateKey(operation string, businessID string) string {
    h := sha256.Sum256([]byte(operation + ":" + businessID))
    return hex.EncodeToString(h[:])
}

// ExecuteIdempotent 在幂等保护下执行操作
func ExecuteIdempotent(
    ctx context.Context,
    store IdempotencyStore,
    key string,
    fn func() (any, error),
) (any, error) {
    record, created, err := store.GetAndLock(ctx, key, 24*time.Hour)
    if err != nil {
        return nil, err
    }
    if !created {
        // 已存在,根据状态处理
        switch record.Status {
        case "completed":
            return record.Result, nil          // 幂等返回
        case "in_progress":
            return nil, ErrConcurrentRequest   // 并发冲突,拒绝
        case "failed":
            return nil, ErrPreviousFailed
        }
    }
    // 执行实际操作
    result, err := fn()
    if err != nil {
        store.Release(ctx, key) // 失败则释放,允许重试
        return nil, err
    }
    store.Complete(ctx, key, result)
    return result, nil
}

幂等键从哪来?

  • 用户请求:前端为每次提交生成 UUID 作为幂等键;
  • Agent 工具调用:用「工具名 + 参数哈希」作为幂等键,但注意并非所有工具调用都适合用参数哈希去重——比如“查询当前时间”就不该被去重;
  • 任务步骤:用「任务 ID + 步骤序号」天然幂等。

8.3 副作用操作的幂等包装模式

对于下游不天然幂等的操作(如没有幂等键支持的支付接口),需要在 Agent 侧包装。常见模式:

模式一:先查后做(Check-Then-Act + 唯一约束)

数据库层用唯一约束兜底,业务层先查再插:

-- 转账流水表:用业务唯一键做幂等兜底
CREATE TABLE transfer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    idempotency_key VARCHAR(64) NOT NULL,
    from_account VARCHAR(32) NOT NULL,
    to_account VARCHAR(32) NOT NULL,
    amount DECIMAL(18,2) NOT NULL,
    status VARCHAR(16) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_idempotency (idempotency_key)
);

模式二:状态机守卫(State Guard)

通过状态机确保只有合法的状态流转才产生副作用:

func (s *TransferOrder) Execute() error {
    // 只有 PENDING 状态才能执行转账,天然幂等
    if s.Status != "PENDING" {
        return nil // 已执行过,幂等返回
    }
    if err := s.doTransfer(); err != nil {
        s.Status = "FAILED"
        return err
    }
    s.Status = "SUCCESS"
    return nil
}

8.4 天然非幂等操作的处理:LLM 调用与随机性

LLM 推理天然非幂等:同样输入两次调用结果可能不同。对这类操作:

  • 不要用“参数哈希”去重,而要用外部幂等键(由调用方显式传入);
  • 对只读探索类 LLM 调用,不幂等也无妨,影响有限;
  • 对有副作用的决策类调用,必须在执行副作用前固化推理结果(持久化)。

9. 状态机与持久化执行:让 Agent 能从任意失败点恢复

生产级 Agent 不能是“一次性的脚本”。它需要一个显式的状态机,把每个任务的每一步持久化,才能在进程崩溃、网络中断、人工介入后准确恢复。

9.1 为什么必须有持久化状态机

假设一个转账 Agent 已经完成了「查询余额」「发起转账」,正在执行「发送通知」时服务进程崩溃。如果没有状态持久化,重启后:

  • 它不知道自己已经转了一笔账,可能重复转账;
  • 用户看到的是“任务失败”,但钱已经转出去了——这是最糟糕的结果。

有了状态机,重启后系统会看到任务处于 TRANSFER_EXECUTED 状态,直接跳过已完成的步骤,继续「发送通知」。

9.2 Agent 任务的状态定义

mermaid diagram

关键状态说明:

  • WAITING_HUMAN:Agent 执行到某一步发现自己无法决定,主动暂停并等人审批。这是一个正常状态,不是异常。
  • COMPENSATING:任务已经产生了副作用,但后续步骤失败,需要执行补偿动作来撤销副作用。这是一个容易被忽视但至关重要的状态。
  • CANCELLED:用户主动取消。取消本身也可能需要补偿(比如已经下单的要撤回)。

9.3 任务与步骤的两级持久化模型

// task_state.go
package agent

import "time"

type TaskStatus string

const (
    TaskCreated      TaskStatus = "CREATED"
    TaskPlanning     TaskStatus = "PLANNING"
    TaskExecuting    TaskStatus = "EXECUTING"
    TaskWaitingHuman TaskStatus = "WAITING_HUMAN"
    TaskCompensating TaskStatus = "COMPENSATING"
    TaskSuccess      TaskStatus = "SUCCESS"
    TaskFailed       TaskStatus = "FAILED"
    TaskCancelled    TaskStatus = "CANCELLED"
)

type StepStatus string

const (
    StepPending    StepStatus = "PENDING"
    StepRunning    StepStatus = "RUNNING"
    StepSuccess    StepStatus = "SUCCESS"
    StepFailed     StepStatus = "FAILED"
    StepSkipped    StepStatus = "SKIPPED"
    StepCompensated StepStatus = "COMPENSATED"
)

type Task struct {
    ID            string     `json:"id"`
    UserID        string     `json:"user_id"`
    Intent        string     `json:"intent"`
    Status        TaskStatus `json:"status"`
    CurrentStep   int        `json:"current_step"`
    MaxSteps      int        `json:"max_steps"`
    IdempotencyKey string    `json:"idempotency_key"`
    Steps         []Step     `json:"steps"`
    CreatedAt     time.Time  `json:"created_at"`
    UpdatedAt     time.Time  `json:"updated_at"`
    Deadline      time.Time  `json:"deadline"`
    Result        any        `json:"result"`
    ErrorAt       time.Time  `json:"error_at,omitempty"`
    ErrorMsg      string     `json:"error_msg,omitempty"`
}

type Step struct {
    Index       int        `json:"index"`
    Description string     `json:"description"`
    Status      StepStatus `json:"status"`
    ToolName    string     `json:"tool_name,omitempty"`
    ToolArgs    any        `json:"tool_args,omitempty"`
    ToolResult  any        `json:"tool_result,omitempty"`
    ErrorMsg    string     `json:"error_msg,omitempty"`
    RetryCount  int        `json:"retry_count"`
    StartedAt   time.Time  `json:"started_at,omitempty"`
    FinishedAt  time.Time  `json:"finished_at,omitempty"`
    Compensator string     `json:"compensator,omitempty"` // 补偿函数名
}

9.4 持久化策略:每次状态变更都要落库

黄金原则:状态变更先落库,再执行操作。 即“先写意图,再执行”。

// persistence_order.go
func (e *Engine) executeStep(task *Task, step *Step) error {
    // 1. 先把步骤标记为 RUNNING 并落库
    step.Status = StepRunning
    step.StartedAt = time.Now()
    if err := e.store.UpdateTask(task); err != nil {
        return err  // 落库失败,坚决不执行操作,防止“做了但没记录”
    }

    // 2. 执行步骤
    result, err := e.invokeTool(step)
    if err != nil {
        step.Status = StepFailed
        step.ErrorMsg = err.Error()
        e.store.UpdateTask(task)
        return err
    }

    // 3. 成功后更新状态与结果
    step.Status = StepSuccess
    step.ToolResult = result
    step.FinishedAt = time.Now()
    return e.store.UpdateTask(task)
}

为什么“先落库再执行”? 如果先执行操作、后落库,而落库失败,系统就不知道操作已经发生了——恢复时会重复执行。先落库保证了“系统说要做的事,一定是有记录的”。

9.5 恢复执行(Replay)

系统重启或扩容后,需要一个恢复器把“半截”任务接续执行:

// recovery.go
func (e *Engine) RecoverStaleTasks(ctx context.Context) {
    // 找出所有“长时间卡在 RUNNING/PLANNING/EXECUTING”的任务
    staleTasks, _ := e.store.FindStaleTasks(ctx, time.Now().Add(-10*time.Minute))

    for _, task := range staleTasks {
        e.recoverTask(ctx, task)
    }
}

func (e *Engine) recoverTask(ctx context.Context, task *Task) {
    for i := range task.Steps {
        step := &task.Steps[i]
        switch step.Status {
        case StepPending:
            // 未开始的步骤,继续执行
            e.executeStep(ctx, task, step)
        case StepRunning:
            // 卡在 RUNNING:可能进程已宕,需先确认工具实际状态
            actual := e.probeToolActualState(ctx, step)
            if actual == "done" {
                step.Status = StepSuccess
            } else if actual == "not_started" {
                step.Status = StepPending
                e.executeStep(ctx, task, step)
            } else {
                // 无法确定,转人工
                task.Status = TaskWaitingHuman
            }
        case StepSuccess:
            // 已完成,跳过
        }
    }
}

恢复时对 RUNNING 步骤的处理是最微妙的:必须通过工具的查询接口确认实际状态,而不是盲目重试或标记失败。


10. 降级策略:宁要次优结果,不要错误结果

10.1 降级设计原则

降级不是“降低标准”,而是“用提前设计好的次优路径替代失败路径”。四条原则:

  1. 有损但可用:降级结果必须仍然对用户有价值,不能为了返回结果而返回垃圾。
  2. 显式化:降级结果必须携带 degraded 标记、降级原因、降级链路,绝不能伪装成完整结果。
  3. 可配置、可测试:降级链是配置驱动,是灰度与故障演练的一部分。
  4. 可恢复:降级是临时状态,一旦上游恢复,系统应能自动回到完整能力,而不是永久停留在降级模式。

10.2 Agent 场景的典型降级维度

降级维度完整能力次优替代适用场景
模型降级强模型弱模型 / 本地模型 / 模板强模型超时、配额耗尽、成本溢出
工具降级实时精确 API缓存 / 近似数据 / 只读副本下游不可用,但能接受非实时或不精确结果
结果降级结构化数据自然语言摘要 / 默认值 / 推荐值解析失败但语义基本可用
能力降级全自动执行半自动 / 仅推荐 / 只读高风险、高不确定性、降级仍不可靠
缓存降级精确检索语义缓存 / 历史成功案例检索或生成链路失败

10.3 模型降级实现

# model_fallback.py
MODEL_CHAIN = [
    ("gpt-4o", {"temperature": 0.2}),
    ("gpt-4o-mini", {"temperature": 0.0}),
    ("local-llm", {"temperature": 0.0}),
]

def complete_with_fallback(prompt: str, prefer_cache: bool = True):
    last_err = None
    for model, overrides in MODEL_CHAIN:
        try:
            result = llm.complete(prompt, model=model, timeout=8, **overrides)
            return {
                "model": model,
                "degraded": model != MODEL_CHAIN[0][0],
                "degrade_reason": "fallback",
                "content": result,
            }
        except AgentError as e:
            last_err = e
    # 全部模型都失败,才使用静态模板
    if prefer_cache:
        cached = template_cache.match(prompt)
        if cached:
            return {
                "model": "template",
                "degraded": True,
                "degrade_reason": "template_fallback",
                "content": cached,
            }
    raise AgentError(
        category=ErrorCategory.MODEL_QUALITY,
        code="MODEL_FALLBACK_EXHAUSTED",
        message="所有模型降级路径均失败",
        retryable=False,
        cause=last_err,
    )

10.4 工具结果降级

# tool_fallback.py
def invoke_tool_with_fallback(tool_name: str, args: dict, ttl: int = 300):
    try:
        return tool_registry.invoke(tool_name, args)
    except AgentError as e:
        if e.category == ErrorCategory.TRANSIENT and cache.has(tool_name, args):
            return {
                "degraded": True,
                "degrade_reason": "stale_cache",
                "source_ttl": ttl,
                "data": cache.get(tool_name, args),
            }
        raise

10.5 缓存降级:从精确缓存到语义缓存

缓存是降级体系中最容易落地也最容易被误伤的一环,分三层:

层级实现方式命中率适用
L1 精确缓存工具名 + 参数哈希高准确,低命中只读、结果稳定
L2 语义缓存Embedding 相似度中等准确,较高命中相似意图、长尾问题
L3 模板降级静态回复 / 兜底话术低质量,高可用全部上游失效

精确缓存示例:

# exact_cache.py
def cached_tool_call(tool_name: str, args: dict, ttl: int = 300):
    key = hashlib.sha256(
        f"{tool_name}:{json.dumps(args, sort_keys=True)}".encode()
    ).hexdigest()
    if cached := redis.get(key):
        return {"cache": "hit", **json.loads(cached)}
    result = tool_registry.invoke(tool_name, args)
    redis.setex(key, ttl, json.dumps(result))
    return result

语义缓存要守住一条红线:副作用操作永不缓存,降级结果永不缓存。 只有只读、无副作用的调用才能进入缓存链路,且所有缓存返回都必须带 degraded: true 标记,进入统一审计与可观测视角。

11. Saga 与补偿事务:多步 Agent 的一致性保证

Agent 的一个典型场景是“多步有副作用的操作序列”:先下单、再扣款、再发货。如果第 3 步失败,前两步的副作用必须被撤销——这就是 Saga 模式要解决的问题。

11.1 什么是 Saga

Saga 是一种分布式事务模式:把一个长事务拆成多个本地事务 T1, T2, ..., Tn,每个本地事务都有一个对应的补偿事务 C1, C2, ..., Cn。如果 Tk 失败,则反向执行 C(k-1), ..., C1 来撤销前面的副作用。

mermaid diagram

11.2 在 Agent 中落地 Saga

Agent 的天然特性(多步规划 + 工具调用)与 Saga 天然契合。落地要点:

  1. 为每个有副作用的工具定义补偿工具。例如 create_order 对应 cancel_order,deduct_balance 对应 refund。
  2. 补偿信息随步骤持久化。每步完成后记录其补偿函数名和补偿所需的全部参数。
  3. 补偿本身也可能失败,因此补偿也要有重试和幂等保障。
  4. 补偿方向必须反向。最后成功的步骤先被补偿。
// saga.go
package agent

type SagaStep struct {
    ExecuteFn  func(ctx context.Context) error
    CompensateFn func(ctx context.Context) error
    CompensateArgs any
}

type Saga struct {
    steps        []SagaStep
    executed     []int  // 已成功执行的步骤索引
    compensateStarted bool
}

func (s *Saga) Run(ctx context.Context) error {
    for i, step := range s.steps {
        if err := step.ExecuteFn(ctx); err != nil {
            return s.compensate(ctx, i)  // 从第 i 步开始反向补偿
        }
        s.executed = append(s.executed, i)
    }
    return nil
}

func (s *Saga) compensate(ctx context.Context, failedAt int) error {
    s.compensateStarted = true
    var compensateErrors []error
    // 反向补偿:从 failedAt-1 到 0
    for i := failedAt - 1; i >= 0; i-- {
        step := s.steps[i]
        if err := step.CompensateFn(ctx); err != nil {
            // 补偿失败也不能停止,记录后继续补偿其他步骤
            compensateErrors = append(compensateErrors, fmt.Errorf("补偿步骤 %d 失败: %w", i, err))
        }
    }
    if len(compensateErrors) > 0 {
        return fmt.Errorf("Saga 补偿部分失败: %v", compensateErrors)
    }
    return nil
}

11.3 Saga 状态持久化与人工兜底

Saga 必须持久化其当前进度,否则进程崩溃后不知道补偿进行到哪一步。持久化结构:

CREATE TABLE saga_execution (
    saga_id VARCHAR(64) PRIMARY KEY,
    task_id VARCHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL,  -- RUNNING / COMPENSATING / COMPLETED / COMPENSATE_FAILED
    executed_steps JSON NOT NULL,  -- 已成功执行的步骤
    compensate_progress JSON,      -- 补偿进度
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

补偿失败怎么办? 这是最棘手的情况:补偿本身失败了,说明系统处在“不确定的中间状态”。此时:

  1. 自动重试补偿(补偿必须是幂等的);
  2. 重试仍失败则告警 + 转人工,人工通过运维界面手动触发补偿或直接修复数据;
  3. 决不私自标记“补偿完成”,否则会造成永久的数据不一致。

12. 降级与人工介入:自动化兜底的终点站

当重试、降级、补偿全部失效或不被允许时,人工介入是最后一道防线。设计人工介入的关键,不是“把人加进来”,而是让人能高效、准确地接手。

12.1 人工介入的触发条件

触发场景示例介入方式
金额/权限敏感操作转账超过阈值审批流:人工 approve/reject
全部自动化兜底耗尽重试耗尽 + 无降级转人工队列
补偿失败Saga 补偿部分失败告警 + 人工修复
模型置信度不足不确定性超过阈值人工确认
高风险动作删除数据、发送公告强制人审

12.2 人审队列设计

人工介入需要一个明确的人审队列,而不是“发封邮件就算”。设计要点:

// human_review.go
type HumanReviewTask struct {
    ID          string    `json:"id"`
    TaskID      string    `json:"task_id"`
    Type        string    `json:"type"`       // "approval" | "data_fix" | "ambiguous"
    Priority    string    `json:"priority"`   // "high" | "medium" | "low"
    Status      string    `json:"status"`     // "pending" | "claimed" | "resolved" | "expired"
    Assignee    string    `json:"assignee"`   // 认领人
    Context     string    `json:"context"`    // Agent 执行到哪一步的完整上下文
    Question    string    `json:"question"`   // Agent 需要人回答的具体问题
    Options     []string  `json:"options"`    // 提供给人的可选项
    CreatedAt   time.Time `json:"created_at"`
    ClaimedAt   time.Time `json:"claimed_at,omitempty"`
    ResolvedAt  time.Time `json:"resolved_at,omitempty"`
    TTL         time.Duration `json:"ttl"`    // 超时自动升级
}

Context 是人工介入体验的灵魂。 人工介入时应该看到 Agent 失败前的完整上下文:用户原始意图、已执行的步骤、中间结果、失败原因、可选处理方案。而不是只看到一条冷冰冰的报错。

12.3 人工介入后的任务恢复

人工处理完(批准/拒绝/修正)后,Agent 任务要能从断点恢复继续执行:

# human_review_resume.py
def resume_after_human(task_id: str, human_decision: dict):
    task = task_store.get(task_id)
    if task.status != TaskStatus.WAITING_HUMAN:
        raise InvalidStateError("任务不在等待人工状态")
    
    if human_decision["action"] == "approve":
        # 用人工确认的结果继续执行
        task.status = TaskStatus.EXECUTING
        task.current_step += 1
        task.steps[task.current_step - 1].status = StepStatus.SUCCESS
        task.steps[task.current_step - 1].tool_result = human_decision["value"]
        task_store.update(task)
        engine.execute(task)   # 从断点继续
    elif human_decision["action"] == "reject":
        # 拒绝则触发补偿
        task.status = TaskStatus.COMPENSATING
        task_store.update(task)
        saga.compensate(task)
    elif human_decision["action"] == "modify":
        # 人工修改后重试当前步骤
        task.steps[task.current_step - 1].tool_args = human_decision["modified_args"]
        task.status = TaskStatus.EXECUTING
        task_store.update(task)
        engine.execute(task)

12.4 人审队列的 SLA 与升级机制

人审不是“等有人有空再看”,而是要有明确的 SLA:

human_review_sla:
  high_priority:
    first_response: 5m      # 5 分钟内必须有人认领
    resolution: 30m         # 30 分钟内必须处理完
    escalate_after: 5m      # 超时则升级给上一级
  medium_priority:
    first_response: 30m
    resolution: 4h
    escalate_after: 30m
  low_priority:
    first_response: 4h
    resolution: 24h
    escalate_after: 4h

超时未处理的任务自动升级(escalate)给上级或触发告警。


13. 可观测性:兜底系统自己的“仪表盘”

兜底逻辑本身也需要可观测。否则你无法知道:熔断器是不是误开了?重试是不是太频繁?降级是不是被过度使用?人工队列是不是在积压?

13.1 三大支柱:日志、指标、追踪

日志(Logging):结构化日志,每条都带 task_id、step_id、error_category。错误日志必须包含足够的上下文,让一个没参与开发的工程师也能定位问题。

# structured_logging.py
import logging
import json

logger = logging.getLogger("agent")

def log_agent_event(task_id: str, event: str, **kwargs):
    logger.info(json.dumps({
        "task_id": task_id,
        "event": event,
        "timestamp": time.time(),
        **kwargs,
    }, default=str))

指标(Metrics):每个兜底组件都暴露自己的指标。推荐用 Prometheus 格式。

指标名含义
agent_task_total{status}任务总数(按最终状态分)
agent_task_duration_seconds任务耗时直方图
agent_retry_total{tool,attempt}重试次数(按工具和尝试次数分)
agent_circuit_breaker_state{breaker}熔断器当前状态
agent_fallback_total{kind}降级触发次数(按降级类型分)
agent_human_review_queue_depth人审队列积压深度
agent_saga_compensate_total{result}补偿次数(按成功/失败分)

追踪(Tracing):在 Agent 的多步执行中,关联所有子调用。用 OpenTelemetry 把 LLM 调用、工具调用、重试、降级都串起来:

# tracing.py
from opentelemetry import trace

tracer = trace.get_tracer("agent")

def invoke_tool_with_trace(tool_name: str, args: dict):
    with tracer.start_as_current_span(f"tool.{tool_name}") as span:
        span.set_attribute("tool.name", tool_name)
        span.set_attribute("agent.task_id", current_task_id.get())
        try:
            result = tool_registry.invoke(tool_name, args)
            span.set_attribute("status", "success")
            return result
        except AgentError as e:
            span.set_attribute("status", "failed")
            span.set_attribute("error.category", str(e.category))
            span.set_attribute("error.code", e.code)
            span.record_exception(e)
            raise

13.2 兜底行为的总览看板

当系统出问题时,值班工程师需要一眼看到:

mermaid diagram

好仪表盘的标准:3 秒内判断“系统现在是否健康”,30 秒内定位“如果不健康,问题在哪一层”。

13.3 告警规则

告警要避免“狼来了”。推荐分级:

alerts:
  - name: AgentTaskFailureRateHigh
    expr: rate(agent_task_total{status="FAILED"}[5m]) / rate(agent_task_total[5m]) > 0.1
    severity: warning
    for: 5m
    annotations:
      summary: "Agent 任务失败率超过 10%"

  - name: AgentCircuitBreakerOpen
    expr: agent_circuit_breaker_state{state="open"} == 1
    severity: critical
    annotations:
      summary: "某个工具熔断器已打开,下游可能故障"

  - name: AgentHumanReviewQueueBacklog
    expr: agent_human_review_queue_depth > 50
    severity: warning
    annotations:
      summary: "人审队列积压超过 50 条"

  - name: AgentSagaCompensateFailed
    expr: increase(agent_saga_compensate_total{result="failed"}[10m]) > 0
    severity: critical
    annotations:
      summary: "出现补偿失败,存在数据不一致风险"

  - name: AgentTaskStuck
    expr: agent_task_stuck_seconds > 300
    severity: warning
    annotations:
      summary: "有任务卡住超过 5 分钟"

14. 灰度发布与回滚:让失败只影响最小范围

再完美的兜底设计,也挡不住一个有缺陷的新版本。灰度发布与快速回滚,是生产实践的必备能力。

14.1 Agent 的灰度发布

Agent 与普通服务不同,它的“版本”可能包含:

  • 模型版本(GPT 版本、微调版本)
  • 提示词版本
  • 工具集版本
  • 兜底策略配置版本

灰度发布要支持对这些维度的组合控制:

# rollout_config.yaml
rollout:
  strategy: canary
  canary:
    percent: 5            # 5% 流量
    dimensions:
      # 按用户 ID 哈希
      - type: user_hash
        range: [0, 5]
      # 或按租户白名单
      - type: tenant_whitelist
        tenants: ["test-tenant-a"]
  versions:
    model: "gpt-4o-2026-08-01"
    prompt: "v42"
    tools: "v18"
    fallback_policy: "v6"

14.2 回滚与“一键降级”

当灰度或生产出现严重故障时,要有快速回滚能力:

  1. 配置回滚:提示词、兜底策略这类配置,回滚就是切回上一个配置版本。用 Git 管理配置,回滚即 revert。
  2. 模型回滚:如果新模型质量劣化,切回旧模型。需要在路由层保留模型别名。
  3. 整体降级:极端情况下,把整个 Agent 降级为“静态回复 + 转人工”。
# emergency_rollback.py
def emergency_rollback(reason: str):
    """一键紧急回滚:关闭 Agent 自动执行,全部转人工"""
    config_store.update("agent.auto_execute.enabled", False)
    config_store.update("agent.fallback.mode", "human_only")
    alerting.notify(f"EMERGENCY ROLLBACK: {reason}")
    # Agent 收到后续请求时,直接创建人审任务,不执行任何自动操作

15. 故障演练:用混沌工程验证兜底真的有效

兜底逻辑如果没有被演练过,就只是“看起来很美”的代码。生产级的团队会用混沌工程手段主动注入故障,验证兜底链路是否真的能扛住。

15.1 演练场景清单

演练场景注入方法验证的兜底能力
LLM 超时代理延迟注入LLM 超时与降级
工具返回 500Mock 下游返回 500重试、熔断、降级
工具频繁限流 429Mock 限流限流感知重试
数据库连接池耗尽压测或连接泄漏注入熔断、快速失败
Agent 进程崩溃kill 进程状态恢复、幂等
补偿失败Mock 补偿接口报错告警、人工介入
模型输出格式错误Prompt 注入干扰输出校验与修复
人审队列积压关闭人审消费者升级机制、告警

15.2 演练自动化

把故障注入做成 CI/CD 的一部分。定期(如每周)在生产镜像上跑一遍核心演练,确保兜底链路始终有效:

# chaos_drill.py
def run_weekly_drills():
    scenarios = [
        ("llm_timeout", inject_llm_timeout),
        ("tool_500", inject_tool_500),
        ("tool_429", inject_tool_429),
        ("db_pool_exhaust", inject_db_pool_exhaust),
        ("process_crash", inject_process_crash),
        ("compensate_fail", inject_compensate_fail),
    ]
    for name, injector in scenarios:
        injected = injector()
        try:
            result = run_probe_tasks()
            assert result.success_rate > 0.95, f"{name} 演练失败"
            report_passed(name)
        finally:
            injected.cleanup()

15.3 演练结果与兜底策略的闭环

每次演练的结果要回流到兜底策略的调优中:

  • 如果某次演练中重试次数超出预期,说明重试预算可能设置不当;
  • 如果熔断器迟迟未触发,说明失败阈值可能太高;
  • 如果人工介入耗时过长,说明人审流程需要优化。

15.4 兜底策略与评估的回归闭环

故障演练验证的是“是否扛得住”,评估回归验证的是“改完之后有没有变坏”。兜底策略不是静态配置文件,阈值、降级链、提示词版本的每次变更都应该进入评估集:

评估集类型样例期望结果
正常路径“查询可用余额”“生成周报”不触发降级,任务成功
已分类失败模拟 429、500、超时、余额不足命中正确的兜底动作,不误判
边界与对抗样本越权转账、自我转账、畸形 JSON触发语义校验 / 人工介入,不执行副作用
恢复路径卡在 RUNNING、补偿失败、人工驳回状态机正确恢复,无重复或遗漏

每次发布前,将新的兜底配置跑一遍评估集,并对比三项指标:任务成功率、兜底动作准确率、平均恢复时长。三者任一下降超过阈值,就阻止发布并回滚配置。评估集本身也需要持续更新——每次线上兜底事件复盘后,把新发现的失败模式固化为回归用例,形成“线上失败 → 复盘 → 评估集 → 发布前回归”的闭环。

16. 生产实践案例:一个可靠的“智能转账 Agent”

为了把前述所有设计串起来,这里给一个完整的生产级案例:智能转账 Agent。这个 Agent 接收用户的自然语言指令(“给张三转 500 元”),执行多步操作。它很有代表性,因为转账是“有副作用 + 高敏感 + 多步”的典型场景。

16.1 业务需求与失败风险分析

任务流:解析意图 → 查询余额 → 风控校验 → 发起转账 → 通知用户。

失败风险:

步骤失败风险兜底设计
解析意图模型理解错误(金额、对象错)Schema 校验 + 语义校验 + 置信度阈值
查询余额下游超时/格式错误重试 + 缓存降级
风控校验风控服务不可用禁止降级,转人工(安全优先)
发起转账重复转账风险幂等键 + 状态机守卫
通知用户通知失败重试 + 异步补偿(消息队列兜底)

16.2 核心代码骨架

// transfer_agent.go
package main

import (
    "context"
    "errors"
    "fmt"
    "time"
)

func (a *TransferAgent) HandleTransferRequest(
    ctx context.Context,
    userID string,
    rawInstruction string,
) (*TaskResult, error) {
    // 1. 入口:生成幂等键
    idemKey := idempotency.GenerateKey("transfer", userID+":"+rawInstruction)

    // 2. 创建任务并持久化
    task := &Task{
        ID:       uuid.New().String(),
        UserID:   userID,
        Intent:   rawInstruction,
        Status:   TaskCreated,
        IdempotencyKey: idemKey,
        Deadline: time.Now().Add(10 * time.Minute),
    }
    if err := a.store.CreateTask(task); err != nil {
        return nil, fmt.Errorf("创建任务失败: %w", err)
    }

    // 3. 幂等保护下执行整个任务
    result, err := idempotency.ExecuteIdempotent(ctx, a.store, idemKey, func() (any, error) {
        return a.executeTask(ctx, task)
    })
    if err != nil {
        return nil, err
    }
    return result.(*TaskResult), nil
}

func (a *TransferAgent) executeTask(ctx context.Context, task *Task) (*TaskResult, error) {
    // 4. 步骤一:解析意图(带输出校验与修复)
    intent, err := a.parseIntent(ctx, task.Intent)
    if err != nil {
        return nil, a.handleFailure(ctx, task, "解析意图", err)
    }

    // 5. 步骤二:查询余额(带重试与缓存降级)
    balance, err := a.queryBalance(ctx, intent.FromAccount)
    if err != nil {
        return nil, a.handleFailure(ctx, task, "查询余额", err)
    }
    if balance.Available < intent.Amount {
        task.Status = TaskFailed
        task.ErrorMsg = "余额不足"
        a.store.UpdateTask(task)
        return a.errorResult("余额不足,无法完成转账")
    }

    // 6. 步骤三:风控校验(禁止降级,失败转人工)
    riskResult, err := a.riskCheck(ctx, task.UserID, intent)
    if err != nil || riskResult.Level == "high" {
        task.Status = TaskWaitingHuman
        a.store.UpdateTask(task)
        a.humanQueue.Enqueue(HumanReviewTask{
            TaskID:   task.ID,
            Type:     "approval",
            Priority: "high",
            Context:  fmt.Sprintf("转账请求: %+v,风控结果: %+v", intent, riskResult),
            Question: "风控校验异常或风险较高,是否批准转账?",
            Options:  []string{"approve", "reject"},
        })
        return a.waitingHumanResult()
    }

    // 7. 步骤四:发起转账(幂等 + Saga 补偿)
    saga := &Saga{}
    transferStep := SagaStep{
        ExecuteFn: func(ctx context.Context) error {
            return a.transferService.Execute(ctx, intent, idempotency.GenerateKey(
                "transfer.execute", task.ID))
        },
        CompensateFn: func(ctx context.Context) error {
            return a.transferService.Refund(ctx, intent, idempotency.GenerateKey(
                "transfer.refund", task.ID))
        },
    }
    saga.steps = append(saga.steps, transferStep)
    if err := saga.Run(ctx); err != nil {
        task.Status = TaskCompensating
        a.store.UpdateTask(task)
        // 补偿已在 saga.Run 内触发,此处记录最终失败
        task.Status = TaskFailed
        task.ErrorMsg = err.Error()
        a.store.UpdateTask(task)
        return a.errorResult("转账执行失败,已触发退款流程")
    }

    // 8. 步骤五:通知用户(异步 + 重试)
    if err := a.notifyUser(ctx, task.UserID, "转账成功"); err != nil {
        // 通知失败不视为任务失败,转入异步重试队列
        a.notifyQueue.Enqueue(NotifyMessage{UserID: task.UserID, Msg: "转账成功"})
    }

    // 9. 完成
    task.Status = TaskSuccess
    task.Result = map[string]any{"transfer_id": intent.TransferID}
    a.store.UpdateTask(task)
    return a.successResult(), nil
}

func (a *TransferAgent) handleFailure(
    ctx context.Context, task *Task, stepName string, err error,
) error {
    var agentErr *AgentError
    if errors.As(err, &agentErr) {
        // 走统一的兜底决策树
        return a.fallbackEngine.Decide(ctx, task, stepName, agentErr)
    }
    // 未知错误转人工
    task.Status = TaskWaitingHuman
    a.store.UpdateTask(task)
    return fmt.Errorf("步骤 %s 发生未知错误,已转人工: %w", stepName, err)
}

16.3 该案例体现的关键设计

  1. 每个步骤失败都有明确的兜底路径,而不是一个吞掉所有异常的 try-catch。
  2. 风控校验禁止降级——安全优先于可用性,这是业务决策,必须显式编码。
  3. 转账执行使用幂等键 + Saga 补偿,确保重复执行和失败撤销都安全。
  4. 通知失败不阻塞主流程——通知是可异步补偿的,不应让一个非关键步骤拖垮整个任务。
  5. 状态持久化贯穿始终——任何时候崩溃,任务都能恢复。

17. 兜底方案的工程落地清单

把所有设计落到工程实践,可以浓缩成一份可执行的落地清单:

17.1 代码层

  • 定义统一的 AgentError 结构化错误模型(分类、可重试性、上下文)。
  • 所有工具调用都经过输出校验器(Schema + 语义校验)。
  • 所有第三方异常都翻译为 AgentError,不泄露裸异常。
  • 实现可复用的重试器(指数退避 + 全抖动 + 限流感知)。
  • 实现可复用的熔断器(正确区分可重试错误与不可重试错误)。
  • 所有外部调用都有超时(LLM、工具、HTTP、DB)。
  • 有副作用的操作全部有幂等保护。
  • 任务状态机完整定义并严格校验状态流转。
  • 所有状态变更先落库再执行。
  • 多步有副作用流程使用 Saga + 补偿。
  • 降级路径预先设计并显式标识降级结果。

17.2 配置层

  • 重试预算(次数、时间、Token)可配置。
  • 熔断阈值(失败率、最小样本、冷却时间)可配置。
  • 超时策略(四级超时)可配置。
  • 降级链可配置。
  • 灰度策略可配置。

17.3 运维层

  • 结构化日志 + 指标 + 追踪全覆盖。
  • 兜底行为专属监控面板。
  • 分级告警规则,避免告警风暴。
  • 人审队列有 SLA 和升级机制。
  • 一键紧急回滚能力。
  • 定期故障演练,演练结果驱动策略调优。

17.4 组织与流程层

  • 定义“哪些场景允许自动降级、哪些必须转人工”的业务决策表。
  • 建立兜底策略的评审机制(每次模型/提示词/工具变更都要评审兜底影响)。
  • 值班手册包含常见兜底失效的处理步骤。
  • 定期复盘“兜底触发”事件,提炼共性失败模式并改进。

18. 结语:兜底不是补丁,是架构的一部分

很多团队把“兜底”当作上线前的最后一刻才想起来打的补丁。这种做法的结果,就是一堆零散的 try-catch、毫无策略的重试、找不到上下文的错误日志、以及被误触发的降级。

真正生产级的 Agent 系统,是把失败当作一等公民来对待的:从需求阶段就开始分析失败模式,架构阶段就设计好状态机与容错组件,开发阶段就实现结构化错误与幂等保障,运维阶段就用可观测性和故障演练持续验证。兜底不是“出错后的应急处理”,而是贯穿整个系统生命周期的架构能力。

本文给出的方案,目标是让读者可以从三个层面直接落地:

  1. 代码层面:直接采用本文的错误模型、重试器、熔断器、Saga 模式等实现;
  2. 架构层面:按分层兜底架构重新审视自己的系统,补齐缺失的层次;
  3. 工程实践层面:用落地清单逐项检查,用故障演练持续验证。

最后强调一句:兜底的目标不是永不失败,而是失败发生时,系统依然可控、可恢复、可解释、可追责。 当你把这一点作为设计原则时,你的 Agent 才算真正准备好了面对生产环境的真实世界。