十三天后,Anthropic 的 Claude Code 2.1.139 也上了同名命令,官方账号 @ClaudeDevs 在 5 月 13 日官宣。思路和 codex 一致:每一轮结束时,请一个独立的小模型回答一个问题,目标达成了吗?
一个月内,两家头部厂商先后交出同一个命令。为什么?
因为编码 agent 的用法正在变。回合制时代,你给一条指令,它干一轮,停下来等下一条。任务跨几十轮的话,你就成了一个人肉的继续按钮。去年 7 月,开发者 Geoffrey Huntley 写了一个 bash 循环,把同一个任务反复喂给 Claude,不达成目标不罢休,还给它起了个名字叫 Ralph。这个土办法先传开,官方团队随后把「干到完为止」做进了产品。
这篇文章拆开 codex 与 Claude Code 这两个主流 CLI agent 的 /goal 源码,看一个 loop 到底怎么跑到终点:goal 存在哪,谁检查完成,循环什么时候停,失败了怎么办。
先交代一句:Claude Code 本身没有开源,occ 是它的反编译开源版本,可用于学习,本文对 Claude Code 的源码分析基于 occ,下文直接称 Claude Code。两边源码都锁定在仓库的具体快照上读,正文引用的代码块都能在各自仓库里逐行对上。
场景大家都熟悉。睡前你给编码 agent 丢下一句话,把这个 bug 修好再睡。早上醒来通常只有两种结局:活干完了,改动说明摆在那里;或者它烧掉一大堆 token,在同一个错误上打转,也可能早就停了,停下的原因却不是活干完了。
决定结局的是什么?一轮干得好不好,看模型。停不停、继不继续,看另一样东西——goal 闭环。
总览:五个问题,一个循环
给 agent 设一个 goal,等于启动了一个自动循环。模型干活,干到想停,有东西来检查,goal 没完成就继续干。Loop engineering 就是给「自动继续」这件事装上刹车和方向盘的工程。
拆开看,一共五件事。
存。goal 放在哪,内存、数据库还是会话记录里,进程重启后还在不在。 查。谁拍板说 goal 完成了,模型自己,还是另一个裁判。 停。循环在什么情况下终止,goal 达成只是其中一种答案。 证。判定依据什么,测试结果、文件状态,还是模型自己的感觉。 救。失败的时候怎么办,原地重试、升级给人,还是干脆终止。
这五件事互相托着。存的方式决定了谁能看见 goal,查、停、证、救都围着它转。哪一环缺位,循环就从哪一环漏出去。

图 1 一个能收敛的 goal loop,至少要回答存、查、停、证、救五个问题。
两份实现对同样五个问题给出了几乎完全不同的答案。一个相信模型的自觉,一个相信外聘的裁判。先看前者。
codex:goal 是数据库里的一行
在 codex 终端里敲 /goal,弹菜单,填 objective,确认,这一串动作最后就是往数据库里写一行。从按键到落库的链路见图 2。

图 2 从敲下 /goal 到数据库落一行,goal 创建的完整链路。
这一行叫 ThreadGoal,住在单独的 goals_1.sqlite 库里。九个字段和约束都画在图 3,值得记住的是两条:thread_id 就是主键,一个 thread 同时只能有一个 goal;status 列带 CHECK 约束,六个状态在数据库这层就锁死。这套功能在 codex 里已经是 Stable,默认开启。

图 3 ThreadGoal 表结构,thread_id 是主键,一个 thread 同时只有一个 goal。
更新这一步也有说法。update_thread_goal 调用时可以带上 expected_goal_id 做乐观锁:对得上才落库,对不上——说明 goal 已被整行换掉——这次更新就默默落空。不报错,不覆盖,过期的写操作安静作废。

图 4 update_thread_goal 的乐观锁,expected_goal_id 对上才落库,对不上更新落空。
状态有几种?看枚举:
// codex-rs/state/src/model/thread_goal.rs#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]#[serde(rename_all = ”snake_case”)]pub enum ThreadGoalStatus {Active,Paused,Blocked,UsageLimited,BudgetLimited,Complete,}
为什么枚举上要挂一个 rename_all = "snake_case"?因为这个枚举落库时要变成 active、paused 这样的小写值,和建表语句里 status 列的 CHECK 约束一一对上。Rust 的类型和数据库的约束,两处守着同一组六个状态。
六个状态里只有两个是终态:budget_limited 和 complete。is_terminal 方法只认这两个。其余状态都能被唤醒回去,到了终态,这个 goal 才算彻底了结。

