法不净空,觉无性也。

第19章 从 REPL 到操作系统

2026.07.29

Agent 内核的演进路径,和操作系统的发展惊人地相似。


引子

你的 agent 正在执行一个需要 30 秒的构建命令。你想查看进度,但终端冻结了。你想取消它重来,但连"取消"这个指令都送不进去。因为你只能等——等命令返回,才能继续。

这不是小问题。当你的 agent 从一个单轮问答工具,变成一个执行多步工程任务的系统时,"一个时刻只做一件事"的模型就不够用了。你需要任务取消、需要并发、需要在后台常驻服务。

有意思的是,agent 内核为了解决这些问题所经历的演进路径,和操作系统的发展惊人地相似:从批处理到事件驱动,再到多任务系统。 不是我们在模仿操作系统——是相同的根本需求催生了相似的架构。

REPL 循环——批处理机

REPL 阶段本质上是一个批处理机:你提交一个任务,agent 处理它,返回结果。处理过程中你不能打断、不能插入新任务、不能询问中间状态。你必须等它完成。

这种架构的优点十分突出。因为执行路径完全确定——一轮输入、一轮处理、一轮输出——调试起来极其简单。你把输入打印出来,把输出打印出来,中间发生了什么一目了然。没有并发、没有竞争条件、没有消息乱序。可预测性是 REPL 最大的资产。

但它的缺点也是与生俱来的。一旦 agent 开始执行一个阻塞操作——比如 run_command 执行一个需要 10 秒的构建——在它返回之前,整个系统都是冻结的。 你不能在这 10 秒中间做任何事:不能问"进度如何",不能发"算了我取消"。你只能等。

另一个问题是跨回合的上下文管理。REPL 每个回合都是独立的——这一回合的输出不会自然流入下一回合的结构中,除非显式拼接 system prompt。这就是为什么很多 REPL 架构的 agent"记不住"上一回合的工具调用结果——每次循环都是新的开始。

这跟 MS-DOS 很像:一个时刻只能跑一个程序,没有硬件级的内存保护,没有抢占式调度。程序一跑,系统就冻结。你只能等它结束,或者按电源键。

事件驱动——单任务内核 + 中断

当 REPL 的阻塞问题变得不可忍受时,自然演进方向是事件驱动。内核不再是"你先说,我再做"的串行循环——而是变成一个事件循环,响应不同类型的消息。

CodeCoder 的双通道拓扑是这种思路的体现:

  • 命令通道(cmd_tx) — 只传递用户主动意图:ProcessMessage(新消息)、Shutdown(退出)、Cancel(取消)
  • 事件通道(event_rx) — 返回流式增量和结构化状态:NewToken、ToolStarted、ToolFinished、StatusUpdate

两个通道的分离是关键设计。命令通道的流量极低——只有用户的明确指令。事件通道的流量可以很高——每个 token、每次工具调用状态变更都经过它。把它们混在一起会导致高优先级指令(取消)被低优先级事件(token 流)阻塞。

最显著的事件驱动特性是协作式取消

在 REPL 模式下,取消是不可能的——agent 在执行 run_command,没有轮询任何取消信号的机会。在事件驱动模式下,当用户按下 Ctrl+C,系统不杀线程,而是翻转一个共享的 CancelToken。Agent 的工具执行循环(比如 run_command 的子进程循环)在每次迭代时检查这个 token。如果被翻转,它优雅地终止子进程,返回一个"已取消"状态,然后等待下一轮输入。

这跟操作系统的信号机制一样:内核不强制终止进程(那会导致资源泄漏),它发送一个信号,进程在安全点响应。

另一个事件驱动的好处是 sub-agent 可以存在了。在 REPL 模式下,"一个 agent 调用另一个 agent"意味着外层循环被递归地阻塞——几乎不可能实现。在事件驱动模式下,agent 工具创建一个新的只读 AgentLoop 实例,它在自己的事件通道上运行,父 agent 通过回复通道等待结果。两个 agent 共享同一套事件分发机制,不互相阻塞。

但这个阶段仍然不是多任务。工具是串行执行的——一个 turn 内,工具按顺序排队运行,没有并行。这套串行执行的设计对验收体系有直接影响:验收门只能串行串联而非并行执行,完整讨论见篇 7「第三级验收门」。

操作系统——多任务内核

事件驱动再往前走一步,就是 OS 风格的多任务架构。

CodeCoder 的最终形态是 OS 线程 + channel。这里有一个关键的选择:为什么不用 async runtime(tokio、async-std 之类)?

