AI重构说无变化?双分支差分测试验真

用git worktree和约50行bash差分脚本,让代理「行为未变」的重构承诺变得可验证。
文章指出AI编程代理完成重构后常声称「行为未变,测试通过」,但测试通过只证明没破坏被检查的部分,隐藏的行为差异(如四舍五入顺序变化)可能逃过全绿测试套件。为此作者提出一套约50行的bash差分测试脚本:用git merge-base定位分支分叉点,用git worktree --detach建立基准代码目录,录制贴近生产的输入(命令行和HTTP请求),在两个worktree中并排运行并逐行对比输出,用jq排序和sed屏蔽时间戳等非确定性内容。信任diff前必须让旧版本自比以消除噪音。该方法只适用于承诺无行为变化的重构任务,有副作用的路径需隔离,且覆盖范围受限于录制的输入。
AI编程代理完成重构后,常会自信地报告「行为未变,所有测试通过」。但测试通过只证明它没破坏被检查的部分,隐藏的行为差异可能正悄悄溜进生产环境。本文介绍一种约50行的bash差分测试脚本:用git worktree把重构前后的代码并排运行,喂入相同的真实输入,逐行对比输出,让代理的重构承诺变得可验证。

测试通过不等于行为不变
一个典型的例子:代理把发票总额计算从InvoiceController搬进了InvoiceTotals用例,声称「无行为变化,测试全部通过」。但旧代码对每行金额先四舍五入到分再求和,新代码先加原始金额最后统一取整。三行净额0.30、税率19%的发票,旧算法每行0.357取整为0.36,合计1.08;新算法合计1.071取整为1.07。如果测试夹具里的发票都只有一行,两个版本结果一致,测试套件保持绿色,账单却少了一分钱。
GitHub的Scientist库在README中写得很直白:「测试可以指导重构,但你真正需要的是在负载下比较当前行为与重构后的行为。」差分测试就是这个思路的本地轻量版。
第一步:用git worktree隔离基准代码
git worktree是链接到同一仓库的第二个工作目录。你需要一个给代理的分支,另一个给代理开始时的提交。关键在于与起始提交比较,而不是与今天的main比较——如果代理工作期间main有变动,与它对比会把别人的改动混进来。用git merge-base找到分支分叉点:
cd ~/code/app
base=$(git merge-base main hermes/extract-invoice-totals)
git worktree add --detach ../app-base "$base"
cp .env ../app-base/.env
(cd ../app-base && composer install -q)
传提交哈希而非分支名,可以绕开git「一个分支只能在一个worktree中检出」的限制。新worktree只含已跟踪文件,.env和vendor/这类被忽略的文件需要复制或安装。
git merge-base 是 git 用于寻找两个提交之间最佳共同祖先的命令。在分支场景下,它返回的是两条历史线分叉的那个提交,而不是当前 main 的末端。假设代理从 main 的 C1 提交拉出分支,在此期间 main 前进到 C3,git merge-base main hermes/... 仍会返回 C1。这样基准 worktree 就精确对应代理开始工作时的代码状态,避免把他人合入的改动误判为代理引入的行为变化。如果分叉点不止一个(历史存在多次合并),可用 --fork-point 或指定多个候选分支来细化。
第二步:录制像生产环境的输入
用例文件每行一条shell命令,每条命令在两个worktree各跑一次。注释行以#开头跳过。用例要覆盖平淡场景、多行场景、缺失ID和格式错误:
# tests/diff/cases.txt
php artisan invoice:show 1001 --json
php artisan invoice:show 1002 --json
php artisan invoice:show 1003 --json --currency=EUR
php artisan invoice:show 999999 --json
curl -s -w "\n%{response_code}\n" "$APP_URL/api/invoices/1001"
curl -s -w "\n%{response_code}\n" "$APP_URL/api/invoices/abc"
HTTP输入不必凭空编造。nginx默认的combined日志格式把方法和路径放在第6、7字段,真实GET路径可以直接从访问日志提取:
awk '$6 == "\"GET" { print $7 }' access.log \
| sort -u | head -200 \
| sed 's|.*|curl -s -w "\\n%{response_code}\\n" "$APP_URL&"|' \
>> tests/diff/cases.txt
提交前要人工检查结果:查询字符串可能带令牌和个人数据,路径里的$会被shell展开。除非两边各有独立数据库副本,否则只保留GET——重放的POST会改变后续用例读到的数据行。
第三步:差分脚本的核心逻辑
脚本中stdout和stderr写入同一文件,非零退出码追加为exit=N,因此新出现的警告或退出码变化都算差异。两道防线防止「什么都没跑却通过」:用例文件含HTTP行但URL未设置时直接报错退出;curl非零退出(如连接失败)时用例直接判失败,因为两个相同的错误在diff看来像是一致。
输出能解析为JSON时,jq -S按键排序——重构改变了数组构建顺序,并不改变JSON客户端看到的内容。纯文本、空文件或exit=N行则走cat直接比较。随后sed屏蔽ISO时间戳和UUID,其他每次运行都变的内容放进JQ_FILTER:
export JQ_FILTER='walk(if type == "object"
then del(.request_id, .generated_at) else . end)'
HTTP用例需要在两个worktree各起一个服务器(如8001和8002端口),把地址分别传给BASE_URL和CAND_URL。
信任diff之前,先让旧版本自己和自己比。 指向同一服务器的自比运行中,每一个差异都是噪音,用归一化规则消除到干净为止。Diffy工具用三个实例做同样的事:候选、主、次(最后已知良好代码),主次不一致时「Diffy将那个信号视为噪音」。
jq 的 walk 函数 会递归遍历 JSON 的每一层,对每个值应用给定表达式,常用于全局清洗。示例中 walk(if type == "object" then del(.request_id, .generated_at) else . end) 表示:遇到对象就删除指定易变字段,其他类型原样返回。配合 jq -S(即 --sort-keys)先按键名排序,可以消除因对象键插入顺序不同而产生的纯格式差异。注意 walk 默认会重建整个结构,若需保留原始键序应改用 del 配合路径表达式;另外 -S 与 walk 同时使用时,排序发生在输出阶段,不影响删除逻辑。
第四步:让差分成为任务的完成条件
这一步是Hermes IDE的用武之地(笔者参与开发,请带着这个背景权衡)。Hermes让每个任务在独立的hermes/<name>分支和worktree中启动,任务本身就是代理的第一条提示,代理可以是Claude Code、Codex或其他CLI代理。
Hermes读取仓库中的.hermes/worktree.toml配方,done_when列表存放任务的默认检查项:
# .hermes/worktree.toml
setup = ["composer install -q"]
copy = [".env"]
done_when = [
"composer test",
"""BASE_URL=http://127.0.0.1:8001 \
CAND_URL=http://127.0.0.1:8002 \
~/code/app-base/scripts/difftest.sh ~/code/app-base .""",
]
三引号是TOML的多行字符串形式,行尾反斜杠会裁掉换行和缩进,使第二条检查成为单条命令。脚本不带第三个参数时从基准文件夹读用例,于是脚本和用例都来自代理worktree之外的基准worktree——否则被告知「让这个检查通过」的代理可能直接改cases.txt直到diff安静下来。
delta检查只该用在承诺无行为变化的任务上。 特性任务的diff本来就该非空,用「有差异就失败」的检查会挡住你要求的改动。这类任务手动跑脚本,把diff当审查辅助。
这套方法在哪里失效
diff只覆盖你录制的输入。用例文件里没有多行发票,开头的四舍五入变化照样溜过去。把精力花在用例文件上,每次生产出bug就补一条用例。
有副作用的路径需要隔离。邮件、队列任务、支付、共享数据库写入都会两侧各跑一次。把两个worktree指向假实现或独立副本,或者把这些路径留给测试套件。
无法屏蔽的非确定性会让检查一直红着。数据库返回的无序列表可以在JQ_FILTER里排序,随机采样需要固定种子,依赖墙上时钟的结果就删掉该用例。时间戳正则也很粗糙——它屏蔽输出中每一个日期时间,包括重构可能改坏的那个。如果日期对被改代码很重要,改用JQ_FILTER按字段名屏蔽。
Diffy 是 Netflix 开源的差分测试服务,其核心是三角色实例:候选(candidate)、主(primary)和次(secondary,即最后已知良好版本)。它把同一请求同时发给三个实例,只有当候选与主不一致、而主与次一致时,才判定候选产生了回归;若主与次之间本身就有差异,则该差异被标记为噪音并丢弃。这种设计能自动过滤掉生产环境中本就存在的非确定性输出,比单纯的「新旧对比」更稳健。本地脚本没有第三个实例,因此用「旧版本自比」来达到类似目的:先让同一份代码跑两遍,把所有非确定性差异归一化到零,再信任新旧对比的结果。
关键结论
代理说「无行为变化」是一个待验证的声明,不是结论。测试通过证明的是「没破坏被检查的部分」,差分测试补上的是「没改变任何一件被录制的事」。把差分检查写进done_when,重构任务从「我相信代理」变成「证据说可以合并」。
相关推荐

AI Agent的本质:模型、工具与循环三要素
AI Agent通常由三个核心部分组成:决策模型、可调用的工具、以及执行循环。本文拆解Agent的基本构成,并探讨设计Agent时如何定义权限边界与停止条件,避免失控。

Claude Pro与Max对比:每月200美元的Max值不值?
Claude Pro与Max套餐深度对比:Max每月200美元不增加任何Pro无法触及的模型,只卖容量。5X/20X是Pro会话额度的倍数,从100到200美元容量变4倍。本文拆解两个限制罐子、Fable 5.1计费、API积分条件,并给出80美元升级分界线与按场景选购建议。

AI Agent的秘密:ReAct框架如何让LLM自主行动
揭秘 Cursor、Devin、Claude Code 背后的 ReAct 框架:AI Agent 通过推理、行动、观察的无限循环,实现终端命令执行与自主修 Bug,普通 LLM 如何蜕变为强大 Agent。