多Agent协作共享黑板模式:CAS乐观锁与并发控制实战

发布时间:2026/9/30 8:23:19
多Agent协作共享黑板模式:CAS乐观锁与并发控制实战 多 Agent 系统最让人头疼的地方往往不是单个 Agent 不够聪明而是它们凑到一起就开始互相踩脚。我最早做多 Agent 协作时踩过一个特别典型的坑三个 Agent 同时往一个共享任务列表里写状态结果一个刚把任务标记成进行中另一个已经把它改回待处理第三个干脆把整条记录覆盖成了自己的版本。日志翻出来一看时间戳几乎一模一样谁都没错但结果就是错的。这个问题困扰了我很久直到我把共享黑板模式Blackboard真正用对才算是把并发协作这件事理顺了。共享黑板模式的核心思路其实很朴素所有 Agent 不直接互相通信而是围绕一块公共的黑板读写信息谁需要什么就去黑板上找谁产出了什么就往黑板上写。听起来简单但真正落地时会发现难点全在并发两个字上——多个 Agent 同时读写同一块区域怎么保证不冲突、不丢数据、不产生脏读这篇内容就围绕这个核心问题展开把 Blackboard 的架构设计、并发控制机制、CAS 乐观锁的实战用法、以及我在真实项目里踩过的坑一次性讲透。不管你是刚接触多 Agent 协作还是已经在做类似系统但被并发问题折磨应该都能从里面找到能直接抄作业的东西。1. 为什么多 Agent 协作需要一块黑板1.1 从点对点通信到共享空间的思维转变大多数人多 Agent 的第一反应是让 Agent 之间直接发消息A 做完发给 BB 处理完发给 C。这种点对点模式在 Agent 数量少、流程固定的时候没问题但一旦 Agent 数量上去、协作关系变复杂就会迅速失控。我做过一个统计当 Agent 数量从 3 个增加到 8 个时如果采用点对点全连接通信理论上的通信链路数量会从 6 条暴涨到 56 条。这意味着每增加一个 Agent你都要重新考虑它和现有所有 Agent 的关系维护成本是指数级上升的。共享黑板模式换了个思路Agent 之间不直接说话而是把信息都放到一块公共区域上。A 把自己的产出写到黑板B 和 C 谁需要谁自己去读。这样一来Agent 之间的耦合度大幅降低新增一个 Agent 只需要让它学会读写黑板不需要改动其他任何 Agent 的逻辑。这就像公司里大家不用挨个私聊而是把所有进展都记在一块公共白板上谁需要看谁自己去看效率反而更高。1.2 黑板模式的三个核心角色一个标准的 Blackboard 架构通常包含三个部分理解这三者的分工是后续做并发控制的基础。黑板本身Blackboard是共享数据结构存放所有协作状态。它可以是内存里的一个字典、一张数据库表、或者一个 Redis 的 Hash。黑板上通常按区域或主题划分不同 Agent 关注不同区域减少不必要的争抢。知识源Knowledge Source就是各个 Agent每个 Agent 具备特定的能力它持续观察黑板一旦发现自己能处理的内容出现就主动介入处理处理完把结果写回黑板。控制机制Control负责调度和协调决定哪个 Agent 在什么时候可以读写黑板的哪个部分。并发冲突的解决主要就靠这一层。提示很多初学者会把控制机制和黑板混在一起实现导致代码耦合严重。建议从一开始就把存储和调度分开后面加锁、加版本号的时候会轻松很多。1.3 并发冲突到底发生在哪里要解决问题先得知道问题出在哪。多 Agent 并发操作黑板时冲突主要分三类。第一类是写写冲突两个 Agent 同时修改同一条记录后写的覆盖先写的先写的数据就丢了。这是最严重的一类因为数据直接丢失且难以察觉。第二类是读写冲突一个 Agent 正在读某条记录另一个 Agent 同时把它改了读到的可能是改了一半的中间状态也就是脏读。第三类是逻辑冲突数据层面没冲突但业务逻辑上冲突了。比如两个 Agent 都判断这个任务没人做于是都去认领结果同一个任务被做了两遍。这类冲突最隐蔽因为从存储角度看完全正常。理解了这三类冲突后面的 CAS、版本号、状态机这些手段你就知道它们各自是在防哪一类问题了。2. Blackboard 的数据结构设计与区域划分2.1 黑板不是一块铁板要分区我见过不少人把黑板实现成一个巨大的全局字典所有 Agent 都往里面塞东西。这种设计在 Agent 少的时候能跑但并发一上来锁的粒度会大到无法接受——A 改一个无关紧要的字段B 就得等着。正确的做法是按业务维度把黑板切成多个区域Region每个区域独立管理并发。举个实际例子一个内容生产的多 Agent 系统我会把黑板分成这么几个区区域名称存放内容主要读写者并发压力任务区待处理任务列表调度 Agent、执行 Agent高素材区采集到的原始素材采集 Agent、编辑 Agent中草稿区各 Agent 产出的中间稿编辑 Agent、审核 Agent高状态区各任务当前状态所有 Agent极高结果区最终产出审核 Agent、输出 Agent低分区之后每个区域可以有自己的并发策略。状态区压力最大可以用更细的锁或者 CAS结果区几乎不冲突简单加锁就够了。这种差异化处理比全局一把大锁高效得多。2.2 每条记录该带哪些字段黑板上的一条记录不能只有业务数据还得带上并发控制需要的元信息。我一般会保证每条记录至少包含这几个字段id唯一标识用于定位记录version版本号每次修改自增CAS 的核心status状态字段用于逻辑冲突控制owner当前处理者防止重复认领updated_at最后更新时间用于排查和超时回收payload真正的业务数据其中 version 和 status 是并发控制的关键。version 解决写写冲突status 配合 owner 解决逻辑冲突。很多人只加了业务字段结果并发一上来就抓瞎就是因为缺少这些元信息。2.3 用状态机约束 Agent 的行为光有字段还不够还得规定状态之间怎么流转。我强烈建议给黑板上每条记录定义一个明确的状态机Agent 只能按照合法路径修改状态。比如一个任务的典型状态流转待处理(PENDING) - 已认领(CLAIMED) - 进行中(RUNNING) - 已完成(DONE) | | v v 已取消(CANCELLED) 失败(FAILED)有了状态机Agent 在修改状态前必须先检查当前状态是否允许这次流转。比如一个任务已经是 DONE 了另一个 Agent 还想把它改成 RUNNING状态机直接拒绝。这就从逻辑层面挡住了大量冲突比单纯靠锁要可靠得多因为锁只能保证操作原子性保证不了操作合法性。注意状态机的流转规则一定要集中定义在一处不要散落在各个 Agent 里。我踩过的坑就是每个 Agent 自己判断状态结果规则不一致出现了这个 Agent 认为能改、那个 Agent 认为不能改的混乱局面。3. 并发控制的核心武器CAS 乐观锁实战3.1 为什么 Blackboard 场景更适合乐观锁并发控制分两大流派悲观锁和乐观锁。悲观锁是我先锁住谁也别动乐观锁是我先改改的时候检查有没有人动过动过就重试。Blackboard 场景下我几乎总是优先选乐观锁原因有两个。一是 Agent 的读写操作通常很快锁的持有时间短冲突概率其实没那么高。用悲观锁的话加锁解锁的开销可能比实际操作还大。二是多 Agent 系统往往是分布式的Agent 可能跑在不同进程甚至不同机器上悲观锁需要分布式锁支持实现复杂且容易出问题。乐观锁只需要一个版本号字段天然适合分布式场景。当然乐观锁也不是万能的。如果冲突概率极高大量操作都在重试性能反而会下降。这时候可以考虑分区降低冲突或者对热点区域局部用悲观锁。选哪种取决于你的实际冲突率这个后面会讲怎么测。3.2 CAS 操作的完整流程拆解CAS 全称 Compare-And-Swap翻译过来就是比较并交换。它的逻辑是更新之前先比较当前版本号是不是我读到的那个是就更新并把版本号加一不是就说明有人改过了本次更新失败。用伪代码表示这个流程def cas_update(blackboard, record_id, expected_version, new_payload): record blackboard.get(record_id) if record is None: return False, 记录不存在 if record.version ! expected_version: return False, 版本冲突记录已被他人修改 record.payload new_payload record.version expected_version 1 record.updated_at now() blackboard.save(record) return True, 更新成功关键点在于比较和交换必须是原子的。在单进程内可以用锁或者原子操作保证在分布式环境下要依赖存储层提供的原子能力比如数据库的UPDATE ... WHERE version ?或者 Redis 的 WATCH/MULTI 事务。3.3 重试策略失败之后怎么办CAS 失败是常态不是异常所以必须有合理的重试策略。我一般用有限次重试 指数退避的组合def update_with_retry(blackboard, record_id, mutate_fn, max_retry5): for attempt in range(max_retry): record blackboard.get(record_id) new_payload mutate_fn(record.payload) ok, msg cas_update(blackboard, record_id, record.version, new_payload) if ok: return True # 指数退避避免所有 Agent 同时重试造成二次冲突 sleep(base_delay * (2 ** attempt) random_jitter()) return False这里有两个细节值得说。一是退避要加随机抖动jitter否则多个 Agent 会同步重试形成惊群冲突反而更严重。二是重试次数不能无限超过上限要放弃并记录否则可能陷入活锁。我一般设 3 到 5 次实测下来绝大多数冲突都能在前两次重试内解决。3.4 一个完整的 CAS 认领任务示例把上面的东西串起来看一个真实场景多个执行 Agent 争抢同一个任务。核心是认领这个动作必须原子否则会出现两个 Agent 都以为自己认领成功的情况。def claim_task(blackboard, task_id, agent_id): for attempt in range(5): task blackboard.get(task_id) if task.status ! PENDING: return False, 任务已被认领或状态不允许 # 用 CAS 保证只有一个人能把 PENDING 改成 CLAIMED ok, msg cas_update( blackboard, task_id, task.version, {status: CLAIMED, owner: agent_id} ) if ok: return True, 认领成功 sleep(0.05 * (2 ** attempt) random.random() * 0.05) return False, 认领失败重试超限这段代码里状态检查PENDING和 CAS 更新是分开的两步中间可能有其他 Agent 插进来。但因为 CAS 会校验版本号即使两个 Agent 都通过了状态检查最终也只有一个能 CAS 成功另一个会因为版本号变了而失败重试。这就是 CAS 的价值——它把检查和更新在效果上合并成了一个原子操作。4. 从冲突到有序多 Agent 协作的调度策略4.1 主动拉取还是被动通知Agent 怎么知道黑板上有自己能干的活有两种模式。主动拉取是 Agent 定期轮询黑板看有没有符合条件的内容被动通知是黑板变化时主动推送给相关 Agent。拉取模式实现简单Agent 之间完全解耦但实时性差轮询间隔不好定——太短浪费资源太长响应慢。通知模式实时性好但需要维护订阅关系复杂度高而且推送失败还得有补偿机制。我的经验是任务量不大、实时性要求不高的场景用拉取配合合理的轮询间隔比如 1 到 2 秒实时性要求高的场景用通知但一定要有兜底的轮询防止通知丢失导致任务卡死。很多线上事故就是因为只依赖通知结果某次推送失败任务永远没人处理。4.2 用优先级和配额避免饿死多个 Agent 抢任务时如果不加约束容易出现强者恒强、弱者饿死的情况。比如某个 Agent 处理速度快它总是第一个抢到任务其他 Agent 长期空闲。解决办法是引入优先级和配额。优先级可以按任务紧急程度排紧急任务先被处理。配额则是限制单个 Agent 在单位时间内能认领的任务数量保证机会均等。实现上可以在黑板上给每个 Agent 维护一个计数器认领前先检查配额。def can_claim(blackboard, agent_id, quota_per_minute10): count blackboard.get_claim_count(agent_id, window1min) return count quota_per_minute这个机制在 Agent 能力不均时特别有用能防止个别 Agent 把活全揽了其他 Agent 干瞪眼。4.3 超时回收处理认领了但不干活的 Agent分布式系统里Agent 可能因为崩溃、网络问题或者逻辑 bug认领了任务却一直不推进。如果不处理这些任务就永远卡在 CLAIMED 状态。所以必须有超时回收机制。思路很简单给每个认领记录一个超时时间后台有个巡检 Agent 定期扫描发现超时的任务就把它重置回 PENDING让其他 Agent 可以重新认领。def reclaim_timeout_tasks(blackboard, timeout_seconds300): now_ts now() for task in blackboard.scan(statusCLAIMED): if now_ts - task.claimed_at timeout_seconds: cas_update(blackboard, task.id, task.version, {status: PENDING, owner: None})这里同样要用 CAS因为回收的同时可能原 Agent 刚好完成了任务不加 CAS 会把已完成的任务错误地重置。超时时间设多少要看任务的平均处理时长一般设成平均时长的 3 到 5 倍比较稳妥。4.4 幂等性让重试变得安全CAS 失败要重试超时回收后任务会被重新处理这些场景都要求 Agent 的操作是幂等的——同一个操作执行多次结果和执行一次一样。如果 Agent 的操作不幂等重试就可能产生重复数据或者重复副作用。保证幂等的常见做法是给每个操作分配唯一 IDAgent 执行前先检查这个 ID 是否已经处理过。黑板上可以维护一个已处理 ID 的集合处理前查一下处理完记一下。这样即使任务被重复认领实际业务逻辑也只会执行一次。提示幂等设计要在系统设计初期就考虑后期补非常痛苦。我见过一个系统上线后才发现不幂等结果每次重试都多发一条通知用户被骚扰得不行最后只能停机重构。5. 实测中的坑那些文档不会告诉你的问题5.1 版本号自增在分布式下的陷阱单机环境下版本号自增很简单但分布式环境下如果多个节点各自维护版本号就会出现版本号回退或者重复。我踩过的坑是Agent A 读到 version5Agent B 也读到 version5A 先更新成 6B 后更新时如果用的是自己内存里的 516就会把 A 的更新覆盖掉而且版本号还看起来正常。正确的做法是版本号必须由存储层统一生成不能由客户端计算。用数据库的话直接UPDATE ... SET version version 1 WHERE version ?让数据库保证原子性。用 Redis 的话可以用 Lua 脚本把比较和自增打包成一个原子操作。总之版本号的生成权一定要交给唯一的权威来源。5.2 长事务导致的锁等待雪崩有些 Agent 处理任务耗时很长如果它在整个处理过程中都持有黑板的写锁其他 Agent 就只能干等。一旦这种长事务多了锁等待会像雪崩一样蔓延整个系统吞吐量断崖式下跌。解决办法是缩短锁的持有时间只在真正写黑板的那一刻加锁处理逻辑放在锁外面。具体来说Agent 先读数据不加锁或加读锁在本地完成计算最后用 CAS 一次性写回。这样锁的持有时间从整个处理过程缩短到一次写操作效果立竿见影。我优化过一个系统把长事务改成 CAS 短写之后吞吐量提升了将近 4 倍。5.3 惊群效应与重试风暴前面提过重试要加抖动这里展开说说为什么。假设 10 个 Agent 同时 CAS 失败如果它们都立即重试就会在同一时刻再次争抢冲突概率极高然后再次失败、再次同时重试形成恶性循环。这就是惊群效应。加了指数退避和随机抖动之后重试时间被分散开冲突概率大幅下降。抖动幅度一般取退避时间的一定比例比如 20% 到 50%。实测下来加抖动之后重试成功率能从 60% 左右提升到 95% 以上。5.4 状态不一致的排查思路即使做了这么多防护线上还是可能出现状态不一致。这时候排查思路很重要。我的经验是按这个顺序查先看日志里有没有 CAS 失败但没重试成功的记录这类往往是重试次数设太少再看有没有超时回收和正常完成同时发生的情况这类是回收逻辑的 CAS 没做好然后检查状态机流转有没有被绕过有些 Agent 可能直接改数据库没走状态机最后看版本号有没有异常跳变如果有说明有地方没走 CAS 直接覆盖了这个排查顺序能覆盖绝大多数状态不一致问题比盲目翻代码高效得多。6. 不同规模下的方案选型建议6.1 小规模单进程内存黑板Agent 数量在 10 个以内、都跑在同一个进程里的时候其实不需要太复杂的方案。一个带锁的字典就够了用语言自带的线程锁保护读写版本号用内存变量维护。这个阶段过度设计反而增加复杂度得不偿失。我一般会用一个简单的类封装import threading class MemoryBlackboard: def __init__(self): self._data {} self._lock threading.Lock() def cas_update(self, record_id, expected_version, new_payload): with self._lock: record self._data.get(record_id) if record is None or record[version] ! expected_version: return False record[payload] new_payload record[version] 1 return True锁的粒度可以进一步细化到每条记录用分段锁或者每条记录一把锁减少争抢。6.2 中规模Redis 作为共享黑板Agent 分布在多个进程、需要跨进程共享状态时Redis 是很合适的选择。它性能高、支持原子操作、还能设置过期时间做超时回收。用 Redis 实现 CAS 的关键是用 Lua 脚本保证原子性-- cas_update.lua local key KEYS[1] local expected tonumber(ARGV[1]) local new_payload ARGV[2] local current tonumber(redis.call(HGET, key, version)) if current expected then redis.call(HSET, key, payload, new_payload) redis.call(HINCRBY, key, version, 1) return 1 else return 0 endLua 脚本在 Redis 里是原子执行的天然满足 CAS 要求。这个方案我用了很久稳定可靠适合几十到几百个 Agent 的规模。6.3 大规模数据库 分区 消息队列Agent 数量上千、跨机房部署时Redis 单点可能扛不住这时候要考虑数据库方案。用关系型数据库的行级锁和UPDATE ... WHERE version ?实现 CAS配合分库分表降低单表压力。同时引入消息队列做任务分发把抢任务变成领消息进一步降低黑板上的并发压力。这个规模下架构复杂度会显著上升建议只在确实需要时才上。很多项目其实几十个 Agent 用 Redis 就够了盲目上大规模方案只会增加维护负担。规模存储选型并发控制适用场景小规模内存字典线程锁单进程、Agent 少于 10中规模RedisLua 脚本 CAS多进程、Agent 几十到几百大规模数据库MQ行锁CAS分区跨机房、Agent 上千6.4 选型时最该关注的三个指标选方案时别只看哪个先进要看三个实际指标。一是冲突率冲突率低于 5% 用乐观锁很合适高于 20% 就得考虑分区或者悲观锁。二是延迟要求要求毫秒级响应的场景Redis 比数据库合适。三是一致性要求强一致场景数据库更稳最终一致场景 Redis 加补偿也能满足。这三个指标测出来方案基本就定了。我见过太多团队一上来就选最复杂的方案结果发现冲突率极低简单方案完全够用白白增加了维护成本。7. 写在最后的一点个人体会做多 Agent 协作这几年我最大的感受是并发问题从来不是靠某一个银弹解决的而是靠一整套组合拳——合理的分区降低冲突面CAS 保证操作原子性状态机约束逻辑合法性重试和幂等保证最终一致超时回收兜底异常情况。少任何一环系统都可能在某个边界条件下出问题。另外别迷信零冲突的目标。多 Agent 系统里冲突是常态我们要做的不是消灭冲突而是让冲突发生时系统能优雅地处理——失败重试、状态可恢复、数据不丢失。把这一点想通了设计思路会清晰很多。真正稳定的系统不是从不冲突的系统而是冲突之后还能自己爬起来的系统。