第一章 运行时模型:Agent 是如何被"驱动"起来的

本系列以 OpenAI Codex(Rust 实现)为样本,拆解一个生产级 agent harness(脚手架/运行时框架)的设计。 所谓 harness,指模型之外的一切驱动逻辑:会话管理、轮次调度、工具执行、审批沙箱、上下文维护、持久化恢复…… 模型本身只是一个“给它输入、它吐输出”的推理引擎;是 harness 让它变成一个能连续做事、能被打断、能与人协作的 agent。


1.1 为什么 agent 需要一个“运行时”

传统程序的控制流是程序员写死的:调用 A,拿到结果,再调用 B,分支条件都在代码里。

Agent 程序的控制流则是倒置的:每一步做什么,由模型根据当前上下文临时决定。模型可能说“我要调一个工具”,也可能说“我做完了,这是答案”,还可能说“我需要问用户一个问题”。没有人能预先知道下一行“代码”是什么。

这就要求模型外面包一层运行时,来回答一系列问题:

  • 一次对话从哪里开始、什么时候算结束?
  • 模型正在跑的时候,用户突然插话怎么办?
  • 模型跑飞了,怎么停下来?停下来之后还能接着跑吗?
  • 进程崩溃重启了,之前的对话还能续上吗?
  • 一个 agent 忙不过来,想再派几个“分身”并行干活,怎么管?

这些问题的答案,构成了 harness 的运行时模型:它定义了“有哪些层级的运行实体、它们各自的生命周期是什么、彼此之间如何传递控制权”。

运行时模型是整个 harness 的骨架。后面章节讲的事件协议、工具系统、安全策略、上下文管理、多 agent 编排,都是挂在这副骨架上的器官。


1.2 五个层级:从进程到一次模型调用

Codex 的运行时可以分为五个层级,从大到小依次嵌套:

graph TD
    TM["线程管理器 ThreadManager<br/>(进程级,唯一)"]
    TM --> T1["线程 Thread / 会话 Session<br/>一段持续的对话,拥有独立历史"]
    TM --> T2["线程 Thread(子 agent)"]
    T1 --> TU["轮次 Turn<br/>用户一次输入 → agent 跑到空闲"]
    TU --> TK["任务 Task<br/>轮次的执行载体:常规 / 审查 / 压缩"]
    TK --> S1["步骤 Step<br/>一次模型采样请求"]
    TK --> S2["步骤 Step"]
    TK --> S3["步骤 Step"]
    S1 -.->|"工具调用并行执行"| TOOL["工具调用 ×N"]
层级 是什么 生命周期 关键不变量
线程管理器 进程级的“大管家”,持有所有线程,共享认证、模型目录、MCP 管理等重资源 与进程同寿 一个进程一个
线程(Thread/Session) 一段持续的对话,拥有独立的消息历史和后台事件循环 从创建/恢复到关闭 同一时刻至多一个活跃轮次
轮次(Turn) 用户一次输入所触发的完整工作单元:从提交输入到 agent 给出最终回复或被中止 秒级到分钟级 有唯一 ID,状态可观测
任务(Task) 轮次的执行载体。常规对话、代码审查、上下文压缩是三种不同任务 与轮次大致同寿 可被取消令牌中止
步骤(Step) 轮次内部的一次模型采样请求:发上下文 → 收流式回复 → 执行工具 → 回灌结果 一次 HTTP 请求的时长 每次步骤冻结一份配置快照

几个值得注意的设计细节:

1. “线程”和“会话”基本是同义词。 对外叫 thread(线程),对内的运行时结构叫 session(会话)。前端程序(host,指接入并承载内核的外部程序,如 TUI、IDE 插件、CLI、app-server 客户端;下文简称“前端”)拿到的只是一个薄句柄(双向消息管道),真正的状态全部藏在内核里。

