一个SKIPPED步骤永远没有创建数据库行,系统报成功实际在空转,调试了数周才发现这个从未触发过的代码路径。
我发现有 27 条 workflow 分支被静默跳过,但每次运行的状态仍然是 COMPLETED。
这些分支的条件永远无法匹配。所以 step 被跳过,run 被标记为成功,却没有任何通知。它们已经这样运行了好几周。
值得深思的并不是这里存在 bug——bug 总是存在的。而是这个系统诚实地报告了 27 次成功,却什么都没做。
我在一个论坛上写了这篇文章,有人问了我一个无法回答的问题:
它能区分"绿色但语义上空闲"和"绿色且实际处理了"吗?还是这仍然需要通过比对运行记录来发现?
我去检查了,以为数据在 step 行的下一层。每个 step 都有自己的状态,SKIPPED 是其中一个值。所以信息一定在那里。
不在。StepExecutionStatus.SKIPPED 从项目一开始就存在于 enum 中,却从未被写入过。代码库中零次出现。
被跳过的 step 根本没有创建数据库行。循环只是往一个内存中的结果列表追加了一个 dict,然后继续。唯一残留的 skip 痕迹,是 execution 行上那个 JSON blob 内部。
the step view reads rows, which meant a skipped step was invisible in the UI too
"which runs skipped something?" had no answer short of parsing JSON
a run that skipped every branch and a run that did all the work were identical at every level a human or an alert would look
我创建了一个 enum 值来描述一件事,却从未记录过这件事。
修复花了一个下午。现在被跳过的 step 会写入一条真实的行,并附带原因。run 报告会显示它处理了多少 step、跳过了多少、失败了多少,这些数字从那些行中衍生出来,所以数字不会和 step 视图显示的不一致。
然后我加了一个徽章:当一个已完成的 run 什么都没处理时,明确说出来。
同一个 reviewer 在一小时内否决了它,他是对的:
零可以是合理的。安静的一天,没什么要发送的,COMPLETED 且 processed 为 0 是正确的。只有当计数是双边的时,告警才有牙齿:run 报告它处理了什么,source 报告它交出了什么,两者必须对上。
一个没有任务要做的调度 workflow 在每个安静的日子都处理零。我的徽章会在所有这些日子触发,一周内就会被静音,然后在重要的那一天根本不存在。
这比什么都不显示更糟糕,因为它看起来像是覆盖了。
徽章被撤下了。计数保留了,作为简单的事实,判断力连同徽章一起被移除了。
两天后我写了一个部署脚本,在本地运行测试,如果任何测试是红的就拒绝部署。它最后验证部署是否成功。
它打印了五行绿色的内容:
✓ deployed
✓ server on c15b730
✓ all containers running
✓ agent-mesh.org/health healthy
每一条都是真的。我刚部署的功能已经死了。容器在我添加它所需的环境变量之前就启动了,所以代码被发布后什么都没做。
我是通过 curl 端点并读取返回值才发现的,而脚本并没有这样做。
验证部署成功了不等于验证功能正常。健康检查是通用的。效果检查才是具体的,而具体这部分没人写。
我发了那 27 条分支的故事,帖子变成了比我原本想写的更好的东西。
关于维护。逐 step 的断言在 workflow 超过几个之后就无法扩展了,因为每个断言都是另一个需要维护的东西。能存活下来的答案是把断言从 workflow 中移出,放到引擎里。一个地方,每个 workflow 都自动获得它,不需要任何人去添加一个节点。
关于零。历史是"合法的零"问题的一个部分答案。不要看一次执行;要看 workflow 自己的基线。正常情况下周二早上 9 点产生什么,和周六相比呢?在通常繁忙的时段出现零是信号。在通常安静的时段出现零不是。这是概率性的而不是确定性的,但它把盲目变成了可疑,这通常足够让你知道该往哪里看。
限制,而且是我自己的:一个新 workflow 没有历史,而这恰恰是某人最可能把条件写错的那段时期。我的 27 条分支从第一次运行就死了。从来就没有一个健康的基线可以偏离。
关于级联。有人描述了一个自托管的设置,请求本地模型时悄无声息地失败了。节点完成了,向下游传递了一个空 payload,随后的每个节点都成功地对着这个空值执行了。
这比我的情况更糟。我的 27 条是各自独立死的。那是一个静默故障制造了更多故障,每个故障都诚实地说成功了,因为每个确实在它收到的空东西上完成了自己的工作。
这也是为什么记录一个 step 收到了什么和它返回了什么同样重要。一个返回空的 step 是可疑的。一个收到了空的 step 告诉你腐败从哪里开始,而这两者通常是不同的 step。
还有一个我无法回答的。一个 dedup 节点因为一个逻辑边界情况吞掉了一整个数据集。节点是对的。代码对它是为什么情况编写的那些 case 来说是对的。run 中没有任何东西是错的——除了行数。
输出形状验证无法捕获这种情况,因为 [] 和一千行具有相同的形状。
所有这些都是同一件事穿着不同的外衣:一个无法报告失败的检查。
只知道是否抛出异常的 run 状态。执行了操作但从未断言的测试。确认进程在监听的健康检查。一个没有地方被记录的跳过的 step。
我在同一个代码库中遇到的更小的版本。我把成本存为整数的分:
cost_cents = int(
(prompt_tokens * 0.5 / 1000) + (completion_tokens * 1.5 / 1000)
)
一次典型的运行成本约 0.0955 分。int(0.0955) 是 0。我几乎所有的运行成本都不到一分,所以几乎所有的运行都记录为零,我自己的分析页面显示大约一百次执行总共 2 分。我相信了一阵子。这不是舍入漂移。数据在写入时就被销毁了,被一次转换。
双边的计数。我的 run 说它们处理了什么;没有什么说 source 交出了什么。HTTP 工具已经计算了一个列表端点返回了多少条记录然后丢弃了它,因为工具结果不会按 step 持久化。直到那改变之前,一个处理了零的 run 看起来是一样的——不管那天是安静的还是条件坏了的。
基线的想法在 backlog 中,数据已经存在了。两者都值得做,它们捕获不同的 bug:历史找到漂移,source 计数找到出生时就坏了的。
我构建的是一个托管 Agent 平台,这就是所有这些发生的地方,所以把这整个东西当作有偏的。但 bug 并不稀奇,修复也不是。enum 值一直在那里。从来没有人写过它。