1. 从"能跑"到"跑得稳":Loop Engineering 到底在解决什么问题
大多数人第一次接触 Loop Engineering 这个词,是在折腾 Claude Code、Codex、Cursor 这类 AI 编程工具的时候。工具装好了,模型接上了,单次对话也能出结果,但一旦把任务拉长——比如让它连续改十几个文件、跑完整条测试链路、根据报错自动回修——就开始出问题:上下文丢了、状态乱了、同一个错误反复犯、跑到一半卡死。这些现象背后其实指向同一件事:你缺的不是更强的模型,而是一套让模型"循环起来还不失控"的工程结构。
Loop Engineering,直译过来是"循环工程",但它不是某个具体框架的名字,而是一类实践的统称。核心思想是:把 AI 编程工具从"一问一答"的交互模式,升级成"设定目标 → 执行 → 观察结果 → 修正 → 再执行"的闭环系统。这个闭环里,模型只是其中一个环节,真正决定成败的是外围的状态管理、上下文裁剪、错误反馈、终止条件这四件事。你可以把它理解成给 AI 装了一套"自动驾驶":模型负责踩油门和打方向,而 Loop Engineering 负责画车道线、装后视镜、设红绿灯。
为什么现在这个话题突然热起来?因为 Claude Code、Codex CLI、Cursor 这类工具已经把"单步能力"拉到了一个很高的水位——写一个函数、改一个 bug、解释一段代码,基本都能做对。瓶颈转移到了"多步协作"上。而多步协作的难点,恰恰是传统软件工程里早就解决过的问题:状态机、重试机制、幂等性、超时控制。所以 Loop Engineering 本质上是一次"软件工程思维对 AI 工作流的反向注入"。
这篇文章适合三类人看:第一类是把 Claude Code 或 Codex 当日常主力工具、但总觉得"不够顺手"的开发者;第二类是正在用 Cursor 做中大型项目、被上下文丢失折磨过的工程师;第三类是想自己搭一套自动化 Agent 流水线、但不知道从哪下手的技术负责人。我会从概念拆解讲到实战搭建,中间穿插我自己踩过的坑,尽量让每一步都能直接抄作业。
需要先明确一个边界:Loop Engineering 不是让你去训练模型,也不是让你去改工具源码。它是一层编排逻辑,通常以脚本、配置文件、提示词模板的形式存在,跑在你的本地环境或 CI 里。理解了这一点,后面的所有内容就都好落地了。
2. 拆开看:一个 Loop 由哪几个零件组成
2.1 目标定义:把"帮我改一下"翻译成机器能执行的指令
很多人做 Loop 失败,第一步就错了。他们给 AI 的目标是"帮我把这个模块优化一下",这种目标对人类来说都要追问三句,对模型来说更是灾难。Loop Engineering 的第一个零件,是把模糊意图转成可判定的目标。
什么叫可判定?就是这个目标完成没完成,能用一个客观标准回答"是"或"否"。比如"优化模块"不可判定,但"让npm test全部通过"就可判定;"代码写得好一点"不可判定,但"把src/utils/下所有函数的圈复杂度降到 10 以下"就可判定。我在实际项目里会强制自己写一句话的目标声明,格式是:在满足 [约束条件] 的前提下,让 [某个可观测指标] 达到 [某个状态]。
举个例子,我做过一个批量重构任务,目标声明写的是:"在不改变任何公开 API 签名的前提下,让legacy/目录下所有文件的 TypeScript 编译错误数从 47 降到 0。"这句话里,"不改变公开 API 签名"是约束,"编译错误数"是可观测指标,"从 47 降到 0"是目标状态。有了这句话,Loop 的每一步该做什么、什么时候停,全都清楚了。
提示:目标声明最好写进一个独立的文件(比如
loop-goal.md),每次循环开始时把它作为系统提示的一部分注入。这样即使上下文被裁剪,目标也不会丢。
2.2 状态载体:Loop 的"记忆"放在哪里
单次对话里,模型的记忆就是上下文窗口。但 Loop 会跨很多轮,上下文窗口装不下,所以必须有一个外部状态载体。这是 Loop Engineering 里最容易被忽视、也最影响稳定性的零件。
状态载体通常有三种形态,各有适用场景:
| 载体形态 | 典型实现 | 适合场景 | 主要缺点 |
|---|---|---|---|
| 文件系统 | 任务清单 md、进度 json | 长任务、需要人工检查 | 读写有延迟,格式易乱 |
| 版本控制 | git commit / branch | 需要回滚、需要审计 | 粒度粗,频繁提交噪音大 |
| 内存数据库 | sqlite、redis | 高频读写、多进程协作 | 需要额外维护,重启易丢 |
我自己的默认选择是文件系统 + git 双写。具体做法是:维护一个progress.json,记录当前迭代轮次、已完成子任务、当前卡点、上次失败原因;同时每完成一个可验证的子任务就做一次 git commit。文件系统负责"给模型看",git 负责"给人看和回滚"。这两者分工明确,不要混用。
这里有个细节值得展开:progress.json的字段设计直接决定了 Loop 的智能程度。我早期版本只记了step和status,结果模型经常重复做已经做完的事。后来加了attempts(每个子任务的尝试次数)和last_error(上次失败的具体报错),模型就能自己判断"这个子任务我已经试了三次都失败,应该换个策略或者上报"。字段不多,但每一个都对应一种决策能力。
2.3 反馈通道:让模型"看见"自己干了什么
Loop 能自我修正的前提,是模型能拿到真实、结构化、及时的反馈。这里最容易犯的错是:只把"成功/失败"告诉模型,不告诉它"为什么失败"。
我见过一个典型的反面案例:有人写了个 Loop 让 AI 修 bug,反馈通道只传回一句"测试失败"。结果模型只能瞎猜,改了五轮还在原地打转。正确的做法是把测试输出的关键片段传回去——不是全部日志(太长会挤爆上下文),而是失败用例名、断言差异、堆栈的前几行。
反馈通道的设计要遵循"三传三不传"原则:
- 传差异,不传全量:只传和上次相比变化的部分
- 传结构化,不传原始文本:把报错解析成
{file, line, message}再传 - 传可操作,不传纯描述:与其说"编译失败",不如说"第 42 行缺少分号"
在 Claude Code 和 Codex 这类工具里,反馈通道通常是靠"工具调用结果"自动实现的——你让它跑npm test,它自己就能看到输出。但如果你在搭更复杂的 Loop,就需要自己写一层"日志裁剪器",把原始输出压缩成模型能高效消费的格式。这层裁剪器写得好不好,直接决定 Loop 的迭代效率。
2.4 终止条件:什么时候该停,比什么时候该继续更重要
新手搭 Loop 最常犯的错是没有终止条件,或者终止条件写得太松。结果要么是无限循环烧钱,要么是模型在明显失败的情况下还在硬撑。
终止条件至少要覆盖四种情况:
- 成功终止:目标达成,正常退出
- 失败终止:达到最大迭代次数(我一般设 10-15 轮)或最大 token 预算
- 卡死终止:连续 N 轮(通常 3 轮)状态无变化,说明陷入死循环
- 人工介入终止:遇到需要人类决策的岔路口,主动暂停
第四种最容易被忽略,但最重要。有些问题模型就是解决不了,比如需要访问内网、需要业务判断、需要和产品确认需求。这时候 Loop 应该优雅地停下来并生成一份"求助报告",而不是继续空转。我在progress.json里专门留了一个needs_human字段,一旦置为 true,Loop 就暂停并输出当前所有上下文,等人来处理。
把这四个零件拼起来,一个最小可用的 Loop 就成型了:读目标 → 读状态 → 执行一步 → 收集反馈 → 更新状态 → 判断是否终止 → 循环。听起来简单,但每个环节的细节都藏着坑,下面几节我会逐个拆。
3. 实战搭建:从零跑通一个能自我修正的 Loop
3.1 环境准备:Claude Code / Codex / Cursor 的定位差异
在动手之前,得先搞清楚手头这几个工具各自适合扮演什么角色。很多人把它们当成互相替代的东西,其实它们的定位差别很大,用错了会很别扭。
Claude Code的强项是长上下文 + 工具调用。它天然适合做 Loop 里的"执行者"——你给它一个明确任务,它能自己读文件、改代码、跑命令、看结果。它的上下文窗口大,能扛住比较长的任务链。缺点是它对"跨会话状态"没有原生支持,需要你自己用文件来补。
Codex CLI的强项是轻量 + 可脚本化。它更适合被嵌进自动化流水线里,作为"被调用的一方"。如果你要搭一个跑在 CI 里的 Loop,Codex 往往比 Claude Code 更好集成。它的配置文件(~/.codex/config)支持比较细的模型和参数控制,适合做精细调优。
Cursor的强项是人机协作。它不太适合做全自动 Loop,但非常适合做"半自动 Loop"——模型执行,人随时可以接管。它的中文设置、插件生态、编辑器集成做得比较成熟,适合日常开发中"边写边让 AI 补"的场景。
我的建议是:全自动 Loop 用 Claude Code 或 Codex 做执行核心,Cursor 做人工监督和兜底。三者不是竞争关系,而是流水线上的不同工位。至于 Trae 这类工具,定位和 Cursor 接近,选一个顺手的就行,不必都装。
环境准备阶段还有几个容易忽略的点。第一,确保工具能稳定访问模型服务,网络抖动会让 Loop 在第 7 轮突然断掉,前功尽弃。第二,把 API 额度和计费看清楚,一个跑 15 轮的 Loop 消耗的 token 可能是单次对话的几十倍,心里要有数。第三,在干净的分支上跑 Loop,别在主分支上直接折腾,出问题回滚成本太高。
3.2 写第一个 Loop:一个"自动修编译错误"的最小实现
理论讲够了,直接上代码。下面这个例子是我实际用过的一个最小 Loop,任务是"把指定目录下的 TypeScript 编译错误修到 0"。我用的是 bash + Claude Code CLI 的组合,逻辑很直白,你可以照着改。
#!/bin/bash # loop-fix-ts.sh - 自动修复 TypeScript 编译错误的最小 Loop MAX_ITER=12 TARGET_DIR="src/legacy" PROGRESS_FILE=".loop/progress.json" GOAL_FILE=".loop/goal.md" mkdir -p .loop # 初始化进度文件 if [ ! -f "$PROGRESS_FILE" ]; then echo '{"iter":0,"errors":-1,"attempts":{},"last_error":"","needs_human":false}' > "$PROGRESS_FILE" fi for ((i=1; i<=MAX_ITER; i++)); do echo "=== 第 $i 轮 ===" # 1. 收集当前错误 ERRORS=$(npx tsc --noEmit 2>&1 | grep "error TS" | head -20) ERROR_COUNT=$(echo "$ERRORS" | grep -c "error TS" || echo 0) echo "当前错误数: $ERROR_COUNT" # 2. 判断是否达成目标 if [ "$ERROR_COUNT" -eq 0 ]; then echo "目标达成,退出" break fi # 3. 检查是否卡死(错误数连续两轮不变) PREV_ERRORS=$(jq -r '.errors' "$PROGRESS_FILE") if [ "$PREV_ERRORS" == "$ERROR_COUNT" ]; then STUCK=$(jq -r '.stuck_count // 0' "$PROGRESS_FILE") STUCK=$((STUCK + 1)) if [ "$STUCK" -ge 3 ]; then echo "连续 3 轮无进展,暂停等待人工介入" jq '.needs_human = true' "$PROGRESS_FILE" > tmp && mv tmp "$PROGRESS_FILE" break fi else STUCK=0 fi # 4. 更新进度 jq --argjson e "$ERROR_COUNT" --argjson s "$STUCK" \ '.iter = .iter + 1 | .errors = $e | .stuck_count = $s' \ "$PROGRESS_FILE" > tmp && mv tmp "$PROGRESS_FILE" # 5. 调用 Claude Code 执行一轮修复 PROMPT="$(cat $GOAL_FILE) 当前编译错误如下: $ERRORS 请修复其中最容易解决的 1-3 个错误。只改必要的文件,不要重构无关代码。 修复后不要运行测试,我会统一验证。" claude -p "$PROMPT" --allowedTools "Edit,Read,Bash(npx tsc:*)" # 6. 提交本轮改动 git add -A && git commit -m "loop: 第 $i 轮修复,剩余错误 $ERROR_COUNT" --no-verify done echo "Loop 结束,最终错误数: $(npx tsc --noEmit 2>&1 | grep -c 'error TS' || echo 0)"这段脚本不长,但每个部分都有讲究。MAX_ITER=12是硬性预算,防止无限跑。head -20是上下文裁剪,只把前 20 条错误喂给模型,避免挤爆窗口。stuck_count是卡死检测,连续三轮错误数不变就停。--allowedTools是权限收窄,只允许它编辑文件、读文件、跑 tsc,不允许它乱跑别的命令。
goal.md的内容也很关键,我一般这么写:
# 目标 在不改变任何公开 API 签名的前提下,让 src/legacy 目录下的 TypeScript 编译错误降到 0。 # 约束 - 不修改 src/legacy 之外的任何文件 - 不删除任何导出函数 - 不引入新的依赖 - 每次只修 1-3 个错误,避免大范围改动 # 完成标准 npx tsc --noEmit 输出中 error TS 的数量为 0这个 Loop 我实测跑过好几个项目,成功率大概在七成左右。失败的案例基本都是"错误之间互相依赖,改一个引出三个",这种就需要人工介入了。
3.3 让 Loop 更聪明:上下文裁剪与错误摘要
上面那个最小版本能跑,但效率一般。问题出在反馈通道太粗糙——直接把tsc的原始输出丢给模型,里面有一半是重复的路径信息,模型得自己过滤。优化方向是在喂给模型之前先做一层摘要。
我写过一个 Python 小工具做这件事,核心逻辑是把编译错误按文件聚合,每个文件只保留最典型的几条:
import re from collections import defaultdict def summarize_errors(raw_output: str, max_per_file: int = 3) -> str: """把 tsc 原始输出压缩成按文件聚合的摘要""" pattern = re.compile(r'^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$') by_file = defaultdict(list) for line in raw_output.splitlines(): m = pattern.match(line.strip()) if m: file, line_no, col, code, msg = m.groups() by_file[file].append({ 'line': int(line_no), 'code': code, 'msg': msg.strip() }) parts = [] for file, errs in sorted(by_file.items()): parts.append(f"## {file} ({len(errs)} 个错误)") for e in errs[:max_per_file]: parts.append(f" - L{e['line']} [{e['code']}]: {e['msg']}") if len(errs) > max_per_file: parts.append(f" - ...还有 {len(errs) - max_per_file} 个同类错误") return "\n".join(parts)这个摘要器把原本可能几百行的输出压到几十行,模型读起来快,token 也省。更重要的是,按文件聚合让模型能看出"哪个文件是重灾区",从而优先处理高价值目标。这是纯文本输出给不了的信息。
上下文裁剪还有一个维度是历史轮次的裁剪。Loop 跑到第 8 轮时,前面 7 轮的对话历史如果全留着,上下文早就爆了。我的做法是只保留最近 2 轮的完整对话,更早的轮次压缩成一句话摘要(比如"第 3 轮修复了 utils.ts 的类型错误,错误数从 47 降到 41")。这样既保留了趋势信息,又不占空间。
注意:裁剪历史时千万别把
goal.md和progress.json裁掉。这两个是 Loop 的"锚点",丢了它们模型就会失去方向感。
3.4 实测中的意外:Loop 跑飞了怎么办
再稳的 Loop 也会跑飞。我遇到过几种典型情况,分享出来让你有个心理准备。
第一种:模型开始"创造性发挥"。你让它修编译错误,它顺手把整个文件重写了,还引入了新依赖。这种情况的根因是约束写得太松。解决办法是在goal.md里加"负面清单",明确列出"不要做什么"。我现在的模板里负面清单比正面目标还长。
第二种:错误数在震荡。第 5 轮 30 个错误,第 6 轮 28 个,第 7 轮又回到 31 个。这说明模型在"按下葫芦浮起瓢",改 A 引出 B,改 B 又引出 A。这时候卡死检测(错误数不变)是抓不住的,需要额外加一个"震荡检测"——记录最近 5 轮的错误数,如果最大值和最小值差距小于 20% 且没有单调下降趋势,就判定为震荡,暂停。
第三种:git 历史被污染。每轮都 commit,跑 12 轮就是 12 个 commit,主分支历史变得很难看。解决办法是用一个专门的loop/xxx分支,跑完后再 squash merge 回主分支。或者干脆用git stash而不是 commit,但这样就没法回滚到中间状态了,各有利弊。
第四种:模型"假装完成"。它说"我已经修复了所有错误",但实际上没改任何文件。这种情况要靠独立验证来防——不要相信模型的自我报告,永远用外部命令(tsc、pytest、eslint)来判定是否真的完成。我在 Loop 里从不读模型的文字输出做判断,只读命令的退出码和输出。
跑飞之后的恢复流程也很重要。我的做法是:Loop 暂停后,先git log看每轮改了什么,找到第一个"改坏了"的 commit,git revert掉它,然后从那个点重新跑。不要试图在已经乱掉的状态上继续,越修越乱。
4. 进阶:把 Loop 从"能跑"做到"跑得省"
4.1 成本控制:token 预算怎么算、怎么省
Loop 跑起来之后,最大的隐性成本是 token。一个 12 轮的 Loop,如果每轮都塞满上下文,消耗可能是单次对话的 30-50 倍。我算过一笔账:一个中等规模的重构任务,用 Loop 跑完大概消耗 200 万 token 左右,按当时的模型价格折算下来不算便宜。所以成本控制不是可选项,是必选项。
省 token 的核心思路是让每一轮携带的信息量最大化、冗余最小化。具体有四个手段:
第一,系统提示只注入一次。goal.md、约束、格式要求这些不变的内容,不要每轮都重新拼进 prompt。Claude Code 和 Codex 都支持会话保持,把不变的部分放在会话开头,后续轮次只追加变化的部分。
第二,反馈做摘要不做全量。前面讲的错误摘要器就是这个思路。原始日志动辄几千行,摘要后可能就几十行,省下的都是真金白银。
第三,历史轮次滚动压缩。只保留最近 2 轮完整对话,更早的压成一句话。这个前面也讲过,效果非常明显。
第四,设置 token 预算硬上限。在 Loop 脚本里记录累计消耗,超过阈值就强制暂停。我一般设的阈值是"预估总消耗的 1.5 倍",留点余量但不至于失控。
还有一个反直觉的经验:有时候多花 token 反而更省。比如让模型一次性读完整