雨天小六

读懂 Codex(3.1):ThreadId、ConversationId 与 Session 身份

· 更新于 2026-08-02 · 专栏:读懂 Codex

#Codex#Agent Runtime#生命周期#软件架构

Codex 当前真正稳定存在的两个协议身份是 ThreadIdSessionId。源码里没有一个仍在生产路径上独立定义的 ConversationId 类型;conversation_id 只残留在恢复历史、旧事件字段和兼容命名中。把三者写成三个并列 ID,会直接误导后续对恢复、派生 Agent 和 Rollout 的理解。

先给结论:身份、对象和历史名字不是一回事

名称当前载体生命周期主要用途
Thread 身份ThreadId跨加载、跨恢复持久存在定位一条对话线程和它的 Rollout
Session 树身份SessionId根 Agent 与其后代共享把一个多 Agent 工作树归到同一会话
活体 RuntimeSession 对象Thread 本次被加载到进程期间持有通道、状态、服务和活动 Turn
旧 Conversation 命名conversation_id 字段兼容历史格式在恢复数据中承载 ThreadId

ThreadIdSessionId 都包装 UUID,并以字符串序列化。默认构造使用 UUIDv7,不只是为了随机唯一性,也让按值排序大致保留创建时间。两者可以显式相互转换,但“能转换”不代表“语义相同”。

ThreadId、SessionId、Session 对象和 Turn 的身份作用域
图 3.1-1:ThreadId 标识一条可恢复线程,SessionId 把根线程和后代 Agent 归组;Session 是一次加载后的运行时对象,不是第三种持久化 UUID。

新建、恢复和派生走不同的身份选择

Session::new 不会无条件生成两个新 ID。Thread 身份取决于初始历史:

  • New、Cleared、Forked 创建新的 ThreadId
  • Resumed 直接采用恢复记录中的 conversation_id
  • Rollout 的 SessionMeta 若带有持久化 SessionId,会参与会话树身份恢复;
  • 非根 Agent 新建时从 AgentControl 继承根会话的 SessionId;
  • 根 Session 没有已保存 SessionId 时,才用自己的 ThreadId 初始化 SessionId。

这解释了为什么根线程刚创建时两个值经常相同,却不能据此把它们永远等同。子 Agent 拥有自己的 ThreadId,但继承根 Agent 的 SessionId。

def choose_identities(initial_history, session_meta, agent_control, source):
    if initial_history.kind == "resumed":
        thread_id = ThreadId(initial_history.conversation_id)
    else:
        thread_id = ThreadId.uuid_v7()

    persisted = session_meta.session_id if session_meta else None
    if source.is_descendant_agent:
        session_id = agent_control.session_id()
    elif persisted is not None:
        session_id = persisted
    else:
        session_id = SessionId.from_thread_id(thread_id)

    return thread_id, session_id

源码还专门过滤一类旧 Rollout:历史子 Agent 可能把自己的 ThreadId 错当成 SessionId 保存。若恢复时机械相信这个值,会把原本属于同一根会话的 Agent 拆成多个会话树。兼容逻辑因此不是简单字段改名,而是带来源判断的数据修复。

为什么恢复的是 Thread,而不是原 Session 对象

ThreadManager 可以从 Rollout 重新构造 Session。恢复前后的 Rust 对象、Channel、Tokio 任务和内存锁都不是同一份,但 ThreadId 保持不变。由此可得到一个严格边界:

async def resume_thread(thread_id):
    live = live_threads.get(thread_id)
    if live is not None:
        return live

    history = await thread_store.read_rollout(thread_id)
    session = await Session.spawn(initial_history=Resumed(history))
    assert session.thread_id == thread_id
    return register_live_session(session)

所以“一个 Thread 对应一个 Session”只在“当前已加载的 live runtime”这个时间切片内成立。持久化 Thread 可以先后对应多个 Session 实例;一个 Session 实例则固定服务于一个 Thread。

同一 ThreadId 在卸载和恢复前后对应不同 Session 对象
图 3.1-2:恢复复用持久化身份,不复用已经消失的内存对象。子 Agent 另有 ThreadId,但仍落在根 SessionId 的会话树中。

设计代价与失败边界

双身份的收益是 Thread 可以独立恢复、归档和分叉,同时多 Agent 运行仍能按根会话聚合。代价是任何存储和遥测代码都必须明确自己需要“线程”还是“会话树”:用 SessionId 查单条 Rollout 会过宽,用 ThreadId 聚合整棵 Agent 树又会漏掉后代。

恢复时还有三个必须拒绝或修复的情况:非法 UUID 字符串、历史元数据与恢复目标冲突、旧子 Agent SessionId 污染。源码的做法是把字符串解析集中在强类型包装器,把历史兼容留在 Session 构造处,而不是让业务代码到处猜测。

源码与测试锚点

  • codex-rs/protocol/src/thread_id.rs:ThreadId、UUIDv7 与字符串序列化。
  • codex-rs/protocol/src/session_id.rs:SessionId 及双向显式转换。
  • codex-rs/core/src/session/session.rs:新建、恢复、根/子 Agent 的身份选择。
  • codex-rs/core/src/thread_manager.rs:按 ThreadId 恢复和注册 live thread。

评论


← 返回文章列表