从 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_id、active_turn和input_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。SessionId和ThreadId可以互转,说明部分命名仍保留旧语义,但 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 外侧”的含义。内环回答“下一步怎么执行”,外环回答“这个长期目标是否还应该继续执行”。
可以先用这张图把外环位置定住:
这张图的重点不是节点数量,而是边界:
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_idgoal_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只能标记complete或blocked,见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。如果目标是Active或BudgetLimited,就把当前 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_seconds和tokens_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_idle调runtime.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. 附:代码锚点
如果要回到源码验证,可以按这条最短路径读:
codex-rs/ext/goal/src/extension.rs#L98-L175- 读什么:
GoalExtension如何挂进 thread lifecycle。 - 读完知道:goal runtime 在 thread start 创建,在 thread idle 调
continue_if_idle,在 thread stop 注销。
- 读什么:
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。
- 读什么:
codex-rs/ext/goal/src/tool.rs#L180-L291- 读什么:模型能用 goal tool 做什么。
- 读完知道:模型可以创建 goal,也只能把 goal 更新为
complete或blocked,不能任意设置paused / budget_limited / usage_limited。
codex-rs/ext/goal/src/runtime.rs#L359-L415- 读什么:自动 continuation 的核心 gate。
- 读完知道:goal 只有在 tools visible、live thread 存在、DB 中目标仍 active、
try_start_turn_if_idle通过时才自动继续。
codex-rs/core/src/session/inject.rs#L38-L129- 读什么:extension 发起 idle work 的共享闸门。
- 读完知道:Plan mode、busy active turn、pending trigger-turn mailbox 都会拒绝自动启动。
codex-rs/state/src/model/thread_goal.rs#L12-L71- 读什么:goal 持久状态 schema。
- 读完知道:状态不是一句话,而是
status / budget / usage / timestamps / goal_id这些可验证字段。