失败闸门 Failsafe
昨天(Day 07)的 Critic 防"说错";今天的闸门防"崩溃"和"烧钱"。任何单点失败都不该让整个 Agent 挂掉或烧光预算——这就是"防御纵深"。它和 Critic 是互补的两台安全设备。
防御纵深:一层挡不住还有下一层
Agent 运行时会遇到各种意外:某个专家调用的工具接口挂了、证据不够、Critic 反复不通过、某一步 LLM 突然烧了很多钱……没有一个单点故障应该导致整个 Agent 崩溃或失控。
解决思路借用军事术语——防御纵深(defense in depth):设多道独立的闸门,就算一道被突破,后面还有下一道兜着。
代码在 packages/ai-trust-toolkit/src/ai_trust_toolkit/failsafe/。它的 __init__.py 开头就列全了这几道闸。
budget_gate(预算硬熔断),所以现在实际是 6 道。这是渐进抽象的又一个例子——成本治理成熟后才补上这道闸。6 道闸门总览
节点级 fallback wrap_with_fallback软
把任意节点包成"永不抛异常"——挂了就返回占位结果,图继续跑。
Synthesizer 失败开关 build_synth_safeguard软
证据不足时,不调贵的综合 LLM,直接返回"信息不足"。省钱又诚实。
Critic 三态路由 build_critic_router软
Day 07 见过:通过/重试/触顶。防止无限重试。
记忆库写回守门 should_writeback软
只有"质检通过且信息充分"才准写进记忆库,防止把错误结论污染知识库。
基础设施降级软
由 memory + safe_tool_result 提供:DB/工具挂了自动降级到内存/兜底值。
USD 硬熔断 budget_gate硬v0.6 新增
单次调用/单个节点烧钱超阈值,直接抛 BudgetExceeded 中断。财务安全底线。
闸① 节点兜底:让节点永不抛异常
代码 failsafe/node_fallback.py。先看配置类 FallbackConfig(node_fallback.py:12)和包装器 wrap_with_fallback(node_fallback.py:33)的真身:
@dataclass
class FallbackConfig: # node_fallback.py:12
state_field: str # 这个节点往哪个 state 字段写(如 'trace_finding')
fallback_finding: dict = field(default_factory=lambda: {
"fallback": True, # ← 关键标记!Critic L1 靠它排除假证据
"health": "broken", "confidence": "低",
"evidence_ids": [], "hint": "节点执行失败,建议人工核实",
})
log_level: int = logging.WARNING
def wrap_with_fallback(node_fn, config): # node_fallback.py:33
async def wrapped(state: dict) -> dict:
try:
return await node_fn(state) # 正常路径:原样返回
except Exception as e: # ← 兜住"任何"异常
logger.log(config.log_level, "Node failed for state_field=%s: %s",
config.state_field, e, exc_info=True)
return {config.state_field: dict(config.fallback_finding, error=str(e))}
wrapped.__name__ = getattr(node_fn, "__name__", "wrapped_node")
wrapped.__wrapped__ = node_fn # 留原函数引用,便于测试/内省
return wrapped
except Exception兜住所有异常(不是某几类)。因为节点内可能抛任何东西——超时、网络、解析错。安全兜底就该"无差别接住"。"fallback": True占位 finding 带这个标记。它不是给人看的,是给 Day 07 Critic ② check_min_specialists 看的——那里靠它把降级假数据排除出"有效专家数"。两处代码靠这个字段咬合。dict(config.fallback_finding, error=str(e))复制默认占位并塞进真实错误信息。用 dict(...) 拷贝而非直接改,避免污染共享的默认字典。__wrapped__ = node_fn把原函数挂上去,测试时可以直接拿到没包装的版本单独测。工程细节。回忆 Day 05:专家工厂 make_specialist_node 内部自动给每个专家包了这道闸(specialists/factory.py:125)。所以哪怕 trace 专家接口彻底挂了,它也只返回一个 health=broken 的降级 finding,其它 3 个专家照常工作。
wrap_with_fallback 捕获异常 → 回写 {"trace_finding":{"fallback":True,"health":"broken","confidence":"低"}} → 图继续跑 → metric/deploy/log 三位专家照常产出 → synthesizer 基于 3 路证据给出结论(并标注"trace 证据缺失")。一路挂,不等于整单废。
闸③ Critic 三态路由(防无限重试)
Day 07 见过它的三态行为,今天看它作为"失败闸门"的真身。代码 failsafe/retry_router.py:工厂 build_critic_router(retry_router.py:12)返回真正的路由函数 critic_router(retry_router.py:35):
def build_critic_router(*, pass_dest="writeback", retry_dest="synthesizer",
end_dest="__end__", max_retries=2): # retry_router.py:12
def critic_router(state: dict) -> str: # retry_router.py:35
if state.get("critique_passed"):
return pass_dest # 通过 → writeback
if state.get("retry_count", 0) >= max_retries:
return end_dest # 触顶 → END(诚实收尾)
return retry_dest # 没到顶 → synthesizer 重做
critic_router.__name__ = "critic_router"
return critic_router
工厂返回函数"工厂 + 闭包"套路:build_critic_router(...) 先把目的地/上限配好,返回一个只吃 state 的路由函数,正好塞进 LangGraph 的 add_conditional_edges。顺序 if判定顺序有讲究:先看通过、再看触顶、最后才重试。通过优先级最高;没通过再问"还能不能重试"。retry_count >= max_retries靠 Day 07 累加的 retry_count 判断触顶。它作为失败闸门的意义:防止"改不好就无限重试"——每重试一次都要花钱调综合 LLM+Critic,不设上限会烧穿预算。end_dest 默认 "__end__"触顶就走 END,agent 诚实输出"信息不足",绝不硬憋一个可能误导的结论。闸④ 写回守门(保护记忆库不被污染)
should_writeback(retry_router.py:46)是写进记忆库前的最后一道门——只有"质检通过且信息充分"才准入库:
def should_writeback(state, *, require_critique_passed=True,
require_info_sufficient=True,
conclusion_field="conclusion") -> bool: # retry_router.py:46
if require_critique_passed and not state.get("critique_passed"):
return False # 没过 Critic → 不写
conclusion = state.get(conclusion_field) or {}
return not (require_info_sufficient
and not conclusion.get("info_sufficient", True))
not critique_passed → False第一关:没过 Critic 的结论一律不入库。防止"错误经验"被固化。info_sufficient, True边界细节:注意默认值是 True——如果结论没显式声明"信息不足",就默认当作充分放行。设计上宁可"没声明就放",把"不充分"的举证责任交给上游明确标记。require_* 参数两道校验都能独立关掉——某些不需要记忆库质量那么严的 agent 可以放宽。默认全开。EpisodicStore.writeback 配套——writeback 的 docstring 明确要求"调用前先用 should_writeback 守门"。
check_evidence_sufficient(synth_safeguard.py:10)+ build_synth_safeguard(synth_safeguard.py:44)。它数有几个专家真出结果、几个是 fallback 降级;真实证据不够就直接返回"信息不足"、不调那个贵的综合 LLM——既省钱又诚实。逻辑和 Critic ② 同源,都靠 fallback 标记数数。闸⑥ 预算硬熔断走读(v0.6 新增)
代码 failsafe/budget_gate.py。先看异常类 BudgetExceeded(budget_gate.py:42)——它带上 scope/used/limit 三个字段,出错时一眼看清"哪个范围、花了多少、上限多少":
class BudgetExceeded(Exception): # budget_gate.py:42
def __init__(self, *, scope: str, used: float, limit: float):
self.scope = scope # "per_invoke" 或 "per_node"
self.used = used
self.limit = limit
super().__init__(f"budget_gate scope={scope} exceeded: ${used:.4f}/${limit:.4f}")
核心是上下文管理器 budget_gate(budget_gate.py:63),用 @contextmanager 装饰,yield 前后各做一次成本快照对比:
@contextmanager
def budget_gate(*, per_invoke=None, per_node=None, on_exceed="raise"): # budget_gate.py:63
tracker = current_tracker() # 从 ContextVar 取当前成本追踪器
if tracker is None:
yield # ← 边界:没绑 tracker(测试/独立调用)静默放行
return
start_cost = tracker.cost_usd # 进块时拍快照
yield # ← with 块内的代码在这里执行
used_in_block = tracker.cost_usd - start_cost # 出块后算差值
if per_node is not None and used_in_block > per_node:
_trigger("per_node", tracker, used_in_block, per_node, on_exceed)
if per_invoke is not None and tracker.cost_usd > per_invoke:
_trigger("per_invoke", tracker, tracker.cost_usd, per_invoke, on_exceed)
tracker is None → yield边界①:测试或没接成本闭环的 agent 里没有 tracker——此时静默放行,绝不因为"想省钱"反而把测试搞崩。默认对无关场景零副作用。start_cost / used_in_blockper_node 看的是块内增量(差值),per_invoke 看的是累计总额。两种口径对应"单节点别失控"和"整次调用别超顶"。yield这是上下文管理器的分界点——with 块里的代码在 yield 处执行,回来才检查。所以只能事后发现超支。on_exceed="raise"默认超了就抛。也可传 "log" 只告警不中断(观察期用)。触发处理 _trigger(budget_gate.py:103):先打 Prometheus 计数器、记 warning 日志,最后按 on_exceed 决定抛不抛:
def _trigger(scope, tracker, used, limit, on_exceed): # budget_gate.py:103
BUDGET_EXCEEDED_TOTAL.labels(agent=..., tenant=..., scope=scope).inc() # 指标
log.warning("budget_gate %s exceeded: $%.4f / $%.4f ...", scope, used, limit)
if on_exceed == "raise":
raise BudgetExceeded(scope=scope, used=used, limit=limit)
budget_gate 是包在业务节点代码外层的 with 块,它没法强迫每个调用点都去检查返回值——返回码很容易被忽略,钱就继续烧了。抛异常是"不可忽略"的中断:它会一路上抛,被 wrap_with_fallback(节点级)或 router 工厂(invoke 级)接住,转成降级路径。用异常正是要那种"啪一下强制刹车"的语义。budget_gate.py:24 注释明说):yield 期间没法中途打断一次正在进行的 LLM 调用。所以"单次 LLM 就超限"拦不住,只能在它返回后检查。把局限如实写进注释,本身就是工程素养。
with budget_gate(per_node=0.5) 时 start_cost=1.20(美元累计)→ 块内综合节点连调两次 Sonnet,出块时累计 1.95 → used_in_block=0.75 > 0.5 → 打指标 + warning + 抛 BudgetExceeded(scope="per_node", used=0.75, limit=0.5)。就像这一路用电超额,闸刀"啪"地跳下来。
👶 小白:软闸门都"降级不中断",为什么偏偏预算这道要"硬中断"?带伤跑完不好吗?
👨🏫 老师:其它失败最多让这一次结果差一点,而烧钱是真金白银、不可逆的。带伤跑完一个亏钱的调用,只会让损失继续扩大。财务安全是底线,底线只能"啪"地拉闸。所以①-⑤软(尽量干完活),只有⑥硬(触底立停)。
第 7 道:数据敏感度闸门
除了上面 6 道,还有一道正交的安全闸——敏感度分级闸门(sensitivity/,架构文档里的 DD-001 第 7 道闸)。它在请求进来时就对输入内容分级:
| 级别 | 含义 | 处理 |
|---|---|---|
| L0 | 公开 | 放行 |
| L1 | 内部 | 放行(红线 service 观察上报) |
| L2 | 机密(含密码/私钥/prod trace 等) | 直接拦截,抛 SensitivityViolation |
分类器 RuleBasedClassifier(classifier.py:81)用正则规则做,按 L2→L1→L0 优先级判定。两个红线设计值得学:
- 它绝不 import anthropic/langchain(
classifier.py:10)——纯规则、极轻、无外部依赖,绝不能因为分类器本身故障而漏过机密。 - 分类结果的
reasons只放"命中了哪条规则名",绝不包含原始的敏感内容——避免日志二次泄漏。 - 它必须排在预算闸门之前:先拦机密输入,防止恶意 input 白白消耗预算。
还有个互补的 api/pii_scrub.py:不管字段叫什么,只要字符串里出现 email/手机号/身份证/token,落库前统一脱敏成 <redacted:类别>。
成本治理四件套(别混淆)
框架里跟"钱/量"相关的机制有 4 个,容易搞混,一张表理清(api/budget.py 的 docstring 有对照):
| 机制 | 限什么 | 粒度 | 超了怎样 |
|---|---|---|---|
| ratelimit | 请求次数 | 每 user / 每分钟(默认 60) | 429 限流 |
| quota | token 累计量 | 每 user / 日 & 月 | 拒绝 |
| cost | 成本归因 | 每次 invoke(记账 + 上报) | 不拦,只记 |
| budget(per-tenant) | USD 累计 | 每租户 / 月度硬上限 | 429 + Retry-After 到下月 |
⚠️ 两个 BudgetExceeded 别混
failsafe.budget_gate.BudgetExceeded = 普通异常,单次调用/单节点硬熔断。⚠️ 另一个
api.budget.BudgetExceeded = HTTP 429,per-tenant 月度上限。语义不同、指标不同。此外 v0.6 的"成本感知路由"(cost/router.py):预算剩余低于阈值(默认 10%)时,get_llm("auto") 自动从贵的 Sonnet 降到便宜的 Haiku——Day 11 细讲。
BudgetExceeded 当成同一个。它们同名但完全不是一回事:failsafe.budget_gate.BudgetExceeded 是普通异常,管"单次调用/单节点"硬熔断(闸⑥,图内用);api.budget.BudgetExceeded 会变成 HTTP 429,管"每租户月度 USD 上限"(HTTP 外壳用)。触发场景、粒度、指标全不同——看它是"图内 with 块"还是"HTTP 返回码"就能分清。今日小结 + 动手
🧠 今天你应该能回答
- "防御纵深"是什么?6 道闸门各防什么?
- 软闸门和硬闸门的区别?(降级不中断 vs 触底直接停)
- 节点兜底怎么保证单点失败不搞垮全图?(
except Exception就地转成带fallback:True的占位 finding) - 为什么预算闸用"抛异常"而不是返回错误码?(异常不可忽略、强制中断,被 fallback/router 接住)
- 写回守门为什么重要?
info_sufficient默认值是啥?(防污染记忆库;默认 True,没声明不足就放行) - 敏感度 L2 为什么必须排在预算闸门之前?(先拦机密,防恶意输入耗预算)
- 成本治理四件套分别限什么?(次数/token量/成本归因/租户月度USD)
✋ 动手
# 1. 看 6 道闸门清单
sed -n '1,12p' packages/ai-trust-toolkit/src/ai_trust_toolkit/failsafe/__init__.py
# 2. 读节点兜底、证据开关、Critic 路由
cat packages/ai-trust-toolkit/src/ai_trust_toolkit/failsafe/node_fallback.py
sed -n '10,66p' packages/ai-trust-toolkit/src/ai_trust_toolkit/failsafe/synth_safeguard.py
# 3. 读预算硬熔断
sed -n '42,110p' packages/ai-trust-toolkit/src/ai_trust_toolkit/failsafe/budget_gate.py
# 4. 读敏感度分类器(纯规则、不碰 LLM)
sed -n '1,96p' packages/ai-trust-toolkit/src/ai_trust_toolkit/sensitivity/classifier.py
# 5. 跑失败闸门单测
uv run pytest packages/ai-trust-toolkit/tests/test_failsafe.py -q