Article
给 Agent 加了子代理,为什么它还是一直自己读代码?
前几天,我让 Orca 修一批问题。计划已经列好了:恢复与队列、结果补发、权限作用域、Skill 缓存、子任务所有权,还有流式重试。
它开始逐个读文件。读到流式重试时,又一路查进 AI SDK 的类型声明和实现,确认 fullStream 的错误到底放在 error 还是 errorText 上。每一步单看都有道理,但屏幕已经滚了很久,计划仍停在“读取问题相关代码”。
我当时问了一句:前面需要读这么多内容,为什么不派出 general?
Orca 已经有子代理了,也有后台运行和进度事件。可工具出现在列表里,并没有让这次调查自然地并行起来。
这轮修改从那里开始,最后变成一次跨十几个模块的重构。原因是我没法只改一个数字:把并发上限从 6 提到 32 之后,排队、等待、预算、期限、取消、恢复、结果交付,每一个都得跟着给出确定语义。任何一个留在旧模型里,新的容量就只是纸面上的。
这些改动随 v0.4.31 发布。下面那张对比表是同一台机器上、带与不带这些改动的两次单独运行。它回答的是“哪条路径被截住了”,不是加速比。想换算提速,得先看旧版那一列到底跑完了没有。
连续读文件,有时确实不该拆
回头看那段 AI SDK 调查,不能只凭读取次数多,就判断应该交给子代理。确认类型、核对运行时对象、追踪流的构造,这几步有先后依赖。如果下一步必须等上一步的结论,换一个 Agent 接着读,只是多了一次交接。
但整张问题清单不同。权限继承和流式错误转换可以分别调查,Skill 缓存也不需要等队列恢复读完。主代理可以保留当前修复的关键路径,同时让几个子代理去回答有明确边界的问题。
这影响了我后来给委派写的指导。笼统地说“善用并行”不够,交出去的任务还得足够具体。比如“检查恢复机制”太宽;“追踪任务已接受、worker 尚未启动时进程退出,重启后会不会丢任务或重复执行,给出代码位置”,就更容易得到可整合的结果。
后来这条经验落成了配置项。委派策略现在有三档:
[subagents]
delegation = "adaptive" # off / explicit / adaptive
off 不允许委派,explicit 只在模型明确点名角色时派出,adaptive 交给模型按任务判断。默认是 adaptive,也就是把“该不该拆”交给最了解上下文的那一方,但容量、权限、预算和停止条件仍由 runtime 检查。
角色也要和实际能力对上。真实测试发现,Explorer 的工具集没有 bash,提示词却可能带着面向 shell 的通用指导。现在系统提示词按生效工具集生成,只展示它实际能调用的工具;只读角色另有一套证据预算。普通并行调查直接用 subagent,不再绕去生成一份没有阶段依赖的 Workflow。
提交了就不该失败
旧模型里最别扭的一点:容量满了,subagent 直接返回工具失败。模型看到失败会做什么?换个说法再提交一遍,或者干脆自己读。两种结果都在浪费。
现在容量不足时正常返回,带一个稳定的句柄:
{
"task_id": "task-42",
"status": "queued",
"wait_reason": "execution_capacity",
"scope": { "running": 32, "limit": 32 },
"message": "任务已接受,空出执行名额后自动运行;无需重复提交。"
}
不把 queued 报成失败,也不虚报成 started。这两条同样重要:报失败会让模型重试,虚报 started 会让它以为可以立刻去读结果。
排队期间真的什么都不做。不启子进程,不复制完整历史,不创建 worktree,只保存启动所需的材料和源码基准引用。256 个排队任务如果每个都预先铺开工作区,光磁盘和内存就先撑不住。真正启动那一刻,再核对一次授权和工作区前提是否还成立。
只有两种边界才拒绝,而且明确 accepted=false、没有创建任何任务:队列满,或存活任务数到顶。拒绝时附上现有任务的观察入口,让调用方有确定的背压方向,而不是干等。
一个事务决定谁能跑
第一版我用了共享原子计数:读一下当前有几个在跑,没满就加一。这个写法在单线程里没问题,但准入要跨线程、跨进程,还有一个持久的作用域文件锁要一起考虑。读和加之间只要有人插进来,上限就是假的。
现在容量检查、执行 lease、预算预留和启动意图作为一个持久事务提交。要么四样都成立,要么什么都没发生。越界拒绝不会留下半条记录。
派发顺序是固定的:校验可运行条件,提交启动意图与预留,按 attempt 身份启动,worker 确认,然后才进入执行。
这里有一个必须承认的边界:进程启动和持久事务没法组成一个原子操作。事务提交成功,不证明进程只启动了一次;反过来,启动状态不明时也不能直接重跑。所以中间用 attempt 身份、owner epoch、启动回执和恢复核对来收敛,不能假装它原子。
同一轮里还有个细节:同一个 tool-call 的重试,通过 runtime 生成的提交身份返回原来的 task_id;但两次不同的调用即使 prompt 一模一样,也不自动当成重复任务。按文字去重会误杀,同样的调查用在不同的工作区状态上,结论可能不同。
排队也要讲公平
32 个名额被谁占着,是有讲究的。
调度按父分支轮转,同一分支内先来先服务,新启动和恢复执行共用这套策略。不这么做的话,一个循环派单的父代理可以把 32 个名额全吃掉,别的分支永远排不上。
参与选取的只有当前可运行的任务。预算预留暂时不可用、provider 正在冷却、依赖还没满足的,各自保留具体的等待原因,但不挡着其他任务先跑。
运行中的任务不会被打断来满足时间片。抢占只发生在模型或工具结算之后的安全边界上,有竞争分支时,它让出名额并重新排队。不能在模型请求还没结算的时候抢,更不能为了凑公平随手杀掉一个正在执行的工具。
排序完全由 runtime 决定,模型不能自报“我这个最高优先级”。恢复队列另有老化规则,避免某一类任务持续压着另一类。
provider 返回 429 之类的限流信号时按退避处理,等待原因记为 provider_rate_limit,和容量等待区分开。前者等的是外部,后者等的是自己。
最后一条是心态上的:32 是配置容量,不是派单目标。实际利用率如实显示,不要求占满。
32 个名额,现在算在一个作用域里
我对照了本机的 Grok、Codex 和 Claude Code 实现。它们给我的参照并不相同:Grok 的普通子代理默认上限是 32,满额排队;Codex 有独立的驻留管理路径;Claude Code 的 Agent 工具已经包含后台执行和进度处理。
改动之前,这个上限的默认值是 6,键名是 max_parallel。不过它当时只是单次工具轮内同步子代理的批量窗口宽度,异步子代理根本不受它约束;超过窗口的提交既不排队也不失败,只是留到下一轮再发。缺的不是一个更大的数字,而是一个所有入口都认的容量概念。
旧设计更麻烦的地方在于,不同执行入口各有一部分计数逻辑。普通委派、嵌套委派、Workflow 里的模型调用、托管子线程、UI 继续执行,如果分别拥有自己的池,配置上的上限就很难解释了。现在它们共用一个执行作用域,统一申请名额,max_parallel 也随之更名为 max_running:
[subagents]
max_running = 32
max_queued = 256
max_live_tasks = 512
max_depth = 2
三个上限是分开的,各有各的边界。max_running 计算正在持有执行 lease 的子任务;max_queued 约束已接受但尚未启动的新任务;max_live_tasks 还包括等待子代理、等待输入等未结束的任务。等待可以释放执行 lease,但会话和调用栈仍可能留在内存里,只限制 running 数量会漏掉这些资源。
重启语义也按这个区分。已接受但未开始执行的任务,按原 task_id 重新排队;已经开始执行却丢失终态的任务标记为 indeterminate,不自动重放。它可能已经修改文件或执行过外部动作,缺少回执不能证明什么都没发生。
用阻塞任务做的调度测试里,100 个任务进入系统,32 个执行、68 个排队;释放一个名额,只补进一个。这验证的是 dispatcher 的容量控制。后面的真实模型测试最多启动八个子代理,没有验证 32 路模型请求的吞吐。
作用域的单位是一棵根任务树,子任务挂在发起它的那个 agent 下面。根主代理不占这 32 个名额,独立根会话各有自己的作用域;模型服务的请求限制和宿主资源仍会约束实际并发。
父代理等待子代理时,还占着名额吗
把并发上限调大以后,有个问题不会自动消失:32 个子代理各自再派一个下属,然后都占着名额等结果。后代全在队列里,没人能开始。
这个场景在旧版其实根本不会发生——内置的 general 当时工具集里没有 subagent,子代理不能再派单。这一版才把嵌套打开,同时保留 max_depth = 2 的深度限制。能力放开的同一刻,等待语义就必须跟上,否则新开的嵌套路径一压满就会死锁。
Orca 现在允许父代理在安全等待时释放执行 lease。这里的“安全”有具体条件:已经启动的工具调用先结算,对话检查点先保存,依赖等待先登记,然后才能让出名额。子代理返回后,父代理重新进入就绪队列,拿到名额才能继续调用模型。
不能看到 UI 显示 waiting 就直接减计数。假如父代理还有一个工具在跑,名额已经给了别人,实际执行量就超过了配置。等待图本身也要拒绝自身、祖先和循环。否则一个子代理等待自己的后代,就是一个永远不会有人来解的结。
同一轮也处理了命令的时间语义。现在 bash 是唯一的启动入口,由 TerminalService 托管,结果返回统一的任务信封(state / exit_code / return_reason / termination_reason / next_cursor);命令和子代理的后续观察统一走任务工具,yield_time_ms 表示多久后把控制权交回,显式的 timeout_ms 才表示执行期限。一次 task_wait 等待超时,也不等于取消被等待的任务。
工具集也因此收敛了。subagent_status 被移除,观察和干预统一为 task_list / task_wait / task_read_output / task_send_input / task_stop;模型侧的 mode: sync | async 参数一并删掉,提交协议只剩一种,由 runtime 决定走哪条路。原来的 exec_command 和 write_stdin 也换成了 bash 加这套任务工具。这四条都写在 v0.4.31 的 Migration 里,属于破坏性变更——max_parallel 只是其中第一条。这套划分的依据记在 ADR 0009。
结果交付必须有顺序。子代理完成以后,结果先进入持久 outbox,走 claim → inject → ack:父会话写入结果并保存检查点后,才确认已经收到。否则进程恰好在“通知成功、对话尚未保存”之间退出,子代理明明做完了,父代理恢复后却看不到。subagent_message 在每个安全模型边界读取并确认,也是一种分离式回推——父代理不必为了等一条消息而空转。
预算同样跨进程持久。child_budget_ledger 先预留再结算:子任务先结算实际消耗,然后才发布终态;缺失账单时保留预留,而不是按零消耗退款。
三种时间不是一回事
等 CI 的时候出现过 shell command timed out after 120s,模型只能换工具或改轮询方式绕过去。当时把三件不同的事混成了一个超时,现在拆开。
task_wait 的 timeout_ms 控制一次观察等多久把控制权交回,默认 30 秒、最长 60 秒。它结束的是等待,不取消任务。这两件事以前缠在一起,模型因此不敢等太久。
任务自己还可以设一个绝对截止时间 deadline_at,默认不设。它包含排队和挂起的时间,恢复之后不重置,后代也不能突破祖先的期限。到期即请求停止,但完成状态要等执行真正静默下来才算,一个不可中断的外部操作没法保证瞬间停住。
耗时也各有归属。运行、排队、挂起分别统计,各自有明确的预算维度。不能把一个 wall-time 字段在恢复时改成只算活跃时间,那样账就对不上了。
通用子任务仍然没有隐式硬超时。硬超时省事,但会在任务快做完的时候把它砍掉。只有内置只读角色有单独的证据收敛协议,后面会说。
钱要留给收尾的人
预算这条我在第一版里做错了:给每个子任务预留一整份长期执行预算。256 个排队任务一来,钱就被锁死了,全都还没开始跑。
改成从同一个根预算派生,入队不预留整份,只在执行准入和每次模型调用之前按有限额度预留。判定用的是“已结算消耗 + 所有在途预留”,启动和恢复都不能绕过。预留、返还、结算各自带 attempt 或 request 身份,重放不会重复扣款。
预算暂时被其他在途请求占着,就等它结算;已经确定耗尽,就产生明确的 budget stop,而不是挂在容量队列里无限等下去。那是两种完全不同的失败,混在一起会让人查错方向。
有一条留给我自己:有限费用预算下,默认把本次操作额度的 10% 留给根代理整合,比例可配置。不这么做的话,子代理把所有额度花光,最后一个报告都写不出来。其余额度共享按需使用,不预先切成 32 份,切了就有任务在闲置别人的份额。
已完成、失败、取消、恢复四条路径都要结算实际用量。根上显示自己和后代的消耗分项及合计,不能先把子总量合并、再把同样的明细重复加一遍。输入 token、输出 token、缓存命中、工具次数、模型耗时、排队耗时分别记录;上下文占用不冒充累计消耗,未知的实际费用明确标成未知。
供应商给不出足够的计费约束、或者用量缺失时,就保持未结算和保守预留,不伪报一个精确的硬预算。宁可显示“不知道”,也不显示一个看起来很确定的错数。
顺带说一句:当前“有费用预算时不接受后台执行”的那层保护,要等持久预留和恢复结算验收通过之后才解除。目标协议统一了,但不能用删掉保护来代替闭环。
取消之后不能再复活
取消最怕两类意外:已经停下的任务被一条迟到的完成通知唤醒,以及 owner 失联后替代进程越额启动。两条都得显式挡住。
取消根任务时原子设置取消 epoch:排队中的子任务直接取消,活动的后代收到停止信号,而派发事务必须再验证一次这个 epoch。最后一句是关键,事务开始之后、进程启动之前,取消可能刚好落地。
已经停止的任务不会因为一条迟到的完成通知或者队列唤醒而复活。这条听起来理所当然,但在事件驱动的运行时里,需要显式挡住。
运行资源只在确认执行结束之后释放。owner 失联或 lease 到期,触发的是核对和停止旧 worker,而不是直接认定 OS 进程已死、放替代进程进来,那会变成超额启动。无法确认的外部副作用保留 indeterminate,不猜。
重启时从任务注册表重建队列,保持提交顺序、任务身份、预算预留、owner 和 attempt。已经接受的恢复任务不会被新配置里更低的队列上限驱逐出去。下调 max_running 时停止新启动,但不是把已有任务一脚踢开。
角色可以自己写,但只能收窄
前面反复提到 Explorer、CodeReviewer,这些是内置角色。但真正让子代理好用的,可能是我能自己定义它们。
现在可以放一个 Markdown 文件到 ~/.orca/agents 或者项目里的 .orca/agents,前置元数据只认五个字段:
---
name: audit
description: 审计一个模块的边界与不变量
extends: general
tools: [read_file, glob, grep]
model: deepseek-flash
---
这里是这个角色的系统提示词正文。
extends 省略就继承 general,tools 只能做交集收窄,不能借自定义角色拿到父角色没有的能力。除此之外的字段(比如 permissions、isolation)会被直接拒绝,而不是忽略。项目目录覆盖用户目录,同名在内置角色上则不被允许。
真正重要的是冻结:文件只在发现阶段读一次,之后随启动记录持久化,执行期间不再回头看文件。否则我在一个子代理跑到一半时改那个 Markdown,它的行为就会中途变化,调试起来是场噩梦。
结果怎么交回父代理
交付最容易出问题的地方,是“通知成功”和“对话已保存”之间的那道缝:进程恰好死在这里,子代理明明做完了,父代理恢复后却什么都看不到。所以结果不直接推给父会话,而是先进持久 outbox,走 claim、inject、ack 三步,父会话先领取,写进对话,保存检查点,然后才确认收到。如果确认这一步失败,比如父对话写不进去,claim 会被释放,结果留在原地,下一轮再领一次。
确认时会校验结果版本和领取身份,所以一条重放的确认不会把更新的结果标成已交付。领取也有宽限期,领了太久没确认的可以被重新领取,避免某个 worker 卡死后结果永远送不到。
另一个变化是推而不是拉。以前模型得自己轮询状态;现在 subagent_message 在每个安全的模型边界被读取并确认,父代理不必为了一条消息空转。
子代理都做完了,父代理又读了几十次
接上这些路径后,真实模型测试仍然超时。
测试固定使用同一份待调查代码、deepseek-flash 和隔离的 ORCA_HOME,旧二进制与修改后的二进制分别运行。在中间复测里,架构审查出现过 300 秒超时,测试缺口和崩溃窗口调查也出现过 240 秒超时。
查日志后发现两段长尾。只读子代理可以不停扩展调查范围;即使子代理都结束,父代理拿到报告后仍然会重新搜索、读取,继续调查已经委派出去的内容。并行工作完成了,整体任务却没结束。
再补一层:v0.4.28 起,子代理的执行身份不再由父代理的审批模式重新推导,而是直接继承父代理已提交的那份 execution_profile。而且每个派发点抓一份不可变快照——用户在会话中途放开权限,已经跑起来的子代理不会被放宽,切换之后新建的才会继承。这条我一开始没往”容量”上想,实际是同一类问题:谁在什么时候决定这份权限,必须有一个明确的时刻和一份可追溯的快照。
所以我给内置 Explorer 和 CodeReviewer 加了一个独立的调查预算:默认 6 个模型回合,或 8 次已开始的工具调用,达到任一个条件就转入最终报告。它们也可以在到达预算之前正常完成。
Explorer 是这一版新加的只读角色,工具只给 read_file、glob、grep、git_status 四个,写和执行被显式排除,提示词第一句就是 “You locate and explain code; you do not change it.”,交付物限定为直接结论、path:line 证据和未解决的问题。它和另外两个只读角色的差别在工具选择上:CodeReviewer 用 list_files,Explorer 用 glob。
[subagents]
max_investigation_turns = 6
max_investigation_tool_calls = 8
转入报告时,runtime 关闭尚未开始的工具调用,保存检查点,清空下一次请求的工具列表,让模型根据已有证据给出结果、代码位置和未解决的问题。这样“停止调查”才落实到了执行路径。
但工具关掉以后,报告本身也可能拖很久。SSE 持续输出思考片段时,只看流是否空闲,超时就可能一直触发不了。最终报告因此又加了 1024 token 上限和 90 秒总截止时间;无法完整结束时返回 partial,把不完整状态交给上层。
这些值是当前的工程取舍,还没有证据说明 6 轮、8 次调用适合所有仓库。范围很大的代码审查可能需要更多证据,1024 token 也可能压缩掉细节。配置可以调整,报告里的未解决问题必须保留。
父代理的规则更窄:只有用户同时明确要求“只读”和“禁止修改”,并且直属子代理已经全部结束、结果已经交付,才进入同样的受限汇总阶段。实现任务仍可以先让子代理调查,再自己修改代码。
目前对这两个用户意图的判断还是关键词匹配,不能当作完整的语义识别。General 和自定义角色也没有被统一套上这套只读调查上限。这里我宁愿把适用范围写清楚,再看更多真实任务里的误判和漏判。
四个文件的实验,最容易看清省掉了什么
下面是最终运行记录。前三个旧版场景的外部测试护栏为 600 秒,优化后最终复跑为 180 秒。超时意味着该次运行没有完成,不能把 600 秒当成旧版的完成耗时来计算加速比。
| 调查任务 | 旧版 | 优化后 |
|---|---|---|
| 八个区域的架构审查 | 600 秒超时;5 个子代理;52 次工具调用 | 149 秒完成;8 个子代理;46 次工具调用 |
| 四个测试领域的缺口调查 | 70 秒失败;7 个子代理;32 次工具调用 | 70 秒完成;4 个子代理;32 次工具调用 |
| 五个崩溃窗口的故障追踪 | 600 秒超时;6 个子代理;134 次工具调用 | 105 秒完成;4 个子代理;32 次工具调用 |
| 只读一个文件 | 4 秒完成;1 次工具调用 | 2 秒完成;1 次工具调用 |
| 明确分配四个文件给四个 Explorer | 46 秒完成;29 次工具调用 | 28 秒完成;5 次工具调用 |
最后一组最容易解释。任务明确要求四个 Explorer 各读一个文件,每人最多返回两条带位置的结论,由父代理整理成四行表格,不附加结构化输出 schema。
优化后的五次工具调用,恰好是四次 subagent 和一次 task_wait。旧版除了提交任务,还用了 14 次 subagent_status、一次 sleep 和额外搜索,期间出现过一次无效的 glob 参数。那个状态查询工具现在已经不存在了。
这组输入 token 从 175,524 降到 32,575,输出从 6,278 降到 1,829。事件记录能解释一部分差距:父代理少了轮询和额外搜索。这比单独展示“耗时下降 39%”更有用。
其他场景没有这么整齐。八区域架构审查从超时变成完成,输入 token 却从 769,766 增加到 877,173,输出也从 10,217 增加到 26,005。最终八个分支都完成了,做的工作更多,不能再把它写成“更快、更省”。单文件控制的输入同样略有增加,4 秒与 2 秒的单次差异也不足以证明小任务性能改善。
五个崩溃窗口的输入从 10,394,037 降到 405,274,差距很大,但旧版是在超时前累积了大量调查。这说明这次限制截住了长尾,尚不能证明同等质量下普遍节省 96% 的 token。这些输入数是记录的累计 usage,不是一次请求的上下文长度,也没有换算成实际账单。
改完之后跑了全量测试,和改动前的基线逐条比对,没有新增失败;残余的失败在基线上同样存在,集中在宿主的 sandbox 与执行环境。但这只能说明代码路径没坏——报告是不是漏了问题,测试答不了,这正是我下一步要做的。
明确没做的那些
一次重构如果没有取舍清单,很容易被读成“什么都做了”。这一轮明确放弃或推迟的:
| 方向 | 决定 |
|---|---|
| 嵌套深度 | 保持 2。先打通被授权角色的委派工具和 runtime 门禁,提高深度留到评测之后 |
| 结果复用 | 不做“相同 prompt 即命中”的最终结果缓存。代码、权限和工作目录都可能变,按文字命中很容易过期 |
| 上下文继承 | 默认全新上下文;可追溯的有限历史继承留作后续,且要保留工具调用配对和明确子目标 |
| 调度粒度 | 长工具无法安全抢占时,不承诺严格的秒级公平 |
| 进度界面 | 改善现有事件和界面,不重写流式系统 |
| 写冲突 | 需要并行修改同一区域时用 worktree,主代理负责整合与冲突处理 |
| 预算保护 | “有费用预算时拒绝后台执行”暂时保留,等持久预留和恢复结算验收通过再解除 |
加的是停止规则,不是更准的委派
回头看,这轮加的三样东西本质上是同一类:调查预算、最终报告上限、只读汇总,都是停止规则。它们让任务结束,但没有让委派变得更准。
真正的解法可能在更前面:如果派出去的问题足够具体,子代理本来就知道什么时候该停。文章开头那个例子就是反证,“检查恢复机制”这种问法,谁都停不下来。预算是兜底,治的是症状。
我暂时选兜底,是因为没法保证模型每次都问得够具体。但如果后面发现覆盖率下降的案例,集中在那些问题本身就很宽的任务上,那要改的就是委派的那一侧,而不是把 6 轮调成 12 轮。
眼下能确认的只有一件事:四个文件的任务从 29 次工具调用降到 5 次,不再轮询十四次状态。至于八个区域的审查查得够不够,这张耗时表答不了,得逐条看报告里引的代码位置撑不撑得住结论,有没有把 partial 当成确定答案。
Keep Reading