在研发 QA Agent 的过程中,团队常会陷入一个极其现实的困境:主 Agent 过得太苦了。

为了完成一次看似寻常的取证任务,它需要跨越数个数据孤岛——去 Jira 翻找冗长的描述与讨论,去 GitLab/GitHub 翻阅 PR diff,去 Slack 的讨论串里拼凑上下文,再去 Figma 提取设计标注。每一个数据源的调取,都是数轮工具调用的起步。当海量的原始数据一股脑塞进主 Agent 上下文时,它甚至还没来得及展开实质性的逻辑思考,就已经被排山倒海的证据彻底淹没。

随后,我们做出了所有 Agent 架构师都会选择的决定:解耦

我们将庞大的上下文收集任务拆解给一组专注于特定领域的子 Agent——一个负责 Jira,一个负责代码与 PR,一个负责 Slack,另一个负责 Figma。架构图一旦勾勒完成,看起来优雅无比:子 Agent 如同工蜂般四散出门采蜜,过滤、去重、交叉核对;主 Agent 则如蜂王坐镇中央,只处理浓缩后的情报,上下文瞬间变得无比清爽。

然而,优雅的图纸很快被现实撕开了一个缺口:如何确保这些“工蜂”只采蜜,绝不上门筑巢?

换句话说,子 Agent 绝不被允许擅自发送消息、写入测试用例、触发审批或上传附件。这个“绝对不许”听起来只是个单纯的业务需求,可一旦落入工程实践,它就演变成了一个近乎哲学的命题——你究竟是在凭借契约要求它“自觉”,还是在物理层面剥夺了它的“能力”?

早在第一版方案中,团队曾尝试过一种看似极为灵活的做法:主 Agent 每次委派任务时,通过 $fromAI 动态生成子 Agent 的 system prompt。这种“量体裁衣”的机制初看令人惊艳,但致命的隐患随之而来——委派指令本身可以轻易覆盖只读规则。当节点的 System_Message 字段直接由上游模型的输出填充时,就相当于把安全门禁卡交给了被检查者自己填写。今天提示词写着“绝对只读”,明天面对紧急任务时提示词可能就变成了“尽快搞定顺便更新一下状态”。规则在传输过程中被无声地抹杀。大模型并非故意造反,它只是无比忠实地执行了最新鲜的指令——而最新鲜的指令,恰恰是没有贴上封条的那张。

意识到漏洞后,我们将规则升级为固定在子 Agent 节点内部的高优先级 Prompt:规则被硬编码在节点配置中,主 Agent 的委派内容只能包含具体任务,绝无权限触碰规则底线。对于语义模糊或无法确切判断副作用的工具,协议强制要求返回 BLOCKED_SIDE_EFFECT——直接显式拒绝,并将决策权安全地交还给上层。与此同时,工具分类表也被严格梳理:Figma export 图片/PDF、渲染、预览及下载已有附件被明确划分为无副作用的只读行为;而任何混合读写或边界模糊的操作则一律拦截。在安全分类的逻辑里,“宁可误杀一个合法的读取请求,也绝不放行一次可疑的写入行为”是基本的敬畏——前者的代价仅仅是一次重试,而後者的代价则是整份审计报告的溃败。

然而,即便做到了这一步,这也仅仅是系统的第一道防线。注意,这里使用的是“防线”而非“方案”:防线的存在,意味着系统假定自身必然存在漏洞,且后方必定有兜底机制;而方案的表述,则暗示着系统自以为绝不会出错。在权限体系的设计哲学里,这种词汇上的微小认知差异,往往价值连城。

两道防线

prompt 是约定,allowlist 才是边界

一个令人扎心的事实是:即便完成了提示词层的修复,子 Agent 在底层依然共享着一套具备写入能力的 Code Mode 工具集。模型在嘴上宣称自己恪守只读规则,手里却依然紧握着足以破坏系统的锤子。Prompt 约束的永远只是“意图”,而工具面决定的才是真正的“能力”——这两者在工程上从来就不能划等号。

如果把 Prompt 比作公司贴在茶水间墙上的告示——“请勿拿走他人物品”,它的确能带来极高的合规率,但它防御的对象仅仅是君子。相比之下,allowlist 则是茶水间压根没有安装大门:它无关乎信任与否,而是让“拿走”这个物理动作在逻辑上根本无法发生。保密单位禁止携带手机进入机房,依靠的绝非员工守则的条文约束,而是设在门口的物理储物柜。将“不许拍照”从一项职业纪律升维成一道物理定律,这才称得上真正的边界。

