fail-open 还是 fail-closed?看职责,不凭偏好
做治理层这段时间,我被同一个设计选择反复拷问:当一个检查组件失败的瞬间,系统到底该倒向哪边——放行,还是拦截?
审批服务抖动超时,手里那笔已获批的工具调用还执不执行?脱敏模块崩溃,这条消息发还是不发?配置项里突然多出一个未知的键,认还是不认?
这篇文章把我过去在两套自托管系统里踩过的坑与做过的十几次抉择,收拢成一套判据、一张决策表与两道自检问题。
1. 两种本能,都是错的
面对“检查组件异常”,工程师往往会滑向两种本能反应:
- 安全本能:宁可错杀,绝不放过。 检查组件既然挂了,那就全部拦截。这种选择在架构评审会上永远“政治正确”,却掩盖了一个巨大的隐性代价:你赋予了一个边缘守卫一票否决全系统的权力。鉴权服务抖一下,全平台停摆;脱敏模块超时,所有消息堆死在队列里。故障从一个外围组件被硬生生放大成了一场核心事故,而这场事故甚至披着“严谨谨慎”的外衣。
- 体验本能:用户优先,故障旁路。 失败即放行,绝不让基础设施挡了业务的路。这种选择在产品演示和初期上线时皆大欢喜,直到某天安全审计介入才发现:重放防护在网关异常的那几分钟里形同虚设,脏数据早已落库。
这两种本能犯了同一个认知错误:把降级策略当成了工程师的性格映射——激进派选 open,保守派选 closed。
我的结论只有一句话:
fail-open 还是 fail-closed,不是性格问题,而是职责问题。判据的核心在于:误放与误拦,哪个代价更大、更不可逆。
2. 守卫位:fail-closed(失败即拦截)
有一类组件,诞生的唯一目的就是“阻断风险”:权限核验、执行令牌核销、工具指纹准入、审批时效管控。我将它们定义为守卫位。
守卫位的失败策略不存在妥协空间——必须 fail-closed,失败即拒绝。
在我的架构里,守卫位严格遵循以下策略映射:
| 关键组件 | 核心职责 | 异常发生时的降级行为 | 策略定位 |
|---|---|---|---|
| 执行令牌核销 | 防重放(单次授权,单次执行) | 校验链路超时或异常,一律返回 409 Conflict 拒绝执行 | fail-closed |
| PreToolUse 权限管线 | 工具调用前的执行安检 | 安检管线不可达或熔断,当前调用严禁触发 | fail-closed |
| 工具指纹与来源仲裁 | 确保仅合规注册的工具可调度 | 白名单外严禁下发;未知动作类型直接转入人工仲裁队列 | fail-closed |
| 审批时效判定 | 权限的时间窗口受控 | 判定逻辑异常视为超时——沉默即拒绝 | fail-closed |
| 外部参数加载器 | 保证运行时配置的确定性 | 出现 schema 未定义的键,应用严禁启动,拒绝静默容错 | fail-closed |
挑两个典型案例展开:
令牌核销:别给攻击者留“等它感冒”的窗口
令牌消费的核心职责是守住幂等与重放防护。如果它在崩溃时选择放行,后果绝不是“少了一次校验”,而是整套防护机制在异常窗口期内完全失效。攻击者甚至不需要正面击破防御体系,只需要通过流量泛洪将令牌服务打崩,就能顺理成章地绕过封锁。
更危险的是,守卫位误放的账单,通常是静默的:没有异常日志,没有报警弹窗,只有在日后审计或资产流失时,才会发现灾难早已发生。
未知配置键:最致命的错误是从不报错
把十几个治理参数抽取到外置配置时,我给加载器写死了一条规则:只要配置里出现 schema 之外的未知字段,应用直接启动失败(Crash-fast) 。
当时有同学提出异议:忽略未知键做向下兼容,不是更鲁棒吗?
并不是。一个拼写错误的配置项被静默忽略,意味着你以为已经收紧的安全策略,实际上压根没有生效。 系统在日志里风平浪静,你在控制台前胸有成竹,真实世界里门却大敞着。严谨系统的鲁棒性,从来不是靠“装作没看见错误”来实现的。
守卫位的统一规律:
1. 误放是静默灾难:它往往不报错,只以不可逆的安全敞口为代价。 2. 误拦是显式故障:系统中断、指标报警、业务催促,但这代表问题在明处,可排查、可回滚、可修复。
守卫位的本质,是用“可逆的显式可用性损耗”,去对冲“不可逆的静默风险”。
3. 增强位:fail-open(失败即放行)
另一类组件,存在的价值是“让结果更好,但没有它系统依然成立”:文本脱敏、证据检索门控、重排序优化。我将它们定义为增强位。
增强位的失败策略十分清晰——fail-open,失败即降级放行。
PII 脱敏:增强层绝无权力扼杀主链路
脱敏挂在文本输入模型的边界上,负责执行不可逆的占位符清洗。当脱敏服务不可用时,它选择放行原文。很多人对此感到不解:如果失败了可以放行,为什么还要做脱敏?
答案在于算这笔工程账:
- 脱敏误拦:脱敏组件故障导致所有上游流量被掐断,系统整体可用性归零。
- 脱敏误放:在特定的短时间内单条数据明文传输,风险被局限在当前上下文中。
增强层是系统的锦上添花,没有权力剥夺主流程的生存权。
但必须澄清,fail-open 绝对不是“两手一摊、假装合规” 。它必须绑定代价:每一次降级放行都必须被打上 fallback 标记沉淀审计日志,且“降级放行率”必须作为 P0 级监控告警指标挂在 Dashboard 首页。
证据门控:被实测数据逼出来的默认归零
在 RAG(检索增强生成)场景中,我曾引入过一道证据门控,利用相关性打分过滤掉低质量切片。初期我把阈值设为 0.7 并满怀信心地开启拦截,实测结果却令人咋舌:62.6% 的有效事实证据被该组件误杀。组件以为自己在严格把关,实则直接斩断了大模型的推理上下文。
这个数字迫使我把门控的默认阈值直接重置为 0(默认全放行,仅由调用方显式配置):
- 门控误放:低相关证据混入上下文,大模型面临轻微的抗噪压力,回答质量轻度降级——结果可控,体验可逆。
- 门控误拦:关键事实链条断裂,模型大面积出现事实性幻觉与截断——系统灾难,不可挽回。
4. 两道自检题与一条生命线
面对任意新增的拦截校验节点,不要凭直觉投票,先过这套自检闭环:
┌───────────────────────────┐
│ 组件出现不可用 / 异常超时 │
└─────────────┬─────────────┘
│
Q1: 误放造成的破坏是不可逆的吗?
(如:资金穿透、越权、配置失效)
│
┌─────────────┴─────────────┐
YES NO
│ │
┌───────────────────┐ Q2: 误放的损耗可观测、可补偿吗?
│ fail-closed │ │
│ (守卫位:失败阻断) │ ┌────────┴────────┐
└───────────────────┘ YES NO
│ │
┌───────────────────┐ ┌───────────────────┐
│ fail-open │ │ fail-closed │
│ (增强位:放行+告警) │ │ (无兜底:仍需阻断) │
└───────────────────┘ └───────────────────┘
- 不可逆性核算:组件防范的安全事故,与组件自身故障引发的业务中断,究竟哪一个更不可逆?数据外泄与越权执行一旦发生永无可能撤回;而体验降级与响应延迟在下一周期即可自愈。不可逆的风险,优先拿 fail-closed 的阻断权。
- 观测补偿能力:如果选择放行,这个损耗能否做到秒级告警、精准审计并提供补救通道?如果放行就意味着变成盲盒,那它依然不配拥有 fail-open 的资格。
最后一条生命线:警惕“职责漂移”
系统的架构绝非一成不变,组件的角色会随着合规标准、技术升级与业务阶段的演进而发生漂移。
证据门控在当前属于典型的“增强位”,因为我们依然信任模型的通识推理;但如果在金融或医疗场景下,法规明确约束“模型输出的每一句话必须具备绝对真实的受控引用来源”,那么证据门控就会瞬间被推上“守卫位”,原本的 fail-open 策略必须被当场废除。
Anthropic 今年 3 月的《Harness design for long-running application development》里,有一句与本节直接相关的总结:
“Harness 里的每个组件,都编码了一个关于『模型自己做不到什么』的假设;这些假设值得被反复压测——它们可能本来就是错的,而且会随着模型进步迅速过期。”
假设会过期,职责会漂移。因此,fail-open 与 fail-closed 从来不是写进代码就能高枕无忧的静态参数,而是一项需要随系统生命周期定期重审的动态决策。
5. 结语
回到最初的问题:面对失败,系统到底该倒向哪边?
答案并不写在工程师的技术偏好里,也不写在敏捷交付的倒排期里。它清晰地刻在每个组件的立身之本上:它是守门人,还是领航员;它是在阻止系统坠入深渊,还是在帮助系统跑得更快。
守卫位 fail-closed,增强位 fail-open。
定好职责,剩下的选择不攻自破。