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

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

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

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

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

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

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

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


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

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

2.1 失败的三层模型

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

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

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

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

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

决策正确,但"做"失败了:

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

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

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

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

2.2 失败的可重试性分类

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

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

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

2.3 失败的成本模型

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

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

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

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

3. 兜底体系的总体架构

3.1 核心设计原则

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

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

3.2 分层兜底架构

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

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

3.3 兜底决策树

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

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 系统定义一个统一的错误模型:

go 复制代码
// 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 }

关键点:

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

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

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

4.2.1 工具调用的 Schema 校验

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

python 复制代码
# 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 校验只能保证"格式对",不能保证"语义对"。还需要业务层的语义校验器:

python 复制代码
# 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 格式错误这种高频问题,一个有效的兜底是输出修复器:解析失败时,把错误信息反馈给模型,让模型自我修复。但要限制修复次数,避免陷入无限循环:

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

python 复制代码
# 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。这层"翻译"就是错误分类的核心:

python 复制代码
# 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)

go 复制代码
// 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

python 复制代码
# 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 重试预算的三维控制

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

yaml 复制代码
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 重试与熔断、超时、幂等的组合策略

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

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

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

go 复制代码
// 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 熔断器状态机

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

6.2 生产级熔断器实现

go 复制代码
// 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_webquery_db)一个熔断器。适合下游依赖明确的场景。
  • 按下游服务粒度:一个下游服务一个熔断器。适合多个工具共用同一后端的情况。
  • 按租户粒度:每个租户一个熔断器。适合多租户 SaaS,防止单租户故障拖垮全局。

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

6.4 熔断与 Agent 的特殊交互

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

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

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

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

7.1 为什么超时如此关键

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

7.2 四级超时体系

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

yaml 复制代码
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 或独立的定时器:

go 复制代码
// 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)机制

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

go 复制代码
// 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 + 唯一约束)

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

sql 复制代码
-- 转账流水表:用业务唯一键做幂等兜底
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)

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

go 复制代码
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 任务的状态定义

关键状态说明:

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

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

go 复制代码
// 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 持久化策略:每次状态变更都要落库

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

go 复制代码
// 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)

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

go 复制代码
// 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 模型降级实现

python 复制代码
# 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 工具结果降级

python 复制代码
# 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 模板降级 静态回复 / 兜底话术 低质量,高可用 全部上游失效

精确缓存示例:

python 复制代码
# 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 来撤销前面的副作用。

11.2 在 Agent 中落地 Saga

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

  1. 为每个有副作用的工具定义补偿工具 。例如 create_order 对应 cancel_orderdeduct_balance 对应 refund
  2. 补偿信息随步骤持久化。每步完成后记录其补偿函数名和补偿所需的全部参数。
  3. 补偿本身也可能失败,因此补偿也要有重试和幂等保障。
  4. 补偿方向必须反向。最后成功的步骤先被补偿。
go 复制代码
// 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 必须持久化其当前进度,否则进程崩溃后不知道补偿进行到哪一步。持久化结构:

sql 复制代码
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 人审队列设计

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

go 复制代码
// 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 任务要能从断点恢复继续执行:

python 复制代码
# 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:

yaml 复制代码
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_idstep_iderror_category。错误日志必须包含足够的上下文,让一个没参与开发的工程师也能定位问题。

python 复制代码
# 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 调用、工具调用、重试、降级都串起来:

python 复制代码
# 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 兜底行为的总览看板

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

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

13.3 告警规则

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

yaml 复制代码
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 版本、微调版本)
  • 提示词版本
  • 工具集版本
  • 兜底策略配置版本

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

yaml 复制代码
# 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 降级为"静态回复 + 转人工"。
python 复制代码
# 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 超时与降级
工具返回 500 Mock 下游返回 500 重试、熔断、降级
工具频繁限流 429 Mock 限流 限流感知重试
数据库连接池耗尽 压测或连接泄漏注入 熔断、快速失败
Agent 进程崩溃 kill 进程 状态恢复、幂等
补偿失败 Mock 补偿接口报错 告警、人工介入
模型输出格式错误 Prompt 注入干扰 输出校验与修复
人审队列积压 关闭人审消费者 升级机制、告警

15.2 演练自动化

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

python 复制代码
# 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 核心代码骨架

go 复制代码
// 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 才算真正准备好了面对生产环境的真实世界。

相关推荐
是大乔家的1 小时前
网文分销商必备神器:用知漫剧快速将授权小说转化为连载视频引流
人工智能
ITmaster07311 小时前
面试官坏笑:“你用 AI 编程一年了,怎么保证 Claude Code 写出来的代码是对的?”我:“直接上 Claude Fable 5 啊!”
人工智能
英雄6271 小时前
给 DeepSeek Harness Web 写了一个美化插件
人工智能
dogstarhuang2 小时前
OpenAI GPT-5.6 降价后如何重算 API 账单?多模型路由与成本治理实战
服务器·网络·人工智能·大模型·api·ai应用开发·接口管理
CRMEB定制开发2 小时前
2026年最值得推荐的10大开源商城系统盘点
开发语言·人工智能·开源·商城系统·小程序商城
MobotStone2 小时前
AI让一个人像一家公司,但没让普通人突然变成老板
人工智能
Pika2 小时前
DeepSeek Harness 架构拆解
人工智能
智购科技自动售卖机厂家2 小时前
2026自动售货机OTA升级系统设计:从全量升级到差分升级的带宽优化工程实践~YH
大数据·人工智能·numpy·pyqt·fastapi
武子康2 小时前
登录不是授权:高能力 AI 的连续身份保证链
人工智能