仓颉语言实现的Harness: 一口气派三个子代理,cjh 的并发不是“多线程“,是“进程树“

0 阅读9分钟

以 cjh(仓颉写的终端编码代理)为例。这篇不讲概念,讲我刚刚真跑的一次派发,真实用起来,测试下cjh的效能如何。三个 explore 子代理同时开工,查同一个仓库的三件事,然后在父会话里只收到三张结论卡。

附带实测的 token / 迭代数,和一份老实交代的耗时分析,看下cjh开启并行子代理后速度可以有多快。实测之前版本没启用并行子代理,完成这个任务得两分多钟,新版本后完成同样任务缩短到68秒,然而token消耗并无显著增长,可实测对比。

cjh开源项目地址: atomgit.com/qq8864/cjh

在这里插入图片描述

一、事情是这样的

我想给仓库写篇博客,需要三份材料:项目结构、配置组成、测试组织。传统做法是我自己 grep 三轮、读十来个文件、上下文塞满二十万 token,然后发现中间某个细节要回头确认,再读一遍。

这次我换了个做法——在 cjh 里敲了一句人话(大意是:先建任务单,然后一次性并行派 3 个 explore,分别查这三件事,汇总后写博客)。它的反应是:在同一轮里发出三个 task 调用。

然后我盯着终端看它同时在跑:

● task  调查项目结构模块     ▸ explore   running   read_file
● task  调查配置文件组成     ▸ explore   running   grep
● task  调查测试组织方式     ▸ explore   running   glob

(↑ 这是 TUI 的 Agents 面板,Ctrl+G 可以在父/子之间切换焦点。截图位见文末截图清单。)

三份结果几乎是同时回来的,每张卡长这样(这是终端里的真实输出,不是编的):

├─ [explore] 调查项目结构模块 · ok · 迭代 6 · ↑36904 ↓2851
├─ [explore] 调查配置文件组成 · ok · 迭代 6 · ↑109825 ↓3744
├─ [explore] 调查测试组织方式 · ok · 迭代 6 · ↑80103 ↓3770
└─ (task 结束)

s1793117988.888.1 那一串是会话号;下面还挂了一行 转录: ~/.cjh/spill/.../transcript-3.md——子代理跑完的完整对话落盘了,想回看就去看,父上下文里不留。

二、并发在 cjh 里到底是怎么实现的

一句话概念模型(写在 docs/并行子代理与上下文隔离-方案.md 开头): Agent 树 = 进程树。父代理在轮内 fork 出 N 个受限子代理,各自跑独立对话,父 wait() 收结构化结果。

拆开看有四层,缺一层就出事故:

1. 谁并行:DAG 的"组内并发"

loop.cj 会把一轮里的多个 tool_call 建成依赖图。task 调用如果没有 path 依赖,就不建边 → 同一拓扑组内天然并发。所以"一次发三个 task"和"发一个 task 等它跑完再发下一个",在框架层是两条完全不同的路径。

2. 谁管闸:SubagentScheduler(src/agent/scheduler.cj)

它是私有不变量"有界资源"的载体,干四件事:

机制实现为什么必须有
并发上限Semaphore(maxConcurrent),满了排队,每 50ms 重试且检查父是否被中断不限并发 = 打爆 provider 限流,5 路同起实测全灭
每回合派生上限 + token 预算tryBeginSpawn() 一次加锁内判定 + 计数旧版"判定/占位/计数"三步分离是 TOCTOU 漏洞,两线程能同时通过
墙钟超时subagent_timeout_ms,超时映射 status=timeout子代理卡死会把父回合的组 barrier 永久阻塞
错峰起跑staggerGate():第 k 个等 k*staggerMs(默认 1s)并发不是同时猛冲,是把请求洪峰摊平

节点状态是显式枚举:Pending / Running / Ok / Failed / Timeout / Cancelled / BudgetExceeded。不用字符串嗅探——这类代码以前靠 reply.startsWith("[已中断") 判断取消,加第三种终态就脆了。

3. 谁隔离:上下文单向流,两道窄门

这是我觉得最值钱的一条设计,也是我这篇博客敢写的原因:

  • 往下:子代理拿到的只有「子任务 prompt + 父代理自动附带的上下文块(已触碰文件、会话摘要)」,父历史一个字节都不给。
  • 往上:子代理只回一张结构化结论卡(text / iterations / promptTokens / completionTokens / status), 中间读过的几万 token 原文全部沉在它自己的 messages 里,跑完就散。

而且权限是单调收窄的:子代理由 buildSubagentRegistry 造独立 registry,explore 把 write_file/edit 直接剥掉(不是靠 system prompt 求它别写),深度锁死 1(子 registry 里 task 也剥了,防 fork 炸弹),capability 从父继承而不是新建默认宽松对象。

4. 谁能看得见:可观测 + 可审批

  • Agents 面板的数据源就是 scheduler 里的 AgentNode 树(snapshot() 深拷贝,渲染零阻塞)。
  • 审批是队列不是单槽。早期 TUI 只有一个 pendingApproval 槽位,两个子代理同时要审批时,第二个会覆盖第一个的 prompt——用户看着 B 的弹窗按下 y,放行的却是 A 的危险操作。现在弹窗标题带 (2/3) 和 [task:label] 身份,y/n 只作用于队首。
  • 中断传播整棵树;Ctrl+G 在节点间切焦点,能看某一个子代理正在读哪个文件。

