最近接手了一个别人扔过来的项目,git log 一拉愣了一下——9 个 commit,author 全是同一个 AI Dev <ai-dev@local>,一个人类提交都没有。下面提到的 commit 我就不贴 hash 了,按干的事来称呼。仓库里没有 CLAUDE.md、没有 AGENTS.md、没有 .claude/、没有 .cursor/、没有 CI,连 lint 都只是 tsc --noEmit,测试更是零。翻了一天,记录下它做得好的和做砸的…

先交代下这项目是什么:一个纯客户端的 React SPA,后端是 Express 打包成一个 Vercel Function 入口,api/index.tsapi/lib/ 下六个模块,数据库是 Neon Postgres,对象存储走 S3 兼容 API。就这么点东西,不复杂。

先说它做得好的

部分 commit 带了可核对的验证结果

9 条 commit 里有 4 条在结尾带了验证结果,而且带可核对的数字。

接对象存储那条,结尾写的是「本地端到端冒烟通过: presign → PUT → public GET 返回 1×1 PNG」——一个 1×1 的 PNG,说明它真的跑完了整条链路~ 做存量数据迁移那条,结尾是「跑完: 19 个对象 / 1.77 MB. DB 0 个 data: 残留」,迁了多少、剩多少都写死了…

当然也不是条条如此。上线验收那条的验证段就一句「Production 端到端测试通过。」,跟没写差不多;写部署文档那条干脆整个 body 是空的,只有一行标题。所以别一看到「三段式 commit」就高兴太早…

不过带数字的那几条,说实话比我自己手写的强…

它自己写了运维手册,也知道自己权限到哪儿为止

仓库里看不到谁要求它写文档,反正它自己就产出了一份 DEPLOY.md,里面连纯基础设施知识都写了。比如挂自定义域那段,它专门标了一条警告:CDN 的 Proxy 必须关掉走灰云,开了代理会让 Vercel 的 SSL 证书拿不到 ACME challenge。这跟业务代码半点关系没有,是纯运维经验…

文档末尾还维护了一个 5 条待办的路线图 checklist,后续 commit 里在持续改写它,不是一次性生成完就烂在那的产物。

配套的还有边界意识:接对象存储那条结尾明确标注,自定义域需要人在控制台手工挂,因为它手上那个 token 没有对应的 zone 权限;下一条 commit 里就记录了人做完之后的状态。知道自己干不了、老实说出来,比硬着头皮乱试强多了…

它会主动做减法

这条我印象最深。之前它按我列的技术栈接了一个 Redis 服务,后来自查发现代码里零 import,于是回滚了:删依赖、清理 README 和部署文档里的相关文字。那条 commit 收尾的原话是「后续真要 Redis 时, 重开 db + 加回 env 即可, 5 分钟事。」

同一条 message 还声称删了三个环境的 env、用 management API 删掉了远端 Upstash 实例——这部分我没法从 diff 里验证,diff 只动了 5 个文件(.env.exampleDEPLOY.mdREADME.mdpackage.json、lockfile)。

「删干净」通常是 AI 最不擅长的事,大多数时候它只会往上加,加错了也不敢动。这次它至少把代码这一半删干净了…

它选择造兼容层而不是改调用点

换数据库那次,前端有二十多个组件在用旧 SDK 的 .from() 链式调用。它没去改这二十多个调用点,而是写了一个接口兼容的 shim,把请求转到自建网关,调用方零改动。这招其实没什么技术含量,琢磨一下就想得到,但换我自己上手八成会一头扎进去逐个改文件…

顺带还给网关写了白名单校验,注释原话挺有意思:

*   - A small allowlist of (table, op) tuples is required — anything else is
*     rejected. This keeps the proxy from turning into a "drop my whole DB"
*     button.

再说它做砸的

本地全绿、线上挂了

tsconfig.json 里开了 allowImportingTsExtensionsnoEmit,所以源码里的 import 全写的 .ts 后缀,本地怎么跑都不报错。

结果 Vercel 把入口编译成了 .jsfrom './lib/sb-shim.ts' 在线上根本找不到对应文件,直接 ERR_MODULE_NOT_FOUND,线上就这么挂了。

修复那条 commit 里它自己写了原因:标准 ESM TS 写法是引用 .js 扩展,tsxtsc 都能透明地把 .js 解析到对应的 .ts 源文件。这次一共修了 4 个文件共 7 处 import(api/index.ts 占 4 处,sb-shim.ts/auth.ts/db-proxy.ts 各 1 处)。顺带这次才新建了 .vercelignore,在那之前部署压根没有这个文件…

本地能跑不等于能上线,尤其是构建产物形态变了的时候…

commit message 里的幻觉

