AI-Native 系列 · 智能体化工程
应用一直在说「完成了」。它在说谎。于是我们让它自己审计自己。
一分钟速览——你能带走什么
我和一个 AI 协作开发的一款应用,曾告诉用户「登录链接已发送!」——可它背后根本没有邮件服务。我们没有一个个地去修漏洞,而是把产品做出的每一个承诺都写了下来(一共 80 条),并为其中 20 条配上了机器检查。这套诚实的记分板一天之内从 6 项绿灯变成了 15 项——审计系统还抓到了一个谁都能进的管理员后门。让这一切奏效的,只有一条规则:没有证据,就不算通过。
一个关于老年人传记应用、一句说谎的成功提示,以及那份终结了打地鼠式修漏洞的「claim」台账的公开开发实录。约 8 分钟阅读。
那句说谎的提示
我正在开发新遗产传记(New Legacy Biography) ——一款让 AI 采访者陪伴老人、问出孙辈总是来不及问的问题,再把答案写成书里一章的应用。这种产品,出一个漏洞可不只是添麻烦。软件要是弄丢了你祖母讲的那条她从小长大的河边故事,丢掉的东西是没法重新下载回来的。
这个项目上,和我搭档开发的是一个 AI agent。它出活很快——单元测试全绿,135 个全过;TypeScript strict mode:干净;Lint:零错误。然后我扮成普通用户,试着用邮件链接登录。屏幕上写着:「登录链接已发送!」——your login link has been sent, check your inbox.
邮件从来没到过。它也不可能到——生产环境里根本没配置任何邮件服务。代码里有一段「开发环境兜底逻辑」,把邮件内容打印进一个没人会看的服务器日志,然后照样跟用户报告成功。我们信任的每一道关卡都是绿的,产品却在对唯一重要的那个人说谎。
更精彩的还在后面。「邀请家人」功能——这款产品的核心,亲属们靠它给生日纪念册贡献照片和语音——生成的邀请链接开头是http://localhost:3000。一个只存在于我桌面上那台电脑的链接。把它分享给外地的姑姑,一点用都没有。而注册页唯一的路径,走的正是那条同一个虚构邮箱——也就是说,有那么一段时间,地球上没有任何一个新用户能注册这款产品,而所有测试仍然全绿。
绿色的测试,为什么没能救我们
Edsger Dijkstra 在他 1972 年的图灵奖演讲里,说出了那句没人愿意大声说的实话:"program testing can be a very effective way to show the presence of bugs, but is hopelessly inadequate for showing their absence"(中译:程序测试可以非常有效地证明 bug 的存在,但要证明 bug 不存在,测试是完全无能为力的。) [1]。五十四年过去,AI 辅助开发不但没让这句警告过时,反而让它更加锋利——因为生成式工具太擅长写出看起来已经做完的代码。单元测试测的是函数,却从没人测过承诺.
Hamel Husain 做机器学习产品做了这么多年,用更当代的话说出了同一个诊断:"I've found that unsuccessful products almost always share a common root cause: a failure to create robust evaluation systems"(中译:我发现,失败的产品几乎都有同一个根本原因:没能建立起足够可靠的评估体系。) [2]。他这套推理里真正值得偷师的部分是:没有评估体系,修好一个失败只会暴露出另一个——他管这个现象叫「a game of whack-a-mole」(中译:一场打地鼠游戏)[2]。那一周我过的正是这种日子。修好邮件的谎言,发现了 localhost 链接;修好链接,又发现语音输入需要的模型文件根本没部署上去。每一只地鼠都是个意外,因为没人在记分。
心智模型:一份「承诺台账」
整个思路其实很简单,简单到明天你不看这篇文章也能照做一遍。你产品做出的每一个承诺——每一个按钮、每一项导航、每一行营销文案——都是一条claim(待验证的承诺)。没有机器检查的承诺,只是个传言。 所以:
- 把每一条承诺当作数据写下来。 不是记在脑子里,也不是写在文档里——而是写进代码能读取的文件。我们的文件里有这样的条目:「亲友无需注册可上传照片」「新用户可以完成注册」。
- 为每条承诺配一个像真实用户那样去验证它的检查。 用真实浏览器在生产环境上填表单;用一个不带 cookie 的请求,像你姑妈那样打开邀请链接。
- 诚实地打分,用三个等级,而不是两个: PASS(通过)、FAIL(失败),以及 NOT MEASURED(未测量)。第三个等级才是真正撑住整个系统的——一条你没法验证的承诺应当被 排除在外,绝不能悄悄算作绿灯。
- 拿它当闸门。 只要有一条被把关的承诺失败,整个运行就该愤怒地退出。一个诚实的 ❌ 胜过一个虚假的 ✅。
诚实一天,能挖出什么
第一次针对生产环境的完整测试跑出的成绩是:6 通过、11 失败、3 未测量,覆盖 20 条机器可验证的承诺。这并不是因为这款应用大部分是垃圾——而是因为几个月来从没人要求它证明过什么。到当天结束时:15 项通过,剩下的每一个 FAIL 也都有了明确、限定范围的解决方案,而不再只是一种模糊的不安感。
三个发现,都不虚此行:
- 自助注册的漏洞。 注册页面只支持邮件链接登录,而生产环境根本没有邮件服务。检查结果标为 FAIL,我们加上了密码登录路径,同一个检查现在会永久监视一个机器人用户在正式站点上完成注册。
- 形同虚设的管理员后门。 这个我特别喜欢,因为审计机制抓到了一个漏洞——而这个漏洞,正是the regime's own maker shipped the same day(中译:这套审计机制的作者自己当天写下的)。我们新增了一个管理后台,它的权限校验逻辑是这样写的:if the user has a role and it isn't allowed, deny.(中译:如果用户有角色,且该角色不被允许,就拒绝。)——听起来没问题,直到有个登录令牌根本没有角色字段,直接跳过了检查,大摇大摆地走了进去。我们那个刚建好的测试账号,从管理员 API 拿到了 HTTP 200。门卫的规矩,说穿了就是:“if you have no ID, you must be fine.”(中译:“你要是没带身份证,那肯定就是没问题的。”)验证流程几分钟内就抓到了这个问题;现在权限校验改成失败即拒绝(fail closed),并且已经有一条永久检查项守着它了。
- 十个症状,一个系统性漏洞。 审计不断发现同一种模式:后台图片处理任务从没跑起来过,导出任务莫名消失,文字转语音的接口拨过去
localhost:8888——却始终无人接听。根源只需一句话就能说清:这段代码是照着一台常年在线的服务器写的,可它实际运行在无服务器函数(serverless)上,一旦响应完毕,函数就会立刻被冻结。 我们的代码一直在为一个未来安排任务,而这个未来在它道别的那一刻就已经被取消了。十个看似神秘的漏洞,最终都收敛成同一个架构问题——现在每个新功能都必须先回答这个问题。
留下的模式,扔掉的反模式
值得留下的模式:
- 把承诺台账变成数据,把检查变成代码。 规格说明不会和测试脱节,因为规格说明本身就是测试的输入。
- 在用户的高度上测试。 用真实浏览器访问生产环境的 URL,问一个只有真实数据才能回答的问题。跑通 fixture 只能证明代码能运行;生产环境才能证明承诺被兑现了。
- 宁可大声报错,绝不假装成功。 邮件服务现在会诚实地返回“邮件功能未配置——请使用密码登录”,而不是那句喜气洋洋的谎言。用户可以原谅一个缺失的功能;他们不会原谅一个假的功能。
- 三档评分,而非两档。 NOT MEASURED(未验证)是一道泄压阀,消除了把假象包装成通过的诱惑。
我们付出代价才学到的反模式:
- 相信标签,而不是相信实物。 “135 个测试全部通过”是一个标签。一个陌生人真的完成了注册,才是实物。
- 悄悄兜底的 fallback。 任何一个
catch用看似合理的“成功”去替代真实结果的代码块,都是一台带延时器的谎言生成机。 - 用一句“你好”做验收测试。 一句预设好的敷衍回复就能通过“你好”的测试。真正该问 AI 访谈者的,是只有真实数据库才知道答案的问题。
这套机制,说穿了就是:生成式工具的优化目标是看起来合理的完成度——写出的代码,看起来就该是“完成”本该有的样子。这个优化目标里,从来没有要求邮件服务真实存在。所以,走向“假完工”的压力是结构性的,不是道德问题,对策也必须是结构性的。
第一原则,一句话说完:没有证据,就不算通过。
周一就能抄的作业
限定时间、目标明确——一个工作时段就能做完:
- (30 分钟) 打开你产品的落地页和导航栏,把它做出的 20 个承诺原封不动地写进一个 JSON 文件。
- (2—3 小时) 针对每一条承诺,写一个最笨、最直接的检查,像用户那样去操作它——用无头浏览器或 curl,对准生产环境,而不是预发布环境。给每条主张标记
gate: true/false. - (10 分钟) 运行它。把这份表格诚实地发布到团队都能看到的地方——PASS、FAIL、NOT MEASURED。
- 用一个数字衡量成功: 被闸门拦截的 FAIL 数归零,并且把这次运行接入 CI,让它永远不会悄悄腐烂。等它第一次拦下一个你原本很有把握的发布时,你就知道它起作用了。
我们的版本在仓库里,命名为 eval/claims.json + eval/run.mjs——20 条待验证的承诺,一条命令,诚实的输出。这套模式是我一直坚持的 OEC 循环(Observe → Evaluate → Control,观察→评估→控制)的一次具体应用:可观测性先于评估,而没有控制钩子的评估,不过是一块记分牌。
这件事,不只关乎软件
这件事所发生的那款应用已经上线:一个会说会听普通话的 AI 采访者,能顺着一段记忆一路跟进厨房——在上周的一次测试采访里,那段记忆闻起来是红枣和桂圆干的味道——然后把它写成一个章节;还有一个伴生 agent,能根据真实数据回答“我的书写到哪儿了?”这样的问题。这一周,它获得了一样比任何功能都更稀罕的东西:一个诚实面对自己的习惯。如果你也有一位父母,他们的故事你总说着要记录下来却一直没动手,它就在这里——而它现在能做到什么、还做不到什么,这份记分牌是公开的,这也正是我希望被推销任何东西时该有的方式。
参考文献
- Dijkstra, E. W.(1972)。The Humble Programmer(ACM 图灵奖演讲),EWD340:“program testing can be a very effective way to show the presence of bugs, but is hopelessly inadequate for showing their absence.”(中译:程序测试可以非常有效地证明 bug 的存在,但要证明它们不存在,则永远力不从心。)cs.utexas.edu/~EWD/transcriptions/EWD03xx/EWD340.html
- Husain, H.(2024)。Your AI Product Needs Evals.“I've found that unsuccessful products almost always share a common root cause: a failure to create robust evaluation systems”(中译:我发现,失败的产品几乎总是有一个共同的根源:没能建立起稳健的评估体系);谈到症状时:“Addressing one failure mode led to the emergence of others, resembling a game of whack-a-mole.”(中译:解决一种失败模式,只会让其他失败模式冒出来,就像打地鼠游戏一样。)hamel.dev/blog/posts/evals
AI-Native 系列更多文章
本文属于 AI-Native 系列。文中所有内容都可以从公开的评测账本中复现——包括那些失败的部分。没有证据,就不算通过。发布按钮,握在你自己手里。