从 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 这些可验证字段。

更多推荐