图 5 Codex 用六态承载 goal 生命周期,只有 BudgetLimited 与 Complete 是终态,Paused、Blocked、UsageLimited 都留人工恢复入口。
循环怎么转?
路径不复杂。一轮 turn 结束,thread 进入空闲,goal 扩展的 on_thread_idle 钩子被触发。continue_if_idle 确认 goal 还是 active,把续跑模板渲染成一条「继续干」的指令,try_start_turn_if_idle 起一个新 turn。turn 再结束,再空闲,循环再转。
指令的送达有两条路。turn 还在跑,inject_if_running 把它塞进当前 turn。thread 空闲,就走前面那个 try_start_turn_if_idle 起新 turn。模板渲染时,objective 被标注为用户数据,不构成更高优先级的指令——防的是 goal 条件本身变成提示词注入的入口。
续跑的入口函数长这样:
// codex-rs/ext/goal/src/runtime.rs(节选)let Some(goal) = self.inner.state_dbs.thread_goals().get_thread_goal(self.thread_id()).await.map_err(|err| err.to_string())?else {self.inner.accounting_state.clear_active_goal();return Ok(());};if goal.status != codex_state::ThreadGoalStatus::Active {self.inner.accounting_state.clear_active_goal();return Ok(());}let item = continuation_steering_item(&protocol_goal_from_state(goal));if let Err(err) = thread.try_start_turn_if_idle(vec![TurnInput::ResponseItem(item)]).await{let reason = err.reason();tracing::debug!(?reason,”skipping goal continuation because automatic idle work was rejected”);}
整个函数没有重试逻辑。读一次 goal,状态不是 active 就清掉记账标记直接返回,循环在这里安静地停掉。还是 active,就把续跑模板渲染成一条指令,试着起一个新 turn。起 turn 被拒绝也不报错,只写一条 debug 日志。为什么这么克制?因为循环的命系在状态字段上,状态说停,就不该再硬塞。

