拆解 Agent 循环的五大组件:调度自动化、工作树隔离、Skill、外部工具、子代理,并给出 lint 清理实战案例。
TL;DR — Agent Loop 由五个执行块加上一条记忆脊柱组成,五个块负责干活,脊柱让每次干活都能积累。没有脊柱,你的自动化每天早上醒来都会失忆。以下是架构图、每个块在 Claude Code 中对应的原语,以及一个真实运行的 lint-sweeper loop——它在 2 次迭代 / 124 秒内清除了 3 个结构性问题。
这是 Loop Engineering 系列的第 2 篇,发表于 ShipWithAI。阅读全文 →
第 1 篇论证了为什么要停止逐轮提示。这一篇是零件清单。
工作定义不变:loop 是一个递归目标——你定义一个目的和一个可验证的停止条件,系统不断派代理(agent)去执行,直到条件成立,不需要你每轮都去提示。
1. 定时自动化(心跳)
2. Worktrees 3. Skills
\ /
[ MEMORY SPINE ] <- 磁盘上的状态,跨运行持久化
/ \
5. Sub-agents 4. Plugins / connectors
这个框架来自 Addy Osmani。每个块都映射到你已有的东西:
关于块 1——/loop 在你的会话保持打开时按间隔重新运行提示。Routines 则在关闭笔记本的情况下云端定时调度执行(有每个账户每日上限)。工具不同,工作相同。
这里没什么稀奇的东西。真正的技能在于组装:为一个给定任务挑选需要哪些块,然后围绕一个状态文件把它们串起来。
脊柱是人们容易跳过的那部分
五个块负责干活。脊柱让干活能够积累。
具体来说:磁盘上的一个小文件,跨运行持久化。可 grep、可版本化、平平无奇。snarktank/ralph(14.3k stars)用 git 加一个 progress.txt 和一个 prd.json 实现了这点。
没有它,第 4 次迭代完全不知道第 1 到第 3 次已经尝试过什么。那不叫 loop,那叫失忆的 cron 任务。
一个你可以直接抄的最小化真实 loop
这个 spec 使用标准 kickoff 模板(来自 loops.elorm.xyz 的卡片格式),符合 /goal 契约:
# lint-sweeper.loop
goal: "flake8 --extend-ignore=E501 scripts/*.py exits 0"
max_iterations: 5
between_iterations_check: "flake8 --extend-ignore=E501 scripts/*.py"
exit_when: "check exits 0 OR iteration == max_iterations"
one_step: "fix one batch of findings (batch = one file), then re-run the check"
self_pace: "stop as soon as exit_when holds; do not keep going"
state_file: "drafts/lint-sweeper-state.md"
以及它在第 0 次迭代时写入的脊柱内容:
goal: flake8 --extend-ignore=E501 scripts/*.py exits 0
cap: 5 iterations
iteration: 0
## open
- gen-agent-map.py:19 F401 'os' imported but unused
- gen-dist-metadata.py:23 E301 expected 1 blank line
- gen-dist-metadata.py:27 E301 expected 1 blank line
## fixed
## skipped
注意 goal 是什么:是一个 shell 命令及其退出码,而不是关于质量的英文句子。正如文章所说——检查命令就是停止信号,所以它必须是可以机器验证的。
实际运行时发生了什么
作者在给出数据之前先诚实说明两个caveat。这个任务跑在 ShipWithAI 的 content-agent 插件仓库的 Python 脚本上,因为 shipwithai.io 没有 lint 命令——他自己也指出了这个替换。另外 flake8 实际报告了 41 个问题;其中 38 个是 E501(行太长),既噪音又主观,所以验证器只针对这 3 个真实的结构缺陷。
$ flake8 --extend-ignore=E501 scripts/*.py
scripts/gen-agent-map.py:19:1: F401 'os' imported but unused
scripts/gen-dist-metadata.py:23:5: E301 expected 1 blank line, found 0
scripts/gen-dist-metadata.py:27:5: E301 expected 1 blank line, found 0
第 1 次迭代后——一个文件的问题已清除:
scripts/gen-dist-metadata.py:23:5: E301 expected 1 blank line, found 0
scripts/gen-dist-metadata.py:27:5: E301 expected 1 blank line, found 0
# no output, exit 0
最终统计:3 个结构问题 → 0,用了 5 次允许迭代中的 2 次,墙上时钟 124 秒。干净退出——从未触碰到上限。改动总量:2 个文件被改,2 处插入,1 处删除。
这部分值得内化。每个块都会留下指纹:
Heartbeat——触发了 3 次检查(基线 + 2 次迭代)
Verifier——根据 flake8 的退出码控制每次迭代。Exit 1 → 继续。Exit 0 → 停止。
Memory spine——记录了每个问题从 open → fixed 的移动,按迭代标记
Maker——生成了 diff,交给验证器
Worktrees 和 plugins/connectors——从未使用
文章在这里很谨慎,你也应该如此:这次运行只使用了一个 agent,用 lint 退出码作为检查器。真正的 sub-agent maker/checker 分离——即用独立 agent 给工作打分——是第 3 篇和第 5 篇的内容。它出现在地图上,没有在 trace 中冒领。四个部件被调用,两个被诚实跳过。
Trace 是你调试 loop 的方式。每个块都会留下指纹,而结束运行的是验证器的退出码,不是 agent 的自信程度。
当某个块缺失时会出什么问题
每个块都有独立的篇章在系列后续讨论——heartbeat 那篇是第 6 篇。
一个可用的 harness——CLAUDE.md、至少一个 skill、一两个 hook
Claude Code v2.1.139+ 支持 /goal
一个在应该报错时能报非零退出的 lint 命令的仓库
另外,单 agent loop 不需要 worktree。只有当超过一个 agent 会触碰仓库时它们才有用武之地。lint sweeper 不用 worktree 也跑得很好。
挑你仓库里最小的东西——那里"完成"已经是一个退出码——lint、类型检查、一个失败的测试。写 spec(goal、cap、检查命令、退出条件、one-step、self-pace)。加上状态文件。上限设为 5。
如果它在触达上限前干净退出,你就建了一个 loop。如果它在检查仍然失败时宣布胜利,说明你的停止条件不可验证——这正是第 3 篇要讲的内容。
这是精简摘要。完整文章逐步演示了搭建过程,展示了完整 diff,并回答了 /loop vs Routines 以及我需要 worktree 吗这些问题:
👉 Anatomy of a Loop: Five Building Blocks and One Spine — Part 2, ShipWithAI
错过了第 1 篇?Loop Engineering: Why You Should Stop Prompting。第 3 篇——Stop Conditions: Making "Done" Mean Something——下次发布。