Day 08 / 共 20 天 · 第 2 周 可信底座

失败闸门 Failsafe

昨天(Day 07)的 Critic 防"说错";今天的闸门防"崩溃"和"烧钱"。任何单点失败都不该让整个 Agent 挂掉或烧光预算——这就是"防御纵深"。它和 Critic 是互补的两台安全设备。

📍 你在 20 天里的位置(第 2 周:可信底座)
D06 toolkit 总览 D07 Critic D08 失败闸门 D09 Memory D10 评测
💡 用一个类比先兜住今天(延续「盖楼/消防」世界观) 失败闸门就是整栋楼的消防与安全冗余——讲究"防御纵深":一道防线破了,后面还有下一道。烟感没响还有喷淋,喷淋没灭还有防火门,最后还有灭火器。本框架 6 道闸门也一样,而且分两种:软闸门(①-⑤)像"某路水管爆了自动关那一路阀门"——降级但不停水,别的房间照用;硬闸门(⑥预算熔断)像"总电表跳闸"——用电超阈值直接拉闸,宁可停也不能烧穿。记住"软闸门降级、硬闸门拉闸",6 道闸就有了主线。
L01

防御纵深:一层挡不住还有下一层

Agent 运行时会遇到各种意外:某个专家调用的工具接口挂了、证据不够、Critic 反复不通过、某一步 LLM 突然烧了很多钱……没有一个单点故障应该导致整个 Agent 崩溃或失控

解决思路借用军事术语——防御纵深(defense in depth):设多道独立的闸门,就算一道被突破,后面还有下一道兜着。

节点兜底
证据开关
Critic 路由
写回守门
设施降级
预算熔断

代码在 packages/ai-trust-toolkit/src/ai_trust_toolkit/failsafe/。它的 __init__.py 开头就列全了这几道闸。

术语澄清(重要):教程标题常说"5 道失败闸门",那是 v0.6 之前的原始设计。v0.6 新增了第 6 道 budget_gate(预算硬熔断),所以现在实际是 6 道。这是渐进抽象的又一个例子——成本治理成熟后才补上这道闸。
L02

6 道闸门总览

1

节点级 fallback wrap_with_fallback

把任意节点包成"永不抛异常"——挂了就返回占位结果,图继续跑。

2

Synthesizer 失败开关 build_synth_safeguard

证据不足时,不调贵的综合 LLM,直接返回"信息不足"。省钱又诚实。

3

Critic 三态路由 build_critic_router

Day 07 见过:通过/重试/触顶。防止无限重试。

4

记忆库写回守门 should_writeback

只有"质检通过且信息充分"才准写进记忆库,防止把错误结论污染知识库。

5

基础设施降级

由 memory + safe_tool_result 提供:DB/工具挂了自动降级到内存/兜底值。

6

USD 硬熔断 budget_gatev0.6 新增

单次调用/单个节点烧钱超阈值,直接抛 BudgetExceeded 中断。财务安全底线。

软闸门 vs 硬闸门:软闸门(①-⑤)是"降级但不中断"——出问题就退而求其次,让 Agent 带伤跑完。硬闸门(⑥)是"直接中断"——触及底线(烧钱)就立刻停。两者配合:日常靠软闸门优雅降级,极端情况靠硬闸门刹车。
L03

闸① 节点兜底:让节点永不抛异常

代码 failsafe/node_fallback.py。先看配置类 FallbackConfignode_fallback.py:12)和包装器 wrap_with_fallbacknode_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把原函数挂上去,测试时可以直接拿到没包装的版本单独测。工程细节。
💡 设计取舍①:为什么"吞掉异常返回 dict"、而不是让它抛?因为 Agent 是多路取证再综合的结构。一路 trace 挂了,不代表整次分析废了——另外 3 路可能已够定位。如果任由异常上抛,整张 LangGraph 图会崩、直接 500。把失败就地转成一条"标记了 broken 的数据",图就能继续跑完。这叫"把故障局部化"。而"该不该信这条降级数据"的判断,交给下游 Critic——各司其职。

回忆 Day 05:专家工厂 make_specialist_node 内部自动给每个专家包了这道闸(specialists/factory.py:125)。所以哪怕 trace 专家接口彻底挂了,它也只返回一个 health=broken 的降级 finding,其它 3 个专家照常工作。

🤔 错误驱动:如果没有这道闸,会出什么事故?假设 trace 专家调用的 SkyWalking 接口超时抛异常。没有兜底时:这个异常会一路往上冒,让整张 LangGraph 图崩溃 → 这次故障分析直接失败返回 500,哪怕另外 3 个专家其实已经查到了根因。就因为一路取证挂了,整单报废——这显然不可接受。
📝 举个例子:兜底把"崩溃"变成"带伤跑完" trace 专家接口超时 → wrap_with_fallback 捕获异常 → 回写 {"trace_finding":{"fallback":True,"health":"broken","confidence":"低"}} → 图继续跑 → metric/deploy/log 三位专家照常产出 → synthesizer 基于 3 路证据给出结论(并标注"trace 证据缺失")。一路挂,不等于整单废。
单点失败被"局部化":一路爆管,别处照跑 trace 专家 ✗超时 metric 专家 ✓ deploy/log 专家 ✓ 兜底→health:broken synthesizer 照常综合 基于 3 路证据出结论
图注:异常被 wrap_with_fallback 就地捕获成占位 finding,图不中断——这就是"局部失败不扩散"。
为什么不直接让它崩? 因为 Agent 是"多路取证再综合"的结构。一路证据挂了,不代表这次分析就废了——剩下几路可能已经够定位问题。让单个节点的失败"局部化",是保证整体韧性的基础。而且 Critic 的 L1 会检查"至少 2 个专家真出了结果",所以降级不会导致瞎猜。
L04