图 6 turn 结束进入 idle 后,continue_if_idle 读 goal 状态,active 才渲染续跑模板、注入 steering、起新 turn。
谁说了算?模型自己报
codex 的答案可能让人意外:完成与否,模型自己报。模型调用 update_goal 工具,把 complete 或 blocked 写进数据库。
没有独立裁判?没有。为了防止模型乱报,codex 加了两道闸。
第一道闸在工具定义。status 参数是个只有两个取值的字符串枚举,门槛直接写在描述里:
// codex-rs/ext/goal/src/spec.rslet properties = BTreeMap::from([(”status”.to_string(),JsonSchema::string_enum(vec![json!(”complete”), json!(”blocked”)],Some(”Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit.”.to_string(),),),)]);
第二道闸在执行器里兜底,模型绕过 schema 传别的状态会在这里被拦下:
// codex-rs/ext/goal/src/tool.rsif !matches!(args.status,ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked) {return Err(FunctionCallError::RespondToModel(”update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system”.to_string(),));}
注意错误类型是 RespondToModel:这条拒绝会作为工具结果送回给模型,会话不会因此中断。模型读到的是规则本身,下一轮还有机会按规矩重报。为什么不直接抛 Fatal?因为报状态是多轮博弈,一次报错不该终结整个循环。
有个细节值得多看一眼。blocked 的门槛是同一阻塞条件连续出现三轮——可这条规则只写在工具描述和续跑模板里,运行时没有任何计数器,GoalAccountingState 的定义里压根没有计数字段。codex 就这么相信模型会守约。
规则书全写在提示词里
相信自报,不等于放任乱报。续跑模板里附了一整套规则书,最关键的两段,原文照贴:
<!-- codex-rs/ext/goal/templates/goals/continuation.md -->Work from evidence:Use the current worktree and external state as authoritative. Previous conversation context can help locate relevant work, but inspect the current state before relying on it. Improve, replace, or remove existing work as needed to satisfy the actual objective.
翻译过来就是:别信「我记得做过了」,信磁盘上现在有什么。对话历史只能用来定位,判定依据必须来自当前的文件、命令输出、测试结果。
<!-- codex-rs/ext/goal/templates/goals/continuation.md -->Completion audit:Before deciding that the goal is achieved, treat completion as unproven and verify it against the actual current state:- Derive concrete requirements from the objective and any referenced files, plans, specifications, issues, or user instructions.- Preserve the original scope; do not redefine success around the work that already exists.- For every explicit requirement, numbered item, named artifact, command, test, gate, invariant, and deliverable, identify the authoritative evidence that would prove it, then inspect the relevant current-state sources: files, command output, test results, PR state, rendered artifacts, runtime behavior, or other authoritative evidence.- For each item, determine whether the evidence proves completion, contradicts completion, shows incomplete work, is too weak or indirect to verify completion, or is missing.- Match the verification scope to the requirement's scope; do not use a narrow check to support a broad claim.- Treat tests, manifests, verifiers, green checks, and search results as evidence only after confirming they cover the relevant requirement.- Treat uncertain or indirect evidence as not achieved; gather stronger evidence or continue the work.- The audit must prove completion, not merely fail to find obvious remaining work.Do not rely on intent, partial progress, memory of earlier work, or a plausible final answer as proof of completion. Marking the goal complete is a claim that the full objective has been finished and can withstand requirement-by-requirement scrutiny. Only mark the goal achieved when current evidence proves every requirement has been satisfied and no required work remains. If the evidence is incomplete, weak, indirect, merely consistent with completion, or leaves any requirement missing, incomplete, or unverified, keep working instead of marking the goal complete. If the objective is achieved, call update_goal with status ”complete” so usage accounting is preserved. If the achieved goal has a token budget, report the final consumed token budget to the user after update_goal succeeds.
两句话是这段的灵魂。Treat completion as unproven——证明完成之前,先假设没完成。The audit must prove completion, not merely fail to find obvious remaining work——「我没发现还有活」不算证据,得拿出正面证明。
为什么把规则写在提示词里,而不是写成代码?因为该看什么证据、什么才算够,是代码枚举不完的语义问题。codex 把判断交给模型,再用下一节的预算兜底。
预算是硬刹车
光靠相信不行,codex 把预算做成了硬的部分。
设 goal 时可以指定 token_budget。每轮记账和预算判定写在同一条 SQL UPDATE 里,看源码:
// codex-rs/state/src/runtime/goals.rs,account_thread_goal_usage 节选let mut builder = QueryBuilder::::new(r#”UPDATE thread_goalsSETtime_used_seconds = time_used_seconds +”#,);builder.push_bind(time_delta_seconds);builder.push(r#”,tokens_used = tokens_used +”#,);builder.push_bind(token_delta);builder.push(r#”,status = CASEWHEN”#,);builder.push(budget_limit_status_filter);builder.push(r#”AND token_budget IS NOT NULLAND tokens_used +”#,);builder.push_bind(token_delta);builder.push(r#”>= token_budgetTHEN”#,);builder.push_bind(crate::ThreadGoalStatus::BudgetLimited.as_str());builder.push(r#”ELSE statusEND,updated_at_ms =”#,);builder.push_bind(now_ms);
QueryBuilder 一层层拼出来的语句,拆开看是这样的:先把本轮增量累加到时间和 token 上,紧接着在同一条语句里,CASE 拿 tokens_used 加上同一个增量和 token_budget 比大小,触顶就把 status 翻成 budget_limited。判定和执行共用一条语句,中间没有状态变化的空隙——这就是原子。为什么非要塞进一条语句?因为先查后写的话,查和写之间可能插进新一轮记账,goal 就可能透支。
WHERE 里的状态过滤也有细节:平时记账这一档放行 active 和 budget_limited 两种状态,也就是说触顶之后用量照记;但 CASE 里只认 active,翻状态的机会只有一次。
token 的口径也有讲究:只算未缓存的输入加输出,缓存命中的输入不计。增量同一个,口径只一套。
翻完状态,系统还会注入一条收尾指令,模板叫 budget_limit.md,内容是:不许开新工作,把手头的活收掉,总结剩余工作,给用户留一个能接着做的 next step。budget_limited 是终态,goal 不会再自己醒来。
循环还会在哪停?
其余几条停止路径,简单说。
供应商用量触顶:当轮错误把 goal 打成 usage_limited。 其他 turn 错误:打成 blocked。注释写明用意——防止自动续跑在错误场景里空转烧 token。 用户的手:随时可以暂停、恢复、清除 goal。blocked 或 paused 之后,终端里有一个 Resume paused goal? 菜单等着。 结构性不续跑:Plan 模式的 turn 不记账;fork 时带上 deferGoalContinuation,会顺手写一条 deferral 记录,continue_if_idle 看到它直接返回。TUI 只在安全重试这类场景选择 DeferUntilNextTurn;resume 不写 deferral,restore_after_resume 把还是 active 的 goal 重新标记,续跑照常。
还要留意一件事:codex 没有轮次上限,也没有时间上限。time_used_seconds 只管记,没人拿它做判断。不设预算的话,循环只会停在模型报完成、turn 出错,或者用户伸手。
把整个循环画成一张图,就是这样:

