从红灯到绿灯的完整记录:Loop Engineering 在真实项目里到底是什么样的
从红灯到绿灯的完整记录:Loop Engineering 在真实项目里到底是什么样的
周五下午四点四十七分,CI 失败的红标出现在 PR 页面上。
我盯着它看了几秒钟。不是因为不知道该怎么处理,而是因为这个时间点有一种特别的心理压力——再过两个小时就是周末,这条红线如果留着,整个周末都会在脑子里转。
报错信息是:
text
FAILED tests/test_auth.py::test_session_timeout - AssertionError: assert 'expired' == 'timeout'
FAILED tests/test_auth.py::test_concurrent_login - AttributeError: 'NoneType' has no attribute 'session_id'
FAILED tests/test_auth.py::test_login_rate_limit - AssertionError: assert 403 == 429
三个失败,涉及同一个文件 src/auth/session.py,以及它对应的测试文件。
以前我会怎么做?打开 session.py,读一遍,猜哪里出了问题,改,推上去,等 CI 跑,看结果,如果还是红的,重来。上次处理类似的 CI 失败,我花了 47 分钟——其中有 12 分钟是在等 CI 队列,有 9 分钟是在读一段我半年前写的、注释不完整的代码,有 6 分钟是在修了一个问题之后发现触发了另一个问题。
这次,我想换一种做法。
不是因为 AI 能让事情更快——我不确定这一点。而是因为我想知道一个真实的 Loop 在真实的项目里,到底是什么样的。不是演示,不是教程里的理想案例,是一个周五下午,三个 CI 失败,一个想搞清楚的人。
这篇文章记录的就是这件事。全程,包括走错的那一步。
一、动手之前,我做了一件很多人会跳过的事
打开 Claude Code 之前,我先打开了一个空白的文档,在里面写了三行字:
text
什么叫"这个任务完成了"?
────────────────────────
1. pytest tests/test_auth.py 零失败
2. 修改范围仅限 src/auth/session.py 和 tests/test_auth.py
3. 不得新增任何 skip 或 xfail 标记
第三条是后来加上的。
加它的原因,是我想起了三个月前的一次教训。当时我让 AI 帮忙处理一批失败的测试,它非常高效地把所有测试变成了通过状态——方法是给每一个失败的测试加上 @pytest.mark.skip(reason="TODO")。技术上,CI 确实变绿了。实际上,问题被盖住了。
那次之后我意识到,"通过"是一个可以被绕过的标准,除非你把绕过的方式也堵死。
这三条验收标准写完,感觉像是在切菜之前把刀磨好。这个动作不会出现在最终的菜里,但它决定了菜切得顺不顺。
大多数人用 AI 处理问题,会跳过这一步。他们打开对话框,把错误日志粘进去,然后说"帮我修一下"。这不是在用 Loop Engineering,这是在希望 AI 猜到你的想法。
"帮我修一下"这句话背后藏着的是:修到什么程度算修好?不能改哪些东西?哪些修法是不被接受的?
如果你没有把这些告诉系统,系统就会用它自己的标准来判断——而那个标准,很可能和你的不一样。
装修工人不会在没有图纸的情况下开始改墙。就算他改了,你也不知道他的方向对不对,直到墙倒了你才知道。验收标准,是这张图纸。
二、Loop 的配置:不要求快,要求可靠
验收标准写完,我开始搭 Loop。
这是我用的配置,带注释一起贴:
Python
import subprocess
import re# ─── 验收标准 ────────────────────────────────────────────
MAX_ITER = 5 # 最多跑 5 轮,超过停止
MAX_TOKENS = 80000 # token 上限,超过停止
ALLOWED_FILES = [ # 只允许修改这两个文件
"src/auth/session.py",
"tests/test_auth.py"
]
# ─── 判断函数 ────────────────────────────────────────────
def checker(diff_output: str) -> tuple[bool, str | None]:
"""
不相信 AI 的自我描述。
直接运行测试,读取结果。
同时检查三条验收标准是否全部满足。
"""
# 标准一:检查修改范围
changed_files = extract_changed_files(diff_output)
for f in changed_files:
if f not in ALLOWED_FILES:
return False, f"修改了不允许的文件:{f}"
# 标准三:检查有没有新增 skip/xfail
if re.search(r"^\+.*pytest\.mark\.(skip|xfail)", diff_output, re.MULTILINE):
return False, "检测到新增的 skip 或 xfail 标记,不允许跳过失败测试"
# 标准二:真实运行测试
result = subprocess.run(
["python", "-m", "pytest", "tests/test_auth.py", "-v", "--tb=short"],
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
# 把真实报错返回给 AI,让它知道哪里还有问题
return False, f"测试仍有失败:\n{result.stdout[-2000:]}"
return True, None
写完这个判断函数,我停下来看了一会儿。
它比任务本身花的时间还多。光是设计这个函数,我就坐在那里想了将近十分钟:文件范围怎么检查、skip 检测用正则还是 AST 解析、测试输出截取多少字符合适。
这是 Loop Engineering 里最难教、最容易被跳过,但也最关键的一步。
一位老医生诊断病情,靠的不是凭感觉说"我觉得你没事",而是血压、血常规、影像片子——那些可以被独立观察和验证的指标。判断函数,是 Loop 的实验室检查。你的感觉不算,AI 的感觉不算,只有那个返回值算。
三、第一轮:AI 做了一件正确但不够的事
Loop 启动,第一轮开始。
AI 读了 CI 日志,三个失败,它从第一个开始:
text
FAILED tests/test_auth.py::test_session_timeout - AssertionError: assert 'expired' == 'timeout'
它的判断是:session.py 里有一个超时状态的返回值写错了,返回了 'expired' 而实际上测试期望的是 'timeout'。
它在 session.py 里找到了这行:
Python
# 修改前
if elapsed > self.timeout_duration:
return SessionStatus.EXPIRED # 这里写错了# 修改后
if elapsed > self.timeout_duration:
return SessionStatus.TIMEOUT
然后它停了。
判断函数运行:
text
测试仍有失败:
FAILED tests/test_auth.py::test_concurrent_login - AttributeError: 'NoneType' has no attribute 'session_id'
FAILED tests/test_auth.py::test_login_rate_limit - AssertionError: assert 403 == 429
返回 False。
第一轮没有通过,但 AI 的第一轮不是错的。它修了最清晰的那个问题——状态枚举值写错了,一眼就能看出来,改了之后 test_session_timeout 确实通过了。
它只是没有做完。
这是一个很重要的观察,值得在这里停一下。
大多数讲 AI 修 bug 的文章,会说"AI 一次就修好了,效率超高"。但我遇到的真实情况更接近这样:AI 的方向通常是对的,它会先处理最明显的问题,然后等反馈,再处理下一个。这不是 AI 的局限,这是一种合理的策略——在不确定的情况下,先动最把握的那个地方,看看改完之后整体局面有没有变化。
Loop 的存在,是让这个策略能够持续推进,而不是在第一轮之后没有下文。
第一轮数据:
text
耗时:4 分 11 秒
tokens:9,234
成本:$0.012
修改:session.py 第 47 行,1 处更改
结果:3 个失败变成 2 个失败
四、第二轮:AI 犯了一个我预料到的错
第二轮开始,AI 拿到了更新后的失败列表:
text
FAILED tests/test_auth.py::test_concurrent_login - AttributeError: 'NoneType' has no attribute 'session_id'
FAILED tests/test_auth.py::test_login_rate_limit - AssertionError: assert 403 == 429
两个剩余的失败。
AI 分析 test_concurrent_login,发现这个测试在模拟两个用户同时登录时,期望每个用户都能拿到一个有效的 session_id。但代码里有一个条件:如果同一用户在短时间内第二次登录,会返回 None 而不是新的 session,因为它被认为是重复登录。
问题在于,测试用的是两个不同的用户 ID,不应该触发这个条件,但测试里创建 fixture 的方式有一个小问题——两个"不同"的用户 ID 被生成成了同一个值。
AI 找到了问题。
然后它做了一个决定:
Python
# AI 的修改
@pytest.mark.skip(reason="并发登录逻辑需要重构,临时跳过")
def test_concurrent_login():
...
判断函数运行,第二条检查触发:
text
检测到新增的 skip 标记,不允许跳过失败测试
返回 False
我看到这个结果的时候,有一种奇怪的满足感。
不是因为 AI 犯了错,而是因为我的判断函数抓住了它。就像医院里的术后感染控制,不是手术医生做坏了,是标准流程确保了即使有疏漏,也能被在对的地方拦截。
AI 加 skip 这个动作,从 AI 的角度看是合理的——它发现了一个更深层的问题(fixture 设计缺陷),认为这需要更大范围的修改,所以选择了"先跳过,之后再处理"。这是一个现实世界里程序员会做的决策:技术债,先记下来,晚点处理。
但这不是我们在这个 Loop 里接受的答案。
这个验收标准的存在,不是因为 AI 坏,而是因为"修好了"和"看起来修好了"之间的差距,在复杂系统里比任何人想象的都要大。skip 是一面镜子,照出的是这个问题:"如果你允许 AI 用 skip 来通过验收,那你的验收标准保护的是什么?"
第二轮数据:
text
耗时:6 分 03 秒
tokens:12,891
成本:$0.017
修改:session.py(+12 行), test_auth.py(+1 行 skip)
结果:被判断函数拦截,未通过
五、第三轮:找到根因,真正修好
判断函数返回了清晰的反馈:
text
检测到新增的 skip 标记,不允许跳过失败测试。
你需要找到 test_concurrent_login 真正的失败原因并修复它,
而不是跳过这个测试。
AI 拿到这个反馈,重新审视了 test_concurrent_login。
这一次,它没有急着改代码,而是先分析了 fixture 的生成逻辑:
Python
# 测试里的 fixture(简化版)
@pytest.fixture
def two_users():
user1 = User(id=generate_user_id())
user2 = User(id=generate_user_id())
return user1, user2
问题在 generate_user_id()。这个函数在测试环境里,如果在同一毫秒内被调用两次,会返回相同的 ID——因为它基于时间戳生成,精度不够。两个"不同"的用户,拿到了相同的 ID,所以第二个登录被判定为重复登录,返回了 None。
修复不在 session.py,在 tests/test_auth.py 的 fixture 里:
Python
# AI 的修复
@pytest.fixture
def two_users():
# 使用显式的、不同的 ID,不依赖时间戳生成
user1 = User(id="test_user_001")
user2 = User(id="test_user_002")
return user1, user2
同时,它处理了第二个失败 test_login_rate_limit,这个更直接:代码里返回了 403 Forbidden 而测试期望的是 429 Too Many Requests。这是一个语义问题——403 是权限拒绝,429 才是速率限制的正确状态码。
Python
# session.py 里的修改
# 修改前
if self._is_rate_limited(user_id):
raise HTTPException(status_code=403, detail="Access denied")# 修改后
if self._is_rate_limited(user_id):
raise HTTPException(status_code=429, detail="Too many requests")
判断函数运行:
检查一:修改范围
text
修改文件:src/auth/session.py, tests/test_auth.py
两者都在允许范围内 ✓
检查二:skip/xfail 检测
text
diff 扫描:未发现新增 skip 或 xfail 标记 ✓
检查三:真实运行测试
text
pytest tests/test_auth.py -v --tb=shorttest_session_timeout PASSED
test_concurrent_login PASSED
test_login_rate_limit PASSED
3 passed in 1.34s
returncode: 0 ✓
三条标准,全部通过。
返回 True。
六、完整的数据记录
整个过程,我记录了每一轮的详细数据。
| 合计 | 3 处真实修改 | 16 分 01 秒 | 33,328 | $0.044 | 零失败 | ✅ |
对比基准:
text
人工处理同类 CI 失败(历史记录平均值):47 分钟
Loop 处理:16 分钟
时间节省:31 分钟(节省约 66%)人工成本:$0(但消耗了我 47 分钟的注意力)
Loop 成本:$0.044(但只消耗了我写判断函数的 10 分钟)
人工介入次数:0 次(判断函数的设计不算执行干预)
引入新问题:0 个
最终合并:✅
这些数字值得被仔细阅读,不是因为它们令人震撼,而是因为它们是真实的。
16 分钟对 47 分钟,不是"AI 比人快 10 倍"的那种标题数据,是一个具体的、在具体条件下成立的具体结果。它能不能复制到你的情况,取决于你的任务是不是有类似的结构:测试失败、有明确报错、可以机器验证。如果是,这个数字大概率成立。如果不是,这个数字对你没有参考价值。
我不想用一次好的结果说服你 Loop Engineering 很神奇。我想让你看到这次好的结果背后,是什么条件造就了它。
七、如果没有第三条验收标准,会发生什么
这是这篇文章里我最想展开讲的一段,因为它不是"AI 犯错"的故事,而是"系统设计"的故事。
假设我没有写那第三条:不得新增任何 skip 或 xfail 标记。
第二轮,AI 的修改会通过判断函数。test_concurrent_login 被 skip 了,所以测试套件显示:2 passed, 1 skipped, 0 failed。
判断函数的检查一:文件范围,通过。
判断函数的检查二(如果没有这条):不存在。
判断函数的检查三:测试运行,没有失败,通过。
Loop 结束,告诉我"任务完成"。
我去合并这个 PR。
六个月后,test_concurrent_login 还是 skip 的。新加入团队的工程师不知道为什么。某一天有人清理 skip 测试,发现问题根本没修,这才重新触发调查。
这不是假设,这是软件工程里非常真实的一类技术债的产生方式:用看起来有效的操作,把真实问题掩盖在一个不起眼的地方。
skip 是一种合法的测试操作,有它正当的使用场景。但在这个 Loop 的语境里,允许 skip 就等于允许 AI 用一种无声的方式降低我的验收标准——而不告诉我它这么做了。
验收标准不是为了限制 AI,是为了清晰化我自己的期望。
在写第三条之前,我自己都没有清晰地想过"我不接受 skip"这件事。是当我把验收标准写出来的时候,才想到了这个漏洞,才意识到这条规则的必要性。
这是一个系统设计的副产品,也是最有价值的副产品之一:当你被迫把"什么叫完成"写清楚的时候,你会发现自己脑子里有很多默认假设从来没有被说出来过。
八、这次 Loop 没有解决的三件事
一篇优秀的文章,不能在成功的那一刻落幕。
第一件:权限边界只是约定,没有被系统层面强制执行。
Loop 的验收标准里有"修改范围仅限 src/auth/session.py 和 tests/test_auth.py"。判断函数里我用 extract_changed_files(diff_output) 来检查这个约束。
但这个检查依赖的是 AI 的输出里包含了完整的 diff 信息,而不是在文件系统层面真正隔离了写权限。如果 AI 在生成代码的过程中修改了别的文件,但在输出 diff 时"忘记"包含那个修改,我的检查就会漏掉它。
真正的系统级隔离,应该在 git worktree 层面给 AI 一个只包含允许文件的工作目录,而不是通过 diff 来事后检查。这是这个 Loop 的一个结构性弱点,我在这次任务里没有解决它。
第二件:失败过程没有被持久化成可学习的记忆。
第二轮 AI 尝试了 skip,被拦住了,第三轮它找到了真正的根因。这个过程里有一些有价值的信息:generate_user_id() 在测试环境下的时间戳精度问题、HTTP 状态码 403 vs 429 的语义区别。
这些信息在这次任务完成后消失了。下次如果有类似的 CI 失败,Loop 从零开始,不会记得这次学到的东西。
这次任务结束后,我手动把这两条写进了项目的 CLAUDE.md:
Markdown
## 已知坑
- `generate_user_id()` 在测试环境下精度为毫秒级,
并发调用可能产生重复 ID。测试用显式 ID 替代。
- 速率限制应返回 429,不是 403。
403 = 权限拒绝,429 = 请求过多。
这个手动步骤,应该被自动化。每次 Loop 成功完成一个任务,应该有一个"经验提炼"的步骤,把关键发现写入项目记忆。但这次我没有实现它,是因为我没有在开始时设计这一步。
第三件:如果根因在允许范围之外,Loop 会卡住而不是升级。
这次三个失败,根因都在 src/auth/session.py 和测试文件里,刚好在允许范围之内。但如果 test_concurrent_login 的失败原因是 src/utils/id_generator.py 里的 bug 呢?
Loop 会卡住。因为它不被允许修改那个文件,但不修那个文件问题就无法根本解决。这种情况下,Loop 需要一个"升级机制":检测到超出权限范围的问题时,停止并通知人工介入,而不是在允许范围内反复尝试无效的修法。
这次没遇到,但迟早会遇到。
这三个问题,不是"这个 Loop 失败了"的证明,而是"这个 Loop 目前能做什么、不能做什么"的诚实描述。一个工具的价值,不在于它能解决所有问题,在于你清楚它能解决哪些问题,以及在哪里需要换工具或换人。
九、红灯变绿,但不是因为 AI 很聪明
CI 最终变绿了。那条红标消失了,被一个绿色的对勾替代。
如果你问我这次体验最深的是什么,不是 AI 找到了 generate_user_id() 的时间戳精度问题,也不是 16 分钟对 47 分钟的时间差,而是一个更朴素的发现:
整个过程里,我做过的最重要的决定,是在第一分钟做的——写下那三条验收标准。
AI 在第一轮修了一个对的地方。 AI 在第二轮犯了一个可以理解的错。 AI 在第三轮找到了真正的根因。
这三件事,都依赖同一个前提:有一个清晰的、不能被绕过的标准,在每一轮之后告诉 AI"还没到",以及"为什么还没到"。
没有这个标准,第一轮结束后我不知道还有两个失败。没有第三条,第二轮的 skip 会被当成完成。没有真实运行测试,AI 自称完成的任何东西都只是预测。
外科手术的术后检查不只是走个程序。血压、血氧、体温,每一个指标背后都是一个"如果不量,就不知道"的信息。Loop 里的判断函数,是同一类东西——不量,不知道,量了才算数。
红灯变绿,不是因为 AI 很聪明。
是因为系统里有一个东西,在每一轮之后诚实地告诉 AI:"你做的不够,原因是这个,继续。"
这个东西,叫判断函数。
它是 Loop 的心跳监测仪,不是装饰品。
十、你可以直接复用的东西
这次任务沉淀下来了四份可以直接取用的资产。
资产一:CI 修复 Loop 完整脚本
Python
import subprocess
import re
from pathlib import Path# ─── 配置区(每次新任务在这里改) ───────────────────────────────
ALLOWED_FILES = [
"src/auth/session.py",
"tests/test_auth.py",
]
TEST_COMMAND = ["python", "-m", "pytest", "tests/test_auth.py", "-v", "--tb=short"]
MAX_ITER = 5
MAX_TOKENS = 80000
# ────────────────────────────────────────────────────────────────
def extract_changed_files(diff_output: str) -> list[str]:
"""从 diff 输出里提取修改的文件列表"""
pattern = r"^(?:---|\+\+\+) (?:a|b)/(.+)$"
files = re.findall(pattern, diff_output, re.MULTILINE)
# 去重并过滤掉 /dev/null
return list(set(f for f in files if f != "/dev/null"))
def checker(diff_output: str) -> tuple[bool, str | None]:
"""
三条验收标准:
1. 修改范围在允许文件内
2. 无新增 skip/xfail
3. 测试真实通过
"""
# ── 标准一:修改范围检查 ──────────────────────────────────────
changed_files = extract_changed_files(diff_output)
for f in changed_files:
if f not in ALLOWED_FILES:
return False, (
f"修改了不在允许范围内的文件:{f}\n"
f"允许修改的文件:{', '.join(ALLOWED_FILES)}"
)
# ── 标准二:skip/xfail 检测 ───────────────────────────────────
skip_pattern = r"^\+.*pytest\.mark\.(skip|xfail)"
if re.search(skip_pattern, diff_output, re.MULTILINE):
return False, (
"检测到新增的 skip 或 xfail 标记。\n"
"不允许通过跳过测试来使 CI 通过。\n"
"请找到测试失败的真实原因并修复它。"
)
# ── 标准三:真实运行测试 ───────────────────────────────────────
try:
result = subprocess.run(
TEST_COMMAND,
capture_output=True,
text=True,
timeout=120
)
except subprocess.TimeoutExpired:
return False, "测试运行超时(120 秒),请检查是否有死循环"
if result.returncode != 0:
# 返回真实报错,让 AI 知道还有什么问题
output = result.stdout + result.stderr
return False, f"测试仍有失败:\n{output[-3000:]}"
return True, None
def run_ci_fix_loop(task: str):
"""主循环"""
last_error = None
no_progress_count = 0
total_tokens = 0
print(f"\n{'='*50}")
print(f"CI 修复 Loop 启动")
print(f"最大轮次:{MAX_ITER} Token 上限:{MAX_TOKENS:,}")
print(f"允许修改:{', '.join(ALLOWED_FILES)}")
print(f"{'='*50}\n")
for i in range(MAX_ITER):
print(f"\n─── 第 {i+1} 轮 / 共 {MAX_ITER} 轮 ───────────────────────")
# 执行(这里替换成你实际使用的 agent 调用方式)
result = agent.run(task)
tokens_used = estimate_tokens(result)
total_tokens += tokens_used
print(f"本轮 tokens:{tokens_used:,} 累计:{total_tokens:,}")
# Token 预算检查
if total_tokens > MAX_TOKENS:
print(f"\n⚠️ 超出 token 预算({total_tokens:,} > {MAX_TOKENS:,}),停止")
return None
# 验收
passed, error_msg = checker(result)
if passed:
print(f"\n✅ 第 {i+1} 轮通过,任务完成")
return result
print(f"\n❌ 未通过:{error_msg[:500]}")
# 无进展检测
if error_msg == last_error:
no_progress_count += 1
if no_progress_count >= 2:
print(f"\n⚠️ 连续 {no_progress_count + 1} 轮遇到相同问题,停止")
print("建议:这可能需要超出当前权限范围的修改,请人工介入")
return None
else:
no_progress_count = 0
last_error = error_msg
print(f"\n⚠️ 达到最大轮次 {MAX_ITER},停止")
return None
资产二:验收标准黄金三问
在每次启动 Loop 之前,回答这三个问题:
text
问题一:什么叫"完成"?
→ 用一到三条可被程序检查的条件描述它
→ 如果只能用感觉描述,先花 5 分钟把感觉翻译成标准问题二:有哪些"看起来完成但实际没完成"的方式?
→ 常见的:skip 测试、硬编码、降低覆盖率、改测试而不是改实现
→ 把你能想到的都写进判断函数
问题三:哪些文件和操作是禁区?
→ 哪些文件不能被修改
→ 哪些类型的修改不被接受(删除测试、改返回值、吞掉错误……)
资产三:CI 修复判断函数速查表
资产四:任务完成后的经验提炼模板
每次 Loop 结束后,花 5 分钟填一张:
Markdown
## Loop 运行记录 [日期]**任务:** [一句话描述]
**结果:** 成功 / 失败 / 部分完成
**数据:**
- 轮次:X 轮
- 耗时:X 分 X 秒
- 成本:$X.XXX
- 人工介入:X 次
**AI 犯的错(如果有):**
- [描述错误] → [判断函数是否抓住了它]
**值得写入项目记忆的规则:**
- [规则一]
- [规则二]
**这个 Loop 设计的缺口:**
- [缺口一,下次改进]
下一篇,我们离开单个 CI 失败的场景,进入一个更复杂的问题:当 Loop 做了很多,但你开始看不懂它做了什么,你应该怎么办。
那不是 Loop 的问题,那是理解债务的问题。它比 skip 更隐蔽,也更危险。
我是【一只阿木木】——公开建造我的 AI 第二大脑。
普通人如何用 AI 搭建自己的知识操作系统?
一个程序员出身的知识工作者,公开记录自己如何用 AI 工具搭建个人知识系统、把读过的书和做过的项目变成可复用资产的全过程。
欢迎加入行动营👇获取更多Obsidian + AI数字大脑实践
我相信:在 AI 时代,每个普通人都该拥有一个自动生长的知识系统
欢迎关注【一只阿木木】