一条内容发布流水线,三个环节加起来要跑三十五秒。
其中敏感词扫描只占零点一秒。
它明明可以和内容审核并行,却只能排在后面干等。
问题不在代码写得差。问题在执行的模型。
这套模型叫同步调用。上游必须等下游返回,才敢走下一步。
很多 AI工具 的链路都卡在这一层。
一、同步调用撞上的三道墙
第一道墙,下游忙,上游只能干等。
一条链路三个环节。内容审核要调模型,大概三十秒。敏感词扫描一百毫秒。版权检查要调外部接口,五秒。
同步写下来,总耗时是三者相加,三十五秒。
可敏感词扫描那零点一秒,本可以跟审核同时跑。
为什么不行。
因为在同步编排里,编排器必须等前一步返回,才知道要不要走下一步。
第二道墙,一个结果,五个下游。
同一份审核结果,要触发敏感词扫描,要更新内容库状态,要通知作者,要写审计日志,要触发风控评估。
同步写就是五个等待。
麻烦还在其次。每加一个下游,就要回调用链里改一次代码。
第三道墙,失败了只能从头重试。
审核跑到第四分钟崩了。同步调用只能把整个请求重跑,包括前面已经成功的那三个环节。
三道墙指向同一个解法。把调用改成发事件。
换过来之后,协作的拓扑变了一层。
以前是编排器牵着每一步走。时间上绑死,空间上也绑死。
换成事件,上游发完就返回。下游什么时候处理都行,上游也不知道下游的地址。
三处变化值得摊开看。
时间耦合没了。上游不必等下游完成,发完就走。
空间耦合也松了。上下游互相不知道对方存在。
失败影响被切开。一环出问题只影响自己,各自重试,互不牵连。
最容易被低估的是空间解耦。
它意味着新增一个下游消费者,不需要改任何现有代码。
想接日志就接日志,想接风控就接风控。原来的组件一无所知。
这才是事件驱动真正值钱的地方。
二、什么才算一条事件
最常犯的设计错误,是把事件当成调用的另一种写法。
那样的事件没有价值,只是给同步调用套了个信封。
真正要回答的是另一个问题。什么信息值得当成一条可持久化的事实记下来。
已经发生的事实值得发。打算做某件事的意图不值得发。
有意图的最终结论值得发。中间的推理过程不值得发。
状态发生了转移值得发。每次心跳每次轮询都不值得发。
需要被多方知晓的事值得发。只对自己有意义的数据不值得发。
出了错而且需要人介入,这要发。下一轮重试就能恢复的错误,不必发。
中间推理过程尤其别发。理由有两个。
一是总线会被大量低价值事件淹没。
二是中间过程本身带着不确定性。重放时得到的结果可能和当初不一样,审计价值反而被破坏。
放在 AI对话 场景里看更直观。
用户问一句,模型想了半天。那半天的思考过程不该变成事件。
事件一旦要跨系统传,又冒出一个新需求。同一份事件得能在不同的消息中间件里跑。
各家格式都不一样。
有一份公开规范解决的正是这件事。它给事件定了一层和传输无关的信封。
四个属性必填。
规范版本号,保证跨版本兼容。
事件类型。事件来源。事件唯一编号。
来源这一项最常被漏。事故排查时第一个问题永远是这条事件谁发的。来源缺了,整条链路就没法追溯。
事件类型有个命名约定,用反向域名加版本后缀。这样不同组织不会撞名。版本升级时老消费者也能靠版本号过滤,继续干活。
还有一个高频踩坑。载荷里别放大对象。
有人把整段模型上下文塞进数据字段。结果一条消息几十兆,总线直接被撑爆,消费者解析时内存飙升。
正确做法是载荷里只放引用编号,真内容丢到对象存储或者向量库。

三、投递至少一次,所以必须幂等
主流消息中间件给的是至少一次语义。意思是同一条消息可能被投递多次。
为什么不做到恰好一次。因为那需要分布式事务,成本高得离谱。
工程上的共识很统一。接受至少一次,用幂等把重复消掉。
有个翻车场景特别典型。
一个任务消费者调了一次模型,花了十二块,成功了。
正准备提交消费位点,进程崩了。
消息被重新投递,消费者以为是新任务,又调了一次模型。
结果同一次任务被处理两遍,账单翻倍,两条结果还可能对不上。
正确顺序是先处理产生副作用,再提交位点。
这样崩溃时消息会被重投。但因为幂等保护,重复处理不会产生重复副作用。
幂等有个细节常被跳过。
只查一次记录存在不存在,是不够的。
两条并发的相同消息会同时通过检查,然后同时执行。幂等就失效了。
正解是先原子占位。拿不到占位的实例,延迟几秒再重试。
顺序这件事也有讲究。
事件总线相对同步调用有个独特优势。它天然保留了因果链。
但事件到达的先后不保证。
同一个实体的事件乱序到达,消费者就会基于不完整的信息做判断。
解法是按实体分区。同实体的事件落同一分区,分区内就有序。
另一种保障是因果指针。消费者沿指针回溯,即使前一条还没到,它也知道自己在等什么。
保序是分区给的,因果是指针给的。两者互补,不是二选一。

