练习 20:并行扇出与上限
练习 19 的 sub_agent 一次只能处理一个子任务。如果模型判断一个任务可以
拆成三个互不依赖的子任务,同一轮里连续发起三次 sub_agent 调用,现在
的代码是一个个 execute——而每一次 sub_agent 调用自己就是
一整场多轮的 LLM 对话,三个排队跑,总共要等的时间是三份时间加起来。这一章把
“这一批调用能不能并发跑“这个判断写成代码,用一个有容量上限的信号量
防止判断成“能“之后,扇出多大都不设限,把本机资源或 provider
的并发配额一次性打满。
敲进去
在练习 19 的代码上继续写。先加一个能不能并发的判据:
// maxParallelSubAgents 限制一轮里最多同时跑几个 sub_agent。每一个都会
// 起自己的一整套 provider 连接和多轮对话,扇出多大都不设上限,就是在
// 拿本地资源和 provider 的并发限额去赌。这个数字没有理论最优解,纯粹是
// 部署环境的取舍——本书的玩具 harness 是单机跑,4 只是"够看出并发效果,
// 又不至于把本机或 provider 打满"的一个保守选择。
const maxParallelSubAgents = 4
// canFanOut 判断这一轮工具调用能不能并发跑。判据故意收得很紧:调用数
// 大于一个,且全部是 sub_agent——不是"只读工具都能并发"这种更通用的
// 规则。原因是这本书至今没有给任何工具标注过"只读",bash/write_file/
// edit_file 都会改共享状态(cwd、trash、registry 的 hasRead 记账),
// 混在一起并发执行没人能担保顺序和结果;sub_agent 不一样——它起的是
// 一整个独立的子 agent,自己的 history、自己的 childReg,跟父 agent
// 的注册表没有任何写共享(sub_agent 的参数里没有 path 字段,注册表的
// hasRead 记账不会被它触碰)。这份安全保证只覆盖 history/registry 这层
// 状态——子 agent 内部如果自己调用了需要人工确认的 bash 命令,confirm()
// 读的是同一个共享 os.Stdin,多个 goroutine 同时问会互相冲撞,见这一章
// "常见问题"里的实测记录,这份判据没有、也不打算解决这个问题。
func canFanOut(calls []toolCall) bool {
if len(calls) < 2 {
return false
}
for _, tc := range calls {
if tc.Function.Name != "sub_agent" {
return false
}
}
return true
}
再加实际分发这一批调用的函数——能并发就开 goroutine,不能就照原来的 串行路径:
// dispatchToolCalls 跑完一轮里的全部工具调用,按原始顺序整理成待追加
// 的 tool 消息。canFanOut 为真时用一个容量 maxParallelSubAgents 的
// channel 当信号量:每个 goroutine 先占一个坑位再执行,执行完释放,
// 坑位不够的调用在 channel 上排队——这就是"goroutine + channel 限流",
// 不需要另起一个调度器或线程池。结果按 index 写回一个和 calls 等长的
// 切片,不依赖 map 的遍历顺序,保证 tool 消息和原始 tool_calls 一一
// 对应。不能并发的这一轮(只有一个调用,或混了 bash/write 这类工具)
// 走原来那条串行路径,行为跟练习 19 完全一样。
func dispatchToolCalls(reg *registry, round int, calls []toolCall) []message {
results := make([]string, len(calls))
if canFanOut(calls) {
var wg sync.WaitGroup
sem := make(chan struct{}, maxParallelSubAgents)
var logMu sync.Mutex
for i, tc := range calls {
wg.Add(1)
sem <- struct{}{} // 占坑位;坑位不够就阻塞在这一行排队
go func(i int, tc toolCall) {
defer wg.Done()
defer func() { <-sem }() // 让出坑位给下一个排队的调用
logMu.Lock()
fmt.Fprintf(os.Stderr, "[round %d] %s(%s)\n", round, tc.Function.Name, tc.Function.Arguments)
logMu.Unlock()
results[i] = reg.execute(tc.Function.Name, tc.Function.Arguments)
}(i, tc)
}
wg.Wait()
} else {
for i, tc := range calls {
fmt.Fprintf(os.Stderr, "[round %d] %s(%s)\n", round, tc.Function.Name, tc.Function.Arguments)
results[i] = reg.execute(tc.Function.Name, tc.Function.Arguments)
}
}
out := make([]message, len(calls))
for i, tc := range calls {
out[i] = message{Role: "tool", ToolCallID: tc.ID, Content: results[i]}
}
return out
}
main() 里原来那段“一个个 execute、一个个 append“的 for 循环,换成一行:
if canFanOut(msg.ToolCalls) {
fmt.Fprintf(os.Stderr, "[round %d 并发扇出:%d 个 sub_agent,上限 %d 个坑位]\n",
round, len(msg.ToolCalls), maxParallelSubAgents)
}
sess.History = append(sess.History, dispatchToolCalls(reg, round, msg.ToolCalls)...)
别忘了在 import 里加一行 "sync"。
跑起来
go build -o ex20 .
造三个互相独立的项目目录,一份提到废弃接口,两份没提:
mkdir -p projA projB projC
cat > projA/README.md << 'EOF'
# Project A
这是一个稳定维护的工具库,所有 API 都在积极使用中,没有计划废弃任何接口。
EOF
cat > projB/README.md << 'EOF'
# Project B
注意:`legacy_parse()` 函数已经 deprecated,请迁移到 `parse_v2()`,
下个大版本会彻底移除旧接口。
EOF
cat > projC/README.md << 'EOF'
# Project C
这个项目还在早期阶段,欢迎贡献,暂无废弃计划。
EOF
实验一:三个独立检查,扇出跑一次。
./ex20 "我这里有三个互相独立的项目目录:projA、projB、projC,各自有一份 README.md。请分别为每一个目录派一个独立的 sub agent 去检查它的 README.md 里有没有提到 'deprecated'(废弃)相关的说明,如果有就摘要是哪个接口废弃了。这三个检查任务彼此没有依赖,请一次性同时发起这三个 sub_agent 调用,不要等上一个做完再发起下一个。"
# 同一份代码但没有并发分发的练习 19,作对照
time ./ex19 "……同一段任务原文……"
time ./ex20 "……同一段任务原文……"
实验二:扇出批里混进一个需要人工确认的 bash 命令。 把任务原文换成
明确要求子 agent 用 bash 的 grep(而不是 read_file)去查:
./ex20 "我这里有三个互相独立的项目目录:projA、projB、projC,各自有一份 README.md。请分别为每一个目录派一个独立的 sub agent,要求每个 sub agent 都必须用 bash 的 grep 命令(例如 grep -i deprecated projX/README.md)去检查有没有提到 'deprecated',不要用 read_file。这三个任务彼此没有依赖,请一次性同时发起这三个 sub_agent 调用。"
你应该看到什么
实验一,DeepSeek 一次性发起三个调用(Python 版真机结果):
[round 1 并发扇出:3 个 sub_agent,上限 4 个坑位]
[子 agent '检查 projA README 的 deprecated 说明' 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent '检查 projB README 的 deprecated 说明' 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent '检查 projC README 的 deprecated 说明' 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent '检查 projB README 的 deprecated 说明' 结束:内部消耗约 3284 tokens,……]
[子 agent '检查 projC README 的 deprecated 说明' 结束:内部消耗约 3330 tokens,……]
[子 agent '检查 projA README 的 deprecated 说明' 结束:内部消耗约 3422 tokens,……]
三个 sub agent 已并行完成检查,结果如下:
projA —— 未提及废弃。
projB —— legacy_parse() 已废弃,替代方案 parse_v2()。
projC —— 未提及废弃。
三条“开始“打印挨在一起,“结束“回来的顺序是 B→C→A,跟发起时的
A→B→C 顺序对不上,这正是并发跑的证据:谁先谁后由各自那次 API 请求的
实际耗时决定,跟代码里的调用顺序无关。对照真实耗时:ex19(串行)跑
这个任务约 22.6 秒,ex20(并发扇出)约 14.1 秒——三个子 agent 各自
只需要一到两轮“读文件+回答”,扇出省下的是“排队等前一个“的那部分时间。
JavaScript 版同一个任务,DeepSeek 那次是 24.9 秒(串行)对 22.9 秒 (并发)——同样有加速,但幅度比 Python 那次小;子任务是不是够“重“、 这一轮网络请求排队和调度本身的开销占比多大,两次真机跑出来的数字 不会完全一致,这属于正常的运行时波动,跟语言之间是否有系统性差异 无关。
实验二,混进 bash grep 之后,Python 版和 JavaScript 版在这一步
撞见了一处真实的语言差异。Python(真机线程):
⚠️ 模型想执行: grep -i deprecated projC/README.md
允许吗?(y/N)
⚠️ 模型想执行: grep -i deprecated projB/README.md
允许吗?(y/N)
⚠️ 模型想执行: grep -i deprecated projC/README.md
允许吗?(y/N) [子 agent '检查 projA/README.md 是否提到 deprecated' 结束:……]
三个线程真的在同一时刻抢着往标准输出写提示、抢着从标准输入读一行——
第二条“允许吗?“还没换行,第三条提示已经插了进来,projC 这条命令的
提示甚至打印了两次(一次是它自己的确认,一次是排在它后面等待读取的
下一次尝试撞上了同一段还没被清空的输入缓冲)。
JavaScript 那次撞见的是完全不同的画面——三条提示逐条完整地打出来, 没有任何交叠:
⚠️ 模型想执行: grep -i deprecated projA/README.md; echo "EXIT_CODE=$?"
允许吗?(y/N)
⚠️ 模型想执行: grep -i deprecated projB/README.md; echo "EXIT_CODE=$?"
允许吗?(y/N)
⚠️ 模型想执行: grep -i deprecated projC/README.md; echo "EXIT_CODE=$?"
允许吗?(y/N)
两边最终结果一样——三个 sub_agent 全部因为权限拒绝没能完成检查,
但没有绕过限制去偷用 read_file。过程画面不一样的原因,见下面
“发生了什么”。
发生了什么
“能不能并发“需要显式判断,起了并发单元不会自动安全。
can_fan_out/canFanOut 故意把判据收得很窄:“必须全部是 sub_agent,
多一个都不行”,比“这批调用里没有明显冲突就并发“这种更通用的规则
严格得多。
原因在练习 19 就交代过:sub_agent 起的是一整个独立的子 agent,有
自己的 history、自己的 registry,跟父 agent 的注册表之间除了共享
只读的工具定义,没有任何写共享。而 bash/write_file/edit_file
都会改这个进程共享的状态(工作目录、备份、read-before-write 记账),
这本书至今也没有给任何工具标注过“只读“,没有这层标注就没法证明“这几个
调用放在一起并发跑是安全的“,判据只能收紧到唯一一种已知安全的情况。
限流阀不是防止“跑错“,是防止“跑爆“。 三个子任务同时发起,逻辑上
完全没问题——真正的风险是模型某一轮里发起了三十个 sub_agent 调用,
每一个都要开一整套 HTTP 连接和多轮对话,一次性全冲上去,本地资源和
provider 的并发配额都扛不住。容量为 MAX_PARALLEL_SUB_AGENTS 的限流阀
就是拿来挡这个的:坑位占满,多出来的调用在获取坑位那一行排队,
等前面的执行单元完成、释放坑位——三种语言分别用 Go 的 buffered
channel、Python 的 threading.Semaphore、JavaScript 一个自己写的
异步 Semaphore 殊途同归,不需要另写一个任务队列或线程池。
三种语言实现“并发“的方式,背后是三种真实不同的运行时模型,不只是
同一套机制换了层语法皮。 Go 的 goroutine 是真正的并发执行单元,由运行时
调度到系统线程上;Python 选的是 threading——真正的操作系统线程,
send() 建立在阻塞的 urllib 之上,多个线程在等待网络 I/O 时会释放
GIL,真的能并发地等待多个请求;JavaScript 完全不同——Node 是单线程
事件循环,send() 是 async function,sub_agent 调用之间“并发“的
不是执行,是等待:await fetch() 把控制权交还事件循环,让其他还没
发出请求或还在等回包的任务有机会往前推进,物理上同一时刻永远只有一段
JavaScript 代码在跑。三种模型最终都能让“发出三个请求、等它们各自返回“
比串行发起更快,但底层原理并不相通。
实验二里 Python 和 JavaScript 的画面差异,直接来自这个运行时区别,
代码本身没有写错。 confirm()/askApproval 读的是同一个共享的标准
输入——这本书至今的人工确认机制,从练习 9 开始就是为单线程场景设计的。
Python 版的三个操作系统线程是真的在同一时刻并发执行,其中两个可能
真的同时走到“打印提示、读一行“这几行代码,谁先谁后、谁的输出先落地,
由操作系统调度决定,交叠、错位都可能发生。JavaScript 版则完全不会
出现这种交叠:readLineSync 是一个同步阻塞调用,一旦某个 sub_agent
任务的 confirm() 开始执行,它会独占整个事件循环,直到读到一行(或
判定读不到)才返回——这段时间里其他“并发“的 sub_agent 任务,包括
它们各自的网络请求回调,全部无法推进,因为 JavaScript 是单线程的,
永远不可能有两段代码真的在同一时刻运行。JavaScript 并没有“修好“
这个问题——它只是把“并发“限定在了 I/O 等待期间,一旦某个任务真的在
执行同步代码(哪怕是等一行终端输入),其他任务只能干等着,谁先抢到
执行权完全不可预测,但至少不会在字符层面撞在一起。
常见问题
- 为什么不干脆把所有工具都标成能并发:因为这本书从没做过“标注只读
工具“这一步——没有这层信息,就没法证明一批混杂的调用放在一起跑是
安全的。判据收紧到只认
sub_agent,图的是“能证明安全的最小范围“, 没有奔着“理论上能做到的最大范围“去。 MAX_PARALLEL_SUB_AGENTS为什么是 4,不是更大或者干脆不设上限: 这个数字没有标准答案,纯粹是部署环境的取舍——本机资源、provider 的并发限额、真实任务的扇出规模,都会改变这个数字该多大。不设上限 等于把这个决定丢给“模型这一轮碰巧发起了几个调用“,那就是把决定权 让给了运气,是听天由命。- 并发扇出会不会让父 agent 收到的 tool 消息顺序乱掉:不会。三种
语言的实现都用下标写回一个和
calls等长的数组,每个执行单元只碰 自己那个下标,全部完成之后再按原始顺序拼出 tool 消息——谁先执行完 不影响最终顺序,乱的只是“结束“日志打印的先后。 - 如果子 agent 内部触发了需要人工确认的 bash 命令会怎样:三种语言 都会撞上共享标准输入这个问题,只是表现形式不同——Go 和 Python 是 真正的多执行单元并发,可能在字符层面交叠打印;JavaScript 单线程, 提示会一条条完整打出来,但同样没有任何标记告诉你哪一条提示对应 哪个子任务。这一章没有解决这个问题,留在这里当一个明确的坑:并发 扇出目前只对“全自动、不需要人工确认“的子任务安全。
- 并发扇出一定会更快吗:不一定。云端 provider(这一章测的 DeepSeek)背后有能同时处理多个请求的算力,扇出通常能换来真实的 加速;本机跑的小模型(qwen3:4b-instruct)物理上往往只有一份模型 实例,能不能真的并行取决于本地推理服务本身的调度方式——真机测试 两种语言各自跑出的结果并不一致(其中一次并发反而更慢),问题不在 代码,而在于“并发扇出在代码层面永远成立,但能不能换来真实的加速, 取决于运行这些请求的后端本身撑不撑得住并发“——这句话在这次真机 测试上得到了印证。
加分练习
- 把并发上限改成 1,重新跑实验一,确认耗时退化成跟串行版本差不多—— 这是拿实测验证“限流阀容量决定了并发度“这句话,不靠肉眼猜。
- 给
confirm()/askApproval(Go/Python)或confirm()(JavaScript) 加一把互斥锁,重新跑实验二,看提示是不是不再交错(Python/Go)或者 行为有没有变化(JavaScript 本来就不会交叠)——注意这只解决“打印 交错“,没解决“三个子任务各自在等谁批准“这个更深的问题。 - 让父 agent 一次发起 8 个
sub_agent调用(比如让它检查 8 个不同的 目录),观察“并发扇出“那行日志和实际的“开始“打印数量,确认同一 时刻正在跑的子 agent 数不会超过设定的上限。 - 用计时器分别测三个子任务从“轻“(读一个文件回答一句话,这一章的 例子)到“重“(让每个子 agent 自己再多跑几轮,比如先列目录再读文件 再总结)的并发收益变化,验证“子任务越重,扇出收益越明显“是不是 站得住,三种语言分别测一遍,比较运行时模型的差异是不是也会影响 这条曲线的形状。