AI 说「改完了」,我怎么在 10 分钟内证明它在说谎
上一篇写的是浏览器自动化里「脚本说成功、其实没成功」的七个坑。这次换个层级,写构建和发布这一侧。
因为真正贵的坑不在「操作有没有执行」,而在「执行完了,但结果是旧的」。
我维护一个小静态站,构建链是一个 Python 脚本管生成、一个管打包、一个管推 CDN。整条链上每一步都返回 0,日志一片绿。有一段时间我确实相信它绿就代表改完了,直到连着撞了四次:
- 本地
sitemap.xml是 139 条,线上一直是 131 条,差的那 8 页在线上根本不存在 - 一批页面下线了,但另外两个地方还链着它们,出去 2 条死链
- 脚本构建「通过」,某个分类下少了一整批页面
- 内容平台接口返回「发布成功」,读者点开是 404
四次都不是代码报错。四次都是「退出码 0」。
一、先把「改完了」拆成三层,否则你永远在自欺
我后来给自己定了个判断标准,任何一次改动都要过三关,关关不是同一个证据:
| 层级 | 要证明的事 | 不算证据的东西 |
|---|---|---|
| ① 写进去了 | 文件真的落盘、内容真的变了 | 函数返回了、日志打印了 |
| ② 变对了 | 产物里的内容和源头一致 | 目录里有文件、大小不为 0 |
| ③ 生效了 | 服务端真的在对外提供新版 | 本地能打开、构建成功 |
绝大多数「AI 说改完了」的翻车,是拿 ① 的证据去顶 ③ 的结论。 脚本写了文件、打了日志,然后我顺手打开了本地预览,看起来对,就宣布完成。中间 ② 和 ③ 两关一次都没过。
二、坑一:删除失败时,它只打日志,不抛异常
这是我这半年最贵的一个坑,因为它导致线上一直是过期版本,而我连续几天以为是自己逻辑写错了。
场景很普通:打包前要清空 dist/。写法也普通:
shutil.rmtree(DIST)
os.makedirs(DIST, exist_ok=True)
在我这台机器上,这个环境的文件安全策略会拦截「批量删除」。拦截的方式是打印错误,但不抛异常。
于是发生了这样的事:
rmtree删了前几十个文件,被拦,返回dist/里剩下的旧文件全部残留copy2复制新文件——同名文件被覆盖了一部分,删掉的那批页面的旧文件反而还在- 整条链继续往下走,一切正常
- 打包、部署,发出去的是一个新旧混合的快照
线上症状就是那个:源文件里 sitemap.xml 已经 139 条,dist/sitemap.xml 还是 131 条,因为那个文件没被覆盖成功。而脚本从头到尾没有一句报错。
正解不是「换个删除函数」就完了,是把「删完了」这件事变成可验证的断言:
def clear_dist():
# 护栏一:路径必须严格等于 项目根/dist
if os.path.normcase(os.path.abspath(DIST)) != \
os.path.normcase(os.path.join(ROOT, "dist")):
print("!! DIST 路径异常,已中止")
raise SystemExit(1)
# 护栏二:不得与源码目录 / 项目根 / 根目录上一级重合
for guard in (SRC, ROOT, os.path.dirname(ROOT)):
if os.path.normcase(os.path.abspath(DIST)) == os.path.normcase(os.path.abspath(guard)):
print("!! DIST 与受保护目录重合,已中止")
raise SystemExit(1)
_native_rmtree(DIST)
# 护栏三:删完必须真的不存在
if os.path.exists(DIST):
left = sum(len(fn) for _, _, fn in os.walk(DIST))
print("!! dist 清理失败,仍残留 %d 个文件,已中止打包" % left)
raise SystemExit(1)
第三道护栏是关键。_native_rmtree 是我用 kernel32.DeleteFileW / RemoveDirectoryW 自己写的后序递归删除,绕过了那个钩子,速度快、也稳。但我依然保留了「删完再 os.path.exists 一次」这一步。
理由很直白:任何删除操作的返回值都不可信,只有「路径还在不在」可信。 这条经验不只对 Windows 成立,rm -rf、shutil.rmtree、云存储的批量删对象,没有一个的返回码值得你当结论用。
三、坑二:copy 完了不代表一致,得逐文件比
上面那一步只能保证「老的清干净了」。还有一个反向风险:清干净了,但复制没成功。
所以我在打包末尾加了一道字节级比对,逻辑土但有效——遍历源目录,逐个文件去产物目录找,不存在或大小不等就记一笔:
stale = []
for dp, dn, fn in os.walk(SRC):
for f in fn:
if f in SKIP_NAMES:
continue
src = os.path.join(dp, f)
dst = os.path.join(DIST, os.path.relpath(src, SRC))
if not os.path.exists(dst) or os.path.getsize(dst) != os.path.getsize(src):
stale.append(os.path.relpath(src, SRC))
if stale:
print("!! dist 与源文件不一致 %d 个,已中止打包:" % len(stale))
for x in stale[:12]:
print(" ", x)
print(" 常见原因:dist 目录清理被安全策略拦截。手动删掉 dist 后重跑。")
raise SystemExit(1)
有人会说比大小不够严,应该比哈希。对,比哈希更严。但这里我要的不是防篡改,是防「没复制成功」——大小不等已经能抓住 100% 这类情况,而且能列出具体是哪些文件。用 sha256 遍历几千个文件会让打包从 2 秒变成 20 秒,收益不匹配。
选校验强度的标准不该是「哪个更严」,而是「我在防哪一种失败」。 防传输损坏用哈希,防漏复制用清单+大小,防内容漂移才需要全量哈希。
这一步上线之后,上面那个「131/139」的问题再没出现过。而且它把一类静默失败变成了响亮的失败——报错信息里直接写「常见原因:dist 清理被拦截」,下次我或 AI 再撞上,不用重新推理一遍。
四、坑三:页面删了,但指向它的链接还在别处
这个是「产物之间的引用」问题,比前两个隐蔽。
我当时批量下线了一批页面。下线动作本身是对的:页面文件删掉、加一条 301。
然后线上出现了 2 条死链。但那 2 个被删的页面自己是 301,跳得好好的。
死链在别处:一个分类聚合页的「最新收录」区块里还挂着它,一个给 AI 索引用的 llms-full.txt 里也还列着它。
根因不是「忘了改」,是三个生成脚本各自读「候选池」来决定要展示什么,没有一个去读「实际生成了哪些页面」:
# 错:从源头推产物
for t in ALL_TOOLS:
if t.get("is_new"):
render_link(t)
# 对:从产物反查
for t in ALL_TOOLS:
if t["slug"] in actually_built_slugs: # 页面真的存在才展示
render_link(t)
我最后的做法是把判定收口到一个函数里,三层同时校验:工具本身合格、来源合格、并且页面真的存在于产物清单中。所有要展示列表的地方一律调它,不允许任何脚本自己拼一套判断。
通用法则:列表类内容永远从产物反查,不要从源头推。 因为源头上「应该存在」和产物里「真的存在」之间,隔着构建跳过、被过滤、被下架、命名不一致这四道缝。你从源头推,就等于默认这四道缝全都不存在。
五、坑四:接口说「发布成功」,读者看到的是 404
第四个坑在内容发布这一侧,跟前三个同构,但更气人。
往一个内容平台发文章,接口返回成功,跳转到文章地址,带着登录态打开完全正常,正文、标题、排版全对。我据此认为发布成功,并把地址记进了台账。
后来用无痕窗口验证了一次——跳「页面不存在或已被删除」。
我去翻了文章状态接口,找到两个字段:
{
"articles_count": 1,
"released_article_count": 0,
"released_time": 0
}
released_time = 0。意思是:收下了,但还没放行。 新账号首发要走审核,这期间作者自己看得见,别人看不见。平台没有任何一个地方提示我这件事——发布按钮是成功的、跳转是成功的、页面在登录态下是完全正常的。
如果我当时没做无痕验证,这篇会被算成一条有效外链,然后我会在两周后疑惑「为什么发了文章一点流量都没有」。
验收动作只有一句话:换一个没有任何登录态的环境,再打开一次。 无痕窗口、curl 不带 cookie、换台设备,都行。登着账号看到的东西,对「公开可见」这件事没有任何证明力。
六、我现在固定跑的 10 分钟验收清单
整理成可以照抄的四步,不含任何特定工具:
第 1 步|清理是否真的发生(30 秒)
删完立刻判路径是否存在,不看返回值。存在就中止,不往下走。
第 2 步|产物是否与源头一致(1 分钟)
遍历源目录出清单,逐个去产物里查存在性和大小,不一致直接抛出。
第 3 步|产物内部的引用是否都能落地(3 分钟)
从产物反查:凡是列表/索引/导航里出现的链接,逐个确认目标在产物里真的有对应文件。不是抽查,是遍历——这类问题抽查抽不到。
第 4 步|对外是否真的生效(5 分钟)
断掉一切本地状态去访问:无痕窗口、清 cookie、换个网络。看的是匿名视角下的内容,不是自己登录后看到的内容。
这四步加起来不超过 10 分钟,但能拦掉我过去两个月所有「以为改完了」的事故。
七、一句总结
AI 说「改完了」,那句话本身不是证据,只是一个待验证的假设。
它说改完了,你就问三句:
- 你怎么知道文件真的写进去了? —— 拿路径去查,别拿返回值
- 你怎么知道产物和源头一致? —— 逐文件比,别看一眼目录非空
- 你怎么知道外面的人看到的是新版? —— 断掉登录态,站到外面去看
三句话都能拿出证据,才算改完。拿不出来,就还没改完,只是命令跑完了。
这两个月我最大的转变不是学会了什么工具,是不再接受「没报错」作为结论。报错会说话,静默失败不会——它只会安静地让你把旧版本发出去,然后在三天后用一个莫名其妙的现象来找你。
以上所有案例都是我自己这段时间踩的,代码片段来自实际在跑的脚本,没有为了让文章好看而加工。这个静态站的构建脚本和巡检脚本都在站上:https://ai-tool-scenes.pages.dev。

浙公网安备 33010602011771号