上周二晚上十一点,我把 Claude Code 改写的一个查询合进了代码库。它跑通了。测试环境一切正常。今天上午,租户 A 的运营后台弹出了租户 B 的客户名单。
翻车的是这一行:
- const settings = await db.get(`settings:${userId}`);
+ const settings = await cache.get(`settings:${userId}`);
+ if (!settings) {
+ const fresh = await db.get(`settings:${userId}`);
+ cache.set(`settings:${userId}`, fresh, { ttl: 300 });
+ return fresh;
+ }
+ return settings;
乍一看没问题:带 TTL 的 cache-aside,有 miss 回源,逻辑完整。问题出在 cache 在线上是全局键空间,而 userId 在系统里按租户内部编号——两个租户各自的用户 #1001 完全对得上。我审 diff 的时候其实扫到过这行,脑子里闪过一个念头:"这个 key 是不是少了租户前缀?"然后它被"前面两百行都对"的惯性带过去了。
我批准了我没想明白的改动。这跟工具没关系,是我没有 push back。
"push back"这个动作,恰恰是"要对 AI 的产出负责"这句话里最容易被跳过的部分。
我们团队从今年开始把 vibe-coding 当默认工作方式,"对产出负责"几乎是所有人挂在嘴边的话。字面意思谁都会说:查 diff、看报错、再上线。可落到具体操作上,最难的那个动作——当场质疑工具替我做的决策——几乎没人做。也不是没人做,是环境在惩罚这个动作。开会的时候你要是说"Claude Code 这个方案没考虑多租户缓存隔离",那个氛围你会感受到的。画外音:像是表扬稿会上有人指出错别字,没人敢说你有问题,但也没人心里爽。有人会出来打圆场:"没事,让它再改一版就好。"
让 AI 再改一版。这话听着轻飘飘,其实把"质疑"从一个正当动作,降级成了一次扫兴的较真。
墙倒不是一蹴而就的。我事后把那天晚上的决策过程掰开看了看,三个受力点,每一个都平平无奇,加在一起就是事故。第一,我 prompt 里写的是"把这个查询改成带缓存的版本",没说"key 要按租户隔离"——如果这是已知边界,就该写进清单喂给它,但我没写,因为当时以为"cache 层肯定封装了 key 前缀"。第二,diff 看起来很完整,CI 是绿的,"推进度"的条件全部就位。第三,最要紧的,我在那个"脑子闪过一个念头"的瞬间,选择相信工具,而不是相信那个隐约的不安。
质疑它的代价是:再看一遍上下文、让它改一版、等一轮 CI——半个小时的即时成本。不质疑的代价是:未来的某一天炸一次——这在当下抽象得跟薛定谔的猫似的。我选择了睡觉。
这里有一条线可以拉通,叫激励错位。工具面对的目标函数是"让这一轮任务看起来完成",它在方案里选"尽快通过"的路径,未来那些边界情况不在它的 reward 里。我面对的回报是"今晚跑完,收工",未来的生产告警对我此刻的决策没有权重。两边都在做同一个贴现——都选了当下便宜的那条路。只是我的贴现率被"深夜十一点"拉满了。
所以那些从来不 push back 的人,不管 push 的是工具还是别的替自己做决策的系统,本质上都一样:他们不是在负责,是在优化"我看起来很放心"这个形象,顺便把责任装进一个叫"是 AI 选的"的袋子里递给下一个人。
这类工作模式我见过太多了——任务进来,整个模块直接甩给 agent 改,等它输出一个十有八九能用的版本,打开 PR 看都不看就 approve。命令是这么写的:
agent run "重构下本模块的缓存策略" --auto-approve
这个命令本身没有错,错的是"本模块的缓存策略"这十个字里,藏了整个模块的历史、租户模型、以及两年前一次线上事故换来的防御逻辑。工具不知道这段历史,它不是故意的,它只是不知道。但发命令的那个人也不知道——他用工具的"不知道"和自己的"不知道"拼了一个完整的盲区。他优化的不是工程质量,是"手速"和"我拥抱 AI 很彻底"这两件事。等它炸了,他不会怪自己:是 AI 这么改的。
这是一个非常平滑的责任转移链。AI 选的,我批的,最后背锅的往往是三周后半夜爬起来看告警的自己。工具不会替你承担责任,它连"承担责任"这个概念都没有。责任这玩意儿,你递出去的每一寸,最后都会以"重大事故"的名义回到你手里,还带利息。
那什么才叫真的负责?说实话,我现在能落地的只有五条。
动手之前先让 agent 给方案,别让它边设计边施工。这相当于一次免费的方案评审——如果那天晚上 Claude Code 先输出"我打算给 settings 查询加 cache-aside,key 是 settings:{userId}"再动代码,我在动手前就能看到那个 key 定义。成本是一个问题,收益是让它的设计决策早一小时暴露在你面前。
质疑要拿数据说话。别凭感觉说"感觉不对",拿你看到的 key 定义、拿复现步骤,不行直接把那几行代码贴出来问它"这里为什么这么选"。模糊的质疑只会得到模糊的答复,然后你会在"它好像解释得挺有道理"里再陷进去一层。
给备选。对每个"它这么选"的点,至少想一个备选方案扔回去,哪怕备选只有一个字:别。备选方案的价值不在于它一定对,而在于它逼着工具重新面对一次 trade-off,而不是一路顺畅地滑向它最省事的那条路。
知道什么时候该自己拍板。凡涉及存量数据和生产路径的决定,人工过一遍,没有例外。这不是不信任它,是这些决定背后的历史太多了,而历史没法写进 context window。
出了事留下来收拾。别把责任挂在"AI 写的"这个钩子上。钩子拆了,责任还是你的。
这几条列出来,每条都像废话——但凡是翻过车的人看到都会点头,因为每条都是用真金白银换来的。第四条是上次那个自作主张加的重试队列,把一批成功了的订单重放了四次换来的。第三条是你在上面看到的缓存串租户换来的。
嗯,写到第三条的时候我停了一下。因为我在缓存事件里,压根没给过备选。我只在脑子里闪过"是不是少了前缀",然后告诉自己"它应该知道"。
这大概是我写这篇文章最诚实的一个瞬间:我知道问题在哪,但我把提出它的成本提前付掉了。
后来我想,我们为什么会走到"嘴上说要负责、手上放弃了决策"这一步?有个解释一直挺扎心,是从之前某篇讨论 AI 编程的文章评论区看到的。一个叫 Xiao Man 的读者说得大致是:ownership 是结构性的,一个人得对自己负责的东西有实际决策权,不然那不叫 ownership,叫执行。
放到工具语境里尤其刺眼:工具替我做了决策,但它对我负责的东西没有决策权,它连"东西"的全貌都只从 prompt 里捞了个大概。在这个结构里,我的"ownership"是一个幻觉——是我用"我在用 AI 这个新范式"给自己搭的一座海市蜃楼。
评论区还有另一个比方,说遇到这种情况的工程师就像租客:按规格说明书盖楼,看着它出问题,然后把账单递给房东。在 AI 工作流里,这个"递给"的过程被拉长了——账单寄到的时候,你可能已经忘了自己签过字。
所以我觉得这事的根子不在工具多聪明或者多蠢,而在我们愿不愿意在"让 AI 干活"这件事上给"质疑"留一个正式的位置。留了,它就是个称职的实习生;不留,它就是那个替你决策的人。
我现在的处理方式很简单。CLAUDE.md 里加了几条:
## 动手前
- 先给方案,再动代码
- 涉及缓存/队列/数据迁移:明确 key 或消息的边界条件
- 不确定时停下问我,不要猜
以及一条给自己立的规矩:任何时候我觉得 agent 的方案有哪儿不对劲,先停下来,把那个"不对劲"写成一个问题发给它,等它解释完再动 diff。解释不通就打回去,解释得通,我还能顺便学到一个 bug 的形态。这周期的"实习生"被我打回去的比例明显上升了,代价是每天多花二十分钟,收益是少被生产告警吵醒几次。值。
说到底,工具不在乎你是不是真正的 owner——反正下线的是你的系统,不是它的。在乎的人只有你。所以你得在乎得具体一点。具体到下一次,在它动手之前,先问一句:"你打算怎么做?"
然后等它答完,再决定要不要点 approve。