2. ID 体系暗示了层级关系。 三类 ID 都是 UUID,但生成方式和作用域不同:

  • Session ID:一棵 agent 树共享一个。根线程的 session ID 直接取自它自己的线程 ID,派生出的子 agent 全部继承这同一个 ID;进程重启后恢复线程时,session ID 也从持久化记录中原样还原。
  • Thread ID:每个线程一个(时间有序的 UUIDv7),父子派生关系持久化下来构成一棵树。
  • Turn ID:用户触发的轮次使用时间有序的 UUIDv7(它就是该次输入的提交 ID,编码了创建时间),前端直接把它作为公开的轮次标识——每个事件都携带它,用来把事件流关联回具体轮次。有两个例外:子 agent 来信自动唤醒的合成轮次使用随机 UUIDv4;恢复(recover)轮次不生成新 ID,而是沿用被中断轮次的原 ID,对外表现为“同一个轮次暂停后继续”。

3. Task 层的存在是为了“轮次也有不同种类”。 用户聊天触发的是常规任务;请求代码审查会跑一个审查任务;上下文太长时压缩历史是压缩任务。它们共用同一套生命周期(创建、运行、中止、收尾),但内部逻辑不同。把“轮次的内容”和“轮次的生命周期”分开,运行时就能用统一的方式调度它们。

类比:线程管理器像餐厅经理,负责开台和共享资源;一个线程像一桌客人的整个用餐过程;一个轮次像客人点一次单到这道菜上完;一个步骤像后厨向客人汇报一次“菜做到哪了”并根据反馈决定下一步;而任务类型区分了“正餐”“甜点”“清台”这几种不同的服务流程。


1.3 消息驱动:一切皆消息,单循环串行

前端(TUI、IDE 插件、CLI、app-server 客户端等)和 harness 内核之间,只有两种消息

  • Op(操作):前端 → 内核。“用户说了句话”“用户点了中断”“用户批准了这条命令”……
  • Event(事件):内核 → 前端。“轮次开始了”“模型说了这段话”“工具在跑命令”“轮次结束了”……

每个线程内部跑着一个后台任务——提交循环(submission loop):它从一个有界队列里逐条取出 Op,串行处理,处理过程中不断把 Event 推回给前端。

sequenceDiagram
    participant Host as 前端(TUI / IDE / CLI)
    participant Queue as 提交队列
    participant SubmitLoop as 提交循环(单任务,串行)
    participant Turn as 轮次任务(可并发 spawn)
    participant Model as 模型

    Host->>Queue: Op:用户输入
    SubmitLoop->>Queue: 取出 Op
    SubmitLoop->>Turn: spawn 轮次任务
    Turn->>Model: 采样请求(流式)
    Model-->>Host: Event:文本 delta / reasoning 流
    Host->>Queue: Op:用户插话(steer)
    Note over SubmitLoop: 轮次在跑,Op 继续排队<br/>循环不被阻塞
    Model-->>Turn: 回复:工具调用
    Turn->>Turn: 并行执行工具
    Turn->>Model: 回灌结果,再次采样
    Model-->>Host: Event:最终回复
    Turn-->>SubmitLoop: 轮次结束
    SubmitLoop->>Queue: 处理排队的插话 Op

这个设计的核心取舍是:Op 的处理严格串行,所有并发都在轮次内部显式开启。

为什么不搞多线程并发处理 Op?因为一个会话的状态(历史消息、当前轮次、待审批请求……)是高度共享的。串行循环让这些状态的读写天然无锁、无竞争——不存在“两个 Op 同时修改历史”这种问题。需要并发的重活(模型流式请求、工具执行、压缩)由循环内部再 spawn 出去,循环本身继续保持轻量和响应性:哪怕轮次在跑,中断、审批应答这类控制类 Op 依然能立刻被取到并处理。

Op 大体分四类:

类别 代表操作
输入类 用户输入(内部再决定开新轮次还是插话)、子 agent 来信、恢复轮次
控制类 中断、关闭会话、清理后台终端、刷新配置
审批应答类 批准命令、批准补丁、回答提问、权限授予响应
维护类 手动压缩、回滚轮次、代码审查

其中最精妙的一点是:内核“向用户提问”也是消息

当工具执行需要授权(比如要跑一条危险命令),内核并不拥有任何“对话框”——它只是发出一个审批请求事件,然后在轮次内部挂起一个一次性的等待通道(oneshot),任务就此停住。用户在界面上点“批准”,这个回答作为一个审批应答 Op 进入队列,循环取出后通过那个等待通道唤醒挂起的工具调用,任务继续。

