第二部分 · RLM

第 3 章:持久 IPython 内核

一个进程、三根 ZeroMQ 管道,以及为毫秒级冷启动付出的全部执念

一个工具的代价

第 2 章说过,Prime Agent 的模型只有一种内置工具:ipython。这个决策把 pi 的七个内置工具(read、edit、bash……)折叠成了一个,但它同时引入了一个 pi 从不需要回答的问题:

那个 Python 解释器进程,从哪来?

在传统的「结构化工具调用」Agent 里,每次工具调用都是无状态的:宿主收到一个 JSON,执行一个函数,返回结果。没有进程需要管理,没有状态需要维持。而 RLM 的整个卖点恰恰是状态——变量、导入、解析了一半的数据集,必须在工具调用之间活下来,甚至在会话重启之后复活。于是「执行一次 Python」变成了「维护一个长寿命的 IPython kernel」:它要启动得快(模型随时可能扇出一批子代理,每个都要自己的 kernel)、崩得优雅(死循环的 cell 不能拖死会话)、忘得有分寸(压缩可以丢转录,不能丢变量)。

这一章拆解 Prime Agent 对这个问题的完整回答:packages/coding-agent/src/core/kernel/ 目录下的约两千行 TypeScript,加上 prime-agent-runtime 里那个 32KB 的 harness.py。它是全书技术密度最高的章节之一——但每一处复杂性都能追溯回同一个根源:把 REPL 当作一等公民的代价

一个 kernel,三根管道

先建立全貌。每个 AgentSession 至多拥有一个 kernel,由三层所有权链管理:

AgentSession ──owns──▶ IpythonKernelProvisioner ──owns──▶ KernelManager
                       (懒启动/恢复/引导编排)            (进程、ZMQ、协议)

KernelManagercore/kernel/index.ts,约 1,530 行)是真正的内核管家:它负责进程生命周期、Jupyter 消息编解码、执行串行化、输出泵和 host request 分发。它的启动序列 doStart() 是本章的主线,我们沿着它走一遍。

选择:标准 Jupyter 协议,不自造轮子

第一个值得注意的决策是通信协议。Prime Agent 没有发明自己的「stdin/stdout JSON」协议,而是完整实现了 Jupyter Messaging Protocol v5.3 over ZeroMQ

const DELIM = Buffer.from("<IDS|MSG>");
const PROTOCOL_VERSION = "5.3";
const PORTS_RESOLVE_TIMEOUT_MS = 5000;
// ...
const IOPUB_SUBSCRIBE_DELAY_MS = 50;
const DEFAULT_MAX_OUTPUT_CHARS = 65536;

连接建立时,宿主在临时目录写一个 Jupyter 连接文件(connection.json),里面的五个端口全部填 0,外加一个随机 16 字节的 HMAC 密钥;然后启动 kernel 进程,由 ipykernel 自己去绑定真实端口并写回这个文件。宿主每 25ms 轮询一次,最多等 5 秒(PORTS_RESOLVE_TIMEOUT_MS):

const kernel = spawn(python, ["-m", "ipykernel_launcher", "-f", connection.path], {
	cwd: this.options.cwd,
	env: this.options.env ? { ...process.env, ...this.options.env } : process.env,
	stdio: ["ignore", "pipe", "pipe"],
});

端口解析完成后,宿主建立三根 ZMQ socket:

通道socket 类型承载的消息
shellDealerexecute_request/execute_replykernel_info_request/kernel_info_reply
iopubSubscriber广播:streamexecute_resultdisplay_dataerrorstatuscomm_*
controlDealerinterrupt_requestshutdown_request,以及 host request 的回包(第 5 章的关键)
stdin / hb不使用allow_stdin: false;心跳通道未接

每条消息是五个 JSON 帧(header / parent_header / metadata / content,外加签名),用 <IDS|MSG> 分隔符分帧,签名是对四个 JSON 帧做 HMAC-SHA256——全部照搬 Jupyter 规范。连接后还要特意睡 50ms(IOPUB_SUBSCRIBE_DELAY_MS)处理 ZMQ PUB/SUB 著名的 slow-joiner 问题,再发一个 kernel_info_request 探活,5 秒内收到匹配的 reply 才算就绪。

与 pi 对照

