第二部分 · RLM
第 6 章:rlm()——作为函数调用的子代理
准入即返回:一个永远不会带回答案的调用,如何撑起整个递归结构
一个反直觉的 API
几乎所有 Agent 系统的子代理 API 都长一个样:调用、等待、拿到结果。Claude Code 的 Task 工具如此,LangChain 的 agent chain 如此,连 pi 的扩展示例子代理也如此。
Prime Agent 的 rlm() 不是这样。看它的文档承诺:
"The call returns immediately after task admission with a child handle; it never waits for or returns the child's answer. Results arrive only through explicit
agent_messagereplies or files, never as anrlm()return value."
一个派生子代理的函数,永远不返回子代理的答案。第一次读到这句话会以为是文档写得保守——读完实现才明白,这是整个系统里最彻底的一个设计决策。本章拆解它的全部后果。
一次调用的旅程
模型在 cell 里写:
handle = await rlm("Review the authentication flow for security issues", name="auth-reviewer")
print(handle.rlm_child_id, handle.name, handle.session_dir, handle.model)
它经过五站:
await rlm(...) 的准入链路。函数在「登记完成」处返回;运行时的创建与任务执行在另一个解耦的异步任务里继续。Python 端的 _RLMCallable.run 只是一次 host request 的包装——host_request("rlm.run", {"prompt": prompt, "kwargs": kwargs})。真正的决策全在 TS 端的 _startRlmChildRun()(core/agent-session.ts,约 L9684),它按顺序做六件事:
- kwargs 白名单:只允许
name和model,其余任何键直接抛Unsupported rlm.run kwargs。API 面在宿主侧焊死。 - 深度检查:
if (this._rlmDepth >= this._rlmMaxDepth) throw new Error("RLM recursion depth limit reached ...")——整个系统唯一的递归强制点(后面细讲)。 - 名字可用性:内存预占集合防并发撞名,再经 daemon 家族目录查全局占用,
finally释放预占。同名只禁于「同父同深度」——名字唯一性键是[depth, parentType, parentValue, name]的结构化编码。 - 模型解析:不指定则继承父模型;指定则必须精确匹配已认证目录——匹配不到直接失败,不静默降级。子代理拿到错误信息,可以自己换个 selector 重试。
- 建目录、立条目:在父会话的 artifact 目录下
mkdir sub-<uuid8>(冲突重试至多 100 次),这个目录名就是子节点 id;创建RlmChildRun登记进_activeRlmChildRuns,状态"queued",发出首个rlm_child_update事件。 - 解耦,然后返回。
第六步是这个函数的灵魂。运行时启动与任务执行被放进一个 void (async () => {...})() 的后台任务,函数本体直接返回:
// Runtime startup and the task run are deliberately detached. The public
// spawn resolves at admission, while this task owns live tracking, usage,
// retention, cancellation, and late-startup cleanup.
...
return {
rlm_child_id: childNodeId,
name: sessionName,
session_dir: childSessionDir,
model: `${modelSelection.model.provider}/${modelSelection.model.id}`,
};
回到 Python,_spawn_handle_from_payload 严格校验四个字段,构造一个 frozen dataclass RLMSpawnHandle。整个 await 只等了「准入」——深度合法、名字可用、模型存在、目录已建、账已记。
为什么是「准入即返回」
把 spawn 切成两半的收益要放到 RLM 的编程模型里才看得清:
- 并发 spawn 不互相阻塞。一个 cell 里连派三个子代理是标准用法(官方文档原话:"Spawn independent children in separate calls and end your turn")。若 spawn 阻塞到运行时就绪,三次派生串行;现在三次准入都是毫秒级。
- 父 cell 不被子任务时长绑架。若
rlm()等答案,子任务跑一小时,父 kernel 的这个 cell 就占一小时——期间 kernel 不能执行任何别的 cell(第 4 章的串行化)。准入语义下,父派生完就结束本轮,kernel 立刻空闲。 - 失败模式被切成两类。准入失败是同步的、可预测的(抛在模型的当前 cell 里);运行失败是异步的,以终态通知回报。两类失败各有自己的处理路径,不会搅在一起。
- 与压缩、重启、恢复天然兼容。父的等待状态不存在,所以没有什么等待会被压缩或重启打断。
代价同样明确:模型必须学会「结果不会作为返回值回来」。这靠系统提示词反复强化——第 8 章会看到 prompts/rlm.ts 里的原文。
子代理的一生
那个解耦的后台任务里发生了什么?简化的时间线:
- 创建运行时。daemon 模式下走
SubagentRuntimeHost.createRlmSubagentRuntime:新建SessionManager(session dir 就是sub-xxxxxxxx,header 记录parentSession与rlmDepth),再建一个全新的 AgentSessionRuntime——独立的AgentSession、独立的 IPython kernel、独立的消息控制器,登记为 daemon 的一个 active session。inline 模式(无 daemon)则同进程直接构造。注意:子代理就是一个完整的 AgentSession,不是一种轻量结构。 - spawn 即消息。子会话的第一条消息构造如下:
const content = `[task from parent]\n\n${prompt}`;
const spawnMessage: AgentSessionMessage = {
role: "custom",
customType: AGENT_MESSAGE_CUSTOM_TYPE, // "agent_message"
content,
display: true,
details: {
id: `spawn:${run.id}`,
message: prompt,
from: { sessionId: this.sessionId, sessionName: this.sessionName, ... },
fromRelationship: "parent",
},
timestamp: Date.now(),
};
await child.promptAndWait(content, { ..., customMessage: spawnMessage });
注意 customType——派生不是一种独立的条目类型,它复用 agent_message 的消息类型,只靠 details.id 的 spawn: 前缀区分。「任务从父来」与「消息从父来」在转录里是同一形状。这个统一在后面结出果实:用量归属的 origin 字段("spawn_task" 还是 "agent_message")就是靠这个前缀判定的。
- 订阅与汇总。父订阅子会话的事件流:嵌套的
rlm_child_update(孙代理事件)直接冒泡;每次 assistantmessage_end触发用量归属;工具执行更新 activity 摘要。 - 终态。正常结束置
"done";若子代理从头到尾没有回复过父,父会替它发一条终态通知(completed_without_reply,附子代理最后一段输出的预览)——保证父永远知道任务结局,即使子代理忘了调agent_message.send。异常路径投递失败通知或 cancelled 通知。 - 保留。成功的子会话进入
_rlmChildSessions——父生命周期内持续可寻址。daemon 把注册表条目更新为"completed"并 fsync。
继承什么,不继承什么
子代理的配置由 _createRlmSubagentRuntimeOptions() 装配,清单很长,挑重要的:
| 项 | 继承方式 |
|---|---|
| model | 默认继承父模型;rlm(model="provider/model") 精确选择,找不到即失败 |
| thinking level | 父级别按子模型能力钳制(clampThinkingLevel) |
| provider 钩子 | convertToLlm / transformContext / streamFn / getApiKey / onPayload 等直接复用父 Agent |
| 重试 / 传输 | 来自共享 SettingsManager:重试上限、transport、thinkingBudgets |
| skills / 资源 | 复用父 resourceLoader 的同一份加载结果;goals、compact 技能开关照抄 |
| tools | 父的活动工具名单拷贝——子代理也有 ipython 与同一套技能 |
| 深度 | rlmDepth: 父深度 + 1,rlmMaxDepth 原样继承(上限不变,余量递减) |
| kernel env | RLM_DEPTH、RLM_MAX_DEPTH、RLM_SESSION_DIR 等——注释明说仅供展示,TS 侧检查才是权威 |
| 上下文 | 不继承。全新 session,全新 kernel,第一条消息只有任务文本 |
最后一行是重点。子代理拿不到父的对话历史,也拿不到父 kernel 里的变量——这正是 RLM 论文式上下文隔离的工程化:父保持聚焦,子只带任务走。能力同构(同样的工具与技能)与上下文隔离(全新的会话)同时成立。
注册表:为什么压缩杀不死它
子代理派出去之后,父怎么知道它们的状态?答案是一个两层注册表:
- 内存层(父 AgentSession 进程内):
_activeRlmChildRuns(进行中)+_rlmChildSessions(保留的已完成会话)。这是rlm.list_subagents()的主数据源。 - 持久层(daemon 专属):
rlm-subagents.jsonl,放在父会话的 artifact 目录里。条目包含 childId、sessionName、sessionDir、父会话标识、深度、prompt(超 4096 字符不存)、spawnCode(派生时的 IPython cell 源码)、模型、状态、时间戳。
持久层的写法语义值得停下来看:append-only JSONL + tombstone。状态变更不改写历史,而是追加一条新条目;删除是追加一条 status: "deleted"。读取端用 Map<childId, entry> 折叠出每个孩子的最新状态。每条追加都 fsync。崩溃安全、天然可审计、磁盘转录永不抹除——delete_subagent 的文档特意写明 "It does not erase the transcript or artifacts on disk"。
这个设计的回报是三条「存活」性质:
| 灾难 | 注册表为何幸存 |
|---|---|
| 上下文压缩 | 注册表根本不在模型上下文里——它不是转录条目,压缩只碰消息 |
| kernel 重启 | 权威状态在 TS 宿主,kernel 只是调用面。技能文档直接教模型:"Use await rlm.list_subagents() after kernel restart or compaction" |
| 父会话恢复 / daemon 重启 | 重新打开的父会话扫描自己的 artifact 目录重建被动子代理清单——注册表作用域跟随父转录,新建的无关会话不继承任何孩子 |
保留、钝化、水合
retained 子代理是「懒资源」:完成即保留(父会话打开期间随时可以 agent_message.send(..., receiver_role="child", receiver_name=...) 追问它),但空闲会被钝化(passivate)——supervisor 定期触发 passivateIdleChildren,满足条件(空闲超时、无客户端连接、无未决准入)的子代理被解除内存跟踪、关闭运行时,只剩注册表行作为它的被动表示。之后再被寻址时水合(hydrate):取会话租约、重开 SessionManager、重解析模型、挂回父会话——多级后代逐级唤醒。
「子代理的结果与上下文,在父会话打开期间始终可回看、可追问;成本由钝化控制」——这是第 13 章「长任务」叙事的一块基石。
递归深度:默认值恰恰是「只许一层」
默认 _rlmMaxDepth = 1。很容易读反——它的含义是:根(深度 0)可以生子(深度 1),而子代理深度 1 ≥ 1,不能再生孙。「允许递归」的默认值,恰恰是「只许递归一层」。
上限的解析有五级优先级(_resolveRlmMaxDepth):会话内 /rlm-max-depth 设置 → 父继承的配置 → 全局设置 → 环境变量 RLM_MAX_DEPTH → 默认 1。强制则在三个层面:
- 宿主硬检查——
_startRlmChildRun开头的那行 throw,唯一权威。Python 侧没有任何深度检查(文档声称的「Python 先拦一道」在当前代码里不存在——以代码为准)。 - 提示词隐身——到达上限后,
buildRlmPrompt的allowRecursion关掉,系统提示词干脆不再提及rlm可调用。模型不知道自己被禁了,硬检查只是兜底。这是「能力治理」的最优雅形态:不是拒绝,是隐身。 - kernel env——
RLM_DEPTH/RLM_MAX_DEPTH注入环境变量,但注释自认「provisioning-time only, may be stale」,仅供展示。
用量归属:加钱,不加上下文
子代理花的 token 算谁的?Prime 的答案精确到消息级:算发起 spawn 的那条父 assistant 消息的。但这里有个陷阱——子代理的用量若原样并进去,父消息的 totalTokens(约等于模型面对的上下文大小)会被夸大,压缩判断会失真。看它怎么拆:
function attributeChildUsage(parentUsage: Usage, childUsage: Usage): void {
const parentContextTokens =
parentUsage.totalTokens ||
parentUsage.input + parentUsage.output + parentUsage.cacheRead + parentUsage.cacheWrite;
// Recursive children are launched from an assistant tool call, so the parent assistant
// message carries their billable usage for session-level cost totals.
addAssistantUsage(parentUsage, childUsage);
// Child work affects session-level billable totals, not the parent's model-facing context size.
parentUsage.totalTokens = parentContextTokens;
}
计费 token(input/output/cache/cost)累加,totalTokens 还原。子代理的工作增加账单,不增加父的上下文窗口占用。归属还会持久化为 child_usage_attributed 转录条目(带 origin:spawn 还是消息触发),会话重载时重放。
配套的是 /context 命令的上下文树(core/context-tree.ts):每个节点有 own 与 total 两个用量——total 是分支总账(含归属来的子用量),own 是 total 减去所有 attribution。这保证「全树 ownUsage 求和 = root totalUsage」永远可对账,即使 fork 丢了某些条目、压缩抹掉了部分转录("totals are deliberately cumulative across compactions")。磁盘上已完成的子代理从 sub-*/ 目录重建进树里——上下文树和注册表一样,是能从磁盘复活的。
实践应用
- 把「登记」与「执行」切开。凡是昂贵且可能很慢的创建过程(进程、会话、工作流实例),都可以提供「准入即返回」的 API:准入是同步事务,执行是后台任务,结果走异步通道。失败模式从此一分为二,各自可测。
- 结果通道越窄,架构越稳。没有 join/await-result,编排就被迫变成「派生 → 结束回合 → 被消息唤醒」——这个形状天然抗压缩、抗重启、抗断线。API 的缺席有时是最强的约束。
- 注册表跟随它所属的转录。子代理注册表放在父的 artifact 目录而不是全局库,作用域问题自动消失:谁的会话,谁的孩子。
- 能力治理优先用隐身,兜底才用拒绝。到深度上限后从提示词里抹去能力描述,比每次调用都弹一个 "permission denied" 温和得多,也省 token 得多。
- 记账要能区分「账单」与「上下文」。同一个用量对象里的不同字段服务不同决策——成本核算看累加,压缩判断看还原。混在一起,两个决策都会错。
总结
rlm() 把子代理重新定义为一次事务性登记:准入在调用路径上同步完成,执行被推入后台,结果被永远排除在返回值之外。子代理是完整的 AgentSession——能力同构、上下文隔离;注册表用 append-only JSONL 和 tombstone 换来压缩、重启、恢复三重幸存;retained 生命周期让子代理成为父会话的懒资源;深度默认值用「提示词隐身」优雅强制;用量归属把账单和上下文拆成两本账。
但子代理干完活,总得把消息送回来。下一章看 agent 之间的直接通信:agent_message 的路由、家族可达域,以及为什么投递语义只剩一种。