四个AI自动化流程表面运行成功但实际未执行预期操作的bug:IMAP触发器时间戳问题、UID水位线未校验等,均通过主动查看系统状态发现。
每一个都跑出了绿色。没有异常,没有执行失败,日志里什么都没有。每一个都是在我去查看系统从未告诉我的某个东西时才发现的。
它们其实是同一个 bug,穿着四套不同的戏服:检查报告的对象与真正重要的事情毫不相干。
我用 IMAP 触发器监听一个邮箱,向其中追加了 17 条测试消息,而触发器只返回了 1 条。
不是报错。是成功执行,只返回了 1 条。
"Fetch Only New Emails"(仅抓取新邮件)默认是开启的,而且在近版该节点上,它会把 SINCE <today> 推送到 IMAP 搜索条件中。我的语料库是回溯日期的,所以匹配不到任何东西。从外表看,一个有邮件的邮箱和一个空邮箱毫无区别。
阅读发版的源码后发现,搜索条件是由两个互斥分支构建的:
if (staticData.lastMessageUid !== undefined) {
searchCriteria.push(['UID', `${staticData.lastMessageUid}:*`]);
} else if (node.typeVersion > 2 && options.trackLastMessageId !== false) {
searchCriteria.push(['SINCE', activatedAt.toFormat('dd-LLL-yyyy')]);
}
一个存储的 UID 水印会压过其他一切条件,而且它永远不会被对照邮箱做校验——代码自己的注释就提到,邮箱重建后 UID 会改变,而 UIDVALIDITY 才是检测它的方式。但并没有检查。一旦水印与邮箱不再匹配,就会永远返回零结果,而触发器仍然报告一切正常。
露馅的地方是:"没有新邮件"和"我的查询排除了所有邮件"产生的输出完全相同。如果你的触发器什么都没返回,在相信邮箱真的是空的之前,先证明你的查询是正确的。
一个工作流用公司名为每个客户创建一个目录。第二个客户是 Acme/West Coast Consulting。
带 recursive: true 的 mkdirSync 做了文档描述的完全一致的事情:创建了 clients/Acme/,然后在其中创建了 West Coast Consulting/,并在两级深处写入了全部六个文件。退出状态码为零,绿色运行,确认页面也展示给了用户。
修复只需要一行——用允许名单过滤,[^a-z0-9]+ 替换为短横线——但教训在于斜杠的形式:任何用户输入后变成路径、键或 URL 的内容都需要在边界处做规范化,失败的形式会是静默而不是抛异常。反过来如果你建的是屏蔽名单,你会记住处理 / 却忘掉……
一个研究 Agent 必须为其引用标注来源;一道关卡检查每条引用是否真的出现在它所引用的页面中。在某次运行中,关卡拒绝了 25 条中的全部 25 条。
我差点把它当模型诚实性的标题发出去。每一次拒绝都是我们自己的 bug:HTML 转文本那一步把标签变成了空格,所以 Doppler 变成了 Doppler 加上 who,一条正确的引用就无法匹配它所来源的页面了。
模型是对的。校验器错了,而它的自信程度和任何一个产生幻觉的模型别无二致。
修复方案:在信任一条拒绝之前,向校验器输入故意损坏的断言——改一个数字、删一个词、换一个名词、凭空编造一个句子——每一个都必须仍然失败。一个只见过正确输入的校验器会批准一切。
这一个是我的最爱,因为一切都是正常工作的。
十八行普通代码决定了一条 AI 起草的回复是否可以发送:其中的每个事实都必须存在于一个真人编写的文件中。在录制的那次运行中,6 份草稿,6 份都诚实通过,每个事实都附了引用。
然后我从已发送邮件中把消息读回来,与关卡批准的正文体做 diff:
Approved body bytes 182
Landed body bytes 251
Added after body 69
This email was sent automatically with n8n. —— 发送节点的归属选项,默认开启,在节点参数解析之后才生效,也就是在工作流中所有检查之后。早期构建的 HTML 路径上同样开启了这个功能,结果多加了 312 字节,其中包括一条带 campaign 参数的追踪链接。
没有恶意。但我差点脱口而出的那句话——"发出的每一条都有源文件支撑"——是假的,而且以一种工作流中任何测试都无法捕获的方式假的,因为工作流在修改消息的那个环节的上游。
四个案例的共同点:检查通过了,却报告在了错误的对象上。
针对产物做断言,而非步骤。 "跑了吗"几乎是免费的信息。"我要的东西在我要的地方吗,内容是我期望的吗"才是值得写的断言。
记录数量,而不只是结果。 "跑了,返回 0"和"跑了,返回 40"显示的是同一个绿色勾,这会让一条坏掉的管道藏上好几天才被发现。
从远端读回来。 无论你的系统产出了什么——一个文件、一行数据、一封邮件——从接收者获取它的地方把它取回来并比较。只有这个检查能扛住某个依赖下个季度悄悄改变行为的风险。
上面所有数字都来自一次记录的运行,工作流是公开的:https://github.com/Ships-Itself/builds