pi 的工具执行是纯函数式的:工具 execute() 在宿主进程内跑,返回内容块。Prime 的「工具执行」变成了一段跨进程、跨语言的会话协议。这不是过度设计——iopub 广播天然给了流式 stdout,control 通道天然给了执行中中断,comm 通道天然给了双向异步消息。自造协议要把这些全部重新发明一遍,而 ipykernel 是现成的、被十年生态验证过的对端。

串行化:一个命名空间,一次一个 cell

IPython kernel 的用户命名空间是共享可变状态,Jupyter shell 通道是请求/应答语义。KernelManager 用一个 Promise 链(executionQueue)把所有 execute() 串行化;工具定义侧也声明 executionMode: "sequential",防止模型的并行工具批次里同时跑两个 ipython 调用。双保险,一个在循环外,一个在循环内。

启动的完整形状

把上面的碎片拼起来,一次 kernel 冷启动的完整形状如下:

图 1:一次 kernel 冷启动。fork 快路径失败会退回直接 spawn,并且重新生成连接文件——防止与可能已经 fork 出的孤儿 kernel 抢端口。

注意序列尾部的两步——restoreState() 与 bootstrap cell——它们让「启动一个 kernel」不等于「得到一个空解释器」。恢复先于引导,顺序是刻意的:

"Revive a prior session's namespace before the bootstrap, so the bootstrap then overwrites live handles (rlm, skills) on top of anything restored."

上一场会话的 df 数据框复活了,但 rlm 和技能模块这些「活句柄」必须由本场 bootstrap 重新注入。这个区分——数据可以复活,句柄必须重建——会贯穿本章的快照一节。

fork-server:为毫秒级扇出付出的执念

现在来到本章最精彩的部分。设想 RLM 的典型场景:模型在一个 cell 里连派三个子代理(第 6 章),每个子代理是一个完整的 AgentSession,每个都要自己的 kernel。三个 kernel 同时冷启动,每个要 import IPython、ipykernel、rlm——实测约 1.2 秒。在评估工作负载下,扇出可能是几十个。

Prime Agent 的答案是 fork-server.ts + fork-server-script.ts:一个常驻的 Python「模板进程」,一次性完成所有 import,之后每个 kernel 请求都用 os.fork() 从模板克隆出来。

图 2:fork-server 拓扑。注意 socket 方向是反的——Node 建监听,Python forkserver 作为客户端连入。fork 出的 kernel 不是 Node 的子进程。

内嵌在 TypeScript 字符串常量 FORK_SERVER_SCRIPT 里的 Python 脚本值得逐段读。模板的 import 清单:

def _import_template():
    # Everything a kernel touches at import time. Paid once; shared COW by children.
    import IPython  # noqa: F401
    import ipykernel  # noqa: F401
    import ipykernel.kernelapp  # noqa: F401
    import jupyter_client  # noqa: F401
    import nest_asyncio  # noqa: F401
    try:
        import rlm  # noqa: F401
    except Exception:
        # rlm may not import cleanly outside a live kernel namespace; the Node-side
        # bootstrap cell wires it up per-child regardless. Preloading is a best-effort
        # speedup, not a correctness requirement.
        pass

import 完成后,脚本做了一件只有真正把内存共享当回事才会做的事:

    _import_template()
    # Freeze the heap so the cyclic GC doesn't write to (and thus COW-copy) the
    # shared module pages, keeping memory genuinely shared across children.
    gc.freeze()

gc.freeze() 把堆冻结出循环 GC 的视野——否则 GC 标记阶段会写入模块对象的共享内存页,触发 copy-on-write 复制,每个子进程各复制一份,「共享」就成了空话。为了真共享,连垃圾回收都被算计进去了。

fork 出的子进程在 _run_child() 里变成真正的 kernel。这里藏着 fork 复用 Python 运行时的经典暗坑:

    # Drop any singleton the template happened to build so the child owns a fresh
    # instance (and, critically, a jupyter_client Session created in *this* pid;
    # a Session inherited from the template silently drops messages via check_pid).
    IPKernelApp.clear_instance()
    app = IPKernelApp.instance(connection_file=connection_path)
    # initialize() binds the 5 ZMQ ports, writes the resolved ports back into
    # connection.json, and starts the heartbeat thread + ioloop — all post-fork,
    # so no thread/loop/socket is ever inherited across the fork boundary.
    app.initialize([])
    app.start()