因此,第二道防线必须是工具级的最小权限隔离:必须为子 Agent 提供一套在物理层面仅包含 read-* API 的独立 Tool Allowlist 或只读 Code Mode。让所有的写入工具在其视野中彻底消失。如此一来,哪怕模型在未来的某一天理解偏差、遭遇了 Prompt 注入、或是单纯陷入了逻辑混乱,它在客观上也根本摸不到任何可以犯错的工具。

这一设计还带来了一个常被忽视的副产品:工具面的收窄,同步收窄了模型的认知负载。子 Agent 不必再在每次发起工具调用前去纠结“这个工具到底会不会产生写入副作用”,分类器的漏判率随之直接归零。在安全设计的哲学中,最顶级的规则,永远是那种根本不需要被执行的规则。

面对当时的工程现状,我们保持了坦诚:Prompt 级的约束已经落地,而工具级的物理隔离仍处于 pending 状态。在架构文档中将其虚报为“绝对只读已达成”不过是自欺欺人,状态页上必须冰冷而精准地注明:“约定已生效,强力隔离 pending”。这种极其克制的区分在日常汇报时或许显得枯燥乏味,但在面临线上事故时却足以救命——当越权写入不幸发生时,你至少能瞬间定位是哪一道防线遭遇了击穿,而不是对着一份写满“绝对只读”的文档怀疑人生。

每一个为了“先跑起来”而折中的 MVP 背后,都暗中标注了等待补齐的欠条。欠条本身并不可怕,假装欠条不存在才是灾难的开始。软件工程中的系统性溃败,极少源于“我们清楚这是个临时方案”,大多源于“这个临时方案静静跑了两年,所有人都在无意识中以为它是永恒的规范”。

两道防线的分工

回传没问题,写入错了地方

这套架构在后续的运行中,爆出过一起极其具代表性的线上事故。

起初,排查人员高度怀疑子 Agent 的执行结果根本没有成功回传给主 Agent。现场的症状被极其具象地描述着:任务持续运行了很长时间,主 Agent 却如同陷入了失聪状态,不断自言自语。然而,当我深入调阅真实的 execution 日志时,却看到了截然不同的事实:整个过程触发了 4 次子 Agent 调用、共计 55 次大模型交互,耗时约 30 分钟。Tool result 的回传链路全程完好无损,父子 Agent 之间的通信逻辑没有发生任何断裂。

但是,这次看似成功执行的代价,却残酷地写在了另一行数据库日志里:属于 FIN-7119 的 8 条自动化测试用例,被一股脑地写入了一个完全不相干的业务模块中。

信息回传得丝毫不差,但数据却写错了地方。 这就好比快递的物流轨迹全程正常、签收记录一应俱全,但当你打开门时,却发现包裹被极其规整地摆在了邻居家的鞋柜里。

导致这场事故的深层根因,绝非物理连接的断裂,而是三层设计疏漏重叠在一起产生的共振效应:

  1. 迭代上限过于宽松:主 Agent 的 maxIterations 被设定为 80,子 Agent 设定为 50。过于宽松的循环上限,放任大模型在证据严重不足的情况下继续盲目空转;
  2. 职责边界模糊导致卡死:Slack 子 Agent 发生了越界,主动跨领域去检索了 Jira 的数据。委派来源的严重重叠导致状态迟迟无法收敛。此时,前端用户因等待超时而手动点击了取消,从而制造出了肉眼可见的“无返回”假象;
  3. 缺少目标不变式校验:最致命的是,数据在写入前缺乏关键的不变式(Invariant)检查。node_path 与当前 Jira key 的业务映射关系是否匹配,系统全程没有进行任何校验,导致 8 条用例就这样堂而皇之地落错了户。

这三因素中,没有一个是由于“大模型不听话”引起的,全是因为系统未能清晰地划定边界——越界取证没有被拦截,写入目标没有被校验,运行过久没有被及时挂断。

这起事故最核心的价值,在于它差一点就被团队“误诊”。

“子 Agent 结果断连”的推测听起来合情合理,毕竟“没有收到返回”是一个直观挂在表象上的症状,甚至当时已经有人提出了重构 Agent Tool 连接层的复杂手术方案。但如果顺着真实的日志一层层剥开事实的茧房就会发现:连接语义本身天衣无缝,ai_languageModelai_tool 构成的子节点通道确实将子 Agent 的最终文本完好地作为 tool result 递交了回去。

