一个靠得住的 AI 编程工作流
先写验收标准和测试,再让 AI 动手,用测试的结果而不是 AI 的自述判断做完没有。用一个真实的小任务跑一遍这个流程,再讲怎么审查 AI 写的代码,以及什么时候不该用 AI。
- 约 40 分钟
- 难度:进阶
- 实测:2026-09-14 deepseek-flash,pytest 9.1
用 AI 写代码,最常见的失败不是它写不出来,而是它写出来了、说"已经完成"了,你也信了,结果上线后才发现有问题。
原因往往出在两头:开头没说清楚"做完"是什么样子,结尾没有客观地检查它是不是真的做完了。中间让 AI 写代码的那一段,反而是最不容易出问题的。
这一课讲一个简单的工作流,把两头都补上。它不绑定任何工具,用补全、对话、智能体都一样。
先写"做完的标准"
在让 AI 动手之前,先回答一个问题:改完之后,我怎么知道它做对了?
最好的答案是一组测试。测试把"做完"变成了一个可以运行、会给出明确结果的东西:全部通过就是做完了,有一个没通过就是没做完。不需要看 AI 怎么说,也不需要你凭感觉判断。
用一个小任务来演示。httpx 答疑助手在调用 API 时可能收到 429(被限流),响应头里的 Retry-After 告诉你过多久再重试。它有两种写法:一个秒数(120),或者一个 HTTP 日期(Wed, 21 Oct 2026 07:28:00 GMT)。我们需要一个函数,把它解析成"还要等几秒"。
先写测试,把我能想到的情况都列出来:
from datetime import datetime, timezone
from retry_after import parse_retry_after
NOW = datetime(2026, 10, 21, 7, 0, 0, tzinfo=timezone.utc)
def test_seconds():
assert parse_retry_after("120", NOW) == 120.0
def test_seconds_with_spaces():
assert parse_retry_after(" 30 ", NOW) == 30.0
def test_http_date_in_future():
assert parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT", NOW) == 28 * 60.0
def test_http_date_in_past_means_no_wait():
assert parse_retry_after("Wed, 21 Oct 2026 06:00:00 GMT", NOW) == 0.0
def test_negative_seconds_is_invalid():
assert parse_retry_after("-5", NOW) is None
def test_decimal_seconds_is_invalid():
# 标准规定秒数是非负整数,"1.5" 不合法
assert parse_retry_after("1.5", NOW) is None
def test_garbage_is_invalid():
assert parse_retry_after("soon", NOW) is None
(完整的 10 个测试在 code/07-ai-coding/test_retry_after.py。)
写测试的过程本身就在帮你想清楚需求。写到"日期已经过去了怎么办",你得决定是返回 0 还是返回负数;写到"1.5 秒算不算合法",你得去查一下标准。这些问题,如果不先想清楚,AI 就会替你随便决定一个。
测试之外,再写一份简短的任务说明(TASK.md),把测试没法表达的要求写进去:只能用标准库、不要修改测试文件、不要针对测试里的具体值写特殊判断。最后一条很重要:没有这条,一个"聪明"的 AI 可能写出"如果输入是 120 就返回 120.0"这种专门骗过测试的代码。
让 AI 动手,用测试检查
code/07-ai-coding/ai_coding_loop.py 把这个流程写成了一个小程序:把任务说明和测试交给模型,让它写 retry_after.py,运行测试;没通过就把测试的输出交回给它修改,最多 3 轮:
for round_ in range(1, 4):
reply = client.chat.completions.create(model=MODEL, messages=messages).choices[0].message.content
TARGET.write_text(extract_code(reply))
code, output = run_tests()
summary = output.strip().splitlines()[-1] if output.strip() else ""
print(f"第 {round_} 轮:退出码 {code},{summary}")
if code == 0:
print("测试全部通过。生成的代码:\n")
print(TARGET.read_text())
break
messages += [{"role": "assistant", "content": reply},
{"role": "user", "content": f"测试没有通过,输出如下。修改代码,再给出完整的 retry_after.py:\n\n{output}"}]
else:
print("3 轮都没有通过,停下来交给人看。最后一次的测试输出:\n" + output)
判断"做完没有"的,是 pytest 的退出码:0 表示全部通过,非 0 表示有失败。不是模型说的"我已经完成了"。
这其实就是 Claude Code、Codex 这类编程智能体在做的事的一个缩影:写代码、跑测试、看结果、再改。区别在于它们能自己决定什么时候跑测试、跑哪些测试。所以给它们一份写好测试命令的规则文件(上一课),它们就能自己验证。
真实的结果
我运行了很多次,分两种情况。
模型能看到完整的任务说明和测试时,4 次运行全部第一轮就通过了。
只给模型一句话的需求、不给它看测试时(加上 --vague 参数,测试留在我手里做验收),11 次运行里,10 次第一轮就通过了,1 次第一轮有 1 个测试没通过,把测试的输出交给它之后,第二轮通过:
第 1 轮:退出码 1,1 failed, 9 passed in 0.01s
第 2 轮:退出码 0,10 passed in 0.00s
(那一次我的脚本还没有打印失败的测试名,所以不知道具体是哪一条。现在的脚本会把 FAILED 开头的行打印出来。)
这是最后一次运行生成的代码,一字未改:
from datetime import timezone
from email.utils import parsedate_to_datetime
def parse_retry_after(value, now):
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
if value.isascii() and value.isdigit():
try:
return float(int(value))
except (ValueError, OverflowError):
return None
try:
retry_time = parsedate_to_datetime(value)
except (TypeError, ValueError, OverflowError):
return None
if retry_time is None:
return None
if retry_time.tzinfo is None:
retry_time = retry_time.replace(tzinfo=timezone.utc)
return max(0.0, (retry_time - now).total_seconds())
有一个细节值得注意:value.isascii() and value.isdigit()。只用 isdigit() 是不够的,它对阿拉伯文数字、上标数字这些非 ASCII 字符也返回 True。模型多写的这个 isascii(),恰好堵上了一个我的测试里没有覆盖的漏洞。反过来说,如果它没写,我的 10 个测试也发现不了。
老实说,这个任务对现在的模型来说不难:Retry-After 的格式在 HTTP 标准里写得很清楚,模型很熟悉,标准库里也有现成的 parsedate_to_datetime 可以解析 HTTP 日期。
但这恰恰是这个工作流的意义:你事先不知道这一次它会不会错。11 次里有 1 次,只给一句话需求时,它漏掉了某个细节。如果没有测试,你拿到的就是那份有问题的代码,而模型会告诉你"已经完成"。有了测试,这一次失败被自动发现、自动修好了,你甚至不用去看它错在哪。
小步走
上面的例子只有一个函数。真实的任务往往大得多:"给 RepoBot 加上用户登录"。这种任务交给 AI,最常见的结果是:它一口气改了十几个文件,几百行的改动,你看不过来,只能选择全部接受或者全部放弃。
更好的做法是把大任务拆成小步,每一步都满足三个条件:
- 只做一件事。"加一个用户表"是一步,"写登录接口"是另一步,"前端加登录框"又是一步。
- 改动小到你能在几分钟内看完。如果一次的 diff 大到你不想看,说明这一步太大了。
- 有自己的验收方式。最好是测试,至少也是一个你能手动检查的结果。
每完成一步,就用 git 提交一次。出了问题,可以退回到上一个好的状态,而不是面对一大堆混在一起的改动无从下手。
动手之前,还可以先让 AI 只出计划不动手(上一课和第 1 课提到的计划模式或只读模式)。它的计划里要写清楚:打算改哪些文件、每一步做什么、怎么验证。你看过计划,觉得方向对了,再让它开始改。方向错了,在计划阶段发现,只浪费了几分钟。
审查 AI 的改动
测试通过了,也不代表万事大吉。测试只能检查你想到的情况。合并之前,把 AI 的改动看一遍,重点看这些地方:
- 它有没有改测试。AI 为了让测试通过,可能会修改测试本身,或者删掉失败的测试。这是最需要警惕的。
- 有没有针对测试的特殊处理。代码里出现和测试用例里一模一样的具体值,要打个问号。
- 有没有顺手改了别的地方。你让它修一个 bug,它顺便"优化"了三个不相关的函数。这些改动没有测试覆盖,也不在你的预期之内。
- 边界情况和错误处理。空值、超长的输入、网络失败、并发。上面的
parse_retry_after用try/except处理了日期解析失败的情况,这一点就是审查时要确认的。 - 安全问题。拼接 SQL、执行命令、处理用户上传的文件、打印或者记录密钥。第 05 模块第 8 课和第 06 模块第 5 课讲过的问题,在 AI 写的代码里一样会出现。
- 有没有引入新的依赖。AI 很喜欢"顺手"装一个包来解决问题。每一个新依赖都是一份长期的维护负担,也是一个潜在的安全风险。
- 你能不能看懂。如果一段代码你看不懂,就不要合并它。等它出了问题,你得能修得了。
什么时候不要用 AI
- 你自己都没想清楚要什么。AI 会很乐意替你做决定,但那些决定不一定对。先想清楚,写下验收标准,再动手。
- 你没法验证结果。你不懂的领域、没有测试也没法手动检查的代码,AI 写得再像样,你也不知道它对不对。
- 改动的代价很高而且不可逆。数据库迁移、删除数据、生产环境的配置。这类操作可以让 AI 帮你写,但一定要你自己审查,并且在测试环境里先验证。
- 你想学会这个东西。用 AI 写练习题,就像这门课第一课说的,是请人替你去健身房。
把整个流程串起来
1. 想清楚:做完是什么样子?写成测试或者可检查的验收标准
2. 拆小:一步只做一件事
3. 计划:让 AI 先说它打算怎么做,你确认方向
4. 动手:让 AI 改,改动控制在你能看完的范围
5. 验证:跑测试,看退出码,不看 AI 的自述
6. 审查:看它改了什么,重点看测试、边界、安全、依赖
7. 提交:git commit,然后开始下一步
8. 复盘:它犯过的错,写进项目的规则文件(上一课)
这个流程看起来比"直接让 AI 写"麻烦。但真正花时间的从来不是写代码,而是发现问题、定位问题、修复问题。这个流程把发现问题的时间提前了,也把出了问题之后的损失控制在了一小步之内。
练习
- 运行
ai_coding_loop.py和ai_coding_loop.py --vague各几次,记录每次几轮通过。 - 往
test_retry_after.py里加一个测试:parse_retry_after("Wed, 21 Oct 2026 07:28:00 +0800", NOW)应该返回什么?先查一下 HTTP 标准对日期格式的要求,决定你的答案,再看 AI 写的代码是否满足。 - 在你自己的项目里选一个小功能,按本课的流程完整走一遍:先写测试,再让 AI 实现,跑测试,审查,提交。记下你在审查时发现了什么问题。
自测
1. 为什么要在让 AI 动手之前先写测试?
测试把"做完"变成了一个可以运行、结果明确的标准。有了它,判断任务是否完成靠的是测试的结果,而不是 AI 说"已经完成"。写测试的过程还会逼你想清楚需求里那些模糊的地方(比如日期过期了怎么办、小数算不算合法),不把这些决定留给 AI 随便处理。
2. 测试全部通过了,还需要审查 AI 的代码吗?审查时重点看什么?
需要。测试只能检查你想到的情况。审查时重点看:它有没有修改或删除测试、有没有针对测试用例写特殊处理、有没有顺手改了无关的地方、边界情况和错误处理、安全问题、有没有引入新的依赖,以及你能不能看懂这些代码。
3. 为什么要把大任务拆成小步,每一步提交一次?
大任务一次交给 AI,改动多到没法认真审查,只能全盘接受或者全盘放弃,出了问题也难以定位。拆成小步后,每一步的改动都小到能看完、有自己的验收方式;每步提交一次,出问题时可以退回到上一个好的状态。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…