图 7 Codex /goal 的完整循环,goal 创建后在 active 下由 idle 检测持续续跑,complete 与 budget_limited 是终态,usage_limited、blocked、paused 可人工恢复后重回主循环。
判定链怎么合拢?
把前面几节串起来。codex 的判定是一棵 turn 结束后走一遍的分支树。
模型在 turn 里调过 update_goal,结局当时就定了:schema 只许 complete 和 blocked,执行器兜底拦住越界的值。报 complete,goal 落终态,循环到此为止;报 blocked,循环停下,等用户从菜单里恢复。
没自报,turn 结束进 idle,判定交给 continue_if_idle:读一次状态,active 就渲染续跑模板起新 turn,循环再转一圈;不是 active,清掉记账标记,安静停下。
硬停止不在枝头等着。预算判定写在记账的同一条 UPDATE 里,触顶就把 status 翻成 budget_limited,翻完注入收尾模板;供应商用量触顶,当轮错误把 goal 打成 usage_limited。判定、续跑、硬停止,各守状态字段的一个侧面。这棵分支树画出来,就是图 8。

图 8 codex 判定分支全景,自报走 complete/blocked 两支,不自报走 continue_if_idle 续跑判定,budget 与 usage 硬停止各在介入点标出。
证据和失败处理
证据这块,codex 是两层。提示词一层,运行时事件流一层。每次模型响应后,core 把 token 用量喂给 goal 扩展;on_tool_finish 钩子给每次工具调用记账。这些硬数据负责记账和界面展示,完成判定靠的还是模型照模板自查。
失败处理跟着状态机走。turn 出错打成 blocked,等人来 resume,恢复后按提示词要求从零重做 blocked 审计。预算耗尽就收尾留 next step,用户改 objective 或预算还能重新激活。还有个容易被误读的地方:thread_fork_goal.rs 看着像重试机制,里面其实只有一个函数 inherit_thread_goal_snapshot——用户手动 fork thread 时,把源 thread 的 goal 行原样拷进新 thread。它只干继承这一件事。
codex 这一章可以收在一句话上:状态机和预算保下限,提示词保上限。
Claude Code:下班前查一次岗
Claude Code的路数完全不同。/goal 注册的是一条会话级 Stop hook——模型想结束这轮、准备下班的时候,要先过一道岗:goal 干完了吗?
condition 存了三处。一个模块单例记下条件、开始时间和轮数;AppState 的 activeGoal 存着给界面看的活状态;会话记录里再写一条 goal_status attachment——这条 attachment 发给 API 前会被过滤掉,模型永远看不到它,留着是给 resume 时恢复 goal 用的。为什么存三份?单例答「当前状态是什么」,AppState 答「界面显示什么」,transcript 那份答的是「进程重启后怎么找回来」。
入口有两道门。hooks 被策略限制的工作区里 /goal 不能跑,未信任的工作区也不行;信任检查只拦交互模式,-p 模式默认视为可信。condition 长度上限 4000 字符。goal 设好后,命令会往下一轮对话里塞一条指令,告诉模型 Stop hook 已经激活,条件本身就是指令,别停下来问用户。
用户也不是完全摸黑。/goal 不带参数会打开 GoalStatus 面板:条件、耗时、轮数、token 消耗和 judge 上次给的理由,都在里面。
裁判是另一个小模型
检查的时机在哪?模型响应结束、没有待执行工具的时候,主查询循环调用 handleStopHooks,goal hook 进场。
执行器 execPromptHook 把整段对话历史加一段评估提示发给一个小模型——Haiku 级别,默认取 small fast model,也能用 ANTHROPIC_SMALL_FAST_MODEL 环境变量换。干活的模型想下班,裁判问一句:活干完了吗?
裁判拿什么判?这是 Claude Code最有意思的设计:用户输入的 condition 本身就是判定提示词。看 hook 注册:
// src/commands/goal/index.tsx,registerGoalHook 内removeSessionHook(context.setAppState, sessionId, 'Stop' as HookEvent, { type: 'prompt', prompt: condition })addSessionHook(context.setAppState, sessionId, 'Stop' as HookEvent, '',{ type: 'prompt', prompt: condition },
一行 { type: 'prompt', prompt: condition },你输入的条件原样变成 Stop hook 的 prompt。没有包装,没有转译,你写什么,裁判就拿什么查岗。对比一下 codex:那边的 goal 语义由一套预制模板消化,这边是用户的原话直接送进裁判手里。
裁判的判定规则是一段固定的系统提示,答案三选一:
// src/utils/hooks/execPromptHook.tssystemPrompt: asSystemPrompt([`You are evaluating a hook in Claude Code.Your response must be a JSON object matching one of the following schemas:1. If the condition is met, return: {”ok”: true}2. If the condition is not met, return: {”ok”: false, ”reason”: ”Reason for why it is not met”}3. If the goal is impossible to achieve (can never be met, not just ”not yet met”), return: {”ok”: false, ”impossible”: true, ”reason”: ”Reason the goal cannot be achieved”}`,]),
输出结构再用 json_schema 锁死:
// src/utils/hooks/execPromptHook.tsoutputFormat: {type: 'json_schema',schema: {type: 'object',properties: {ok: { type: 'boolean' },reason: { type: 'string' },impossible: { type: 'boolean' },},required: ['ok'],additionalProperties: false,},},
三个答案,三条路。达成,{"ok": true}。未达成,{"ok": false} 加理由。永远达成不了,ok false 加 impossible 加理由。schema 只要求 ok,reason 和 impossible 可选——结构本身就把三分支表达清楚了。为什么要用 schema 锁死?因为裁判的输出要驱动程序逻辑,自由发挥的文本会让下游解析靠运气。

