跳到主内容
创建时间:最后更新:作者:白川

子智能体的设计取舍

Pi 官方明确不做子代理和 plan mode。这篇讲清背后的设计判断:Pi 提供的是十三个可组合的原语而不是成品,多智能体完全由工具机制涌现,durable 是官方唯一的一等公民多智能体机制。

子智能体的设计取舍

你想让 Pi 派一个子代理去查文档、另一个去改代码、第三个去跑测试,然后自己只看结论。这个需求几乎所有人第一次用 Pi 都会提。

Pi 的回答是:不做。

这不是能力缺失,是一个明确的设计决定。官方 README 里有一句话直接把这条边界划出来了:

Pi ships with powerful defaults but skips features like sub-agents and plan mode. Ask Pi to build what you want, or install a package that does it your way.

—— README.md:19

同一份 README 的上一段还说了另一句更根本的话:

Adapt Pi to your workflows, not the other way around. Customize Pi with extensions, skills, prompt templates, and themes. Bundle them as Pi packages and share via npm or git.

—— README.md:17

两句连起来读,答案就出来了:Pi 提供原语,成品交给生态。 子代理不是一个功能,是十几种可能的编排形态的统称——同会话共享、fork 子会话、独立进程、git worktree 隔离、持久化任务图、judge 循环、外部验证 gate。官方要在内置里挑一种,等于替所有人做了决定;不挑,把决定权交出去,反而每个场景都能拿到最优解。

但"交出去"不等于"什么都不给"。这篇讲清三件事:Pi 到底给了哪些原语、这些原语怎么拼出多智能体、唯一一个被官方当成一等公民的多智能体机制是什么。

前置知识:知道 Pi 的扩展机制和 Pi package 的概念(第十章第1 篇),以及工具调用的基本闭环(第三章第1 篇)。

一个只有 Agent 的运行时

先说事实。packages/agent/src/agent.ts:194 有且只有一个类:

typescript
export class Agent {
	private _state: MutableAgentState;
	private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();
	private readonly steeringQueue: PendingMessageQueue;
	private readonly followUpQueue: PendingMessageQueue;
	// ...
}

整个 pi-agent-core 里没有 "multi-agent" 这个抽象。 在 packages/agent/src/ 全目录 grep subagent、multi-agent、multiagent,零命中。

这不是漏写。回头看 packages/agent/src/types.ts:478:

typescript
export interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
	/** Human-readable label for UI display. */
	label: string;
	prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
	outputSchema?: object;
	execute: (
		toolCallId: string,
		params: AgentToolParams<TParameters>,
		signal?: AbortSignal,
		onUpdate?: AgentToolUpdateCallback<TDetails>,
	) => Promise<AgentToolResult<TDetails>>;
	replay?: "never" | "safe";
	executionMode?: ToolExecutionMode;
}

AgentTool 就是全部。一个"子智能体"在 Pi 里就是一个工具——它的实现是一个函数,函数内部要么再开一个 Agent,要么 fork 一个子进程,要么调一次远程 API。对模型来说,"派个子代理"和"读一个文件"是同一类东西:一次工具调用。

这个设计有一个直接后果:多智能体的所有难题,最终都落在工具机制上。

  • 上下文隔离怎么办?→ 工具内部自己决定传什么给子进程。
  • 并发怎么限?→ 工具内部维护信号量。
  • 完成怎么判定?→ 工具返回什么,主会话就信什么。
  • 子代理的工具权限怎么管?→ 见下面的 runToolCall()。

理解了这一点,就不会再去官方文档里找"多智能体 API"了——它不存在,因为拼图块全在工具这一层。

十三个原语

Pi 给多智能体准备的东西散在不同的包里。这张表把它们按在多智能体链条上承担哪一环排开:

#原语出处链条上的一环
1
工具调用
types.ts:478
委派:子智能体 = 一个工具
2
runToolCall()
agent-loop.ts:821
权限穿透:工具内部调别的工具,钩子照样生效
3
权限钩子 beforeToolCall / afterToolCall
types.ts:326,341
管控:能不能调、调完怎么改
4
终止提示 terminate: true
types.ts:73,103,445
收敛:批次提前停
5
事件总线(10 个事件)
types.ts:528
观测:编排层看得见内部进度
6
steer / followUp 队列
agent.ts:308,313
干预:运行中改变子智能体方向
7
codemode 沙箱
packages/codemode/README.md
编排即代码:把编排写成 JS 跑在 QuickJS
8
MCP
packages/mcp/
能力复用:子智能体复用外部工具
9
durable 持久化
packages/durable/README.md:712-802
归属:谁拥有谁、崩溃后怎么恢复
10
env 远程执行
packages/env/README.md
位置:子智能体在远端机器跑
11
RPC 协议
docs/rpc.md
跨进程:父进程驱动另一个 pi
12
JSON 事件流
docs/json.md
单向管道:--mode json -p 拉结构化结果
13
SDK
docs/sdk.md
进程内嵌:直接嵌一个 Pi 会话

这张表最重要的信息是它的形状:13 个原语里,只有一个(原语 9)自带"多智能体"语义。 剩下 12 个都是通用机制,多智能体只是它们的一种用法。

换句话说:Pi 提供的每一块积木都是方的,具体的房子由你拼。

原语一:runToolCall()——最容易被忽略的那块

这一条值得单独讲,因为它决定了多智能体系统安不安全。

直觉上,"子智能体内部的工具调用"是子智能体自己的事,父会话管不着——子代理就是个黑盒进程,它爱调什么调什么。

Pi 的答案是不允许出现这个黑盒。 packages/agent/src/agent-loop.ts:812:

typescript
/**
 * Run one tool call through the same steps as a model-issued call: argument preparation, schema
 * validation, `beforeToolCall`, execution, and `afterToolCall`. Emits no events and adds no
 * messages. Tools that call other tools use this so the hooks (for example permission checks)
 * apply to those calls too.
 *
 * Never rejects for tool failures: unknown tools, validation errors, blocked calls, and thrown
 * errors come back as `isError: true`.
 */