那条被大家当成“断联铁证”的样本,实际上是两个子 Agent 依然在工具循环中艰难收敛时,被用户强行人工中断的执行。拿一个处于取消状态中的 execution 去证明通信链路失败,无异于拔掉了病人的氧气管,随后向所有人宣布这个病人原本就不会呼吸。 如果当时盲目去“修复”一条根本没有断裂的连接,不仅无法解决 55 次模型空转与跨模块错写的问题,反而会白白引入一整套全新的系统回归风险。

基于这次教训,团队迅速锁定了重构方向:

  • 激进压缩迭代轮次:将子 Agent 压缩至 12~15 轮,主 Agent 设定在 30 轮左右作为调试基准(这并非放之四海而皆准的固定常量,必须经过真实业务任务的成功率验证);
  • 按数据源严格隔离委派:收拢子 Agent 的数据获取权限,禁止跨源探查;
  • 引入领域不变式校验:在正式执行写入前增加强校验逻辑,一旦发现 node_path 与当前的 Jira key 或 feature 业务关联不匹配,立即触发硬性拦截,禁止写入。

系统层面的 success 状态必须重新经过领域业务验收——在复杂的 Agent 系统中,接口抛出的绿勾仅仅代表流程执行完毕,绝不代表事情做对了。

此外,还有一个极具价值的排查纪律值得被单独强调:在本次调查过程中,全过程保持绝对只读,不改动任何 workflow,也不直接清理已错位的用例数据。 当面对 Agent 系统报出的“无返回”、“不生效”等疑难杂症时,必须先将现场精准拆解为四种完全不同的病灶,再决定如何动刀:

  • 通信连接真的丢失了;
  • 状态依然在循环中尚未收敛;
  • 任务在中途被用户或系统强行取消了;
  • 结果已正常返回,但后置的业务逻辑将其写错了地方。

这四种病灶对应的处方截然不同。将它们混为一谈进行盲目治疗,最终只会把普通的头痛彻底医成截肢。

执行解剖

顺带的教训:取消也是控制面

在 Agent 的工程世界里:业务写入、交互 ACK、消息前端展示、以及最终结果汇总,是四个在物理上完全独立的状态面。 其中一个状态呈现绿色,绝不代表其他三个状态也能顺理成章地亮起绿灯。用户眼睛所能看到的那个 500 报错,仅仅是这四个状态面中唯一损坏的一角——但因为它直接裸露在用户视野的最前端,于是在用户的认知框架里,这就等同于“整个系统彻底瘫痪了”。

任务被强行杀死后,已经停止的旧 execution 显然不可能再“复活”替你清理残局——你不可能指望一个已经宣告死亡的进程去写自己的遗书。 相关的收口逻辑必须由控制 workflow 自身全权承担:通过 chat.update 彻底移除卡片上的操作按钮并追加最终的终止状态;正常的任务完成由原 workflow 自行清理,而面对进程崩溃或超时的异常场景,则交由 error workflow 或后台的定时清理器(GC)进行兜底。

这一切探索最终再次印证了那句古老的工程铁律:对话语义与运行时控制,本质上是两种完全不同的通信协议。

两个协议

留下的不等式

回溯整场关于权限与边界的探索,所有的工程血泪最终都可以浓缩为一句极其不中听的硬道理:

权限规则必须固化在被委派 Agent 自身不可被篡改的底层架构中,而副作用的物理边界,终究要靠工具的视野可见性与凭证控制来进行机械式的强制剥夺。

主 Agent 可以宣称“子 Agent 是只读的”,Workflow 流程图可以标注“子 Agent 是只读的”,架构文档上更可以印上醒目的“绝对只读”——但只要子 Agent 的手里依然握着具备写入能力的工具,这三个所谓的“只读”,就永远只是文学上的修辞手法,而非系统真实的物理状态。

一个真正令人安心的只读子 Agent,其安全感完全建立在一个极其诚实的不等式之上: 约束 Agent“能做什么”,必须依赖工具面的物理隔离;而约束 Agent“该做什么”,才能交给 Prompt 的语义引导。

两道防线只要缺失了任意一道,所谓的“只读”,就不过是一张贴在系统架构上的许愿帖。

最后,我们在工程演进计划中依然保留了一项尚未执行的待办验证:真实诱导测试。我们将分别在仅有 Prompt 约束阶段与引入 Allowlist 物理隔离阶段,对子 Agent 注入极具诱惑性的越权指令,精准统计两个阶段下副作用拒绝率的真实数据对比——架构防线吹得再怎么固若金汤,也必须亲自拿着利针狠扎一下试试。