闸③ Critic 三态路由(防无限重试)

Day 07 见过它的三态行为,今天看它作为"失败闸门"的真身。代码 failsafe/retry_router.py:工厂 build_critic_routerretry_router.py:12)返回真正的路由函数 critic_routerretry_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 诚实输出"信息不足",绝不硬憋一个可能误导的结论。
💡 为什么它既是"路由"又算"失败闸门"?路由是它的形式(决定下一个节点),闸门是它的作用——它拦住了两种失败:无限重试烧钱、以及低质量结论强行输出。同一段代码,从控制流角度是条件边,从可信角度是安全闸。
L05

闸④ 写回守门(保护记忆库不被污染)

should_writebackretry_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 可以放宽。默认全开。
闸④为什么关键? 记忆库是 Agent 的"经验沉淀",会被未来调用召回参考(Day 09)。若把"没过质检/证据不足"的结论也写进去,等于把错误经验固化、未来反复污染。写回前守门,是保护知识库长期质量的闸。它和 Day 09 的 EpisodicStore.writeback 配套——writeback 的 docstring 明确要求"调用前先用 should_writeback 守门"。

⚠️ 提一句闸②:证据不足开关 check_evidence_sufficientsynth_safeguard.py:10)+ build_synth_safeguardsynth_safeguard.py:44)。它数有几个专家真出结果、几个是 fallback 降级;真实证据不够就直接返回"信息不足"、不调那个贵的综合 LLM——既省钱又诚实。逻辑和 Critic ② 同源,都靠 fallback 标记数数。
L06

闸⑥ 预算硬熔断走读(v0.6 新增)

代码 failsafe/budget_gate.py。先看异常类 BudgetExceededbudget_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_gatebudget_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" 只告警不中断(观察期用)。

触发处理 _triggerbudget_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)
控制流:进块拍快照 → 块内执行 → 出块算差值判定 进 with start_cost=1.20 yield:块内跑(连调两次 Sonnet) 出 with cost=1.95 diff=0.75 > 0.5 → 抛异常
图注:budget_gate 只能"事后"判定——yield 前后各拍一次成本快照,差值超阈值才熔断。
💡 设计取舍②:为什么用"抛异常"而不是返回错误码?因为 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.95used_in_block=0.75 > 0.5 → 打指标 + warning + BudgetExceeded(scope="per_node", used=0.75, limit=0.5)。就像这一路用电超额,闸刀"啪"地跳下来。

👶 小白:软闸门都"降级不中断",为什么偏偏预算这道要"硬中断"?带伤跑完不好吗?

👨‍🏫 老师:其它失败最多让这一次结果差一点,而烧钱是真金白银、不可逆的。带伤跑完一个亏钱的调用,只会让损失继续扩大。财务安全是底线,底线只能"啪"地拉闸。所以①-⑤软(尽量干完活),只有⑥硬(触底立停)。

L07

第 7 道:数据敏感度闸门

除了上面 6 道,还有一道正交的安全闸——敏感度分级闸门sensitivity/,架构文档里的 DD-001 第 7 道闸)。它在请求进来时就对输入内容分级:

级别含义处理
L0公开放行
L1内部放行(红线 service 观察上报)
L2机密(含密码/私钥/prod trace 等)直接拦截,抛 SensitivityViolation

分类器 RuleBasedClassifierclassifier.py:81)用正则规则做,按 L2→L1→L0 优先级判定。两个红线设计值得学:

  • 绝不 import anthropic/langchainclassifier.py:10)——纯规则、极轻、无外部依赖,绝不能因为分类器本身故障而漏过机密。
  • 分类结果的 reasons 只放"命中了哪条规则名",绝不包含原始的敏感内容——避免日志二次泄漏。
  • 它必须排在预算闸门之前:先拦机密输入,防止恶意 input 白白消耗预算。

还有个互补的 api/pii_scrub.py:不管字段叫什么,只要字符串里出现 email/手机号/身份证/token,落库前统一脱敏成 <redacted:类别>

L08

成本治理四件套(别混淆)

框架里跟"钱/量"相关的机制有 4 个,容易搞混,一张表理清(api/budget.py 的 docstring 有对照):

机制限什么粒度超了怎样
ratelimit请求次数每 user / 每分钟(默认 60)429 限流
quotatoken 累计量每 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 返回码"就能分清。
🍼 记忆口诀(成本四件套)「次(ratelimit限次数)、量(quota限token)、账(cost只记账)、顶(budget月度封顶)」——前两个限流量、cost 只记不拦、budget 是真封顶。
L09

今日小结 + 动手

🧠 今天你应该能回答

  • "防御纵深"是什么?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
明天预告 · Day 09:Agent 怎么"记住"过去的经验、跨会话不失忆?Day 09 讲 四层 Memory Protocol——工作/情景/语义/向量底座,以及"换向量数据库不动业务代码"的 Protocol 抽象。
← Day 07 Critic Day 09 · 四层 Memory Protocol →