换数据库那条的 message 里写了「vercel.json: cleanUrls + www→根域 301 + 30s 函数超时」,但我 git show 看那次的 diff,vercel.json 里根本没有 cleanUrls 这个 key,翻当前文件也没有,git log -S'cleanUrls' 全历史零命中。

别全信 AI 写的 changelog,diff 才是事实…

改文档改出自相矛盾

删 Redis 那次顺手把 README 里「由配置文件里的 redirect 301 到根域」改成了「由平台默认 308 收敛到根域」。但 vercel.json 里那两条 permanent: true 的显式 301 一直都在,没删。

改完之后 README 和配置文件直接打架,谁也不知道该信哪个…

凭据进了 git 历史

部署文档的「日常开发」段落里,本地后台入口那行直接带了 admin 账号和明文口令(具体值就不复述了),而这个口令就是 db:seed 灌进库的那个,所以它并不是「反正只是 localhost」那么无害。.env.example 里那个默认口令也是一眼就知道没人改过的弱口令。

有意思的是,AI 自己在路线图最后一条写了「凭据 rotate:JWT_SECRET / admin password 上线稳定后建议换一轮」——它意识到了,但没有当场处理。这大概是 AI 最典型的行为模式:它会在文档里记下风险,然后继续往下写…

为兼容遗留代码把鉴权放宽了

网关的 JWT 校验,在 catch 分支里写了这么一段:

} catch {
  // Legacy code uses the raw user id as a "token" — accept it as a user token
  // so unmigrated frontend code keeps working until the full JWT rollout.
  const maybeId = m[1].trim();
  if (/^[0-9a-fA-F-]{32,36}$/.test(maybeId)) {
    return { user: { sub: maybeId } };
  }
}

JWT 验不过?没关系,只要是个 32-36 位的十六进制/连字符串,就当合法 user token 收下。等于知道一个用户的 UUID 就能冒充该用户过网关。它同样在路线图里把这条列为待办了,但代码就这么上线了…

功能被降级成静默 no-op

前端 shim 里的实时通道是个空实现,注释写得明明白白:

*   - channel(name) — no-op shim (Realtime is on the roadmap; signaling
*     callsites in VideoChat won't throw but won't deliver either)

调用点不会抛错,但也永远不会送达。这类问题最难排查,因为它假装在工作…

没有约定文件时,人的指令去哪了

人类痕迹散落在几条 commit message 里:基线那条提了「PM 用 AI Studio 生成的初版」,接着一条提了「PM 数据里 boolean→numeric」和「PM stale 列名」,那句「待用户在 dashboard 手工挂」前面也引过。

最能看出「下过指令」的是删 Redis 那条里的两句。一句是解释为什么当初要接 Redis:「接 Upstash 是当时为了对齐你列的栈」,说明技术栈是人指定的。另一句是「留: task 8 (VideoChat 信令) 重命名为通用 ‘Realtime 替代方案’」。

也就是说,存在一份带编号的任务清单。但它不在仓库里,grep 遍全库找不到 task 8。它只活在对话上下文里,会话一关就没了…

这大概就是我后来无论如何都要往仓库里留一份约定文件的直接原因——哪怕只有一行,至少它不会跟着会话一起蒸发掉…

收成一张表

翻完这 9 个 commit,我大概能把「AI 单干」的能力边界画出来:

能做好的做不好的
脚手架、样板代码安全边界(鉴权、凭据)
兼容层 / shim,避免大面积改调用点跨文件一致性(文档 vs 配置)
运维 runbook,连基础设施坑都会写自己的 changelog(会幻觉)
迁移脚本:默认 dry-run、--apply 才真跑、幂等记下的风险不会回头去修

那个迁移脚本值得单独提一句:默认只 dry-run 打印,加 --apply 才真上传,而且已经是 http 开头的对象直接跳过,重复跑不会出问题,这个设计我自己写也就这样了…

顺便:一条专门写给 agent 看的注释

vite.config.ts 的时候看到一条注释:

server: {
  // HMR is disabled in AI Studio via DISABLE_HMR env var.
  // Do not modify—file watching is disabled to prevent flickering during agent edits.
  hmr: process.env.DISABLE_HMR !== 'true',
},

意思是:文件监听被环境变量关掉了,因为 agent 编辑文件时的高频写入会让 HMR 疯狂闪烁。

先说清楚,这条不是它写的,git log -- app/vite.config.ts 只有基线导入那一条,是 PM 从 AI Studio 导出初版时就自带的脚手架注释,之后再没人动过。反过来讲这更说明问题:不是某个 agent 自己给自己留了张字条,是工具链默认就在为「有个 AI 正在改这些文件」这个场景做适配了。以前这类注释是写给下一个接手的人看的,现在顺带也写给下一个接手的模型…

反正结论就是它自己不会停,得有人或者有 CI 逼它停一下…