最气人的 bug 是「成了,但看起来没成」。
那天 QA Agent 的审批链路跑出一个怪相:用户在 Slack 卡片上点「批准」,56 秒后飞书正文写入成功,业务侧一切正常——唯独 Slack 按钮旁边挂着一个鲜红的 500,原消息也没收口。用户视角里,这个审批炸了;系统视角里,这个审批成了。两边都是真的。
我去翻 execution 记录:业务的 19655382 完整跑完,旁边却躺着一条 19655419,只存活了 21 毫秒,死在 Slack Trigger 的入口解析上,连第一个 Filter 都没摸到。
根因说出来有点蠢萌。Slack 的 URL 按钮会产生两条并行效果:按钮 URL 打开后恢复 n8n 的 waiting execution——这是业务那条路;同时 Slack 还会把 block_actions POST 到 App 的 Interactivity Request URL,要求 3 秒内回 200——这是协议那条路。我们的 Interactivity URL 指向的 Slack Trigger 只认识 Events API 的 envelope,上来就找 event.type;而 block_actions 的 payload 里根本没这个字段,于是 21ms 崩盘,ACK 永远没发出去。Slack 只好如实把 500 挂在按钮旁边。
这锅不全是我们的。Slack 的 Events API 和 Interactivity 本来就是两套独立产品线:前者是「有事发你」的推送订阅,后者是「用户动了」的交互回执,payload 结构、时限要求、失败语义全不一样。麻烦的是它们共用一个 App,凭直觉会把「Slack 回调」当成一回事。而 n8n 侧还顺手埋了几个小限制:Send and Wait 节点只吐 approved/respondedAt,不给你原消息的 ts;实例里也没有现成的「谁在几点批的」回执路由——想收口卡片,得自己回 thread 里捞。
一句话:「点按钮」在用户手里是一个动作,在 Slack 协议里是三条平行链路——恢复业务执行、ACK 交互、收口原消息。它们各有各的成功标准,谁也不替谁作证。

四个状态面,四种「成功」
这次事故教会我的第一件事,是把「成功」拆开数。一个审批链路至少有四个独立状态面:业务写入、交互 ACK、消息展示收口、结果汇总。它们可以任意组合地坏——我们就撞上了「业务成、ACK 崩、卡片没收口」的三合一。排查时还发现一个更隐蔽的同款:标题更新其实早就因缺 scope 返回 ok:false 了,但 workflow 整体照样被标成 success。Agent 系统里「执行成功」常常只是「图跑完了」,跟「事办成了」之间隔着一整个语义层。
换个角度想,Slack 本质上是我们的外部系统,有自己的一致性模型——只是我们太容易把「调了个 API」当成「完成了交付」,忘了对面是个异步、多通道、会重试的分布式系统。跟数据库、消息队列一样,渠道也该有协议文档、有状态机、有对账。后来每次聊 Slack 交付,我都先问一句:你说的是哪个成功?
修法上有个细节值得记:后置节点救不了入口 500。Trigger 在解析阶段就崩了,后面挂再多错误处理都是给尸体做心肺复苏。正解是分流——Events API 继续走原 Trigger 伺候 app_mention,Interactivity 单独挂一个轻量 webhook,用 onReceived 模式先 200 再说,实测首字节 0.74 秒,远低于 3 秒军规。业务 resume 继续走 URL 那条慢路,两条链路从此互不拖累。顺带一个部署上的坑:编辑器域名和生产 webhook 域名是分开的,我第一次验证拿编辑器 host 打了个 404,一度以为路由没注册——生产入口长什么样,不能从编辑器页面 URL 推断。
也想过更省事的方案,每个都有硬伤:升级用平台原生 approval 节点,自动收口消息——但当前实例根本没有那条回调路由;只补消息更新不修 ACK——治标,500 照样挂;让原 Trigger 同时接 mention 和 block_actions——入口解析假设就不成立。最后留下的兼容层是那个独立 ACK endpoint,但它有个暗代价:一个纯 ACK sink 会把所有交互 payload 一律 200 吞掉。哪天 App 加了 modal、shortcut 或非 URL 按钮,有业务语义的交互就被它礼貌地丢弃——ACK 得越漂亮,丢得越安静。所以这个端点不能只图快,得验签、幂等、按 payload.type 和 action_id 留好路由余地;顺手还得把成功 execution 的 payload 保存关掉,否则按钮里携带的用户信息和 response_url 会悄悄落库。
消息收口是第三条路,也是最能体现「协议各自独立」的一条。Send and Wait 的输出里没有原消息 ts,想更新卡片得先找到它:先读当前 thread 的消息列表,没有 thread 时回退到频道近期消息,再用 document token 精确定位那张审批卡;构造新 blocks 时保留原内容、剥掉 actions 块、追加「已批准 / 已拒绝」,最后 chat.update 回去。它是纯展示副作用——更新失败不该阻断已完成的业务写入;反过来,业务成功也绝不能被当成「卡片已更新」的证据。排查过程还有个插曲:后续又出现一次 mention 无响应,查下来不是分流惹的祸,是一个 Filter 表达式 ={{ $json || $json.type }} 返回了整个对象而比较期望字符串。相邻的两个故障,根因可以毫不相干——别把「修完 ACK」当成「这条链路从此健康」。
取消也是协议,不是对话
同一段时间,另一个需求把同样的问题又演了一遍:用户在 thread 里喊「停止」,系统热情地——新开了一条 execution。旧任务照跑不误。语言层的「停」根本够不到运行层,因为 workflow 本来就是每条消息一条 execution,谁也不抢占谁。
后来定的是 Control Card:任务起步时发一张紧凑卡片,停止按钮的 value 里塞签名过的 executionId + workflowId + requesterId,配 HMAC 防篡改——无表方案里,按钮 payload 就是全部关联证据,不签就等于把「谁能停谁的任务」交给客户端自觉。点击走 Interactivity,先验 Slack 签名和 5 分钟时间窗,3 秒内 ACK,校验点击者是不是发起人,再调 execution stop API 精确杀那一条——不是「停最新的」,并发场景下那是误伤队友。
这里有个容易漏的收口责任:execution 被强杀后不会再执行任何清理节点。也就是说,杀掉任务的那一刻,清理卡片的活儿就过户给了控制 workflow。卡片的生命周期也随之分成三条岔路:正常完成由原 workflow 自己把 actions 剥掉;强杀由控制 workflow 兜底 chat.update;worker 崩溃留下的孤儿卡,只能等定时清理器收尸。卡片本身也按这个分层设计——running 态是两行 section 加一颗红色 danger 按钮,停止前要二次确认,并且明确告知「已完成的外部副作用不会随取消回滚」。别小看这句提示,用户以为「停止」等于「撤销」是另一层认知债。
首版我选了无运行表的设计,卡片自携关联键,省一张表;代价也写明白:管不了同 thread 并发排他,也主动发现不了孤儿卡。将来要加暂停、恢复、审计,还是得回到 Data Table 那条演进路径。MVP 的每一行「先这样」,都是一张欠条,得记着还。
往深了说,这也是为什么渠道适配层和持久任务层必须分开:Slack Channels 这类 adapter 能改善展示、thread 和审批交互,但去重、job lease、恢复和 outbox 幂等不是 UI adapter 的职责。「webhook 直接驱动 Agent」在聊天场景是最短路径,在长任务场景就是把可靠性外包给了运气。