如果不 clear_instance(),模板里顺手创建的 jupyter_client.Session 会带着模板进程的 pid 进入子进程,其 check_pid 检查会让消息静默丢失——不是崩溃,是无声地丢。这类 bug 在 fork 服务器的口口相传里属于「每个人都踩一次」的级别。

健壮性:正确性从不依赖优化

fork-server 的工程设计遵循一条清晰的原则:一切失败都降级为直接 spawn

  • 仅 Linux 默认开启isForkServerEnabled() 里写着:if (process.platform !== "linux") return false;,注释 "fork-without-exec is unsafe on macOS"。PRIME_AGENT_KERNEL_FORKSERVER=0 可完全关闭。
  • 模板按解释器共享servers Map 以 python 路径为键,cwd/env 不参与——因为 sys.path 在 import 时就固化了,而 cwd/env 在子进程里才应用。共享与重建的边界精确地切在「import 完成」那一刻。
  • 环境变量逃逸检测。若某个 kernel 覆盖了 PYTHON* / VIRTUAL_ENV / CONDA_PREFIX 等影响解释器启动的变量且与模板启动快照不同,forkserver 主动声明不可用(ForkServerUnavailable)——宁可慢,不可错。
  • 孤儿超时回收。fork 请求 10 秒无应答即放弃;迟到的 pid 回复视为孤儿,直接 SIGTERM。
  • 连接文件重铸。退回直接 spawn 前先重新生成连接文件(图 1 的虚线)——因为那次失败的 fork 请求可能真的 fork 出了一个正在绑端口的孤儿,复用同一份连接文件会与之冲突。
  • 没有 exit 事件就自己造一个。fork 出的 kernel 不是 Node 的直接子进程,死亡不会触发 "exit" 事件。startForkedLivenessMonitor() 每秒用 process.kill(pid, 0) 探活——ESRCH 才是死,EPERM 视为活着;杀进程前也要先探活,防止操作系统回收 pid 后误杀无关进程。
深入一点:zombie 的双重收割

forkserver 脚本给 SIGCHLD 挂了 _reap_children()os.waitpid(-1, WNOHANG) 循环收尸),同时 accept 循环每一轮再扫一遍——「信号处理是主收割机,循环扫描兜底被合并的信号」。注释甚至考虑到了 PEP 475:被信号打断的 socket read 会自动重试,所以 handler 是安全的。一个教学级的「防御性 fork 编程」样本。

boot-gate:fork 消灭不了的那部分成本

fork 把 import 成本降到了毫秒级,故事本该结束。但 boot-gate.ts 的存在说明团队撞上了下一堵墙。看它的注释——这是本书引用过最诚实的工程记录之一:

// Fork-per-child is ~ms and bypasses the FS, so we can admit well past the
// direct-spawn cap. But fork only removes the *import* cost — every admitted
// kernel still starts a live ioloop + heartbeat thread + rlm bootstrap, and
// letting all N do that at once trips the ready timeout (measured: 256 collapses
// to ~28% boot at N=200; core*4 holds 100%). So the gate stays a real bound.

翻译一下:256 路并发 fork 在 N=200 核的机器上,启动成功率坍缩到约 28%;把并发限到「核数 × 4」则保持 100%。fork 消灭的只是 import 成本——每个 kernel 仍要启动真实的 ioloop、心跳线程、rlm 引导,全部同时挤在一起会集体撞上就绪超时。

于是 withKernelBootPermit() 用一个惰性创建的信号量给冷启动限流:直接 spawn 默认 min(16, max(4, cpus×2)),forkserver 模式默认 min(128, max(32, cpus×4))PRIME_AGENT_MAX_CONCURRENT_KERNEL_BOOTS 可覆盖,但解析失败或越界时回退默认——注释特意说明,连 "00" 这种值都要优雅回退而不是在模块加载时抛错。

许可的边界也值得注意:它只包住 KernelManager.start()(进程 spawn + 端口解析这段真正竞争 OS 资源的部分),不包后续的 restore/bootstrap cell——一个卡死的 bootstrap 若占着许可,会把整个扇出堵死。哪些阶段需要限流、哪些不需要,边界切在「资源竞争」这个语义上。

Python 环境:内容哈希驱动的 venv

