Agent 的脆弱与反脆弱:当保护机制开始制造故障
Agent 系统的一类脆弱性,来自它处理异常的方式:一个局部问题经过层层保护,最终让整个任务失去继续和交付的机会。
读取失败,加一次拦截;状态变化,再做一次校验;模型准备结束,再请一个检查者确认。每项措施单独看都有理由。可当它们接在一起,一份辅助便签变了,已经准备好的模型请求就不能发出;某次操作失败了,其他仍然有效的工作也被提前截断。代码完全可以稳定、确定地执行这些拒绝。
在 ETF Agent 的演进中,我们经历过从分散拦截,到明确终结权,再把相关协调知识收进已有终结职责的过程。这些变化值得放在一起看:系统如何承受一次偏离,以及开发过程如何吸收这次偏离,是两件相连却不同的事。 前者决定今天的任务会损失多少,后者决定明天是否还会用同一种办法制造问题。
本文把这个关系称为“系统对扰动的反应结构”。这里的扰动可以是一次读取失败、一个辅助状态变化,也可以是新增能力使旧假设失效。它不是一种新的架构层,而是一个观察角度:沿着问题看影响如何传播,再沿着修复看经验怎样进入下一次设计。
要讨论脆弱与反脆弱,先把这条路径讲清楚,比给系统贴上“有韧性”“能自愈”的标签更有用。
一、小问题为什么会变成整项任务失败
先用一个教学任务贯穿全文。用户上传几份已经授权的产品资料,请助手比较差异并保存报告。其中两份能够读取,另一份暂时打不开。研究过程中还有一份可选便签,用来记录待核实的问题。
对用户来说,合理结果可能是:取得基于已读材料的有限比较,清楚知道哪份资料缺失,之后还能继续查看。这不等于完整比较已经完成,更不能把打不开的资料写成“没有差异”。
可在一种实现里,任何辅助环节报错都会向上抛出异常,顶层统一把任务标成失败。另一种实现则允许读取模块准确报告本次失败,保留不依赖缺失资料的工作,由任务生命周期决定是否继续、转入收尾,或者停止。
两者遇到的是同一个局部问题,损失范围却不同。第一种设计让一个组件是否正常,决定其他成果是否还有机会交付。第二种设计先问:哪些后续动作真的依赖这个失败的条件?
这给了我们一个比“错误多不多”更有解释力的问题:一次偏离,经过系统之后,被放大到了哪里?
如果只是少了一份资料,影响首先应落在依赖该资料的结论上。如果某项实际写入结果未知,则不能假设没有发生,重新执行同一动作。前者允许缩小结论,后者要求约束相关操作。把它们统一成一个 error,再让所有任务走同一种停止或重试逻辑,会丢掉真正决定下一步的信息。
这类问题并非 Agent 独有。Agent 让它更加突出,是因为一部分后续路径要等模型读到新材料后才形成。一次搜索没有找到答案,可能促成更换资料、缩小问题或如实保留缺口;它不天然意味着整个研究应该结束。运行时不确定性与后台开发的区别由此落到具体工程责任:让合法的适应空间存在,同时守住实际动作的边界。
二、保护为什么会慢慢取得不属于它的权力
分散拦截并非毫无来由。初期路径短、每一步都必需时,遇到异常就尽早停止,能够避免带着无效前提继续工作。请求准备后再核对状态,也可能是为了防止使用过期条件。问题出现在系统演进之后:辅助信息与必要条件已经分开,旧检查却继续拥有原来的全局否决能力。
ETF Agent 的 Research Needs 就是一例。它是一份可选的研究问题列表,帮助模型整理还有什么需要核实。旧实现会在模型请求准备完成后,再看这份列表是否变化;变化可能使整次请求无法派发。
“列表变了”这个判断可以完全正确,随后作出的拒绝却未必有充分依据。便签不是用户授权,也不是工具资源的凭证。真正的身份、权限和资源条件需要继续检查,但不能因为一项辅助工作状态变化,就自动撤销已经准备好的请求。这个反例在终结权专文中有更细的机制说明。
更早的收尾修复还处理过另一种冲突:任务已经发表一部分正文,旧逻辑便限制后续发表,结果需要补充的局限和冲突说明也无法送出。这里,“曾经发表过”被当成了“之后不再需要发表”。这个假设在渐进研究中不再成立。该修复发生在终结权集中之前,不能把它倒写成后来某个模块的一次性成果。
把这些经历放在一起,能看见一种共同变化:原本用来辅助任务的状态,逐渐成为决定任务能否存在的前提。
这也解释了为什么保护不能只按数量评价。一项检查会消耗时间、增加依赖,还可能改变哪些结果能够到达用户。它已经参与了系统行为。评审它时,需要同时看阻止了什么错误,以及是否截断了本来合法的路径。
例如,对同一个当前授权事实,在多个执行边界核验,可能是必要的纵深保护:不同边界面对不同调用者,或者授权在中间已经发生变化。相反,几个模块反复解释同一份研究便签是否“足够完整”,再各自决定整项任务能否结束,就可能是在复制同一个没有相应依据的否决权。
应当收回的是没有相应责任的决定,必要的保护仍留在它真正守护的效果边界。
三、终结权要收敛的是决定,不只是最后一行写库
发现局部错误不断变成全局失败后,一个自然的修法是增加统一入口:所有异常都调用 finish(failed),由同一个地方保存状态。
这样做统一了结果格式,结束任务的判断却仍然散落在各处。读取模块决定失败,便签模块决定失败,Hook 也决定失败;新入口只是替它们记录决定。下一次增加能力,仍然可能新增一个隐含的终结者。
2026 年 10 月的终结权收敛,明确处理的就是这个差别。各模块先在自己的责任范围内处理失败,把已经确认的事实交给 Run 生命周期。Run 是一次任务运行;生命周期掌握它的当前阶段、已发生的执行事实、原有资源条件与已交付状态,据此决定继续、进入有限收尾,还是结束。
这里的 Owner,指的是对一项具体决定承担最终责任的逻辑模块。它不等于一个进程、一个文件,也不意味着所有事情都交给一个管理器。
| 决定 | 它需要掌握的事实 | 不能借机取得的权力 |
|---|---|---|
| 某次读取、写入或工具调用是否允许 | 当前身份、对象、授权及实际执行条件 | 代替整个研究判断是否还有价值 |
| 还有多少资源可供使用 | 原任务已经使用和保留的资源 | 因为切换阶段而重新发放预算 |
| 哪份正文已合法保存 | 对应内容、版本、依赖与提交事实 | 用“保存过”证明研究完整或正确 |
| 本次 Run 如何继续和结束 | 上述事实、当前阶段、取消与期限 | 绕过其他职责的拒绝,重做未知效果 |
因此,收敛终结权之后也不是“都让它继续”。用户取消、硬期限到达,或者无法建立安全的继续依据时,生命周期仍须停止相应执行。它可以保留已经成立的独立成果,却不能为了看起来完成而修改这些事实。
收尾同样要有边界。研究阶段用掉了资源,转入收尾并不会获得一份新预算;收尾之后也不能重新打开探索,再申请一次收尾。已有成果需要保存、已有局限需要说明,但这些动作仍要经过原来的准入条件。否则,“再给它一次机会”只是把失败改造成更长的失控。
还有一个常被遗漏的边界:观察结果的组件,不应反过来拥有改写结果的权力。 如果任务已经按事实完成结算,通知或观察回调出错应留下诊断,不应把已提交的结果重新改成另一个结论。当然,正文根本没有保存成功时,也不能用通知成功掩盖它。
这次重构的价值,是把“某一步发生了什么”与“整项任务应怎样结束”的连接变得明确。它减少了一类错误传播;研究内容是否足够好,仍需针对内容本身核验。
四、有了 Owner,为什么还要继续拆掉重复判断
职责名字写清楚,并不代表知识已经集中。
终结权收敛之后,后续审查发现:阶段判断与下一轮请求意图仍有一部分留在主循环里,终结决定在另一个位置。两边需要共同理解何时结束研究、何时只允许收尾,以及阶段为什么不能倒退。维护者改一条规则,仍得同时照顾两个地方。
后来的调整把这些协调知识移进已有的 run-termination-owner,删除主循环里重复维护的阶段状态与推进逻辑。这里没有新增一个 Finalizer Agent,也没有另起执行循环或资源账户。 这是对现有职责的深化;相关验证针对行为等价和边界保持,不能写成又解决了一次已确认的线上事故。
为什么这一小步值得放进全景?因为它揭示了两种不同的集中。
一种集中只移动代码:调用者仍须知道里面每个状态,仍然在外面判断该怎么走。另一种集中减少了调用者必须掌握的规则:主循环执行获准的动作,阶段与终结规则由相应职责统一解释。后一种才真正减少下一次变化需要同步的地方。
同样的判断也适用于“是不是还要一个总管理器”。如果新管理器需要理解每个工具的异常、每种发布规则、全部资源和所有恢复细节,它可能只是把耦合装进一个更大的盒子。错误会集中,变化也会集中。
更合适的边界是:一项决定有明确责任,各项决定依靠各自掌握的事实协作。 工具仍负责实际调用,资源模块仍负责额度,发布模块仍负责正文的提交事实,生命周期负责整次运行的走向。集中的是某项权力,协作的职责仍然分开。
这也有一个“不做”的条件:如果一个小任务的终结规则本来就在单一执行入口里,没有第二份判断,也没有跨阶段协调负担,就不必为了概念完整再拆出一个同名模块。只有它真正吸收了原来分散的知识,抽象才值得留下。系统为何越来越难升级讨论的维护成本,在这里与运行时的脆弱性接上了:重复知识既容易制造不同判断,也会放大下一次修改。
五、能够扛住故障,离反脆弱还有多远
到这里,我们证明过的是若干具体机制:局部拒绝不再轻易扩大,终结决定有了责任归属,阶段协调减少了重复。直接把这些叫作“系统已经反脆弱”,结论仍然跳得太远。
Taleb 与 Douady 在关于脆弱和反脆弱的论文中,讨论的是系统对波动与扰动的敏感性。反脆弱要求从特定扰动中受益;仅仅少受损或恢复原状,并不足以支持这个判断。
沿着本文案例,可以区分几种工程结果。它们不是按等级排列的成熟度阶梯:
| 观察到的结果 | 可以说明什么 | 仍不能直接说明什么 |
|---|---|---|
| 可选便签变化没有打断合法请求 | 系统对这类变化的承受能力更合适 | 变化越多,系统就越好 |
| 中断后按原事实回到可用状态 | 某条恢复机制有效 | 这次中断给系统带来了净收益 |
| 反例推动了责任调整,并在后续设计中被采用 | 研发过程吸收了这次教训 | 运行中的 Agent 会自动修改自身架构 |
| 在明确范围内,扰动带来的后续收益超过损失与改进成本 | 可以进一步讨论该范围的反脆弱性 | 任意强度、任意类型故障都值得增加 |
这里必须说明“谁”在受益。一份报告失败,后来团队学到了东西,对这次报告的用户仍可能只有损失。把团队长期获得的知识拿来抵消当次用户损失,会混淆评价对象。系统版本、开发过程与使用者,也可能面对不同的收益和代价。
同样,研究系统对可选资料暂缺更能适应,不代表它也能承受存储损坏、错误权限或无限增长的负载。反脆弱性必须附着在具体扰动、范围和评价对象上。
Martin Monperrus 在《Principles of Antifragile Software》中讨论了软件产品与开发过程的区别,并提出从故障中改进的软件工程方向。这给本文提供了一个有用的分层;它不替我们的项目证明长期效果。
对这段工程经历,最扎实的结论是:我们在减少一类会放大局部异常的结构,也在让反例影响之后的设计。是否形成持续净收益,还需要比较后续同类问题、维护负担、任务交付与修复成本。现有受控验证不能代替这项长期观察。
六、Spec 和 History 怎样让一次踩坑改变下一次设计
运行时能够保存错误日志,开发过程也可能什么都没学到。下一位开发者看到“请求失败”,再加一个拦截;Coding Agent 读到旧说明,又把已删除的检查补回来。材料增加了,设计方向没有变化。
要让经验留下来,需要保留的首先是判断为什么改变。
以研究便签为例,只记录“删除了一次检查”,后来者很容易认为少了保护。更有用的历史会保留:当初希望避免请求使用过期条件;后来发现变化的是可选工作记录;身份、权限和资源仍由原职责核验;所以撤销的是便签对整次派发的额外否决,而非必要准入。
这段因果关系使下一次评审有机会识别同一种错误,即便新模块不再叫 Research Needs。
Spec 与 History 因而承担不同作用。现行 Spec 说明今天必须满足什么行为;History 解释旧选择为什么合理、什么反例使它需要改变。 原始验证记录说明当时真正观察到了什么。三者可以互相引用,无须把同一份日志抄进每个文件。
在终结权之后的职责缺口审查中,这种判断被继续用于 Child:子任务的技术结束应由其自己的职责处理,不能因为没有正文就永远悬在活动状态,也不能让一次本地失败顺带决定 Main 或其他子任务的终局。相关受控案例里,Main 原本就能消费合法片段并交付;修复直接补齐的是 Child 的持久结束状态与活动关系。这一点要讲清,才不会把所有进步都记到“保住主任务”名下。
后来设计内容信任职责时,同样可以看见这一经验的迁移:在真实输入边界保护来源与结构身份,同时明确它不取得 Run 终结权、正文否决权或新的授权权力。这里值得关注的是旧教训进入了新设计的限制条件,而不只是修复原来的文件。
还有一个更有启发的选择:为了避免职责再次越界,我们曾考虑增加自动扫描。对照已有审查和实际反例后,没有取得它能额外发现问题的证据,于是撤回了这个方案,保留原有设计评审与行为验证。这不是实现一个新扫描器之后测出了收益;它说明工程经验也可以让团队决定“不新增”。
这些经历把反馈关系接了起来:异常暴露隐含假设,反例帮助定位责任,修复改变实际行为,现行规则与历史解释共同影响下一个设计。经验产生价值的时刻,是后来的选择因此发生变化。 文件数量、原则数量和记录长度,都不能替代这件事。
七、学习也会制造负担,所以规则必须能够退出
如果每次故障都增加一条永不撤销的规则,系统也会“记住历史”,只是记忆变成了越来越厚的限制。
新的终结职责已经接管阶段判断,主循环却保留旧判断;原有权限边界已经验证了对象,外围又按过时字段拒绝;某次迁移已经结束,回归测试仍要求旧实现继续存在。这些东西各有来历,也可能各有一条通过的测试,却在共同维护一套已经变化的世界。
这使得“从失败中学习”需要包含第二种动作:在保留有效保护的同时,删除不再成立的要求。
假如旧测试仍断言“便签变化后不得派发”,当职责澄清之后,不能为了保住这个绿灯,把错误的全局拒绝重新放回去。应当调整已被替代的行为预期,同时保留身份无效不得执行、资源不足不得派发、未知效果不得盲目重做等仍然成立的约束。文档、代码与测试共同演进解释的正是这类差别。
被证明无效的前提应退出现行约束;解释它来历的历史可以留下。 历史可追溯,与旧机制持续参与执行,没有必然绑定关系。
这也让我们能够更准确地判断一次修复的代价。除了修好当前反例,还要看它是否让所有正常任务新增等待、输入和判断;以后加入一个工具,是否又要修改更多调用者;新机制被替代时,能否退出而不改写用户既有的任务记录。
这些成本不是拒绝保护的理由。真实授权、资金执行限制和事实完整性不能拿性能高分抵扣。但对辅助检查、额外 Reviewer 和协调层,需要说明它增加了什么可观察的价值。若旧职责已经能解决问题,新一层又没有独立贡献,合并或不做都可能是更好的设计。
从这个角度看,架构学习的一个有力信号,是新反例让系统的责任更清楚,也让未来维护者少背一份重复规则。它有时表现为新增能力,有时表现为删掉曾经很认真写下的代码与断言。
八、怎样验证“更不脆弱”,又不把验证变成新的拦截
回到最初的资料比较任务。若只验证“错误出现时请求被拒绝”,最容易通过的实现就是把所有请求都拒绝。要判断保护范围是否合适,至少需要同时看两面:危险的效果是否被阻止,以及独立合法的工作是否还能到达结果。
这不是要求额外搭建一个庞大的审查平台。在既有行为测试里,用能区分设计的输入即可:
| 改变的条件 | 应当观察到什么 |
|---|---|
| 可选便签在请求准备后变化 | 便签的变化不能单独否决派发;当前身份、权限与资源检查仍然有效 |
| 一份资料读取失败,已有其他独立材料 | 不把缺失说成不存在,依赖它的结论收窄;其他合法工作不被无关异常提前截断 |
| 某次外部写入结果未知 | 不伪造失败回执,也不直接重做该效果;其他成果是否独立仍按实际依赖判断 |
| 用户取消或硬期限到达 | 停止新的相关执行,保留已成立的事实;不能借“最后收尾”重新启动模型工作 |
| 终态已提交,通知回调随后失败 | 诊断可以失败,已提交的任务事实不能因此被回调改写 |
对这些确定的运行边界,受控故障能帮助检验因果关系:故障放在实际执行接缝,观察到底哪项效果被拒绝、哪项工作还可继续。模型是否会用剩余材料写出好报告,则需要另一类真实任务评估。允许路径存在,不等于模型一定善用了它。
混沌工程原则强调围绕可测的正常行为提出假设,用受控扰动挑战它,并限制实验的影响范围。对本文而言,借鉴的是这种验证方式。故障注入能发现薄弱处,或增加对恢复行为的信心;它本身不证明“故障越多越好”。
实验的尺度也应与问题相称。一个明确的便签误拦,可以先用局部行为验证;怀疑存储与进程交接存在问题,再进入对应的集成环境。
如果要进一步主张这套研发方式长期有益,应在可比较的任务与改动中观察:同类责任错误是否减少,正常路径是否新增负担,故障后的独立成果是否更容易保留,以及收益是否值得修复和维护成本。测量规则变了,要先恢复可比口径;不能把更会记录错误,误写成系统更少出错。局部改善与整体进步在这里提供了必要的约束。
九、下一次出错,先决定这次经验应该留下什么
回看资料比较任务,我们希望系统能够容纳这样的现实:有一份资料暂时缺失,有一次探索没有结果,有一项辅助信息在变化。它仍要尊重权限、资源和用户意图,让可以独立成立的部分得到适当交付,在不能继续时准确结束。
分散的拦截曾经试图保护这些目标,后来也可能因为旧假设与职责错位成为障碍。终结权收敛处理的是谁能决定整个任务走向;深化已有模块处理的是谁需要知道这些规则;Spec、History 与行为验证则让这次变化有机会影响下一次设计。三者共同回答了同一个问题:系统遇到偏离之后,怎样避免把损失和复杂度不断向外推。
下一次准备“再加一道保护”时,可以先沿实际失败路径问清楚:这项异常破坏了哪一个前提,谁有权拒绝对应效果,哪些工作并不依赖它。修复之后,再看它给现行规则留下了什么、撤掉了什么,以及下一位开发者是否能理解这个取舍。
对运行中的 Agent,把失败限制在应有的范围;对持续演进的工程,把反例转成下一次可用的判断。 这两件事都需要认真做,才有基础讨论从扰动中受益。若一次修复让责任更明确、合法路径仍可到达、过期负担能够退出,那么即使暂时不用“反脆弱”命名,我们也已经知道系统具体向前走了哪一步。
延伸阅读与交流
- Agent 开发与后台开发有什么不同:运行时的路径为什么会变化,以及约束如何保持。
- 终结权、阶段预算与安全恢复:局部拒绝怎样与整次运行的终结分工。
- 代码越来越便宜,为什么系统却越来越难升级:重复知识、正常任务的额外成本与机制退出。
- 文档、代码和测试一起向前:保留有效保护,让已经被替代的要求退出。
更多文章见 ETF Agent 阅读站;欢迎在 GitHub 文集 Star 或提供不同的工程反例。