进度和审批,也是协议问题
渠道这层还埋着两个更隐蔽的契约。
一是进度反馈。翻执行样本时我发现,26 分钟的评审全程静默只发最终结果,16 分钟的用例生成只在开始吱了一声。直觉方案是定时心跳——60 秒首报、2 分钟静默上限——被否了,理由很硬:时钟只能证明时间过了,证明不了任务有新事实。定时心跳还会诱导虚假打卡,「仍在查看资料」这种消息除了显得热闹,信息增量为零。更微妙的是伪事件的反向诱导:给每个 intent 强制一份阶段清单,模型就会为了凑数而「发明」阶段。
最后改成阶段事件驱动:证据收敛、设计完成、开始写入、阻塞、恢复,这些可验证的状态变化才值得发;同阶段没新事实就保持安静,相邻阶段自然合并成一条。比如取证很快完成并直接进入设计,那就只发一条包含两项真实结果的消息,而不是为了「覆盖率」硬拆成两条。每个 intent 的事件实例不同——评审是「证据收集完成 / 发现冲突」,批量生成是「范围收敛 / 开始写入回查」——但共享同一个判定:接收者读完,是否知道了一个此前不知道的事实,下一步是否真的变了。旧规则里 3 分钟、90 秒、最低两条那些东西全删了,改完用 24 种「渠道 × 意图」组合做静态验收,确认旧时间规则清零。反直觉的地方在于,规则打架的根源不是少了一句「多汇报」,而是主提示词、工具描述、消息 Skill 三处各写各的频率——同一个长任务的行为取决于走到哪个 intent 和渠道。工具描述也是提示词的一部分,这话得刻在墙上。还有个小收获:改生产 workflow 时,第一次保存因为把 GET 回来的只读字段原样 PUT 回去被 API 整个拒掉,换成最小载荷才成功——配置更新也要讲事务完整性。
二是审批卡的所见即所得。审批卡必须渲染最终写入参数,而不是让模型另写一份摘要——摘要和真实写入之间的漂移就是这么堵死的。工程细节也得跟上:Slack section 有 3000 字上限,审批正文 1500 字以内完整展示,超长就标出预览和全文长度;极端输入渲到 2575 字,贴着上限留了安全边。以及一个啼笑皆非的回归:把 Slack 格式规则挪进按需 Skill 之后,Agent 算出答案却不发了——工具描述里一句「普通短查询不要调用」跟发送职责打架,模型老实巴交地选择了沉默。最后只能在协议里写明「普通 output 对 Slack 用户不可见,想被看见必须调发送工具」。「模型生成了答案」和「用户看见了答案」是两个状态,中间隔着一次必须真实发生的工具调用。
怎么验收这套东西
渠道层的验收要把四个状态面拆开打点:每渠道首次进度时间、最长静默时间、最终交付缺失率,加上 ACK 端点自己的健康检查。阶段事件驱动之后,监控指标也得换——不能再拿「消息间隔达标」当合格,得看阶段覆盖率、同阶段重复反馈率和无事实废话的比例;模型漏识别阶段事件时,要不要由 workflow 层补一条「系统阻塞」的兜底,到现在还是个开放问题。抽样也不能只看数量,得人工判断每条进度是否真的含具体对象、完成事实和下一步。
我至今还留着的待办是一次真实 approve + reject 双 E2E——同一个动作里同时验 3 秒 ACK、waiting resume、卡片收口和业务分支,而不是分开验完就当全链路通了。另外得定期盘一遍 App 的交互资产:这个 Interactivity URL 上还挂着哪些按钮和 modal,别让 ACK sink 默默吃掉新加的交互。
一句话收束:在 Slack 上,「发送成功」从来不是 POST 返回 200,是三条协议各自对齐之后,用户最终看到的那个状态。