图 9 Claude Code 的 judge 三选一与各自去向,达成摘钩结束,未达成注回理由续跑,impossible 判 failed,续跑有 cap 8 保护。
这个设计把 checker 放在了干活模型的外面。裁判读整段对话,包括所有工具结果,但不直接读文件;工具列表虽然传入,工具调用不会被执行,整个评估就一次模型往返。证据质量取决于对话里有没有留下可核对的痕迹。
裁判的输入还多一样东西。hook input JSON 会跟着条件一起送进去,带着 transcript_path、stop_hook_active 这些字段;条件里没写 $ARGUMENTS 占位符的话,这段 JSON 直接追加到提示末尾。
两种结果的路是通的。ok true 触发 onHookSuccess:清掉 activeGoal,记一条 lastAchievedGoal,移除 hook,会话自然结束。impossible 把 goal 标成 failed,界面显示无法达成,hook 同样移除。这个出口值得夸一句——它让 agent 承认做不到然后体面退场,比吊着强。
ok false:理由注回去,接着干
第三种结果 ok false,是三条路里最常走的一条。裁判给的 reason 不会石沉大海:它被包成一条带 Stop hook feedback 前缀的消息注回对话,模型下一轮就看得到。查询循环同时把 stopHookActive 置 true,consecutiveStopHookBlocks 计数加一,构造下一轮状态——模型读到理由,接着干。裁判的批评,就是下一轮的输入。
续跑有安全阀。连续 block 超过 8 轮,查询循环强制收尾,防止裁判和模型无限互相说服;上限还能用环境变量 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 调,默认就是 8。

图 10 Claude Code的 Stop hook 流程,ok false 一支以 feedback 注回对话续跑,cap 8 兜底。
Claude Code的失败救援就这些:未达成走注回续跑,impossible 给一个 failed 面板。没有 resume,也没有预算。forkedAgent.ts 看着沾边,实际服务的是轮末 fork 的 prompt cache 快照,和 goal 重试无关。
判定怎么判,两边不一样
两边的判定机制摆到一起,差别集中在三处。
一个约束输入侧,拿什么当证据;一个锁定输出侧,答案长什么样。codex 赌模型读了规则书就会守约,代价是要用预算兜住乱报;Claude Code赌裁判的客观,代价是每轮多一次模型往返,而裁判只能看见对话。续跑的驱动方式不同,落点却一致:判未完成,loop 就得拿着理由继续干。
摆在一起,差别就清楚了
五件事并排放,分叉看得很清楚。