kernel 进程用的 Python 从哪来?ensureKernelPython()kernel/bootstrap.ts)管理一个专用 venv(默认 ~/.prime/agent/kernel-venv),里面装着 ipykernel、prime-agent-runtime 本身、快照用的 dill,以及一组默认扩展包(requests、httpx、pandas、numpy、scipy、beautifulsoup4、pydantic 等——让模型开箱即有数据处理的常规武器)。

图 3:venv 引导流水线。RUNTIME_READY_CHECK 是一段内嵌在 TypeScript 里的 Python 断言串,充当两个语言运行时之间的可执行契约。

这套引导有三个设计值得停下来看:

其一,运行时身份是内容哈希,不是版本号。resolveRuntimeIdentity()pyproject.toml 加所有 src/rlm/**.py 做 sha256,写进 venv 里的 .bootstrap-version 标记。改一行 Python 源码,哈希变化,下次启动自动重建 venv。没有发布流程、没有版本协商——源码本身就是版本。

其二,跨语言契约用可执行断言锁死。RUNTIME_READY_CHECK 是一段 TS 字符串里的 Python 代码,在 venv 里实际运行:

import rlm
from rlm.harness import HarnessEntry
assert callable(rlm)                    # rlm 对象本身可调用
assert callable(rlm.host_request)       # 宿主桥存在
assert callable(rlm.find_models)
# …13 个 harness 方法逐一断言(create/update/delete × memory/skill/subagent/prompt_note
#   + record_refinement)…
assert 'reference' in HarnessEntry.__dataclass_fields__
assert 'scope' in HarnessEntry.__dataclass_fields__
assert not hasattr(rlm, 'background')   # 后台执行 API 必须不存在

最后一行最有意思:它断言某个 API 不存在。TS 侧把「不得提供后台执行接口」这条产品决策写进了健康检查——API 的负面清单,用断言锁死。

其三,并发与锁。venv 重建有目录级锁(.bootstrap.lock + pid 文件,持有者死亡或 30 秒无心跳视为 stale),模块级 memo 去重并发调用。多个子代理同时冷启动时,只有一个会真的去 uv pip install

第一个 cell:把 rlm 装进命名空间

venv 就绪、kernel 探活成功之后,宿主执行引导 cell(buildRlmBootstrapCode(),位于 core/tools/ipython.ts)。它做三件事:

import asyncio
import os as _prime_agent_os
_prime_agent_os.environ["NO_COLOR"] = "1"
get_ipython().colors = "nocolor"
try:
    import nest_asyncio as _prime_agent_nest_asyncio
    _prime_agent_nest_asyncio.apply()
except Exception:
    pass
try:
    import rlm as _prime_agent_rlm_module
    rlm = _prime_agent_rlm_module.rlm
except Exception:
    ...  # 绑定一个占位对象,调用时给出修复指引
  • nest_asyncio.apply() 是整个 RLM 编程模型能成立的关键之一:它给 ipykernel 的事件循环打补丁,让 cell 顶层的 await rlm(...) 可以直接运行。没有它,模型写的每一行异步代码都要包进 asyncio.run()
  • NO_COLOR + nocolor:traceback 不带 ANSI 色码——这些错误文本最终要回传给模型,色码只会污染 token。
  • rlm 导入失败不炸 kernel,而是放一个占位对象,调用时抛带修复指引的错误(重建 venv 或设置 PRIME_AGENT_KERNEL_PYTHON)。模型收到的是可行动的建议,而不是一个死掉的解释器。

引导 cell 还会为每个 Python 技能做包装:模块若有可调用 run,就用一个特殊模块类替换 sys.modules 里的对象,让 await goal.create(...) 这种「模块即函数」的写法直达 module.run(...)。技能的完整机制留给第 10 章

深入一点:模块也能被调用

rlm 包在 __init__.py 的最后一行把自己的模块类换掉了:sys.modules[__name__].__class__ = _CallableModule。于是 import rlm; await rlm("subtask")await rlm("subtask")(bootstrap 注入的实例)两种写法都成立。为一个用户 ergonomics 动用 Python 的元编程暗角——这是「一切皆程序」哲学在微观层面的体现。

崩溃与重生:逐变量快照

kernel 是长寿命进程,长寿命进程会死。Prime Agent 的恢复策略分两层。

第一层:命名空间快照。state-snapshot.ts 生成三段内嵌 Python,把用户命名空间的每个顶层变量逐个用 dill 序列化到 kernel-state.dill(外加 JSON manifest):

  • 跳过 _ 前缀、IPython 内建名,以及一个「活句柄」清单——rlmasyncioIn/Out/get_ipython/exit/quit/open。它们无法 pickle,更重要的是不需要 pickle:bootstrap 会重建它们。
  • 单变量或总量超过 256MiB 记为 skipped 并注明原因;socket、打开的文件这类活对象逐个跳过,不连坐——部分失败容忍是这套机制的核心,因为活对象必然存在,连坐式快照会让整个恢复机制不可用。
  • 快照代码全程 import builtins as _b 使用内建函数——因为用户命名空间可能已经覆盖了 list/open/print。模型写的代码能污染一切,宿主代码必须免疫。
  • 触发时机:每次 execute 成功后 1.5 秒 debounce;dispose()/SIGINT/SIGTERM 前强制刷盘(5 秒上限,超时用磁盘上已有的副本兜底)。

恢复时逐名 dill.loads,失败逐个记录。restart() 方法 = 关闭 → 启动 → 恢复 → 重新引导,即「带记忆的重启」。

第二层:进程层面的清理。直接 spawn 的 kernel 靠 child 的 exit/error 事件触发拆除;fork 的靠探活轮询。所有 kernel 一启动就登记进进程级 liveKernels 集合,信号处理器在进程退出前批量关闭它们。另有 orphan-process-journal.ts——一份追加式 JSONL 日志,登记 detached 后台子进程的 pid(含进程启动标识,防 PID 复用误杀),供 daemon 重启时回收「进程的进程」。它是跨进程责任链的一环,第 14 章会再见到它。

kernel 与 session 的对应关系

把边界钉死:

  • 一个 AgentSession ↔ 一个 Provisioner ↔ 至多一个 KernelManager。代码注释明说 "the session never holds two live kernels"——/reload 时先拿到旧 provisioner 的 dispose Promise 作为 readyGate,再建新的,防止新旧 kernel 抢同一份快照文件。
  • kernel 懒创建:第一次 ipython 工具调用才启动。配置预热或存在快照时 prewarm(),让恢复通知发生在第一轮对话之前。
  • 子代理(rlm(...) 派生)是完整的 AgentSession,因此拥有自己独立的 kernel,快照目录在自己的 sub-xxxxxxxx artifact 目录里。父子 kernel 完全隔离——这是「每个子代理一个解释器」的递归结构,第 6 章展开。

实践应用

这一章的模式几乎都能直接搬到别的系统里:

  1. 正确性不依赖优化。fork-server 的每条快路径都有完整的降级回退;快路径失败时甚至重铸连接文件以防与可能的孤儿冲突。优化层可以大胆,因为正确层从不假设优化成功。
  2. 用可执行断言做跨语言契约。TypeScript 与 Python 分属两个运行时、两种类型系统,版本号协商成本很高。RUNTIME_READY_CHECK 用「在对方环境里跑一段断言」替代了它——契约即测试,测试即契约。
  3. 内容哈希作为缓存键。venv 是否有效不靠版本号对齐,靠源码哈希。任何「派生产物是否过期」的问题都可以这样回答。
  4. 限流要基于实测的失败曲线。boot-gate 的上限不是拍脑袋的 16/64,而是「256 并发坍缩到 28%,core×4 保持 100%」的实测拐点。注释里保留原始数据,让后来者知道边界为什么在那里。
  5. 部分失败容忍的恢复。快照逐变量进行,活对象跳过不连坐,不可恢复的名字记录在案。全有或全无的恢复机制在真实系统里几乎总是死路。

总结

本章回答了「那个 Python 解释器从哪来」:KernelManager 用标准 Jupyter 协议管理一个长寿命进程;fork-server 把 import 成本摊薄到毫秒级,代价是一整套关于 fork 语义的防御性工程;boot-gate 承认 fork 消灭不了的成本并给它限流;内容哈希驱动的 venv 让两个语言运行时之间的契约自动失效重建;bootstrap cell 把 rlm 和技能装进命名空间;逐变量 dill 快照让 kernel 的记忆比它的进程活得更久。

现在 kernel 活着了。下一章看模型如何与它对话——那个参数只有一个 code 字段的工具,以及输出从 iopub 广播回流时经过的全部整形。