消息中间件是有运维成本的。很多场景用进程内队列就够了。
先问自己一句,最多能丢多少数据。
能丢,比如推荐系统的行为日志,进程内队列就够。
不能丢但规模不大,用 Redis Stream,运维成本接近一个数据库。
不能丢而且量很大,上万级每秒,才轮到 Kafka。
别因为 Kafka 是标配就用它。
为一个中小规模系统配一个需要专人维护的消息中间件,是净负担。
四、从事件模型到跑通一条流水线
例子里挑的是内容发布。一篇内容要经四步,提交,扫描,审核,发布。
原来是一条同步链路。本篇把它改成事件驱动,要求三条。
三个下游并行消费,互相不阻塞。任何一环失败都能独立重试。全流程可审计可回放。
第一步,定义事件模型。
事件类型用枚举收在一处,把版本号放进取值里。这样路由按名字过滤,升级时老消费者清楚自己该处理哪个版本。
信封里保留四个必填属性和三个高频可选属性。
另外补了两个智能体场景特有的字段。一个贯穿全流程的业务编号,一个指向上游父事件的指针。
有个方法最关键,叫分区键。
它决定事件落哪个分区,而同分区才有序。取值优先用关联实体编号。
因为需要保序的,是同一篇内容之间的事件。不同内容之间本来就不必。
载荷大小还加了一道护栏。超过十六千字节直接报错,而不是让它流到消费者。
这类护栏在 AI编程 场景里格外必要。模型的上下文动辄几十万字符,顺手塞进事件就会出事。
第二步,写事件总线。
订阅关系要支持精确匹配和通配。通配是给审计用的,它要收到链路上的每一条事件。
消费者组用来分摊负载。同组内一条事件只投给一个成员,不同组各自收到一份。
分区有序靠每个实体一把锁。信号量管总并发,锁管同实体串行。两个机制管的是不同维度。
背压也要有。每个订阅者的队列容量有限,队列满了就显式记录并且告警。
发布的时候顺手做一次顺序一致性检查。同实体收到乱序事件,当场告警。
这类问题等消费者出错再去查,成本高得多。
第三步,给消费者加幂等和重试。
幂等存储有三个状态,处理中,已完成,不存在。
处理中这个状态,才是并发去重的关键。
提交顺序也有讲究。先把业务处理完,再标记完成。
反过来写,失败的事件会被当成已完成,永远不会重试。
错误要分三类。
超时和连接这类可重试,走指数退避。格式不合法和权限不对这类不可重试,直接进死信,不浪费重试次数。
超时的处理中记录还得定期清理。
有实例崩在占位之后,这条记录会永久卡住。幂等记录就变成了死锁源。
第四步,把流水线跑起来。
三个下游各订阅自己的事件。审计用通配,收全部事件。通知只在审核完成后触发。发布只在审核通过且没有高危词时才动手。
扫描环节改动最大。
它不再返回值,而是发一条事件出去。
这一步才是事件驱动的核心。
跑起来的结果很直观。
审核用了零点八秒。之后通知和发布同时开始,而不是排队等。
发布失败转死信,通知和审计照常完成。
重投一条固定编号的事件,四个消费组全都跳过,幂等生效。
五、生产环境还要补三件事
第一件,重试分三层。
最里面一层是即时重试,扛瞬时故障。限流超时和网络抖动都算。
退避要加随机抖动。否则所有失败事件会在同一刻一起重试,给下游来一次二次冲击。
中间一层是延迟重试,等外部依赖自己恢复。
最外面是死信。永久失败和未知失败都进这里,等人来看。
死信队列绝不能静默丢弃。丢一条事件链路就断了,而且你永远不知道是哪断的。
死信还要再分两类。瞬时类修好之后应该能自动重放,永久类必须人工过目。
不分的话,要么什么都要人工,累死。要么什么都能重放,危险。
第二件,重放要有隔离。
事件都持久化了,所以可以重放。发布新版本想验证效果,就拿历史事件跑一遍新逻辑。
但重放不是幂等的替代,它真的会产生副作用。
生产上至少做到一条。要么先跑一次只统计不执行的空转,要么给重放事件打独立标记,让下游能区分。
直接拿生产数据裸跑,最容易出二次事故。
第三件,事件契约要有纪律。
事件一旦发出去,就是对外承诺。
加可选字段可以直接加,老消费者忽略未知字段就行。
加必填字段必须新发版本。删字段也得新版本。
最危险的是改字段语义。名字和类型都没变,版本号机制根本发现不了。
这类改动要新版本加双发。老新两个版本并行发一段时间,盯着老版本的消费速率归零,再停掉。
别靠时间估算。
观测上盯六个指标。端到端延迟,消费积压,重复投递率,死信率,重试分布,还有一个孤儿事件率。
孤儿事件率是事件驱动特有的。它统计的是找不到父事件的事件,超过百分之一就该查。
原因通常有三个。上游没把事件持久化。存储保留策略把父事件截断了。因果指针填错了。
第三个最隐蔽。手工填编号的出错概率,远高于让框架自动生成。
六、别一步跳到 Kafka
对照着看,三种形态各有位置。
同步调用延迟最低。但一环失败全链路跟着失败,加下游要改上游代码。
轻量事件用进程内队列。延迟加十毫秒级别,运维成本极低,加下游只改配置。
完整事件驱动能力最全。能持久化,能重放,能横向扩展,代价是运维成本高。
我的建议是分两级跳。
先从同步开始。出现我需要一个下游做某件事的需求时,引入轻量事件。
出现这条流程要跑好几天而且还得能重放的需求时,再上完整的事件驱动。
有一类场景明确不要用事件驱动。
用户在前台同步等结果,事件反而增加延迟。
链路只有两环而且都很快,总线的开销超过收益。
需要立刻拿到结果才能做下一步判断,事件是异步的,做不到。
还有一条最容易被忽略。只是想解耦,那函数内部的模块拆分就够了,不必引入总线。