图 11 两种实现的五维对照,续跑通路两边都有,差距集中在硬停止和完成判定的信任对象。
三个分叉值得展开。
第一个,完成由谁说了算。codex 赌模型的诚实,代价是模型想偷懒时,能在审计规则里找到理由报完成,预算是最后的兜底。Claude Code赌 judge 的客观,代价是每轮多一次模型调用,而 judge 只能看见对话里的东西。两种流派各防一种失败:codex 防模型乱报完成,靠硬校验和预算;Claude Code防模型自我感觉良好,靠独立裁判。
第二个,硬停止放在哪。codex 把预算判定写进记账的同一条 UPDATE,判定和执行不分离。Claude Code的 cap 是通用 stop-hook 安全阀,goal 自己没有预算概念。不挂在 goal 身上的上限,像装在隔壁楼的灭火器。
第三个,续跑由什么驱动。codex 的续跑是状态机的默认行为,goal 还 active,空闲就自动续,不需要额外授权。Claude Code的 judge 有权否决下班,驱动继续靠把理由注回对话再跑一轮。两边的设计意图其实一致:checker 判未完成,loop 拿着理由继续干。差别在触发方式,一个看状态字段,一个靠反馈消息。
防无限循环这件事,Claude Code比官方多备了一个阀门。cap 8 是自研加固,官方没有连续 block 上限,唯一约束是调用方的 --max-turns。
五件可以照抄的事
想给自己的 agent 装一个 goal 循环,这五件事可以直接照抄。每条后面站着这次调研里的代码。
一,goal 写成可验证条件,别写愿望。codex 的续跑模板要求证据逐项对着文件、命令输出、测试结果、PR 状态;Claude Code的 judge 只能读对话,对话里没跑过验证命令,judge 就没东西可查。能验证的条件写「测试命令 foo 返回零」,愿望写「让代码变健壮」。
二,停止条件独立于 goal 检查。预算、轮次、时间,至少一个硬上限。codex 的 token 预算值得抄,判定和记账绑在同一条原子 UPDATE 里,状态没有变掉的空隙。Claude Code没有预算,只有通用 stop-hook 的 cap 8,连续 block 八轮强制收尾。上限不挂在 goal 身上,力度就低一档。
三,检查结果要有执行通路。未达成时执行者下一轮干什么,写死在机制里。Claude Code的做法是现成的例子:judge 的 reason 以 Stop hook feedback 注回对话,stopHookActive 置 true,模型看到理由接着干,再用 cap 8 防止无限互驳。注回反馈驱动继续,硬上限兜底,这套做法是行业共识——有权否决停止的 checker,都得配套这两样,否决才有意义。checker 的判定得有人接住才算数。
四,失败给梯子,不给悬崖。可恢复的失败留人工入口,codex 的 blocked 能走菜单恢复。不可恢复的快点终止并留 next step,codex 的 budget_limit 模板要求总结剩余工作,Claude Code的 impossible 标 failed 退场。最怕的是失败没有出口,状态悬在继续和结束之间,两条路都走不到头。
五,证据放提示词里,记账放代码里。模型自查和运行时记账是两层,缺一层都会漏。codex 的续跑模板有一段证据要求,事件流在旁边记 token 和工具调用。Claude Code的 judge 只有对话可读这一层,对话缺痕迹时就成单点。
结尾
回到开头那个问题:一个 loop 怎么跑到终点?codex 的答案是状态机加预算保下限,提示词保上限,完成与否让模型照着规则书自查。Claude Code的答案是外聘一个小模型裁判,下班前查一次岗,未达成就把理由注回去接着干。循环的收敛是 checker、执行通路、硬停止三样东西乘在一起,缺哪一样,就以各自的姿势失控。两份答卷分叉的位置,恰好是各自最怕摔跤的位置。
Harness、Loop、Graph 是三层工程,这篇只讲了中间一层。外层和内层,另文再叙。
最后一个问题留给你:把任务交给 agent 再去睡觉之前,你押的是模型的能力,还是那套知道什么时候该停的循环?
参考
codex:https://github.com/openai/codex occ(Claude Code 的反编译开源版本,可用于学习):https://github.com/cnwenf/occ Ralph(Geoffrey Huntley 博客,2025-07-14,引言里那个 bash 循环的出处):https://ghuntley.com/ralph
夜雨聆风