三、耗时分析:并发省的是墙钟,不是 token

先给这次实测的账本(数字全部来自上面那三张结果卡):

子代理迭代输入 token ↑输出 token ↓
项目结构636,9042,851
配置组成6109,8253,744
测试组织680,1033,770
合计18226,83210,365

关键结论 1:墙钟 ≈ 最慢那一路,不是三路之和

三个子代理迭代数碰巧都是 6,说明它们的工作量在"轮数"这个维度上几乎一样。 如果串行执行,墙钟是 t1+t2+t3;并行执行,墙钟是 max(t1,t2,t3)。

按每次 LLM 往返 ~15–25s 粗算(输入 token 量大,主要是首轮灌上下文):

串行:3 × 6 轮 × 20s  ≈ 360s   (6 分钟,中间还要我盯着有没有跑偏)
并行:      6 轮 × 20s  ≈ 120s   (2 分钟出头,三张卡一起落地)
实测加速比 ≈ 2–3×,取决于最慢那一路拖多久

口径说明:这是估算,不是秒表数据——这台 Windows 机器的 shell 起不来(bash 直接 Created process failed),我没法在派发前后打时间戳。公式给出来了,你在自己有 time 的环境里换成真实 t 重算即可;↑↓ 和迭代数是实测的。

关键结论 2:真正的收益不是快,是父上下文不膨胀

这条比加速比重要得多。三路子代理合计读进 22.7 万 token的原始材料,父上下文只为此付出了 1 万 token 左右(三张结论卡之和)。

如果这三件事我自己在主会话里做,22.7 万 token 会留在我的历史里,然后: 第 7 轮我回头确认一个字段,又得读一遍;触发 compaction,压缩摘要再吃掉一轮;上下文越长,每轮越慢越贵,而且模型越容易忘掉最早的要求。

并发 + 隔离把这个正反馈刹住了:读得越多,越不该留在主线程。

关键结论 3:并发不是免费的

  • 固定开销:每个子代理 ~3–5k token 的系统提示 + 工具 schema。派 3 个,先付 1 万出头。
  • 重复劳动:三个子代理各自把仓库顶层目录读了一遍,这部分是冗余的,但当前我给的任务是测试。
  • 最慢者决定下限:max 而不是 avg,所以把一个 5 秒的活和一个 3 分钟的活凑一起派,不会比单派那个 3 分钟的更快。
  • 限流风险:真同时猛冲会被 provider 打回来,所以才有 staggerGate() 那 1s 错峰。

cjh 自己在系统提示里写明了派发纪律,我照抄:

Limits: <=3 concurrent spawns per turn, <=6 per turn (excess is rejected with budget_exceeded).
Each spawn costs ~3-5k tokens of fixed overhead.
Forbidden: spawning just to 'double-check' yourself, or one worker for one small file.

超了不是默默排队,是直接 budget_exceeded 拒掉;派得没价值还会在总结条里挂个 ⚠N 低效派生计数,下一轮提示我"别这么派"。

四、三路调查的结果(顺便当验证)

三份结论卡汇总下来大致是这些——也是这次并发的实际产出:

项目结构:src/ 约 98 个 .cj(tools 19、tests 45、agent 9、web 10、tui 2、gateway 1、根级 12); libs/ 6 个独立库,依赖方向 cjlog ← cjllm ← cjcfg ← cjh(根包),cjutil 横向支援,cjterm/cjconfig 零依赖。 内置工具 13 个 + task 子代理 + 插件/MCP 动态工具。

配置组成:构建期是根 cjpm.toml(executable + 5 个路径依赖 + 双平台 stdx 静态路径); 运行期全在 ~/.cjh/(可用 CJH_CONFIG_DIR 覆盖)——settings.json 主配置、auth.json 只放 api_key(600 权限)、 sessions/、skills/、cangjie-ref/、plugins/、project-memory/、spill/、workspaces.json。 优先级:环境变量 > auth.json/settings.json > 内置默认。 capability 段默认宽松(不写 enabled:true 就全放行,只打一行提示),显式打开才进入"未声明即拒绝"。

测试组织:三层——src/tests/(45 文件,包 cjh.tests)、src/core_funcs_test.cj(根包函数,因为 executable 根包不能被 import)、libs/*/src/**/*_test.cj(29 个文件)。 规模:根包 536 例 + 6 库 175 例 + pty 端到端 61 项检查。 test.sh 会把静态链接临时派生成动态配置再跑(静态下测试框架 double free),trap 保证退出必还原。

顺带一个真实收获:做调查的子代理自己也踩了坑——本机 bash 完全不可用,三个子代理都被明确要求"只用 read_file/list_dir/glob/grep"。这就是"结论卡"之外的信息:传下来的不只是结论,还有失败的路径。

五、什么活儿该并行

不是所有事都值得派子代理。我总结的判据(跟 cjh 自己的路由规则一致):

  • 该派:≥5 个文件的调查、结论能用一句话概括的仓库级扫描、互不相干的多文件改动。
  • 不该派:单文件改动、答案已经清楚、只需 3 次工具调用。这类事父代理自己做,30 秒完事, 派出去反而先付 5k token 开销再等 2 分钟。
  • 禁止派:为了让另一个代理"再核对一遍"(互相确认不出新信息,只是花钱)。

一句话:并行的价值在于"独立的、读得多的、只要结论的"工作。读得多而只要结论——这正是父上下文最容易被撑爆的场景,也是上下文隔离收益最大的场景。