sequenceDiagram
    participant Turn as 轮次任务
    participant SubmitLoop as 提交循环
    participant Host as 前端

    Turn->>Host: Event:请求审批(命令详情)
    Note over Turn: 挂起,等待应答……
    Host->>SubmitLoop: Op:审批应答(批准)
    SubmitLoop->>Turn: 通过等待通道唤醒
    Note over Turn: 命令执行,轮次继续

这意味着人机协同(human in the loop)不是一套特殊机制,而是消息协议的自然延伸。工具问用户、子 agent 给父 agent 发消息、外部插件应答,走的都是同一个“发事件 → 挂起 → 收 Op → 唤醒”模式。第 2 章讲事件协议、第 8 章讲安全审批、第 9 章讲 Human in the loop 时还会回到这个设计。


1.4 轮次的生命周期

1.4.1 状态机

每个线程对外暴露一个代理状态(AgentStatus),它是一个简单的有限状态机:

stateDiagram-v2
    [*] --> PendingInit: 线程创建
    PendingInit --> Running: 轮次开始
    Running --> Completed: 模型给出最终回复
    Running --> Interrupted: 用户中断 / 预算耗尽
    Running --> Errored: 不可恢复错误
    Interrupted --> Running: 恢复(recover)或新输入
    Completed --> Running: 下一次用户输入
    Errored --> Running: 下一次用户输入
    PendingInit --> Shutdown: 关闭会话
    Running --> Shutdown: 关闭会话(先中止活跃轮次)
    Interrupted --> Shutdown: 关闭会话
    Completed --> Shutdown: 关闭会话
    Errored --> Shutdown: 关闭会话
    Shutdown --> [*]

注意 Interrupted(已中断)不是终态——它表达“轮次停了,但线程还活着,可以继续”。这是“打断后还能恢复”的关键。Completed(已完成)、Errored(出错)、Shutdown(已关闭)才是终态。

关闭会话可以发生在任何存活状态——无论线程是刚创建还没跑过轮次(PendingInit)、轮次正在运行(Running)、还是空闲在已完成/已出错/已中断状态,前端都可以发起关闭。关闭操作在提交循环里是无条件处理的:若有活跃轮次先中止它,然后回收全部资源(中止任务、关闭 MCP 连接、终止后台进程、flush 持久化),最后发出“关闭完成”事件。典型触发场景有三类:用户退出 TUI/CLI;app-server 在线程被删除或最后一个订阅者断开且线程空闲时自动卸载;子 agent/审查线程完成收尾。除了显式关闭,提交循环还有一条隐式终止路径:当所有提交端都被丢弃时,循环取不到消息会自行退出并执行同样的资源回收——这相当于“拔掉电话线”,来不及也不需要等待确认,正常退出走显式路径,异常拆解走隐式路径。

状态不是内核自己拍脑袋改的,而是从输出事件中推导出来的:看到“轮次开始”事件就是 Running,看到“轮次完成”就是 Completed,看到“轮次中止”且原因是中断/预算就是 Interrupted,看到“关闭完成”就是 Shutdown。事件是唯一事实源,状态只是事件流上的一个投影(这也为第 10 章的事件溯源持久化埋下伏笔)。

1.4.2 输入路由:开新轮次、插话,还是拒绝

用户输入到达时,内核要做一个路由决策,这是全系统唯一做这个决策的地方:

  • 开新轮次(start):线程空闲,输入开启一个新 turn;
  • 插话(steer):有正在跑的常规轮次,输入注入进去(详见 1.6);
  • 拒绝(not submitted):忙且不能插话、输入为空、或指定的轮次已经结束——输入被原样拒绝,不记录、不入队、不改变任何状态

前端可以用三种模式表达自己的意图:

  • StartIfIdle:只在空闲时开新轮次,忙就拒绝(比如自动触发的场景,不该打断用户正在进行的工作);
  • StartOrSteer:空闲就开,忙就插话(用户手动发消息的默认行为);
  • Steer(指定轮次 ID):只给指定的那个轮次插话;如果它已经结束了就拒绝(防止把消息发给错误的轮次)。

