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 核心设计原则
在展开具体技术之前,先确立六条贯穿全文的原则:
- 可观测优先于自动化:在你能看清失败之前,任何自动兜底都可能是灾难。先有日志、指标、追踪,再有重试和降级。
- 显式状态优先于隐式状态:所有任务状态必须持久化,禁止依赖进程内存。这样重启、扩容、故障转移才不会丢失任务。
- 幂等优先于重试:任何重试的前提是操作幂等。没有幂等保障的重试等于制造重复数据。
- 快速失败优先于盲目等待:对确定性失败,快速失败并把问题暴露出来,远好于长时间占着资源。
- 降级优先于失败:能返回一个次优结果,就不要返回一个错误。用户体验的底线是"有结果",而不是"完美结果"。
- 人工介入是兜底的兜底:自动化兜底链条的最后一环,永远是清晰、高效的人工介入通道。
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 }
关键点:
Retryable与Category分离:可重试性是一个"决策字段",由错误生产者根据对业务的理解显式设置,而不是由兜底层去猜测。这避免了"重试策略层需要理解所有业务语义"的反模式。Context携带结构化上下文:失败时的工具入参、目标资源 ID、用户 ID 等都应该放在 Context 里,方便后续自动修复或人工介入时无需重新分析。StepID与ToolName:让错误可以被定位到具体执行步骤和工具,这是可观测性的基础。
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 重试的前提条件清单
在启用重试前,逐项确认以下条件:
- 操作是幂等的(详见第 8 节)。非幂等操作禁止重试,除非有专门的幂等协调机制。
- 错误被标记为可重试 。
retryable=false的错误无论重试多少次都不会成功。 - 有重试预算。包括最大次数、总时长、总 Token 消耗三层预算,任一耗尽即停。
- 不会加剧过载。对 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 倍请求。治理手段包括:
- 下游限流:每个工具调用方配置独立限流器,重试请求同样受限于限流器。
- 重试配额按租户隔离:一个恶意或故障租户的重试不能耗尽全系统的重试预算。
- 断路器优先于重试:先看熔断器是否打开,打开则直接短路,不进入重试(见第 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
}
组合策略的三条铁律:
- 先问熔断器,再决定重试。下游已经熔断时,重试请求只会被立刻拒绝,浪费预算并放大噪声。
- 重试必须纳入父级超时窗口 。任何脱离
context截止时间的重试,最终都会演变成超时雪崩。 - 没有幂等键就不重试副作用操作 。重试次数越多,重复写入风险越高;这条约束应固化在
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_web、query_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 超时后的处理:不是简单返回错误
超时后要区分两种本质不同的情况:
- 客户端超时,但服务端可能继续执行。例如 HTTP 客户端超时了,但下游可能已经收到并正在处理请求。此时直接重试会导致重复执行。
- 任务确实被废弃。服务端已经确认停止。
对于第 1 种情况,超时后禁止盲目重试,应先通过查询接口确认下游实际状态。这引出了下一节的核心问题:幂等性。
8. 幂等性设计:重试与补偿的绝对前提
幂等性是整个兜底体系的基石。没有幂等保障,任何重试和补偿都可能造成重复扣款、重复下单、重复发送等灾难。
8.1 幂等的定义与层次
定义:一个操作执行一次和执行多次,效果相同。
在 Agent 系统中,幂等性需要在三个层次保证:
- 请求层:同一个用户请求不会被重复提交执行。
- 工具层:同一个工具调用(含参数)重试不会产生副作用。
- 任务层:整个 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 降级设计原则
降级不是"降低标准",而是"用提前设计好的次优路径替代失败路径"。四条原则:
- 有损但可用:降级结果必须仍然对用户有价值,不能为了返回结果而返回垃圾。
- 显式化 :降级结果必须携带
degraded标记、降级原因、降级链路,绝不能伪装成完整结果。 - 可配置、可测试:降级链是配置驱动,是灰度与故障演练的一部分。
- 可恢复:降级是临时状态,一旦上游恢复,系统应能自动回到完整能力,而不是永久停留在降级模式。
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 天然契合。落地要点:
- 为每个有副作用的工具定义补偿工具 。例如
create_order对应cancel_order,deduct_balance对应refund。 - 补偿信息随步骤持久化。每步完成后记录其补偿函数名和补偿所需的全部参数。
- 补偿本身也可能失败,因此补偿也要有重试和幂等保障。
- 补偿方向必须反向。最后成功的步骤先被补偿。
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
);
补偿失败怎么办? 这是最棘手的情况:补偿本身失败了,说明系统处在"不确定的中间状态"。此时:
- 自动重试补偿(补偿必须是幂等的);
- 重试仍失败则告警 + 转人工,人工通过运维界面手动触发补偿或直接修复数据;
- 决不私自标记"补偿完成",否则会造成永久的数据不一致。
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_id、step_id、error_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 回滚与"一键降级"
当灰度或生产出现严重故障时,要有快速回滚能力:
- 配置回滚:提示词、兜底策略这类配置,回滚就是切回上一个配置版本。用 Git 管理配置,回滚即 revert。
- 模型回滚:如果新模型质量劣化,切回旧模型。需要在路由层保留模型别名。
- 整体降级:极端情况下,把整个 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 该案例体现的关键设计
- 每个步骤失败都有明确的兜底路径 ,而不是一个吞掉所有异常的
try-catch。 - 风控校验禁止降级------安全优先于可用性,这是业务决策,必须显式编码。
- 转账执行使用幂等键 + Saga 补偿,确保重复执行和失败撤销都安全。
- 通知失败不阻塞主流程------通知是可异步补偿的,不应让一个非关键步骤拖垮整个任务。
- 状态持久化贯穿始终------任何时候崩溃,任务都能恢复。
17. 兜底方案的工程落地清单
把所有设计落到工程实践,可以浓缩成一份可执行的落地清单:
17.1 代码层
- 定义统一的
AgentError结构化错误模型(分类、可重试性、上下文)。 - 所有工具调用都经过输出校验器(Schema + 语义校验)。
- 所有第三方异常都翻译为
AgentError,不泄露裸异常。 - 实现可复用的重试器(指数退避 + 全抖动 + 限流感知)。
- 实现可复用的熔断器(正确区分可重试错误与不可重试错误)。
- 所有外部调用都有超时(LLM、工具、HTTP、DB)。
- 有副作用的操作全部有幂等保护。
- 任务状态机完整定义并严格校验状态流转。
- 所有状态变更先落库再执行。
- 多步有副作用流程使用 Saga + 补偿。
- 降级路径预先设计并显式标识降级结果。
17.2 配置层
- 重试预算(次数、时间、Token)可配置。
- 熔断阈值(失败率、最小样本、冷却时间)可配置。
- 超时策略(四级超时)可配置。
- 降级链可配置。
- 灰度策略可配置。
17.3 运维层
- 结构化日志 + 指标 + 追踪全覆盖。
- 兜底行为专属监控面板。
- 分级告警规则,避免告警风暴。
- 人审队列有 SLA 和升级机制。
- 一键紧急回滚能力。
- 定期故障演练,演练结果驱动策略调优。
17.4 组织与流程层
- 定义"哪些场景允许自动降级、哪些必须转人工"的业务决策表。
- 建立兜底策略的评审机制(每次模型/提示词/工具变更都要评审兜底影响)。
- 值班手册包含常见兜底失效的处理步骤。
- 定期复盘"兜底触发"事件,提炼共性失败模式并改进。
18. 结语:兜底不是补丁,是架构的一部分
很多团队把"兜底"当作上线前的最后一刻才想起来打的补丁。这种做法的结果,就是一堆零散的 try-catch、毫无策略的重试、找不到上下文的错误日志、以及被误触发的降级。
真正生产级的 Agent 系统,是把失败当作一等公民来对待的:从需求阶段就开始分析失败模式,架构阶段就设计好状态机与容错组件,开发阶段就实现结构化错误与幂等保障,运维阶段就用可观测性和故障演练持续验证。兜底不是"出错后的应急处理",而是贯穿整个系统生命周期的架构能力。
本文给出的方案,目标是让读者可以从三个层面直接落地:
- 代码层面:直接采用本文的错误模型、重试器、熔断器、Saga 模式等实现;
- 架构层面:按分层兜底架构重新审视自己的系统,补齐缺失的层次;
- 工程实践层面:用落地清单逐项检查,用故障演练持续验证。
最后强调一句:兜底的目标不是永不失败,而是失败发生时,系统依然可控、可恢复、可解释、可追责。 当你把这一点作为设计原则时,你的 Agent 才算真正准备好了面对生产环境的真实世界。