原因有三。首先,工具调用往往涉及阻塞式系统操作——读文件、跑命令、等子进程。在 async runtime 里,一个阻塞操作会卡住整个事件循环,所以你必须确保每个工具调用都是非阻塞的。这在实践中几乎不可行——不是每个第三方工具都提供 async API。OS 线程允许你在线程里阻塞而不影响其他线程。

其次,sub-agent 需要独立阻塞。如果父 agent 在等子 agent 返回,它应该释放 CPU 给其他线程,而不是卡住整个运行时事件循环。OS 线程天然支持这种模式——一个线程可以阻塞在 channel receive 上,其他线程继续运行。

第三,取消路径更清晰。异步框架的取消通常靠 abort()drop(),但这不能保证子进程的正确清理。OS 线程的取消算是指向共享 CancelToken 的——你明确知道取消时执行了什么清理代码。

当然,这不是说 CodeCoder 真的是一个多任务操作系统。它的 turn 内仍然是串行的——agent 在一个时刻只做一件事。多线程主要体现在:

  • 主 agent 线程处理 turn 的逻辑
  • 工作线程运行为后台任务(比如 headless runner 的里程碑推进)
  • daemon 线程监听 Unix socket、接受客户端连接
  • Compaction 线程周期性地压缩上下文

它更像早期 Unix——多进程,但进程间隔离清晰,每个进程也只做一件事。

对 agent 架构设计者的启示

这三个阶段的区分不是学术分类——它是帮助你判断"我的 agent 现在需要哪种内核"的工具。

阶段适合场景瓶颈迁移信号
REPL单轮问答、短任务无法取消、无法并发遇到需要取消的操作
事件驱动多步任务、交互式工作工具串行执行需要同时处理多个请求
OS 内核常驻服务、后台运行架构复杂度需要常驻 Capability

不需要一开始就设计 OS。 REPL 就够起步,直到你遇到"取消不了正在执行的命令"的问题时才需要事件驱动。直到你遇到"需要在后台跑一个持续服务"的问题时才需要 OS。

关键警告是:不要设计一个"想有 OS 的能力但只有 REPL 的结构"的中间态。 具体表现为:试图用全局变量实现并发、用 sleep 循环实现定时任务、用写临时文件来实现进程间通信。这些不是架构演进——是 hack。如果发现自己在做这些事,说明该迁移到下一阶段了。

最后,代码调用栈和 agent 调用栈之间有一个深层类比,可能帮助判断你在哪个阶段:

  • REPL = 单线程同步函数调用——简单、可预测、易调试
  • 事件驱动 = 回调 + 信号——灵活、可中断、调试困难一点
  • OS = 进程 + 消息传递——强隔离、可组合、最复杂

代价与权衡

从 REPL 到事件驱动到 OS 的每一步演进都伴随着可量化的代价。

第一级代价:调试复杂度。 REPL 循环的调试是最简单的——你只要打印输入和输出,就知道"它的输入是什么,输出了什么"。事件驱动模式下,你还要追踪消息何时到达、是否被阻塞、Token 是否被翻转。OS 级的多线程则引入了竞争条件、死锁、状态同步等调试维度——println 调试不再够用,你需要结构化事件追踪。

第二级代价:确定的承诺。 REPL 的 turn 是完美确定的——每次的输入产生输出,没有其他东西插进来。事件驱动的协作式取消增加了"turn 可以中途中断"的可能性。OS 级的多线程意味着 agent 可以在你不知情的情况下并行处理多个事情——这在提高利用率的同时也意味着"agent 做了什么"不再是可预设的路径。对于需要确定性回放的安全审计场景(比如金融交易 agent),OS 级架构可能比 REPL 更难满足需求。

第三级代价:操作系统不是所有问题的答案。 CodeCoder 的多线程仅限基础设施层(daemon 线程、工作线程、compaction 线程),主 agent 的 turn 内仍然是串行的。这不是偷懒——是有意保持"一个 turn 执行逻辑的确定性"。如果你的 agent 不需要 daemon 化运行、不需要并行 compaction、不需要多客户端同时连接,OS 线程 + channel 架构的复杂性是不必要的。对大多数单用户、单进程的 agent 场景来说,事件驱动就是最终形态。

不要为了技术趣味而过度设计。

下一篇,我们把这些机制的信任边界打开来审视——当一个 agent 能写代码、能跑代码时,大门开在哪里?