一个重要的工程细节:输入可以携带设置变更(比如切换模型、调整审批策略)。内核会先“预览校验”这些设置,输入被接受后才真正应用。这样被拒绝的输入不会在系统里留下半应用的配置——“要么全发生,要么不发生”。

1.4.3 轮次内部:agentic 主循环

轮次一旦启动,就进入 agentic 循环(也叫 ReAct 式循环)。它和模型之间的契约非常简单:

每次采样,模型要么返回工具调用,要么返回最终回复。 是工具调用,就执行、把结果喂回去、再采样一次;是最终回复,轮次结束。

flowchart TD
    A([轮次开始]) --> B[轮次前检查:必要时先压缩上下文]
    B --> C[捕获步骤快照 StepContext<br/>记录环境变化]
    C --> D[运行会话开始 / 用户输入 hooks]
    D --> E{"还有事要做?"}
    E -->|是| F[排空待处理输入<br/>插话消息 / 子 agent 来信]
    F --> G[组装本次采样的上下文<br/>历史 + 环境片段 + 工具清单]
    G --> H[向模型发采样请求<br/>流式接收回复]
    H --> I{"模型回复内容"}
    I -->|工具调用| J[工具并行执行<br/>结果按调用顺序回灌历史]
    I -->|最终回复| K[运行停止 hooks]
    J --> L{token 预算检查}
    L -->|超限| M[自动压缩 / 开新上下文窗口]
    M --> E
    L -->|未超限| E
    K -->|hook 要求继续| F
    K -->|允许停止| N([轮次完成])

这个循环里有几个值得展开的点:

两级快照。 轮次级有一份 TurnContext(整个轮次不变的东西:工作目录、模型、策略……);每个步骤开始时再捕获一份 StepContext(这一次采样的“世界观”:当前模型信息、对模型可见的工具清单、MCP 绑定、执行环境快照)。步骤快照存在的原因是:轮次中途配置可能变化(模型 fallback、工具动态注册、环境切换),必须保证同一次采样请求中,“上下文里宣称的工具”和“真正能执行的工具”是同一份快照,绝不能出现模型看到了一个工具、调用时却发现不存在的情况。工具系统一章会详细讲这一点。

工具并行、回灌有序。 模型一次回复里可能同时调用多个工具,harness 会把它们全部 spawn 出去并行跑(等待 I/O 的时间互相重叠);但收集结果时严格按模型发出的调用顺序回灌历史。这样既吃到了并发的性能收益,又保证了上下文内容的确定性——同样的历史长出同样的下一步。

停止不只是模型说了算。 模型给出最终回复后,还要跑“停止 hooks”:外部扩展可以在此时投反对票,注入一条“你还有个测试没跑”之类的指令让轮次继续。这给了前端在轮次终点插入策略的能力(呼应第 11 章扩展性)。


1.5 中断:协作式取消,而不是一枪毙掉

用户按下 Esc(或调用中断 Op)时,系统里同时有很多在途的工作:一个可能正在流式传输的模型请求、若干正在跑命令的工具、一个可能正挂起的审批等待、后台的 dev server……强杀整个线程显然是不对的。

Codex 的方案是协作式取消(cooperative cancellation)

flowchart LR
    RT["轮次取消令牌"] --> RS["采样请求取消令牌"]
    RT --> RT2["工具调用取消令牌 ×N"]
    RS --> RS2["流式传输任务"]
    RT2 --> RT3["命令进程等待"]

取消令牌构成一棵树:轮次持有根令牌,每次采样、每个工具调用派生子令牌。中断信号从根令牌发出后沿树传播,所有在途工作各自决定“看到取消后如何尽快体面收场”:模型流停止读取、工具调用提前返回、挂起的等待被放弃。

中断的处理流程是:

  1. 取消令牌触发,状态标记为 Interrupted;
  2. 给运行中的任务一个很短的优雅期(约 100ms)自行收尾;
  3. 超时仍未退出的任务被运行时强制中止;
  4. 向历史追加一条中断标记(“上一轮次被用户中断了”),而不是删掉或改写已产生的内容;
  5. 发出“轮次中止”事件;
  6. 如果此时有“要求触发轮次”的子 agent 来信排队等着,可以自动开一个新轮次处理。

