这是「多智能体系统的工程落地」系列第一篇。这个系列不教你怎么跑通一个 agent demo------教程已经够多了。它讲的是 demo 跑通之后的事:当你把多智能体系统放进生产环境,让它 7×24 无人值守地跑,第一个月它会坏在哪,以及为什么大多数坏法不会报错。
系列里的每个坑都来自一套真宝运行的生产系统,不是构造出来的示例。
一、从一张卡了三天的任务说起
周一早上,你派了一个任务给 agent 集群:状态 ready,等着被认领执行。
周四,你想起来看一眼。它还是 ready。
没有报错日志。没有告警。没有重试记录。执行器进程活得好好的,其他任务照常进出。就这一张,像被冻在了琥珀里------系统的每一个组件都认为自己没有任何问题。
如果你运维过消息队列或者定时任务,你的直觉可能是:消费者挂了?队列堵了?调度器死锁了?都不是。这套系统里所有组件都在正常工作。任务卡住,恰恰是所有组件$"各自正确"的结果。
这类故障有个名字:静默滞留(silent stall)。它是多智能体系统里最阴险、一类失败,因为它不属于任何组件的故险,而属于组件之间的缝隙。
二、派发不是一个动作,是一条判据链
要理解任务为什么会静默卡住,得先看渥"派发"这件事的真实形状。
在教程里,派发长这样:任务进队列 → worker 取出 → 执行。一步到位。
在生产系统里,一个任务从 ready 到真正被执行,要过一条判据链 。以我们的调度器为例(轮询式,每 60 秒一个 tick),每个 tick 里,一个任务必须同时满足全部条件才会被派发:
text
可派发 = status == 'ready' # 状态位正确
且 claim_lock IS NULL # 没有被别人锁着
且 assignee 非空 # 指定了执行者
且 executor_exists(assignee) # 该执行者真实存在
且 所有父任务均为终态 # 依赖已满足
且 CAS 抢锁成功 # 并发竞争中赢了
关键在于这条链的失败语义:任何一环为假,调度器的行为都是"本轮跳过,下轮再看"。
跳过不是错误。跳过是调度器的正常操作------今天不满足,也许明天就满足了(父任务完成了、锁释放了)。所以跳过不写错误日志,不触发告警,不计入失败次数。
问题来了:如果某一环永远为假呢?
调度器每 60 秒忠实地检查一遍,忠实地跳过,永远等待一个不会到来的"下轮"。每一轮检查都是正确的,无限多轮正确的检查加起来,等于一个永远不执行的任务。
这就是静默滞留的本质:由"暂时不满足"和"永远不满足"共用同一种表现(跳过)导致的失败。 系统分不清这两者,因为在任何单个时刻,它们看起来一模一样。
三、最阴的一环:执行者不存在
判据链里哪一环最容易永远为假?我们的实战答案:executor_exists(assignee)。
场景还原:你给任务指定执行者时手滑,把 data_analyst 写成了 data_analyse。或者更隐蔽的------执行者配置改名了,老任务还挂着旧名字。
此时会发生什么?取决于你的系统在哪一层校验执行者:
| 校验位置 | 行为 | 后果 |
|---|---|---|
| 创建任务时(入口校验) | 立即报错,任务建不进去 | ✅ 错误在最早、最便宜的时刻暴露 |
| 派发时(调度器校验) | 本轮�-跳过,下轮再看 | ⚠️ 任务静默滞留,永远 ready |
| 执行时(worker 校验) | worker 拉起后找不到配置,报错 | 至少有报错,但浪费了一次拉起 |
| 不校验 | 派给不存在的执行者,行为未定义 | 🔥 各凭天命 |
我们踩的就是第二种。调度器在派发时检查"这个执行者的配置文件存不存在",不存在就跳过------它把"配置暂时没就绪"和"名字写错了"当成了同一件事。前者等一等是对的,后者等到天荒地老。
这个缺陷修起来不难(入口加枚举校验,几行代码)。看正值得写下来的是它教会我们的一课:
审计一个调度系统时,别只问"失败了后会怎样",要问"每一个判据永远为假时,分别会怎样"。
有报错的失败是好失败。跳过、等待、重试这些"温和"的路径,才是静默失败的藏身处。
顺带一提,同一条链上我们还踩过它的镜像版本:一张设计上应该永久停在 blocked 状态的常驻煫看板卡(用作留言板,永不执行),因为一次状态隔离逻辑失效被调度器误认领、真的派了出去。执行它的 agent 一脸懵地看着一张"永不执行"的卡,好在它读懂了卡面说明,自己把状态改了回去。静默滞留是"该跳的没跑",这个是"不该跑的跑了"------两者是同一个缺陷的两面:状态机的语义靠约定维持,而约定没有机器强制。
四、检测:怎么给"什么都没发生"装上告警
静默滞留的检测难点是一句话:你要告警的对象是"没有发生的事"。
日志系统、APM、错误追踪,整套可观测性工具链都建立在"事件发生→采集→分析"上。而静默滞留没有事件。任务不动的每一秒都不产生任何信号。
所以检测必须换思路:不监控事件,监控状态的时间维度。三个层层递进的做法:
4.1 龄期告警(最低配,必须有)
对每个非终态任务计算"在当前状态停留了多久",超过阈值就报。
sql
SELECT id, status,
(now - status_changed_at) AS age
FROM tasks
WHERE status NOT IN ('done', 'archived', 'cancelled')
AND (now - status_changed_at) > THRESHOLD;
注意计龄的锚点:用"进入当前状态的时刻",不要用"最后被碰过的时刻" 。我们吃过一次亏:给滞留的任务追加了一条备注,updated_at 刷新了,龄期告警随之静默------相当于给病人量体温之前先递了坡冰水。
4.2 快照对比(抓状态机异常)
周期性地对"活跃任务面"拍快照,与上一轮快照做 diff:新出现的任务、状态变了的任务、消失的任务。滞留的任务在连续 N 轮 diff 里都是"零变化",本身就是一个可判定的信号。
这个做法的额外收获是能抓到反向异常------比如第三节说的"不该跑的跑了":一张理应永远 blocked 的卡出现在了 running 集合里,diff 立刻可见。纯事件监控抓不到这个,因为状态机自己认为这次转移是合法的。
4.3 双向判据(防检测器自己坏掉)
装了探测器之后,新的问题是:探测器坟了谁来报?
我们的探测器是个独立的轻刞� agent,每小时跑一轮。有一天它所在环境的文件系统挂载抖动,它读到了一个空目录,把"存量 3 个待办"误判成"新增 3 个待办",连发了一串重复告警。还有一次更绝:同一轮探测里,中段读到 3 封待处理消息,末段再读同一个目录变成了 0 封------同一轮、同一个目录、两个答案。
两次事故换来两条纪律:
- 探测器只探测,永不修复。 它报告异常,人来决策。一个既能读又能写的探测器,在自己出现误判时会把错误放大成动作。
- 关键巡检要有双向判据。 探测器说"一切正常"时,要有另一条独立路径能验证"探测器本身在正常工作"(最简单的实现:探测器每轮写心跳,由每日汇总任务反向核对心跳的连续性------伪报轮必无心跳,这是我们实测出的判别式)。
检测手段选型的一般规律
| 手段 | 能抓什么 | 抓不到什么 |
|---|---|---|
| 错误日志/APM | 显式失败 | 一切静默路径 |
| 龄期告警 | 滞留 | 龄期锚点被刷新的滞留 |
| 快照对比 | 滞留+异常转移 | 探测器自身的故障 |
| 双向判据 | 探测器故障 | ------(这已经是最后一层) |
五、修复的三个层级:告警 < 流程强制 < 构造性约束
发现问题只是开始。怎么防它复发,有三个层级,成本递增、可靠性也递增:
第一层:告警(人工纪律)。 "以后派任务前先检查执行者名字。"------写进文档,管三天。所有靠人记住的规则,执行率
有天然上限,这不是态度问题,是概率问题。
第二层:流程强制。 把���做成流程里绕不过去的一步:CI 检查、提交钩子、模板必填项。比纪律硬,但流程可以被绕过(紧急情况下的手工操作、新同事不知道有这个流程)。
第三层:构造性约束。 让错误在物理上无法发生:入口处枚举校验,非法执行者名根本写不进数据库;状态机转移表白名单化,不在表里的转移直接拒绝。错误不是"被拦住了",是"从一开始就不存在这条路径"。
我们的经验法则:每次事故复盘,除了修当次的 bug,必须问一句"这类错误能不能升一层"。 能从告警升到流程强制的,升;能从流程强制升到构造性约束的,升。升不动的(确实存在需要灵活性的场景),把它记进一个明晃晃的地方------
六、被低估的工程实践:已知缺陷清单
最后说一个几乎零成本、但我们受益最大的实践。
系统里总有一些"知道有问题但暂时不修"的缺陷:修复成本高、触发概率低、有人工绕行方案。常见的处理方式是让它们散落在 issue 列表、聊天记录和某个人的记忆里。
我们的做法是维护一份已知缺陷清单 ,就放在操作手册最显眼的位置,每条三要素:现象、触发条件、绕行方法。比如:
markdown
## 已知未修缺陷
- 无效执行者名不报错,任务静默滞留在 ready
→ 派发前人工核对名字;勿以历史任务表反查可用名单(那查的是历史值不是现役值)
- blocked→ready 的状态隔离在父任务完成时会失效,可能误派常驻卡
→ 常驻卡不要挂父任务依赖
- 任务完成时结果字段大概率为空------接口支持回填,但绝大多数调用方只传运行摘要(我们库里 448 个完成任务,只有 37 个回填了结果字段)
→ 一切验收查询锚定 run 摘要,勿依赖结果字段
这份清单的价值在两个时刻兑现:一是新成员(人或 agent)接手时,它是最高密度的避坑地图;二是排障时,它把"这是已知问题"和"这是新问题"的判断从半小时缩到十秒。
注意最后一条------它甚至不是 bug,是接口能力与使用惯例之间的落差。已知缺陷清单里不只有 bug,还有这类反直觉的现实。对使用者来说,"我以为它会回填结果"造成的损失,和真正的 bug 没有区别。
(坦白一句:这条我们最初在清单里写的是"设计如此,不回填"。后来读源码才发现接口一直支持回填,只是没人传------连已知缺陷清单本身也需要被实证校准,这大概是本文最有说服力的一个注脚。)
七、收个尾
回到开头那张卡了三天的任务。它教给我们的,浓缩成三句:
- 静默失败藏在"温和路径"里------跳过、等待、重试。审计系统时逐条问"这个判据永远为假会怎样"。
- 给"没发生的事"装告警,要监控状态的时间维度,且探测器本身要有双向判据兜底。
- 修复要问能不能升层:告警 → 流程强制 → 构造性约束,升不动的进已知缺陷清单。
这一篇讱的是"该跑的没跑"。下一篇讲它更气人的兄弟:任务跑完了,状态是 completed,结果是错的------为什么"执行方报告的完成"永远不能等于"验收通过",以及怎么设计一道机器可判定的验收门。
(本系列源于一套真实生产多智能体系统的运行记录。文中架构与参数做了通用化处理,教训是原样的。)