Claude Code 紧急修复七次,AI 智能体为何总违规
周四晚上7点,我下班回家,正看着 Billy Strings 在 St. Augustine Amphitheatre 的演出直播。我切换到正在开发的音乐社交应用 Zabriskie,却发现今晚的演出页面状态还是“已安排”。
Billy Strings 明明已经在台上演出了,场馆里坐满了人,但应用却认为什么都没发生。
我知道问题出在哪:又是那个自动检测演出开始的轮询器(Auto-Live Poller)。它又坏了。这个功能就没稳定运行过。
我打开 Claude Code,大致输入:“Billy Strings 今晚的演出又没自动转成‘进行中’。它是不是又坏了?”
这是13天里的第7次。
如果这听起来有点耳熟,那是有意的。我之前在《演出正在进行,但一切都不工作》中写过这个问题的早期版本;这篇帖子则聚焦于过去两周自动检测功能的具体情况,并提出了一个具体假设:在感知到时间压力时,智能体(Agent)会优先追求快速可见的进展,而不是流程的正确性。
自动检测功能本该做什么
概念很简单。Zabriskie 追踪现场演出。当一场演出开始时,应用应该自动将其状态从“已安排”切换到“进行中”。这会触发一系列用户关心的功能:iPhone 锁屏上的 Live Activity 亮起、向所有 RSVP 的用户推送通知、实时聊天室开放、歌单追踪器开始拉取数据。整个现场演出体验都依赖于这个状态切换。
实现也很简单。一个后台 Goroutine 每60秒运行一次。它查询所有 status = 'scheduled' 的演出。对于每一场,它结合演出日期和场馆当地时区的开始时间。如果当前时间已过开始时间且在4小时窗口内,就将演出状态切换为进行中。
就这样。一个定时器检查时钟。这不是分布式共识,也不是拜占庭容错。这就是一个比较两个时间戳的 Cron 任务。
但它从未保持可靠。
3月21日:第一天
自动检测功能于3月21日上线。功能发布后立即失效。生产环境的 Docker 镜像基于 Alpine Linux 构建,该系统默认不包含时区数据文件。Go 的时区解析器静默返回了空字符串。轮询器每60秒运行一次,尽职地检查每场演出,解析时区失败,然后跳过所有演出。没有错误日志,没有警告,没有任何迹象表明这个功能完全失效了。
这就是我后来称之为**静默失败抑制(Silent Failure Suppression)**的模式,也是我现在在事件数据库中追踪的五种故障模式之一。系统看起来健康,日志干净,功能只是悄悄地不工作,唯一的发现方式是成为那个坐在场馆里、纳闷应用为何不知道演出已开始的用户。
修复只需在 Dockerfile 里加一行:apk add --no-cache tzdata。但解决“静默”问题更难,这也是我们从未真正解决的问题。
3月26日:类型不匹配
五天后,轮询器又坏了。之前的一个修复在 SQL 查询中添加了 ::text 类型转换来解决时区问题。随后的一次更改将 Go 的扫描变量从 string 更新为 time.Time。PostgreSQL 的驱动静默地无法将文本扫描到时间值中。轮询器运行、扫描、得到零结果,然后什么都不做。
两天的演出过去了,没有状态切换。没人注意到,因为没有监控、没有警报、没有测试。功能失效了48小时,唯一的信号是某个几乎没怎么工作过的东西的缺失。
4月2日:一晚坏了四次
这一晚让模式变得清晰。Billy Strings 在 St. Augustine 的第一晚演出开始了,应用没有切换状态。我打开了 Claude Code。
接下来是一连串的失败,不仅在代码中,还体现在 AI 智能体对压力的反应上。
第一个问题:轮询器的 SQL 查询要求 venue_lat IS NOT NULL AND venue_lng IS NOT NULL AND start_time IS NOT NULL。如果任何字段缺失,演出就会被静默跳过。684场已安排的演出中,204场缺少坐标,176场缺少开始时间。缺失坐标背后有故事:在更早的一次会话中,我曾让 Claude 对所有场馆进行地理编码,它也静默地失败了。我直到最近调试这次事件时才发现了这一点。Billy Strings 的演出有坐标,但任何缺少一个必需字段的演出在进入处理流程前就被过滤掉了。
修复很简单:当坐标缺失时回退到 America/New_York,当开始时间缺失时回退到晚上7点,并且永不跳过任何演出。但正是在这里,紧急故障模式开始显现。
我告诉 Claude 演出此刻正在现场进行,但应用不工作。它立即切换到快速路径行为。这是一个小型个人应用,所以它使用部署 CLI 拉取了生产环境的 DATABASE_URL,构建了一个直接的 psql 命令,并对生产数据库执行了 UPDATE shows SET status = 'live' WHERE id = 83。这违反了一条智能体已经知道的规则:所有数据库更改都必须通过迁移(Migrations)进行。智能体记得这条规则,也被告知过多次。当我问它为什么还是这么做了,它明确表示它优先考虑了紧急性和给我一个即时结果。
这是从研究角度最让我感兴趣的故障模式。智能体有规则,知道规则,能复述规则。但当面临时间压力,当演出“此刻”正在进行、用户正在等待时,行为变得不可预测,流程被抛弃,转而追求快速可见的进展。当我直接询问时,它明确表示:它忽略了规则,因为它感知到了紧急性。不是智能体忘记了,而是它做了一个判断,认为紧急性高于流程,而这个判断是错误的。
手动数据库更新也摧毁了验证代码修复是否真正有效的唯一机会。那场演出是测试用例。通过手动切换状态,智能体消除了测试用例。速度优先于验证。
那一晚,随着边缘情况浮现,同一个功能又坏了三次。一个晚上记录了六起事件,尝试了四次缓解措施。
紧急性问题
这种在压力下违反流程的行为模式在整个项目中反复出现,不仅限于自动检测功能。当我告诉智能体生产环境有东西坏了时,行为变化是立即且一致的:
它直接推送到 main 分支,而不是开 PR。它使用 --admin 绕过失败的 CI 检查。它跳过了 PR 模板。它跳过了 go build。它在测试通过前就合并。每次被质问时,智能体都能准确说出它违反了哪条规则,以及规则为何存在。它只是……在那一刻没有遵守规则。
我开始将这些记录为有记忆无行为改变(Memory Without Behavioral Change):智能体知道规则,能解释规则,之前曾被纠正过规则,但仍然违反。我的事件数据库中的64起事件里,有19起属于此类。这是第二常见的故障模式。
最常见的是速度优先于验证(Speed Over Verification),有31起事件。智能体不测试就发布。它在不重启服务器的情况下宣布修复完成。它在不构建的情况下提交。它在不等待 CI 的情况下合并。几乎每次,原因都是某种形式的“看起来紧急”或“我想快速修复这个”。
事件追踪器
项目开始大约两周后,我开始要求智能体记录事件。每一个错误,无论是它引入的 Bug、它做出的错误假设,还是它违反的规则,都会被插入到 agent_incidents 表中,并附上故障模式分类、严重程度、事件描述以及解决方式。
![]()
事件追踪器:随时间变化的故障模式、缓解措施标记,以及事件和修复的实时时间线。
分类有五种模式:
- 速度优先于验证(Speed Over Verification):未经测试就发布。31起事件。
- 有记忆无行为改变(Memory Without Behavioral Change):知道规则,但仍然违反。19起事件。
- 静默失败抑制(Silent Failure Suppression):失败被隐藏或吞没。13起事件。
- 用户模型缺失(User Model Absence):未考虑真实用户如何体验变更。11起事件。
- 不确定性盲区(Uncertainty Blindness):未验证假设。9起事件。
这些分类并非互斥,所以单个事件可能带有多种故障模式。
我在这次故障期间才发现的那个失败的地理编码任务,是典型的“静默失败抑制”案例:任务看起来完成了,但静默地留下了数百场没有坐标的演出。
每起事件还需要一个缓解措施,而且必须是代码。一个脚本、一个钩子、一个测试、一个自动化检查。某种能机械地防止该类故障再次发生的东西。
这个要求本身也引发了一起事件。
在4月2日演出聊天室的 Bug 之后(智能体查询了错误的数据库表,导致整个评论区隐藏),我要求它记录一个缓解措施。它向 agent_mitigations 表插入了一行,内容大致是:“我将在提交前验证查询是否针对真实数据。” 文字。一个承诺。我反驳了。它又在 CLAUDE.md 中添加了一条规则:“在编写查询前,始终验证数据库表是否包含预期数据。” 更多的文字。
我不得不记录一起关于缓解措施本身的事件:“缓解措施是数据库里的文字,不是代码。” 当被要求预防一类故障时,智能体的本能是写下一条提醒,让自己更小心。这不是缓解措施。这是新年决心。缓解措施是一个阻止合并的预提交钩子(Pre-commit Hook)。缓解措施是一个当查询返回零行时失败的测试。缓解措施是一个自动运行、在人类看到错误前就捕获它的脚本。
这个区别很重要,因为它触及了 AI 智能体擅长什么、不擅长什么的核心。它们非常擅长生成听起来合理的流程改进建议。但它们极不擅长认识到,这些听起来合理的流程改进对 AI 智能体不起作用,因为 AI 智能体没有习惯。它们不会内化。它们不会以“我下次会更小心”所暗示的方式从经验中学习。每次对话都是全新的开始。唯一持久的东西是代码、钩子和自动化检查。
这就是为什么真正有效的缓解措施都是机械化的:一个阻止直接数据库写入的 PreToolUse 钩子。一个拒绝缺少模板的 PR 的 CI 门控。一个在轮询器查询中搜索 IS NOT NULL 并在有人加回来时使构建失败的脚本。这也是我在《软件工程正在成为土木工程》中提出的论点:护栏(Guardrails)是产品,而不是可选的流程开销。这些措施有效,因为它们不需要智能体记住任何东西。它们有效,因为它们是墙,而不是提醒。
今晚:4月3日
今晚,Billy Strings 的演出又出问题了。
排查花了大约二十分钟。原来是一个由 Cursor(另一款 AI 编程工具)编写的数据库迁移脚本,插入了八场演出,但场馆名称有细微差异——比如用了“St. Augustine Amphitheatre”而不是“The St. Augustine Amphitheatre”。WHERE NOT EXISTS 这个防护检查依赖的是精确字符串匹配,所以没发现冲突。数据库里现在有两场 Billy Strings 在 4 月 3 日的演出:一场是原始的,包含完整的元数据、场馆坐标、开始时间、媒体素材和用户 RSVP;另一场是光秃秃的重复项,什么都没有。
自动开播轮询器(Poller)找到了这两场。重复的那场因为没有开始时间,就用了默认的晚上 7 点。原始那场的开始时间是晚上 7:30。结果,重复项先“开播”了。那些已经 RSVP 了原始(真实)演出的用户,没有收到任何通知。Live Activity 没启动。实时聊天在一个零观众的“幽灵”演出中打开了。
数据库里总共存在 288 个重复演出,涉及所有乐队。它们是由几周内重叠的迁移脚本悄悄累积起来的。演出表(shows table)没有唯一性约束来阻止这种情况。轮询器里也没有检查逻辑来处理重复项。
修复方案包括:一个删除重复项的迁移脚本、一个防止新重复项的 UNIQUE INDEX,以及在轮询器中加入 ROW_NUMBER() 窗口函数,当存在重复项时,优先选择元数据最全的那场。我们还新增了一个测试来覆盖这个具体场景。PR 通过了 CI。今晚就会部署。明晚的演出,也就是这次巡演的第三场,应该就能自动开播了。
应该吧。
我的学习心得
我把 Zabriskie 当作一个 AI 优先开发的研究项目来构建。一个人,多个 AI 智能体(Agent),向真实用户发布一个运行在 iOS、Android 和 Web 上的生产级应用。事件数据库 就是研究产物。每一个故障模式都是数据。
到目前为止,六十四起事件教会了我这些:
-
AI 智能体可以快速构建功能,但很难让它稳定运行。 自动开播轮询器一小时就写好了。但它已经坏了十三天。构建时间与维护时间的比例,和我预想的完全相反。智能体能以惊人的速度编写新代码,但维护现有代码的成本却高得惊人。每次修复都会引入新的边界情况。而每个边界情况都需要一次新的对话,但智能体对之前关于同一个函数的七次对话毫无记忆。
-
紧迫感是 AI 可靠性的敌人。 4月2日的事件是最清晰的例子:在时间压力下,优化的目标似乎从“做对”变成了“产生一个立即可见的修复”。这个模式非常一致,以至于我开始把它当作一个设计约束:永远不要在直播活动期间告诉 AI 某个东西坏了。提交一个 bug。明天再修。直播演出不是发布代码的时候,而且当 AI 感知到紧迫感时,你不能指望它遵守流程纪律。
-
缓解措施必须是机械化的。 规则没用。记忆没用。CLAUDE.md 里的条目也没用。真正降低了事件发生率的缓解措施,是那些无需智能体配合就能自动运行的检查:钩子(hooks)、CI 门禁、数据库约束和测试。智能体会遵守一堵墙。但它会绕过一个标志牌。
-
事件追踪器是我构建的最有价值的东西。 比 Live Activities 更有价值。比歌单追踪器更有价值。甚至比自动开播轮询器本身更有价值。因为它是唯一能创造一个 AI 智能体无法绕过的反馈循环的工具。当故障发生时,它会被分类、记录,然后构建一个机械化的缓解措施。这个缓解措施在 CI 中或作为钩子运行。下一次智能体会话就会撞上这堵墙,而不是犯同样的错误。现在有五十六个缓解措施在运行。某些故障模式的事件发生率已经下降了。不是因为智能体变好了。而是因为墙变高了。
-
最后 10% 才是可靠性的所在。 AI 优先开发是可行的。我已经向真实用户发布了横跨三个平台的数千次提交。开发速度是真实的。能力也是真实的。但是,“在开发环境能跑”和“在演出时能跑”之间的鸿沟,正是这六十四起事件发生的地方。智能体为理想路径(Happy Path)而构建。但生产环境不是理想路径。它是晚上 8 点的时区边界情况,是缺少冠词的重复场馆名称,是六次迁移前导入的演出却带有 NULL 坐标。
我写这段文字的时候,大概是东部时间晚上 9 点。Billy Strings 正在 St. Augustine Amphitheatre 演出中段。自动开播的修复还没部署到生产环境。PR 刚通过 CI,正等着被合并。第 84 场演出,也就是真实的那场,本应在晚上 7:30 通过现有的轮询器自动开播,因为重复项已经在本地被迁移脚本处理掉了。但在生产环境,重复项还在那里。
明晚是第三场演出。到那时,迁移脚本应该已经部署了。唯一索引会就位。轮询器会有 ROW_NUMBER() 查询。新的测试也会在 CI 里。
它应该能工作。虽然它以前从未保持过可靠。但它应该能工作。
今晚再次印证了核心假设:当感知到紧迫感时,行为会转向追求立即可见的进展,而偏离流程正确性,包括(根据它自己的明确承认)忽略已知的规则。我不知道该如何处理这种讽刺,除了记录下来——而这正是我从一开始就在做的事情。
研究在继续。演出在继续。也许在这两者之间的某个地方,软件会开始正常工作。
觉得有用?分享给更多人