export async function runToolCall(toolCall: AgentToolCall, options: RunToolCallOptions): Promise<AgentToolCallOutcome> {

官方 README 把这条写成了多智能体场景的直接结论:

@earendil-works/pi-mcp connects to MCP servers and @earendil-works/pi-codemode runs model-written JavaScript that calls tools. examples/mcp-codemode wraps both as AgentTools: one tool per MCP tool, and a codemode tool whose scripts call the agent's tools through runToolCall(), so beforeToolCall and afterToolCall apply to those calls too.

—— packages/agent/README.md:515

"so beforeToolCall and afterToolCall apply to those calls too" 这半句是整篇最重要的话之一。 它意味着:

场景有没有被父会话的权限钩子管住
父会话直接调 bash
管住
子智能体(工具内部)调 bash
管住
codemode 脚本里 tools.bash(...)
管住
MCP 工具内部再调别的工具
管住

多智能体系统里最危险的那条路径——"子代理偷偷绕过权限检查"——在 Pi 的机制下不成立。 因为子代理调工具只有一条合法路径:runToolCall(),而这条路径强制经过同一套 beforeToolCall / afterToolCall。

代价是你必须用对姿势:子代理内部要调工具,就用 runToolCall(),不要自己 tool.execute()。后者绕开了参数校验,也绕开了钩子。这条区分是"Pi 原生多智能体"和"自己糊出来的多智能体"在安全性上的分水岭。

原语二:steer / followUp——运行中干预

packages/agent/src/agent.ts:307:

typescript
	/** Queue a message to be injected after the current assistant turn finishes. */
	steer(message: AgentMessage): void {
		this.steeringQueue.enqueue(message);
	}

	/** Queue a message to run only after the agent would otherwise stop. */
	followUp(message: AgentMessage): void {
		this.followUpQueue.enqueue(message);
	}

两个队列的语义差别是注入时机:

队列注入时机典型用途
steer()
当前助手回合结束后、下一轮请求前
"停一下,改成查数据库"
followUp()
agent 本来要停的时候
"再补一轮验证"

队列的排空模式由 steeringMode / followUpMode 控制,默认 "one-at-a-time"——一次只取一条。改成 "all" 则一次全灌进去。

为什么这对多智能体重要? 因为多智能体的难点经常不是"派出去",而是"派出去之后发现方向错了"。如果子智能体是一个只能等待的黑盒,你只能在它跑完之后说"不对,重来",成本是一整轮。

steer() 提供的语义是:子智能体跑到下一个回合边界时,你能插一句话进去。 它不改写已发生的历史,但改变接下来的走向。

这也是生态里那些包把"中途转向"做成独立工具的原因——比如第十章第 5 篇讲的 @tintinweb/pi-subagents,它的 steer_subagent 工具就是在包一层这个机制。

原语三:durable——官方唯一的一等公民多智能体机制

前两个原语是通用机制。durable 是唯一一个把"谁拥有谁"当成一等语义来定义的官方机制。

packages/durable/README.md 有一节标题就叫 ## Abort and Subagents(:712)。它先描述了一个"拥有"关系:

A conversation can be owned by a task. A subagent tool creates its child inside api.commit() with ownership: { kind: "task", taskId: api.taskId }, then drives it through api.conversation(id).

然后是这个包的完整 subagent 工具实现(:718-745):

typescript
const Subagent: Extension = defineExtension({
	name: "subagent",
	tools: [
		defineTool({
			name: "subagent",
			description: "Delegate a self-contained task to a subagent and get its answer back.",
			parameters: obj({ task: str() }),
			replay: "safe", // a rerun after a crash finds the same child and submission
			execute: async (args, api, context) => {
				const child = await api.commit(async (tx) => {
					// The ownership index remembers the child, so a rerun reuses it.
					const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
					if (existing !== undefined) return existing.id;
					// Starts as a copy of this conversation's agent: model, extensions, tools, cwd.
					const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
					// A cheaper model, and no subagents of its own.
					await configure(tx, created.id, { model: haiku, extensions: { remove: [Subagent] } });
					return created.id;
				}, context);
				await api.details({ conversationId: child }, context); // lets a UI attach to the child
				const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` } as const;
				const settled = await (await (await api.conversation(child, context))!.submit(request, context)).wait(context);
				return { output: [{ type: "text", text: settled.status }] };
			},
		}),
	],
});

这段代码里有四处值得逐行看的设计决策。

第一,子会话是父会话 agent 的复制:createConversation 之后注释写着 "Starts as a copy of this conversation's agent: model, extensions, tools, cwd."。这就是"fork"语义——它继承了父的模型、扩展、工具和工作目录,但之后立刻被改:换成更便宜的模型(haiku),并且 extensions: { remove: [Subagent] } 把 subagent 工具从子会话里摘掉。

这一行是防递归的关键机制:子代理不能再生子代理。不是靠深度计数器,是靠能力移除——子会话的扩展清单里根本没有那个能派子代理的工具。这种防护比计数器更硬,因为计数器可以被绕过(改配置),而工具不存在是物理性的。

第二,replay: "safe" 和那个 ownership 索引查询。注释说得很清楚:"The ownership index remembers the child, so a rerun reuses it."。崩溃后重跑,scanConversations({ ownerTaskId }) 会找到同一个子会话,不会派出一个重复的子代理。

第三,requestId 是确定性的:subagent:${api.taskId}。同一个任务重跑,提交给子会话的请求 ID 一样,durable 靠它去重。

第四,api.details({ conversationId: child })——把子会话 ID 通过工具结果带出去,让 UI 能"挂"上去观察。这条对应的是 durable README 里提到的那个例子(21-subagent-foreground.ts):UI 通过 call 的 details 找到子会话,把子会话的事件缩进打印在这次调用下面。

归属语义是这个包的核心。 README :748-752 三条规则:

  • Aborting the call aborts the child. So does the call failing: execute() throwing, or a crash that interrupts a call that is not replay-safe.
  • The parent is idle only once the child is.
  • A task created with { background: true } is a boundary: work it owns survives the parent's abort and does not keep the parent busy. root.abort(context, { background: true }) aborts it too.

翻译成工程语言:

规则含义
中止父 → 中止子
子代理不是独立生命,是父的延伸
调用失败 → 中止子
execute() 抛异常,或崩溃打断了一个非 replay-safe 的调用
父只在子空闲后才空闲
父的 waitForIdle() 包含子代理
background: true 是边界
后台任务的工作能扛住父被中止,也不让父一直忙

"父只在子空闲后才空闲"这一条,是判断一个多智能体系统是否真的可用的关键指标。 很多自制的子代理工具在父进程返回时就认为完事了,结果子代理还在后台写文件——主会话以为结束了,实际上工作区还在变。

同样的"中止自底向上"语义在任务图层面也成立(README :782):

Aborting a task aborts the work it owns first, and its own abort handler starts only once that work is done, so each task undoes its own effects.

注意"each task undoes its own effects"——不是父替子撤销,是子自己撤销自己的效果,且父等子撤完才撤自己的。

剩下十个原语各管一段

上面三个是最容易被忽略的。其余十个的定位:

原语多智能体场景里它做什么
工具调用
委派的唯一载体。没有"注册一个 agent"这种 API
权限钩子
决定子代理能不能碰某些工具。block: true 返回错误结果而非抛异常(types.ts:66-74)
终止提示
terminate: true 让批次提前停。但只有批次内每个结果都 terminate 才停(agent-loop.ts:701)
事件总线
10 个事件,编排层靠它做进度显示和结算判定
codemode 沙箱
编排写成 JS 跑在 QuickJS 里,只有脚本输出进模型上下文
MCP
子智能体复用外部系统的能力,不用自己实现
env 远程执行
子智能体的工具在远端机器执行,Durable worker 和凭据留在本地
RPC
父进程通过 JSONL 驱动另一个 pi 进程
JSON 事件流
pi --mode json -p 单向拉结构化结果,最轻的子进程集成方式
SDK
进程内嵌一个完整 Pi 会话,最重的集成方式

"终止提示"那条规则值得单独强调,因为它很容易被误解。 agent-loop.ts:700:

typescript
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
	return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}

every,不是 some。一个批次里 3 个工具调用,只有 1 个返回 terminate: true,整个批次照常继续。 这个"全票通过"的设计是为了防止单个子代理的失败或完成误伤整批并行任务。

为什么"原语 + 生态"是对的

把上面的选择放到天平上看,两边都有真实代价:

官方内置成品官方只给原语
开箱体验
好
需要自己拼
覆盖形态
只有一种
十几种都能做
上下文隔离策略
官方定死
按任务选
完成判定
官方定死
按任务选(自评 / judge / 外部 gate)
递归防护
官方定死
按任务选
生态包的价值
重复实现
补齐形态

生态实际补上的正是最后一列。第十章第 5 篇拆过的那些包——不同编排形态、不同隔离方式、不同完成判定——没有一个是在重复官方内置功能,因为官方没有内置功能可以重复。它们填的是"官方故意留空"的那一格。

这个取舍还有一个不太明显的副作用:生态包成了事实上的规格。 你要理解 Pi 的多智能体怎么用,最好的文档不是官方 README,是 pi-crew 的信号量实现、pi-subagents 的上下文过滤、@tmustier/pi-ralph-wiggum 的 completion gate。它们把"原语可以怎么组合"演示得比任何官方文档都具体。

与其它章节的衔接

与第五章(会话与上下文)。 多智能体最难的部分是上下文隔离,而隔离的选项都在第五章讲过:fork 继承父历史(会话树与压缩,04-session-tree-and-compaction.md)、fresh 会话只带任务简报、上下文裁剪(03-context-management.md)。读第五章第 3 篇和第 4 篇,再回来读本模块的上下文隔离六方案表,会顺畅很多。

与第八章(codemode)。 codemode 是"编排即代码"的官方答案:把并行调用、多路 fan-out、结果过滤写成 JS 脚本,跑在 QuickJS 里,只有脚本输出进模型上下文。第八章第 3 篇讲 codemode 怎么用,本模块讲它在多智能体里扮演什么角色。

与第十章第 5 篇。 那一篇讲的是有哪些生态包;本模块讲的是机制本身——为什么 Pi 不做、官方给了什么、怎么自己拼。两篇配合读,一篇是菜单,一篇是厨房。

谁负责什么

职责由谁承担
提供可组合的原语
Pi 官方
决定要不要做多智能体
你
选隔离形态(同会话 / fork / fresh / 子进程 / worktree / durable)
你
决定完成判定(自评 / judge / 外部 gate)
你
并发上限
你
递归深度防护
你 + 生态包
子代理的工具权限
Pi 提供钩子,你写规则
子代理输出的正确性
你——judge 也在出错,gate 也要人看
崩溃恢复
durable 提供机制,你决定哪些工作需要恢复

三种场景的可观察结果

场景一:想让三个互不相关的调查同时跑,主会话不被撑爆。 用官方示例的子进程方案(packages/coding-agent/examples/extensions/subagent/index.ts),每个子任务一个 pi --mode json -p --no-session 进程。可观察结果是三个子进程各自推进,主会话历史里只有三段结论;如果主会话也在积累子代理读文件的过程,说明你用的是共享上下文形态,需要切到独立进程或 fresh 会话。

场景二:子代理想跑一个你禁掉的命令,期望被拦住。 在 beforeToolCall 里按工具名或参数返回 { block: true, reason: "..." }。可观察结果是子代理收到一条错误工具结果而不是执行成功——注意是"错误结果"而不是"进程崩了",子代理会看到失败信息并自己换路子。如果子代理直接绕过了你的检查(真的执行了那个命令),那说明它的内部调用没走 runToolCall()。

场景三:父进程崩了,重启后子代理还在原地。 用 durable 的 ownership 机制。可观察结果是重跑同一个任务时,scanConversations({ ownerTaskId }) 命中已有子会话并复用它,而不是派出一个新的重复代理;子代理的模型和工具清单也保持和上次一致。

你现在能做什么

  • 通读 packages/agent/src/agent.ts 和 types.ts,找出所有 AgentOptions 字段(20+ 个回调),逐个判断它在多智能体场景里能派上什么用场。
  • 用 packages/coding-agent/examples/extensions/subagent/index.ts 跑一次官方子代理示例,观察 --mode json -p --no-session 的事件流和它怎么汇总 usage。
  • 写一个最小 runToolCall() 包装:让一个工具内部调另一个工具,在 beforeToolCall 里打日志,确认钩子确实触发了。
  • 读 packages/durable/README.md 的 ## Abort and Subagents 一节,把 21-subagent-foreground.ts 和 22-subagent-background.ts 两个例子跑起来,观察前台和后台子代理在中止时的差别。
  • 对照 pi-crew 的 global-worker-cap.ts 和 nested-slots.ts,看一个生态包是怎么在 Pi 原语之上实现并发信号量和嵌套预算的。
  • 列出你手上任务的三个最常见形态,判断各自该用哪种上下文隔离——这张表在第二篇里。

排障速查

现象检查点处理
找不到"多智能体 API"
不是 bug
pi-agent-core 里没有这个抽象。多智能体 = 一个工具
子代理绕过了权限检查
调用路径
子代理内部必须走 runToolCall(),直接 tool.execute() 会绕开钩子和参数校验
beforeToolCall 拦截后整个流程崩了
返回值形态
返回 { block: true, reason },不要抛异常。抛异常会变成错误工具结果
批次没有按预期提前停
terminate 语义
只有批次内每个结果都 terminate: true 才停(every,不是 some)
父会话结束了但子代理还在跑
归属与等待
用 durable:父只在子空闲后才空闲。自制方案要自己实现这条
子代理无限派生
递归防护
参照 durable 的做法:子会话移除 subagent 工具(extensions: { remove: [Subagent] }),别只靠深度计数
崩溃重跑派出了重复子代理
幂等
replay: "safe" + ownership 索引 + 确定性 requestId
父被中止后后台任务也死了
background 边界
{ background: true } 的任务扛得住父中止;普通 owned 工作扛不住
想让 UI 看到子代理内部
事件出口
durable 里用 api.details({ conversationId }) 把子会话 ID 带出去
生态包的行为和官方文档对不上
版本
生态包多数把宿主包写进 peerDependencies("*"),宿主版本变了对不上

参考资料