六个典型陷阱里,最贵的是重复副作用。它直接对应真金白银。
崩溃重投导致同一次任务被处理两次,而对模型的那次调用会真实产生费用。
防范办法是幂等键加原子占位,而且要做到处理成功之后才提交位点。
第二贵的是事件丢失。队列满了悄悄丢弃,链路断了还没人知道。
第三是乱序处理。第四是载荷过大。第五是事件风暴,一个事件触发十个下游,每个下游又发三个事件,消息量指数增长。
第六是契约漂移,也是最隐蔽的一个。
有几条纪律是踩过坑才写下来的。
来源字段绝不能漏。它是这条事件谁发的唯一来源。
事件类型用反向域名加版本后缀,全局唯一加上版本可过滤,两个价值都在名字里。
死信队列绝不静默丢弃。重放要有隔离标记。队列满了要记录要告警。
还有一条,别因为某样东西是标配就用它。
七、让事件链路可测
事件驱动在测试上有个天然优势。事件本身就是数据,可以直接构造,直接断言,直接回放。
测试要锁住四件事。
第一件,幂等。同一条事件进来两次,只该被处理一次。
第二件,原子占位。占位之后再占位必须失败。
这条测试守的是占位,不是存在性检查。实现一旦退化,它会立刻报警。
第三件,分区有序。故意让同实体的第一条事件处理得慢一点,第二条不该抢先。
第四件,分区不互相阻塞。三个不同实体的事件,应该在一百五十毫秒内全部跑完。串行需要三百毫秒。
还有一条容易漏的。超时的处理中记录要被清理。
实例崩在占位之后,这条记录会永久卡住后续所有重试。
整套测试零模型调用,没有外部依赖,毫秒级跑完。
回到开头那个问题。为什么要把调用换成事件。
因为三道墙指向同一个解法。智能体从被调用者变成事件响应者,时间和空间一起解开。
走下来有四条认知值得记住。
第一,事件驱动的核心价值是空间解耦,不只是解耦本身。
新增一个下游不用改任何现有代码,这是架构上的自由。
第二,至少一次是既定现实,幂等是必答题。
而且关键细节在于原子占位,不只是查一次记录。
第三,总线天然带因果链,但顺序要靠分区而不是时间戳。
保序是分区给的,因果是指针给的,两者互补。
第四,升级要分级跳。
进程内事件总线成本极低,能覆盖大部分解耦需求。真需要跨进程或者需要重放时,再上消息中间件。
落地之前,可以照着清单过一遍。
四个必填属性齐不齐,尤其来源有没有漏。
分区键有没有定,同实体的事件是不是落在同一分区。
载荷有没有大小护栏,大对象是不是走的引用。
幂等是不是用了原子占位,而不是只查存在性。
处理成功之后才标记完成,失败要释放占位,超时的记录要有清理。
错误分三类,退避带抖动,死信不静默丢弃。
语义变更走新版本加双发,按消费速率归零再停旧版。
重放有隔离标记,不裸跑生产数据。
观测指标接上,中间件按规模选型,没有过度引入 Kafka。
最后一句提醒。事件驱动最大的风险不是技术,是心智模型。
它要求你从调用链思维,切换到事实流思维。
已经发生的事,不会因为你现在需要它而改变时序。
这个弯转过来,一切都顺。转不过来,就会陷在事件发了没人收、事件收了顺序不对的反复调试里。
