Agent 循环控制原理解析:Stop Hook 与 Ralph Loop
最近有位读者问起 Ralph Loop 和 Stop Hook,我顺便来整理这个话题。
这个话题我将分为两篇:这篇主要是 Stop Hook 和 Ralph Loop 的原理解析,下一篇会讨论如何结合这些机制,设计一个长周期运行的 Agent 循环。
先了解两个概念
Stop Hook
Claude Code / Codex CLI 的 hooks 系统中的一类事件。触发时机是:模型本轮不再发起工具调用、准备结束当前 turn 的那一刻。Hook 脚本可以通过返回值决定是真的停下,还是把一段反馈注入回会话、让模型继续跑下一轮。
Ralph Loop
来自 snarktank/ralph 仓库的一种工程模式:让 agent 在”生成 → 外部验证 → 反馈 → 再生成”的循环里迭代推进,直到任务完成或触及边界。名字后来被 Anthropic 插件市场的 ralph-loop 插件沿用,指代这一类”由外部机制决定 agent 何时停下”的循环控制策略。具体可以看我之前写的一篇:从 Prompt 到 Ralph Loop:一文了解 AI 的循环编程。
Stop Hook 是机制,Ralph Loop 是用这个机制搭出来的循环形态——下面三层讲的就是同一个循环思路,在 Shell / Plugin / Runtime 三个不同层级的落地。
层级一:基于 Shell 的外部控制循环(以 ralph.sh 为例)
在这方面,GitHub 上一个名为 snarktank/ralph 的 Bash 脚本仓库提供了一个非常直观的样本。它的核心逻辑 ralph.sh 建立在系统最外层的 Shell 循环上:
1
2
3
4
5
6
7
8
for i in $(seq 1 $MAX_ITERATIONS); do
OUTPUT=$(claude --print < CLAUDE.md)
if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
exit 0
fi
done
exit 1
在它配套的 CLAUDE.md Prompt 里有这样一段话:
“If ALL stories are complete and passing, reply with:
<promise>COMPLETE</promise>”“If there are still stories with passes: false, end your response normally (another iteration will pick up the next story).”
这是一种外挂式的控制策略:
- 隔离的会话流:依靠外层
for循环触发,每轮都是一个全新的会话进程,状态传递必须通过读写系统文件和 Git 记录完成 - 提示词约定的停止信号:停止标识
<promise>COMPLETE</promise>是通过 Prompt 约定产生,属于”内层协议”,由外部的grep验证
这个实现把循环控制权放在外部脚本,而不是交给模型判断。但这种方案存在两个主要局限:
- 每次重新运行都会带来全量构建上下文的性能与 Token 开销
- 单纯依赖
grep进行文本匹配的判定方式,在面对大语言模型多变的输出格式时不够稳定
层级二:基于 Plugin 层的会话流拦截(以 Ralph-loop 为例)
当业务无法忍受反复冷启动的开销时,处理便下沉至插件系统。Anthropic 插件市场中的 ralph-loop 插件页明确提到其实现方式:
“intercepts session exits via a stop hook”
其大致控制流为:
- 用户启动长会话,例如
/ralph-loop "任务需求" --completion-promise "DONE" - 当单轮回合结束、Claude 准备退出会话前,Plugin 内部的 Stop Hook 介入
- 若输出中未包含约定的 completion promise 字符串、且未达最大迭代次数,Hook 阻止本次退出并触发下一轮迭代
- 用户可通过
/cancel-ralph命令终止循环,其实现方式是删除本地 loop state 文件
此时 Plugin 层的 Stop Hook 对应”外层干预动作”,ralph.sh 脚本里的 <promise>COMPLETE</promise> 对应”内层文本协议”——两者处在同一问题的不同层次。
层级三:基于核心 Runtime 的原生收口流(以 Codex 与 Claude Code 为例)
前两个层级仍未解决一个根本问题:判断终止依然依赖不够稳定的文本字符串匹配。到了工程化更为完备的 Codex CLI 及 Claude Code 阶段,Stop Hook 脱离了”强行拦截文本退出”的定位,上升成为了核心状态机(State Machine)的原生运转节点。
以 codex-rs 的源码实现为例,Hook 触发逻辑的判定基础是:
1
2
3
4
5
6
7
8
9
10
11
12
13
let needs_follow_up = model_needs_follow_up || has_pending_input;
// 当 LLM 不再发起任何工具调用行为时,准许触发 Stop Hook 机制
if !needs_follow_up {
let stop_outcome = sess.hooks().run_stop(stop_request).await;
if stop_outcome.should_block {
// 将 continuation prompt 以 role="user" 的 message 注入会话
sess.record_conversation_items(...).await;
stop_hook_active = true;
continue; // 继续流转主循环
}
}
这里确立了两个机制转变:
脱离文本依赖结构化触发
系统不再扫描输出流里是否包含 "COMPLETE" 或 "DONE"。触发 Stop Hook 的条件纯粹是 needs_follow_up == false——即模型本轮不再发起工具调用,与输出文本内容无关。
以 User 角色注入反馈
当外部验证失败并返回 Block 状态时,错误信息会被封装为 role: "user" 注入上下文。从模型视角看,这等同于收到一条新的用户消息。
Claude Code 则在 handleStopHooks(...) 中把这一阶段组织为明确的 turn epilogue,将状态提取(extract memories)、长期沉淀(auto dream)等收尾动作统一收口;同时在 API error 场景下显式跳过 stop hooks,以避免形成 “error → hook blocking → retry → error” 的死循环。
以 Runtime 层面的 Stop Hook 机制为例,其完整的循环执行与停止判断流程如下:
结语:控制架构边界的演化
将三种处理策略横向剖析,这不仅是简单的方案迭代,更是软件工程对大模型边界的精细划分:
- 外部脚本 + 文本协议:Shell 循环读取模型输出,用
grep匹配约定字符串判断终止(Shell 等级) - 插件层会话拦截:在 session exit 处通过 Stop Hook 拦截,结合 completion promise 和迭代上限决定是否续跑(Plugin 等级)
- Runtime 原生状态机节点:以
needs_follow_up == false这种结构化行为信号作为触发条件,由外部验证和工程工具作为判定依据(Runtime 等级)
Agent 自主循环的演变,反映了系统在逐步将”判定权”从模型生成的非确定性中剥离,交回到更为严谨的工程验证系统手中的过程。
