读 Agent harness 源码时,最容易踩的坑出现在概念层:开发者常把 Thread、Session、Turn 一概称作「会话」。这三个词在界面上经常互相替换,在代码里却对应三套不同的生命周期,存活时间相差极大。Codex 在这套对话机制上同时暴露了五个概念:Thread、Session、Turn、Submission、Event,每个概念解决一个独立问题。混用的代价很具体:中断一个执行轮次时误删整段历史,或者进程重启后找不回原本的任务。
为什么一次对话需要五个概念
一个 Agent 执行任务时,内部同时存在多条时间线。用户在 Agent 工作时追加要求、审批一条命令、点击中断,这三件事可能在同一秒内到达;模型请求会失败重试,工具进程会超时报错,上下文会被压缩。如果系统只用一个「会话」对象承载这一切,任何一侧的异常都会牵连其他侧。我们的做法是把状态按生命周期切开,分成五层:长期任务、运行实例、目标周期、输入通道、输出通道。
上一代聊天机器人的状态机很简单:一个请求进,一个回复出,Session 基本等同于一次连接。Agent 把这条直线换成了一个循环------模型决定调用工具,工具结果再喂回模型,中间还可能向人发起审批。循环一旦成立,「一次请求」这个单位就不够用了,必须同时描述「这个人正在处理的长期任务」「这个进程里正在跑的执行体」「这一轮目标」以及进出执行体的两股消息流。下面逐层看 Codex 的实现。

Thread:用户可见的长期任务
Thread 是侧边栏里能看到、日后还能继续的任务。「排查登录超时」是一个 Thread,它内部可以连续发生多次提问、修改和测试。Thread 拥有稳定 ID,可以被列出、恢复、fork、归档和删除。
ThreadManager 负责创建 Thread,并在内存里维护 ThreadId → CodexThread 的映射。CodexThread 是控制这个任务的句柄------通过它提交操作、读取事件、查询状态、关闭任务;运行逻辑全在它指向的实例内部。把句柄与实例分成两个类型,界面上的按钮和生命周期操作就不需要直接触碰执行体。
Session:Thread 当前的运行实例
Thread 可以长期存在,进程却不会永远运行。历史 Thread 被加载进当前进程后,会得到一个新的 Session。Session 持有真正的运行资源:配置、模型客户端、认证、工具、MCP(Model Context Protocol,连接模型与外部工具的开放协议)、扩展、历史、输入队列以及当前活动的 Turn。
医院里的类比很方便:Thread 是病历号,Session 是这次住院期间占用的床位、医生和设备。病历长期保存,床位可以退掉再开一次。
Codex 创建 Session 时同时启动 submission_loop 后台任务。这个循环持续从队列取出指令并处理,直到收到 Shutdown 或所有发送端关闭。Session 因此带有活的行为:它有启动、有循环、有关闭。判断资源归属时按这条规则操作------随 Session 结束而失效的,属于运行实例;退出后被存下来、下次加载可恢复的,属于 Thread。

Turn:一次用户目标的执行周期
Turn(执行轮次)从一条用户任务开始,到 Agent 给出本轮最终结果为止。一个 Thread 包含许多 Turn,一个 Turn 又包含多次模型请求和多次工具调用。
Thread:修复登录问题
├─ Turn 1:分析调用链
│ ├─ 模型请求 1
│ ├─ 工具调用:搜索代码
│ └─ 模型请求 2
├─ Turn 2:实现修复
└─ Turn 3:运行测试
上例中 Turn 1 用两次模型请求夹一次搜索,仍然只是一个 Turn。判断 Turn 边界的依据是本轮目标是否完成,与内部请求次数无关。
TurnContext 保存本轮相对稳定的运行条件:模型、推理强度、权限、沙箱、工作目录、环境选择和 Turn ID。它的作用是隔离配置漂移。执行到一半时外部设置发生变化,本轮语义不受影响,新的条件从下一个 Turn 开始生效。少了这个快照,一次中途修改模型参数会让同一段推理的前半与后半来自不同模型,事后无法解释输出为什么跳变。
Submission:送给 Session 的内部信封
界面向 Session 施加动作时,中间隔着 Submission(内部信封)。用户的一次操作被包装成信封送入有界队列,信封内容包含唯一 ID、具体操作 Op、追踪信息以及父子 Turn 信息。
操作可以是启动 Turn、中断、审批结果、用户回答、刷新 MCP、压缩上下文、更新设置或关闭。后台的 submission_loop 顺序接收信封,再分派给对应处理器。
这层消息边界解决并发写入的问题。补充要求、审批命令和中断点击同时到达时,统一排队让顺序可推理;若让多个界面回调直接改写 Session 状态,最终状态取决于调度时机,难以复现。有界队列还提供背压,防止界面高频操作压垮执行端。唯一 ID 派上用场的地方在回执:审批结果必须绑定到当初发出的那条审批请求,否则两次同名请求会互相认领。
Event:Session 向外发出的事实
Submission 表达外界希望 Session 做什么,Event 记录 Session 已经发生了什么。Session 持续发出配置完成、Turn 开始、文本增量、工具开始、审批请求、工具结束、Token 用量和 Turn 完成等事件。
界面消费事件流,Session 的内部字段保持私有。App Server 可以把同一组核心事件映射成对客户端更稳定的协议通知,客户端版本与内部数据结构因此解耦,两边独立演进。Token 用量事件支撑计费与上下文水位判断,水位触顶后系统安排一次压缩上下文的 Submission,整个过程仍然走同一条输入通道,界面无需理解内部实现。

五层放在一起
vbnet
ThreadManager 管理 Thread
Thread 加载后拥有 Session
Session 接收 Submission、产生 Event
Session 在每个 Turn 中运行 Agent Loop
TurnContext 固定本轮运行条件
切分的价值在故障处理路径上显现。模型请求失败时,Turn 可以在本轮内重试;Turn 被中断时,Thread 仍保留已完成的 Turn 历史;进程退出使 Session 结束,持久化的 Thread 下次加载时重建运行实例。每种故障只影响自己那一层,重试、恢复和并发才有清晰语义。五个命名各占一段生命周期,这套切分才有落点。

对 harness 设计的启示
只装了历史、当前请求、工具进程和 UI 状态的巨大 Conversation 对象,迟早出现状态互相污染。常见的表现有两条:一次中断清掉了本该保留的上下文,一条迟到的回调改写了新请求的参数。
自建 Agent 系统时,建议先画清五层边界:长期身份、当前运行实例、单次目标、输入命令、输出事件。命名可以与 Codex 不同,边界不能缺少。检验边界是否画对的办法很实际:任意一层挂掉时,能否明确说出上层是否还在、要不要恢复、恢复后从哪里接上。三问都答得出,分层才成立。
源码可以按这条线索读:
bash
codex-rs/core/src/thread_manager.rs ThreadManager、NewThread
codex-rs/core/src/codex_thread.rs CodexThread
codex-rs/core/src/session/mod.rs Session、SessionIo
codex-rs/core/src/session/turn_context.rs TurnContext
codex-rs/protocol/src/protocol.rs Submission、Op、EventMsg
读这套代码的顺序建议从字段定义开始:先看 Submission 与 EventMsg 两个枚举弄清系统允许的输入和输出,再跟一遍 submission_loop 的分派看动作如何改变状态,最后回到 ThreadManager 与 Session,确认实例的创建与销毁时机。五段生命周期各自安好之后,「会话」这个词在代码里反而不再需要出现。