团队复盘:流式中断仍被收费、重试导致双倍扣费、数据库外键约束被忽略等真实事故,分析根因与修复方案。
每一条信念都曾被深信不疑,最终被这个仓库中一个具体的缺陷证伪。它们按"信念的内容"而非"崩塌的时间"分组,因为只有并排放在一起时,规律才会显现出来。
这一条代价最高、反复踩坑,也是我们最希望别人能认真对待的一条。
取消路径上的一个注释平铺直叙地说明了不变量:中止一个流并不是获得免费推理的方式。代码没有实现这个约束。Completion token 只从终止 usage 消息中读取,而中止时永远不会收到那条消息,所以客户端可以流式输出一个大回答、断开连接,却被按完整计费——供应商全额向我们收费,我们却只收到一小部分。
输出护栏路径上的一个注释断言:那些路径不收取任何费用。实际上那条路径对供应商真实生成的 completion 扣了费、返回了错误、然后释放了幂等声明——所以客户端用同一个 key 重试时,会以全价购买第二个 completion,重试多少次就买多少次。
一个注释为"明知存在数据库不一致却不修复"这一决策辩护,理由是外键约束是开启的,所以删除会抛出异常而不是悄无声息地把列置空。实际约束是关闭的。一个虚假的前提支撑着一个"不去修复某问题"的决策。
一个注释为"优先路由候选者预留资金"这一决策辩护,理由是故障转移目标更便宜或价格相当。在产品文档的升序价格排序下,第一个候选者就是最便宜的,每一个回退方案都更贵。
取代这个信念的做法:把注释当作一条需要验证的声明,把被证伪的注释本身当作一个发现——因为一定是有人根据它做了决策。上述四个案例中,注释不仅仅是过时了;它是"不再进一步查看"这一决策的承重结构。
多家供应商都宣称提供了 OpenAI 兼容的端点。这个说法覆盖了请求和响应的外壳,但覆盖得仅此而已,而钱的问题全在缝隙里。
各供应商对哪个状态码表示账户余额耗尽意见不一。有些会静默丢弃不支持的参数而不是拒绝,返回一个字段缺失的完整计费成功。严格的兼容实现会拒绝未知字段,而宽松的实现会接受,所以同一个请求体在一个上面正常、在另一个上面返回 400。Usage 总计出现在不同的位置——这正是取消计费 bug 得以存在的根因。
换了一种马甲的同类信念:与同一个第三方做同一件事的两种方式是等价的。手动充值在生产环境跑了几周都没问题;自动充值对任何人来说都不可能正常工作,因为发票的货币取自客户记录,而结算会话的货币取自第一个行项目。完整的事后分析写在一份事故报告里,写得很完整。
负责门禁部署的套件中有一个断言大约每三次运行就会失败一次。它被记为"flaky"(不稳定)而没有进一步诊断。诊断结果是:这套测试保留了真实的供应商和真实路由的种子目录,用真实的供应商 key 在环境中启动,所以自动路由器每次都解析到最便宜的可用真实模型并发起一个真实的、产生账单的调用。断言通过还是失败取决于第三方的可用性。
隔壁的测试套件早就删除了真实目录,并在注释中用一句话说明了原因:测试数据库里一条真实的供应商记录离一次真实的路由错误只有一步之遥,而那会产生一张真实的发票。缓解方案早就存在,只是从未迁移过来。
取代这个信念的做法:一个不稳定的断言是关于"耦合"的信息,而不是关于"运气"的信息,它值得比它的严重程度所暗示的更多的关注。详见"我们的 evals 漏掉了什么"。
Schema 声明了 43 个外键,其中二十个是级联的。数据库默认关闭约束执行,没有任何配置去开启它。每一个声明都只是装饰:删除一个账户会留下它的账本条目、请求、支付、成员资格、密钥和 webhooks,全部成为孤儿,而一条引用了不存在父记录的记录可以毫无怨言地插入。
同一类问题又出现了两次。一次部署脚本通过列出两个精确的文件名来排除 secrets,而磁盘上位于两者之间的第三个 secrets 文件与这两个都不匹配。一次版本控制忽略规则在使用破折号的地方真实文件名用的是点号,所以每一次手动的数据库副本都是未跟踪的,离被永久提交只差一条命令。
取代这个信念的做法:声明是意图的陈述,而断言是执行的机制。在差异重要的地方,要测试机制——现在的部署脚本会 grep 自己的完成归档,如果在 secrets 模式匹配到了任何东西就拒绝上传。
"这是我们自己的服务器"这句话承担的工作超出了它的承载能力。宿主机上还运行着一个无关的生产服务。上传归档中包含一个加密密钥和五个有效的供应商密钥,且没有限制性权限。回滚归档是环境文件的完整副本,全局可读。没有任何记录表明服务器运行的是哪个 commit,所以那台机器执行的代码只存在于一个地方:一台笔记本电脑上的一个未提交文件。
取代这个信念的做法:边界是机器,而不是组织,版本控制不是秘密离开笔记本电脑的唯一方式。那个 ignore 规则工作得完美无缺,但对真正导致泄漏的那件事毫无关联。
一个金额解析器做了这件事:
input.replace(/[^0-9.,-]/g, "").replace(",", ".")
第二次调用传的是一个字符串而不是一个正则表达式,而 JavaScript 的字符串替换只替换第一个匹配项。所以 "1,000" 变成了 "1.000" 并被解析为一美元,而 "1,234.56" 变成了 "1.234.56",解析在第二个点处停止并返回 1.23。
两端都是静默的,因为它确实解析了。没有抛出异常,也没有触发任何验证。一个输入一千美元充值的客户看到报价更新为一美元,没有任何理由不认为那是他自己的输入错误。受影响的有 11 个调用点,包括所有消费限额设置器——同样的失误把一个巨大的上限变成了一美元的上限,然后开始拒绝客户自己的流量。
替代方案不是全局标志,因为在欧洲一半的国家逗号是小数点,在美国是千位分隔符,而这是一个以阿姆斯特丹为基地销售美元计价产品的业务,所以两种形式都会出现。猜错了在一个方向上是 100 倍的差异,在另一个方向上是 1000 倍,所以分隔符现在由规则决定而不是假定的,测试文件就是规格说明书——47 条断言,每一条都是某个客户可能实际输入的字符串。
取代这个信念的做法:在任何处理金钱的代码路径上,测试文件就是规格说明书,实现就是注释。参见"用整数表示金钱"。
计划写一千页、只写好了四页时,最明显的约束显然是写作吞吐量。实际上不是。生产问题在两个 session 里用并行 agents 就解决了;真正耗费思考的是它周围的一切——发布节奏、链接到尚不存在的页面的交叉引用、必须与文件系统保持一致的注册表,以及"任何页面不得声称进行了无人执行的测量"这一规则。
由此推论的结论令人不适但值得一说:既然量不再是约束,量也就不再是差异化的因素了。唯一站得住脚的立场是我们自己施加的约束,即网站上没有任何内容声称了未曾发生的事。这个系列是对该约束最严格的检验,这就是为什么它的二十篇第一人称随笔计划中有十二篇被转化成了工程指南而不是写成故事。
七条中有六条犯的是同一个错误:用真相的一个表征替代了真相本身。注释替代了代码。兼容性声明替代了实际行为。绿色套件替代了实际路径。Schema 声明替代了实际执行。排除清单替代了归档的实际内容。函数名替代了它的语义。
这提示了在这种系统中应该首先去哪里找问题:不是找困难的问题,而是找那些"某样东西陈述了一个没有任何东西去检查的事实"的地方。相应的实践——断言同一事实的两种表征是一致的——被证明是整个仓库中价值最高的测试类别,详见"我们的 evals 漏掉了什么"。
在我们的技术栈上运行安全评审
如果从头开始我们会做哪些不同的事