两个语义细节特别能体现设计的克制:

中断不杀后台进程。 通过工具拉起的后台终端(比如 npm run dev)在中断后继续运行——中断的语义是“停止当前 agent 工作”,不是“把这台机器上我启动过的东西都杀光”。清理后台终端是另一个独立的显式操作。

中断不回改历史。 被中断的轮次已经产生的消息、工具调用、工具结果全部保留,只追加一条带标记的说明。模型下一次被唤醒时,会从历史里读到“我上次干到一半被打断了”,从而知道哪些事可能没收尾。这是一条贯穿全局的原则:上下文只追加、不重写。中断因此不是“回滚(rollback)”,而是“翻篇(turn the page)”。第 4 章会看到,这条原则同时服务于缓存效率和可恢复性。

轮次中止有四种原因,语义各不相同:

原因 含义 中断后状态
Interrupted 用户主动中断 Interrupted(可恢复)
BudgetLimited rollout 预算(用量/时长上限)耗尽 Interrupted(可恢复,等用户追加预算)
Replaced 被新轮次取代(旧任务让位) 视情况收尾
ReviewEnded 审查任务随父轮次结束而结束 正常清理

1.6 Steer(转向):模型跑的时候,用户如何插话

场景很常见:agent 正在埋头修一个 bug,用户看着输出突然想起“对了,别忘了顺便更新测试”。如果等它跑完再说,它可能已经按错误方向走了十分钟;如果直接中断,又会丢失当前进度。

Steer 是第三条路:消息既不触发中断,也不被拒绝,而是进入一个待处理输入队列。

它的消费时机经过了精心选择——不在中途打断当前采样,而是在下一个步骤边界被排空

sequenceDiagram
    participant U as 用户
    participant T as 运行中的轮次
    participant M as 模型

    T->>M: 采样请求 #1(进行中)
    U->>T: 插话:"别忘了更新测试"
    Note over T: 进入待处理队列,不打断 #1
    M-->>T: 回复:工具调用
    T->>T: 执行工具,回灌结果
    Note over T: 步骤边界:排空队列<br/>插话写入历史
    T->>M: 采样请求 #2(历史中已包含插话)
    M-->>T: 调整后的行为(去更新测试)

这个设计的考量:

  • 不浪费在途请求。 一次采样已经为上下文付了费(prompt 处理、推理计算),中途打断等于直接扔掉;等它走完这一轮,插话自然出现在下一次请求里。
  • 不破坏上下文一致性。 正在流式接收的回复对应着“旧上下文”,插话属于“新上下文”。在步骤边界注入,保证任何一次采样请求的输入都是自洽的。
  • 对模型就是一条普通消息。 插话进入历史后和用户在轮次开始前说的话没有任何结构上的区别,模型不需要理解“插话”这个概念。

Steer 有明确的边界:只有常规轮次可被插话(审查、压缩任务拒绝插话——它们有自己确定的工作目标,不该被带偏);只接受用户输入;前端还可以指定“只给某个轮次插话”来防止串台。

子 agent 之间的来信走的是同一个队列机制(称为“邮箱”),但多了一个相位控制:如果父 agent 已经输出了用户可见的最终答复,迟到的子 agent 来信就留到下一个轮次再处理,而不是让一个“已经回答完”的轮次又偷偷活过来。第 6 章多 agent 编排会展开讲这套邮箱语义。


1.7 Recover 与 Resume:两种“续上”

“中断后接着跑”在 Codex 里有两个层次,容易混淆,值得分清:

同进程内的恢复(Recover)。 中断后线程仍然活着、历史都在内存里。恢复操作沿用原来的轮次 ID、不带新的用户输入,重新启动一个常规任务。模型从历史里读到中断标记,自行决定从哪里继续。因为轮次 ID 不变,外部观察者看来这是“同一个轮次暂停后又继续”,而不是“新开了一轮”。

