一个 Subagent 改动,为什么拖住了我们整整一天?
一次 Agent Runtime 重构的复盘:为什么局部变化会扩散,以及怎样让下一次修改停在合理的边界内。
2026 年 10 月 25 日,我们准备继续改进投研 Agent 的 Subagent(子代理)能力。看起来,这是一项围绕子任务执行与交接的改动;实际动手时,却反复碰到恢复编解码、内存存储和 PostgreSQL 存储,后来又牵涉主请求与子任务执行。
这不是线上停机,也不是所有工作都停了一天。被卡住的是共享 Runtime 和 Subagent 的研发主线:修改者需要先弄清哪些地方共同解释同一条规则,其他并行工作也要避开可能互相覆盖的区域。到晚上,第一批调整已经合入主干,我们仍在确定第二天哪些工作可以安全并行。
回看这一天,难点并非某个算法特别复杂。我们把代码分进了不同文件,却没有让每条规则、每份状态都有清晰的归属。 原本方便扩展的接线方式、历史兼容和测试辅助设施,逐渐变成了修改时必须一起理解的隐含契约。
一条规则,为什么会散落在多个模块?
子任务收到用户回复后能否继续,涉及几个本来就不同的问题:回复是否被接受,任务当前是否允许交接,以及这个结果如何可靠保存。随着恢复、人工交互和不同存储实现逐步加入,相关条件出现在恢复编解码和两套存储代码中。
一开始,在离手头数据最近的地方加判断是容易理解的选择:存储知道当前记录,恢复代码知道历史格式,都需要防止非法状态进入下一步。但当它们各自解释同一条业务规则时,维护者修改一个交接条件,就要检查所有副本是否一致。10 月 25 日的两次相关提交都触及恢复编解码和两种存储,正是这种变更扩散的具体信号。
跨模块修改本身不是错误。新增需要持久化的事实,就可能同时改变契约、存储和恢复。值得警惕的是:持久事实没有改变,同一个业务条件却仍要在多个适配器里同步修改。 当时恢复编解码文件已超过五千行;行数不是根因,更关键的是其中同时包含编解码、子任务准入和交接规划等不同判断。
这也是一种浅模块:外面看到的是文件和接口,使用者却仍要了解大量内部条件,才能安全修改行为。Martin Fowler 在讨论模块装配时强调,模块应隐藏调用者不需要了解的实现细节。我们的具体问题进一步落在规则由谁解释:仅仅把判断搬到另一个文件,不能消除分散的决定权。
后续设计将职责划清:人工交互模块提供回复被接受的事实,Child 模块解释子任务的交接规则,存储在原有锁或事务内读取事实并提交结果,Run 生命周期仍决定整轮执行的状态。重复解释收拢,不意味着可以删除事务、并发版本或幂等保护。
防复发规则: 每次新增或修改状态转换,先写下事实来源、唯一的业务解释者和负责提交的模块。检查两种存储是否需要复制同义条件;如果只是各自落实锁、事务和存储约束,可以保留适配器差异。评审时再做一次删除检验:拿掉某个转发或重复判断后,调用者是否仍须自己知道这条规则?如果是,模块边界还没有真正收紧。
共享状态让文件边界失效
当时的 ProductionRunContext 汇集了多组可变运行状态,多个模块拿到完整对象,既能读取其他模块的字段,也依赖初始化顺序。Factory 还负责用户 Memory 的缓存、等待、使用去重和摘要。装配时传递整个上下文很省事:新能力可以直接取到现成信息;但代价是,每个消费者都可能依赖它并非负责的内部状态。
这使得“文件拆开”与“可以独立修改”成为两回事。一个字段的变更可能波及多个调用者,初始化顺序也可能成为没有写进接口的约定。如果团队只按目录分工,不检查谁能写状态,就容易在不同文件里同时修改同一个逻辑对象。
迁移中的一个例子说明了边界有多细:发布模块把候选内容放进私有状态后,对外返回的对象仍共享内部引用。调用者修改预览对象,仍可能改变内部候选内容。字段声明为私有,并不自动阻止外部通过引用修改它。
防复发规则: 状态由负责其不变量的模块持有,对外提供需要的操作和只读事实;检查返回值是否泄漏可变引用,也检查回调绑定、依赖读取时机和构造顺序。接缝(seam)应该让调用者表达“要做什么”,而不是要求调用者知道“先改哪些字段、按什么顺序初始化”。只有确实需要替换实现或隔离职责时才增加接口;不要把整个上下文换名为依赖对象后继续透传,也不要为封装再造一个万能管理器。
这里仍有取舍:收窄接口会增加设计和迁移成本,有些共享的只读事实也确实需要跨边界传递。判断标准是调用者是否必须了解另一模块的状态机,而不是接口越小越好。
旧兼容承诺会悄悄变成永久负担
恢复系统不仅处理当前代码,还要解释过去留下的请求。每保留一种旧读取路径,今后的规则变更就多一个需要理解和验证的分支;但删除读取器又可能破坏真实的待恢复工作。
这次系统尚未正式上线,团队决定退役没有当前消费者需要的旧 pending Main @39、@44 读取路径。同时,仍需读取的原始 HIO @41 和组合 Child @43 封存请求,必须保留原身份、参数和工具描述;绑定变化时应拒绝,不能用当前协议重新包装历史,让它看起来有效。
关键不在版本号新旧,而在承诺的对象是否仍然存在,以及恢复时必须忠实保留哪些事实。未上线也不自动等于没有需要保护的持久记录;项目负责人的“没有真实用户”说明,不能替代对实际持久数据范围的核对。
防复发规则: 增加兼容分支时,记录消费者、需要保留的历史事实、验证方式和退出条件;定期核对实际读取者与持久记录范围。准备删除时,先区分无消费者的开发协议和仍需恢复的封存请求,对后者验证原始身份与绑定,而不是拿当前格式拼出一份貌似有效的旧记录。无法确认实际数据范围时,保留未知状态,不把“没发现”写成“确定不存在”。
测试也会继承已经过期的契约
那天一个 PostgreSQL 场景看起来像交接出了问题:子任务仍在执行,用户回复已到达,测试却失败了。进一步核对发现,辅助代码给整轮执行加了 1.5 秒等待限制;限制先于最终业务断言触发。调整辅助等待方式后,同一组场景通过,而原有阶段控制和业务断言被保留。这支持测试辅助时限导致失败的判断,并不意味着每个类似失败都只是测试问题。
另一些旧测试还在用 type: "web" 创建子任务,而当前入口已改为 agentId。它们在抵达原本要验证的恢复行为之前就被拒绝。此时直接给生产代码加兼容分支,会让一个已经退役的测试输入重新变成产品承诺;反过来,简单删掉失败测试,也可能丢掉仍有价值的恢复、授权和并发保护。
Martin Fowler 对契约测试的讨论指出,测试替身也可能偏离它所代表的真实服务。我们的例子不完全相同,但有相似的问题:测试入口和辅助设施本身也有契约,生产入口变化后,它们需要一起维护。
防复发规则: 接口变化时同时盘点测试生产者、测试数据和辅助工具,使用真实的当前装配方式构造输入。遇到红灯,先确认失败发生在准备、等待还是目标断言;然后按现行契约判断是生产缺陷、测试过期,还是证据不足。迁移测试输入时保留原有业务断言和负向保护,不以放宽断言换取绿灯;异步测试用明确的阶段条件协调时序,并保留合理的外层超时。
并行能力取决于边界,而不只是人数
耦合最终变成了协作问题。为了避免多人或多个开发 Agent 同时修改共享运行状态,当天下午曾把较大范围的 Research Runtime 交给一个负责方,其他工作暂时绕开。这个做法降低了互相覆盖的风险,也让一些不直接冲突的工作被迫等待。后续交接把范围收窄:稳定接口可以继续使用,真正修改相同位置或共享不变量时再协调。
这里的经验不是“多人协作一定制造冲突”,也不是“每个文件只能有一个人碰”。两个改动即使在不同文件,也可能共享同一条状态规则;修改同一文件的不同部分,则未必产生实质冲突。Git 能提示文本冲突,却无法自动判断两份代码是否各自改变了同一项业务决定。
防复发规则: 并行开始前先列出每项工作的规则归属、状态写入点、接口和持久事实,再划定具体写入范围与集成负责人。共享不变量或接口正在变化时,先约定契约和集成顺序;边界稳定的调用方可以继续工作。交接要注明代码基线、未提交修改、依赖和验证范围,集成时检查语义重叠,而不只看能否自动合并。只有实际冲突范围需要串行时才收紧写入,避免把整个系统长期锁给一个人。
把问题留在该由谁解决的地方
这次后续调整收拢了 Child 交接规则,缩小共享 Run 状态和 Factory 对 Memory 内部细节的接触面,也清理了部分旧读取路径。相关定向验证支持这些具体变化;截至 10 月 27 日所核对的记录,完整正式验收尚未完成,因此还不能把局部结果说成整体验收通过,也没有数据证明节省了多少维护时间。
对下一次改动,我们更希望早点发现的是几个可观察的信号:同义条件开始出现在第二个适配器;新模块需要改别人的内部字段;旧协议找不到明确消费者却一直被保留;测试还没到业务断言就失败;并行任务不断扩大彼此的写入范围。
这些信号并不要求立刻重构一切。它们提醒我们在变化进入下一层之前,先确定规则、状态、历史承诺和协作边界。一次局部修改能否保持局部,往往就取决于这些决定。
延伸阅读
《Agent 架构如何做到该稳定的稳定,该变化的变化?》讨论不同模块应承担怎样的演进契约;《测试也会过期》进一步讨论旧测试怎样保留有效保护、退出过期断言。
本文收录于 ETF Agent 工程手记。如果这些工程经历对你有帮助,也欢迎在公开文集仓库点一个 Star。
相应的架构思考见核心原则 2:复杂度应归属正确的 Owner。