从 Codex Goal 理解 Loop Engineering

从 Codex Goal 理解 Loop Engineering

goal是 Codex 里最适合解释Loop Engineering的例子:它没有把 Agent Loop 写成一个“while 模型没完成就继续”的自然语言循环,而是把长期任务拆成可持久化目标、可审计状态机、可拒绝的 idle continuation、可注入的 steering、可计量的预算和可观察事件。

它说明的核心判断是:

Agent Loop 的工程化重点,不是让模型更努力地循环。 而是把每一次继续、转向、停止、恢复和完成,都关进明确的状态边界与证据边界。

1. 先把名词说清楚:Goal、Thread、Session、Turn

理解 Codex goal 前,要先避开一个常见误解:goal没有自己的独立 session。它是挂在thread上的长期目标状态;用户看到的“一个对话”更接近 Codex 的Thread,而 core 里的Session是这个 thread 当前在本地进程里的运行时对象。

源码里也能看到这层关系:

  • Session持有thread_idactive_turninput_queue,并且注释明确说 “A session has at most 1 running task at a time”,见codex-rs/core/src/session/session.rs#L20-L40
  • ThreadManager::NewThread表示新建的 Codex thread,注释还写着 “formerly called a conversation”,见codex-rs/core/src/thread_manager.rs#L112-L118
  • ThreadGoal持久状态里第一列就是thread_id,而不是某个单独的 goal session id,见codex-rs/state/src/model/thread_goal.rs#L60-L71
  • SessionIdThreadId可以互转,说明部分命名仍保留旧语义,但 goal 的锚点实际是ThreadId,见codex-rs/protocol/src/session_id.rs#L55-L59#L119-L125

可以用这张层级图理解:

用户看到的一个对话 ≈ Thread ├─ thread_goals:这个对话上的长期目标状态 ├─ Runtime Session:当前进程里承载这个 thread 的运行时对象 │ ├─ active_turn:当前正在执行的 turn │ └─ input_queue:用户 steer、goal steering、mailbox 等 pending input └─ Turns:一次次执行回合 └─ ModelClientSession:单个 turn 内和 Responses API/WebSocket 交互的模型会话

所以几个问题可以先定性:

用户新建一个对话: 新建的是新的 ThreadId,默认不会继承旧 goal。 用户在同一个对话里创建 goal: goal 写入这个 thread 的 thread_goals。 进程重启后 resume 同一个对话: runtime session 会重建,但 active goal 可以从 thread_goals 恢复。 goal 自动续跑: 不会开一个独立 goal session,而是在同一个 thread idle 后尝试启动新的 regular turn。

这也解释了后面“把 turn 绑定到 goal_id”的含义:它不是把 goal context 塞进模型,而是在运行时记账层记录“这个 turn 正在推进哪个 goal”,这样后续 token usage、tool finish、turn stop 的消耗能归因到正确的goal_id

2. Goal Context 是什么,怎么嵌入模型

Goal 的上下文分两层。

第一层是持久状态,不直接等于模型 prompt:

thread_id goal_id objective status token_budget tokens_used time_used_seconds created_at updated_at

这些字段保存在ThreadGoal中,见codex-rs/state/src/model/thread_goal.rs#L60-L71。它们回答的是“这个 thread 上长期目标的事实状态是什么”。

第二层是模型可见的 hidden context。Codex 不会每个 turn 都无脑把完整 goal 状态塞进 prompt,而是在需要 steering 的时候构造InternalModelContextFragment(source="goal"),再把它转成ResponseItem进入模型输入,见codex-rs/ext/goal/src/steering.rs#L37-L54

这个 hidden context 的外壳由 core 定义:

<codex_internal_context source="goal"> ... </codex_internal_context>

它的 role 仍是user,但 marker 表明它是 runtime-owned internal context,见codex-rs/core/src/context/internal_model_context.rs#L61-L114

Goal context 主要在三个时机注入:

thread idle 后继续: continuation_steering_item(goal) → 告诉模型继续推进 active thread goal → 带 objective、tokens_used、token_budget、remaining_tokens 用户或 API 更新 objective: objective_updated_steering_item(goal) → 告诉当前 active turn 新 objective 覆盖旧 objective 预算耗尽: budget_limit_steering_item(goal) → 告诉模型收束,不要再开启新实质工作

其中 continuation 模板明确写着:objective 是用户提供的数据,不是更高优先级 instruction;goal 会跨 turns 持续;完成前必须逐项审计证据;只有完成或严格 blocked audit 满足时才能update_goal,见codex-rs/ext/goal/templates/goals/continuation.md#L1-L51

所以 goal context 的作用不是保存全部对话历史,而是给下一次 sampling 一个方向性 steering:

