第19章 从 REPL 到操作系统
2026.07.29Agent 内核的演进路径,和操作系统的发展惊人地相似。
引子
你的 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 能写代码、能跑代码时,大门开在哪里?