跨进程的恢复(Resume)。 进程重启过、内存全空,这时从磁盘上持久化的事件流(rollout)重建整个会话:历史消息、配置、token 用量、中断标记……逐条回放,线程被还原到上次退出时的状态,之后用户可以继续对话。这是第 10 章持久化与可恢复性的主题。

此外还有 Fork(分叉):从某条历史线程的某个点复制/引用出一条新线程,像 git 分支一样并行探索两个方向。根因在于历史是只追加的事件流——“复制一个会话”本质上就是“从某个事件位置开始读同一份流”。

gitGraph
    commit id: "turn 1"
    commit id: "turn 2"
    branch fork
    checkout fork
    commit id: "探索方向 A"
    checkout main
    commit id: "turn 3(方向 B)"

1.8 并发模型总览:串行外壳,并发内核

把运行时的并发策略串起来看,是一张层次分明的图:

graph TD
    subgraph P["进程"]
        TM["线程管理器"]
        subgraph S1["线程 1(串行提交循环)"]
            L1["Op 队列 → 串行分发"]
            T1["活跃轮次 ≤ 1"]
        end
        subgraph S2["线程 2(子 agent,同样串行)"]
            L2["Op 队列 → 串行分发"]
            T2["活跃轮次 ≤ 1"]
        end
    end
    T1 --> P1["采样请求"]
    T1 --> P2["工具调用 1 ∥ 工具调用 2 ∥ 工具调用 3"]
    TM -.->|"全局并发上限<br/>+ spawn 深度限制"| S2
  • Op 处理层:每个线程一个循环任务,严格串行,无锁;
  • 轮次层:同一时刻每个线程至多一个活跃轮次;
  • 步骤内工具层:多个工具调用显式并行,结果按序回灌;
  • 跨线程层:整棵 agent 树有并发轮次上限(超出则排队等待容量),还有 spawn 深度限制防止“子 agent 生孙 agent、孙 agent 又生”的无限繁殖;
  • 空闲唤醒:线程空闲时如果收到“要求触发轮次”的子 agent 来信(或有定时唤醒类任务),运行时会自动开一个新轮次——线程的“睡”与“醒”也是事件驱动的。

这种“外壳绝对串行、内核按需并发”的结构,把并发 bug 的表面积压到了最小:共享可变状态只在串行循环里碰,所有并行任务拿到的都是不可变快照和自己私有的执行上下文。


1.9 小结:这副骨架的四条设计原则

  1. 单循环串行化。 一个线程一个提交循环,Op 排队顺序处理。并发只出现在轮次和工具内部,且拿到的都是冻结快照。状态机因此简单到可以直接画在纸上。

  2. 一切皆消息。 前端与内核之间只有 Op/Event;连“内核问用户要审批”都是发事件、挂起、等应答 Op 唤醒。人机协同、子 agent 通信、扩展回调因此共用同一套机制,没有特例。

  3. 快照驱动。 轮次有轮次快照,每次采样有步骤快照;工具集、环境、配置都在步骤边界冻结。每次请求自洽,也让事后重放成为可能。

  4. 中断是“翻篇”不是“回滚”。 协作式取消沿令牌树传播;历史只追加不重写,中断留下标记;后台进程不受影响;中断是可恢复的非终态。

留给读者思考的几个问题(后续章节会逐一回答):

  • Steer 为什么选择“排队到下个步骤”而不是立即打断采样?这和模型上下文的缓存机制有什么关系?(→ 第 2、4 章)
  • 既然轮次是串行的,为什么工具执行要做成并行?“结果按调用顺序回灌”如果顺序被打乱,会发生什么?(→ 第 5 章)
  • 审批挂起期间,轮次任务一直“停在那里”,它占用了什么资源、释放了什么资源?为什么这不会卡住提交循环?(→ 第 7、8 章)
  • 事件既是给前端看的 UI 数据源,又是状态推导和持久化恢复的依据——一份事件流同时服务三个用途,这对事件的设计提出了什么要求?(→ 第 2、10、12 章)

下一章我们进入交互与事件协议:Op/Event 这层消息总线具体长什么样,模型的流式协议又如何被翻译成前端能消费的事件。

分类:Agent Harness标签:#agent #harness #codex