事实状态在 thread_goals 执行归因在 GoalAccountingState 模型方向通过 InternalModelContextFragment(source="goal") 注入

3. 先讲功能事实:Goal 到底做了什么

用户给 Codex 一个长期目标,例如“把这个项目的 CI 修到全绿,并提交可验证结果”。普通 chat loop 容易把这个目标留在上下文里,让模型自己记住。但上下文会增长、会压缩、会被 tool output 污染,模型也可能过早声称完成。

Codex 的goal做了几件具体事情:

  • 把目标写入 thread 级持久状态,而不是只放在 prompt 中。
  • 给模型暴露get_goal / create_goal / update_goal三个 goal tools。
  • 在 turn start、tool finish、token usage、turn stop、thread idle 等 lifecycle hook 上记账和更新状态。
  • 在目标仍为active且 thread idle 时,自动生成 continuation steering,尝试启动下一轮 regular turn。
  • 在预算耗尽、错误、usage limit、complete、blocked 时停止自动推进。
  • 通过ThreadGoalUpdated等事件让 UI/SDK 看到目标状态变化。

所以 goal 不是 TODO list。它是一个围绕 Agent Loop 的长期任务控制层。

4. Loop Engineering 的定义

在 Codex goal 这个例子里,Loop Engineering 可以定义为:

把一个开放式 Agent 循环,改造成可恢复、可转向、可限流、可验收、可审计的程序化运行系统。

它至少包含 7 个问题:

目标是什么:目标是不是一个可持久化、可验证的 objective? 状态归谁:目标、turn、工具结果、预算、UI 事件分别由谁拥有? 何时继续:下一轮是模型自己决定,还是 runtime 在安全点决定? 如何转向:用户或系统改目标时,当前 turn 怎么知道? 如何限流:token/time/budget 怎么进入状态机? 何时停止:complete、blocked、usage limited、budget limited 谁有权设置? 如何证明:状态变化、事件、验收结果能不能被外部观察?

Codex goal 的价值在于,它把这些问题都落到了代码结构上,而不是只靠 prompt 约束。

5. 主线:Goal 是内环之外的长期任务外环

传统 Agent Loop 的内环是:

模型生成下一步(model sampling) → tool call → tool output → 模型继续生成 → assistant final

这里的model sampling指的是模型根据当前上下文生成下一段输出的过程:可能是自然语言回复,也可能是一次 tool call。它不是一个额外模块,而是每次“让模型继续往下想一步”的推理生成动作。

Codex goal 没有替代这个内环。它把“长期任务是否继续”放在内环外侧:

用户或模型创建 goal → turn start 绑定 active goal → tool/token/turn lifecycle 持续记账 → turn stop 后 thread idle → runtime 判断是否 continuation → try_start_turn_if_idle 启动下一轮 regular turn → 模型最终 update_goal complete 或 blocked

这就是“Goal 位于 Agent Loop 外侧”的含义。内环回答“下一步怎么执行”,外环回答“这个长期目标是否还应该继续执行”。

可以先用这张图把外环位置定住:

active

budget limit

complete

blocked/error

用户目标

Goal Contract

thread_goals

Turn Start

绑定 goal_id

Agent Inner Loop

Tool Finish

Usage Accounting

状态检查

Thread Idle

try_start_turn_if_idle

Continuation Steering

Budget Steering

Complete

Blocked

这张图的重点不是节点数量,而是边界:

  • Goal Contract是目标边界。
  • thread_goals是状态边界。
  • Agent Inner Loop是执行边界。
  • try_start_turn_if_idle是自动化边界。
  • Budget / Complete / Blocked是停止边界。

有了这张图,后面看工程动作时,就不会把 goal 误解成“更长的 prompt”或“模型自己继续循环”。源码阅读路径放到文末,避免在主线刚展开时打断叙事。

6. Loop Engineering 的 6 个工程动作

这 6 个动作本质上是在回答同一个问题:如何让目标、运行时、模型输入和外部观察各自有清晰的状态边界,而不是把所有东西都塞进 prompt。

持久状态:thread_goals 里的事实记录 运行时状态:GoalRuntimeHandle 里的控制面 模型可见状态:get_goal tool 与 goal hidden context 观察状态:ThreadGoalUpdated 等事件

这四层不能合并。目标事实不靠模型记忆,执行控制不靠模型自觉,外部观察也不靠 assistant 最终回复。每一层都有自己的 owner、生命周期和证据出口。

6.1 把目标从上下文移到状态表

如果 goal 只是 prompt 中的一段文字,compact、tool output、用户 steer 都可能改变模型对目标的理解。Codex 用thread_goals把目标变成 thread 级状态。

这一步解决的压力是目标漂移。

没有状态表: 目标 = 模型上下文中的一句话 有状态表: 目标 = objective + status + budget + usage + goal_id

goal_id很关键。外部更新时会带expected_goal_id,避免旧 turn 或旧 mutation 误伤新 goal,见codex-rs/ext/goal/src/api.rs#L153-L164

6.2 把模型权限关进 tool schema

模型可以调用create_goal创建 active goal,但update_goal只能标记completeblocked,见codex-rs/ext/goal/src/tool.rs#L221-L234

这一步解决的压力是模型越权。

模型可以判断: 我认为目标完成了 我被阻塞了 模型不能判断: 我要暂停 我要恢复 我触发 usage limit 我触发 budget limit

这些状态归用户或系统所有。Loop Engineering 的重点是分清“模型判断”和“runtime 裁决”。

6.3 把每一轮执行绑定到 active goal

on_turn_start会读取当前 thread goal。如果目标是ActiveBudgetLimited,就把当前 turn 绑定到goal_id,见codex-rs/ext/goal/src/extension.rs#L201-L239

这一步解决的压力是责任归属。

turn 不是孤立执行 turn 要知道自己是不是在推进某个 goal token/time/tool progress 也要知道应该记到哪个 goal 上

Plan mode 会清掉当前 turn goal,见codex-rs/ext/goal/src/extension.rs#L216-L221。这说明 Codex 区分“规划”和“执行”,不会把 plan turn 当成 goal progress。

6.4 把工具和 token 变成进度记账

on_token_usage记录当前 turn 的 token usage,见codex-rs/ext/goal/src/extension.rs#L326-L352

on_tool_finish会在工具完成后调用account_active_goal_progress,如果目标进入BudgetLimited,就注入 budget limit steering,见codex-rs/ext/goal/src/extension.rs#L359-L402

状态库里的account_thread_goal_usage会增加time_used_secondstokens_used,并在tokens_used + token_delta >= token_budget时把状态转成budget_limited,见codex-rs/state/src/runtime/goals.rs#L411-L523

这一步解决的压力是无界循环。

没有记账: Agent 只知道“我还没完成” 有记账: runtime 知道这个 goal 已经消耗多少 token/time,是否触顶

6.5 把继续执行放到 idle gate

on_thread_idleruntime.continue_if_idle(),见codex-rs/ext/goal/src/extension.rs#L154-L166

continue_if_idle做完状态检查后,不直接开后台 worker,而是构造 continuation steering,然后调用thread.try_start_turn_if_idle(vec![item]),见codex-rs/ext/goal/src/runtime.rs#L359-L415

try_start_turn_if_idle会拒绝:

  • input 为空。
  • 有 pending trigger-turn mailbox。
  • 当前是 Plan mode。
  • 已经有 active turn。
  • 构造默认 turn 后又出现 pending work 或 Plan mode。

证据在codex-rs/core/src/session/inject.rs#L38-L129

这一步解决的压力是自动化抢占用户。

错误做法: goal active → timer loop → 新 turn Codex 做法: goal active → thread idle → try_start_turn_if_idle → regular turn

所以 goal automation 不是新执行系统,而是重新进入 Codex 原本的 turn loop。

6.6 把错误、预算和完成变成停止线

Codex goal 有多条停止线:

  • update_goal complete:模型声明完成。
  • update_goal blocked:模型声明阻塞。
  • turn error:runtime 把 active goal 转为blocked
  • usage limit:runtime 把 active goal 转为usage_limited
  • budget limit:状态库把 active goal 转为budget_limited
  • paused:用户或外部 API 暂停。

状态定义在codex-rs/state/src/model/thread_goal.rs#L12-L41

stop_active_goal_for_turn在 turn error 或 usage limit 时先记账,再用expected_goal_id更新状态,并清掉 active goal,见codex-rs/ext/goal/src/runtime.rs#L243-L333

这一步解决的压力是“坏循环不停跑”。错误不是被 prompt 忽略,而是进入状态机。

把这 6 个动作合起来看,Codex goal 反驳的是一种看似简单的 prompt loop:

请持续推进目标,直到完成;如果遇到问题就停止。

这类 prompt 把关键判断都交给模型:目标是否仍有效、是否该继续、是否消耗过多 token、是否被用户新输入覆盖、是否真的完成、错误是否应该重试。Codex goal 的做法是拆权:模型推进任务并声明complete / blocked;工具执行具体动作;runtime 绑定 turn、记账、续跑和错误停止;state DB 保存目标事实和预算事实;user/API 负责设置、暂停、恢复和替换目标;UI/SDK 观察状态变化。

所以差别不是“有没有让 Agent 继续做事”,而是继续这件事有没有被工程化治理:

有 Codex Goal: 目标被保存 → turn 绑定 goal_id → 工具和 token 形成 usage delta → idle 时 runtime 判断 continuation → busy 或 Plan mode 时拒绝 → complete / blocked / budget / error 都落状态 只靠 prompt: 目标只在上下文 → 模型自己决定是否继续 → 工具输出挤占上下文 → 用户转向和旧目标混在一起 → 出错后可能继续重试 → 完成只靠 assistant 自述

7. 用 Codex Goal 反推 Loop Engineering 框架

如果从零构建一个长期 Agent Loop,不要先写循环。应该按这个顺序构建:

7.1 定义目标契约

压力:长期任务不能靠一句模糊愿望驱动。

状态:objective、done evidence、scope、constraints、anti-cheat、verification。

不变量:没有可验证完成线,不进入 autonomous loop。

验收:另一个 Agent 能只看 goal contract 判断目标是否完成。

7.2 定义状态所有权

压力:模型、用户、系统都可能改状态,必须分权。

状态:模型只能 set complete/blocked;用户可以 pause/resume/edit objective;系统控制 usage/budget/error。

不变量:模型不能伪造系统状态。

验收:状态转移表里每个状态都有唯一或明确的 owner。

7.3 定义单执行线和 pending input

压力:多 turn 并发会破坏 worktree 和 history。

状态:active turn、pending input、mailbox、thread idle。

不变量:同一 session 同时最多一个 active task。

验收:运行中 steer 进入 pending input,automation busy 时被拒绝。

7.4 定义 continuation gate

压力:长期任务需要自动继续,但不能抢用户输入。

状态:thread idle、Plan mode、pending trigger work、active turn。

不变量:自动化只能在安全空闲点启动普通 turn。

验收:busy、Plan mode、pending user work 时 continuation 不启动。

7.5 定义预算和停止线

压力:长期任务如果没有停止线,会把 token 和时间耗尽。

状态:tokens_used、time_used_seconds、token_budget、usage limit、turn error。

不变量:预算和错误由 runtime 裁决,不由模型自说自话。

验收:预算触顶后不再开新实质工作,只总结当前状态和下一步。

7.6 定义可观测事件

压力:长跑任务必须能被 UI、人和其他客户端观察。

状态:ThreadGoalUpdated、metrics、analytics、turn attribution。

不变量:状态变化必须能追到 event_id 或 turn_id。

验收:create/update/blocked/budget/complete 都能被外部订阅者看到。

8. 最后总结

Codex goal 说明的 Loop Engineering 可以压缩成一句话:

把 Agent 的开放式循环,改造成以 goal state 为锚点、以 turn lifecycle 为节拍、以 idle gate 为自动化入口、以 budget/error/complete 为停止线的可治理系统。

它最值得学习的不是goal这个功能名,而是这个构建顺序:

先定义目标契约 再定义状态所有权 再定义执行线边界 再定义 continuation gate 再定义预算和停止线 最后定义可观测事件

这也是 Agent 产品从 demo 走向可靠系统的关键分水岭:不是模型会不会循环,而是循环本身是否被工程化治理。

9. 附:代码锚点

如果要回到源码验证,可以按这条最短路径读:

  1. codex-rs/ext/goal/src/extension.rs#L98-L175

    • 读什么:GoalExtension如何挂进 thread lifecycle。
    • 读完知道:goal runtime 在 thread start 创建,在 thread idle 调continue_if_idle,在 thread stop 注销。
  2. codex-rs/ext/goal/src/runtime.rs#L23-L50

    • 读什么:GoalRuntimeHandle拥有哪些运行时状态。
    • 读完知道:goal runtime 持有thread_id、state DB、event emitter、metrics、thread manager、accounting state、enabled flag 和goal_state_lock
  3. codex-rs/ext/goal/src/tool.rs#L180-L291

    • 读什么:模型能用 goal tool 做什么。
    • 读完知道:模型可以创建 goal,也只能把 goal 更新为completeblocked,不能任意设置paused / budget_limited / usage_limited
  4. codex-rs/ext/goal/src/runtime.rs#L359-L415

    • 读什么:自动 continuation 的核心 gate。
    • 读完知道:goal 只有在 tools visible、live thread 存在、DB 中目标仍 active、try_start_turn_if_idle通过时才自动继续。
  5. codex-rs/core/src/session/inject.rs#L38-L129

    • 读什么:extension 发起 idle work 的共享闸门。
    • 读完知道:Plan mode、busy active turn、pending trigger-turn mailbox 都会拒绝自动启动。
  6. codex-rs/state/src/model/thread_goal.rs#L12-L71

    • 读什么:goal 持久状态 schema。
    • 读完知道:状态不是一句话,而是status / budget / usage / timestamps / goal_id这些可验证字段。