我是从一次特别狼狈的联调开始接触“共享黑板模式Blackboard”的。当时我们做一个合同审核的多 Agent 系统三个模型同时分析同一份合同各自往同一个结果表里写数据。单跑每一个 Agent 都正常一并发起来就出问题——有人读到了别人写到一半的记录有人把别人刚写的字段整个覆盖掉最诡异的是两个 Agent 同时认为“这条条款是自己发现的”最后落库只剩一份数据另一份凭空消失了。排查了两天才意识到问题根本不在于模型能力而在于多个 Agent 之间缺少一套明确的并发协作机制。后来把架构改成 Blackboard共享黑板模式所有 Agent 不再互相争抢数据而是围绕一块公共“黑板”分区域读写冲突率直接降到了零。这篇文章就围绕这套模式展开讲清楚多 Agent 并发协作不冲突的核心机制并用一个可落地的案例拆解完整实现。如果你是做 Agent 编排、多模型调度、复杂任务拆解的或者你只是想知道“多个 AI 助手同时干活怎么不打架”这篇都值得读完。我会用代码、配置、时序说明把关键点讲透也会把我在实践中踩过的坑原原本本列出来。1. 共享黑板模式到底在解决什么问题1.1 一句话说清 Blackboard 模式Blackboard 模式最早来自分布式问题求解领域它把多个独立专家知识源对同一个问题的求解过程统一收敛到一块公共的“黑板”上。每个专家只做两件事从黑板读取自己需要的信息把自己擅长处理的结果写回黑板。专家之间不直接通信所有信息交换都经过黑板。这个设计里最有价值的一点不是“共享数据”而是“把共享变成规则”。日常开发里我们说的“共享内存”“共享数据库”往往只定义了数据在哪没有定义谁在什么时候能写什么。Blackboard 模式则强制要求每个知识源声明自己负责的区域再由一个控制单元统一调度。看起来像多了一层中间代理但实际上这层代理就是解决冲突的关键。我习惯把这种协作想象成医院急诊室病人是问题黑板是墙上的电子病历每个科室的医生是 Agent。内科医生往病历里写体征外科医生补检查结果护士标出待处理事项。没有哪个医生会直接抢别人的键盘去改病历但每个人都能看到黑板上的全部信息并根据自己的专业判断决定下一步操作。急诊室的效率就来自这种“共同看板 分科负责”的协作方式。1.2 为什么多 Agent 协作一定会冲突多 Agent 并发协作的冲突从根上讲只有一个来源多个主体共享同一份可变状态却没有约定谁在什么条件下可以改哪一部分。这就像多个协作者同时编辑一个共享 Word 文档。如果不做权限划分A 在第三段加了一行B 正好把整个第三段删掉重写A 的内容就没了。数据库里这叫“更新覆盖”分布式系统里叫“写冲突”到了多 Agent 场景里更加隐蔽因为每个 Agent 的判断带有随机性同样一句文本两个模型可能得出微不同的结论结果就是同一个槽位被写入两次不同数据。另外还有一类冲突是“读到中间态”。Agent A 写了一半还没写完Agent B 读到了这个残缺数据基于错误信息做出了后续判断。这种错误不会立刻暴露而是会像滚雪球一样越往后越难排查。我在最初联调时碰到的最头疼的一个 bug就是召回 Agent 读到了分类 Agent 尚未完整的中间标签导致后续所有统计全部偏了。所以让多 Agent 不冲突不能靠“每个人小心点”或者“加把锁”就能解决。必须从数据组织、写入权限、调度策略三个层面整体设计这正是 Blackboard 模式擅长的领域。1.3 和管道模式、总线模式相比Blackboard 强在哪业界做 Agent 协作除了 Blackboard还有几种常见方案管道模式Pipeline、总线模式Bus、发布订阅模式Pub/Sub。我梳理一张对比表方便你看清各自的适用面。模式协作思路典型场景主要痛点管道模式数据按固定顺序流经多个处理节点固定流程、步骤明确的流水线任务流程一旦需要动态调整就非常僵化总线模式所有 Agent 挂到总线上消息按主题分发事件驱动、广播通知、解耦调用消息多了之后顺序和一致性很难追发布订阅生产者发布事件订阅者各自消费异步任务、状态广播生产者不关心结果容易“发出去就没了”Blackboard所有 Agent 围绕共享黑板分区域读写问题边界动态变化、需要多专家共同求解的复杂任务控制单元设计有一定复杂度选型时我的判断标准很简单任务的子步骤边界是否会在执行过程中发生变化。如果任务是一锤子买卖的流水线比如“读取文件 - 转格式 - 压缩上传”管道模式干净利落。但如果是那种“先看合同再抽实体又发现需要回看条款定义再补抽一轮”的递归式场景流水线根本描述不了这种回环恰恰是 Blackboard 最出彩的地方。2. 核心设计拆解把黑板当成共享状态机来设计2.1 黑板的数据结构决定协作质量很多初学 Blackboard 的人最容易犯的一个错误是把黑板设计成一个大而全的共享 MAP所有 Agent 都往里塞东西。这看起来自由实际等于没设计。黑板的本质是“共享工作区”它的数据结构必须反映任务的领域结构。我在做合同审核 Agent 时讲过黑板至少要有三个分区各司其职领域分区存放与任务强相关的事实数据比如合同条款、实体列表、分类结果。控制分区存放每个 Agent 的执行状态、优先级、完成标记供控制单元做调度判断。解释分区存放 Agent 对自身产出可信度的评估比如置信度分数、依据片段。分区的价值在于它为“写入权分配”提供了明确边界。比如我可以规定分类 Agent 只写领域分区里的“条款类型”字段抽取 Agent 只写“金额与日期”字段控制分区只允许控制单元写入。这个规则一旦定下来很多冲突在架构层面就被直接消解了。另外黑板上的每条数据最好都带状态标记。我之前用一套三元标记validating正在写入未完成、committed已完成可信、rejected被审查发现矛盾已废弃。读写规则很简单只有committed数据才能被其他知识源读取。这一条规定直接解决了大量“读到中间态”的问题。2.2 知识源如何“声明”自己的写入区域Blackboard 里的 Agent 通常叫“知识源Knowledge Source”。每个知识源不是随便什么都能写而是在启动前就要明确申报自己的“写区域write region”。我一般用一个配置对象来定义dataclass class KnowledgeSource: name: str read_regions: list[str] write_regions: list[str] max_executions: int priority: int举例来说合同分类 Agent 的声明可能是KnowledgeSource( nameclause_classifier, read_regions[doc.raw_text], write_regions[doc.clauses[].type], max_executions5, priority10, )这里write_regions写得很具体精确到“哪一条的哪个字段”。这样有两个好处第一控制单元在触发知识源前就能判断该知识源这次执行是否允许写入目标区域第二两个知识源如果写入了同一个区域配置检查阶段就会报错而不是等数据被覆盖了才发现。我在实际项目中把这种检查写成了一条硬性断言def assert_write_permitted(ks: KnowledgeSource, target_region: str): allow False for pattern in ks.write_regions: if fnmatch(target_region, pattern): allow True break if not allow: raise PermissionError( f知识源 {ks.name} 试图写入未声明区域 {target_region} )这等价于每个知识源进门之前先出示自己的通行证没有权限就拒绝执行。看似少了灵活性其实保住了长期稳定性。特别当知识源数量超过 5 个之后这种硬约束的价值会体现得淋漓尽致。2.3 控制单元仲裁者而不是传话者没有控制单元的 Blackboard 不是 Blackboard只是一块共享数据库。控制单元Controller才是这个模式的灵魂。它自己不做业务处理职责是持续做五件事监控黑板状态判断当前整体任务是否完成。根据黑板状态找出所有“可触发”的知识源即它所需的数据已经committed且它还有执行配额。对候选知识源按贡献度、优先级做排序选出本轮最合适的一个。触发选中的知识源执行并收集执行后的黑板变化。更新控制分区状态进入下一轮循环直到满足终止条件。控制单元本质上是“仲裁者”而不是“传话者”。所有协作决策在它这里收敛而不是由各个 Agent 之间互相商量。恰恰是这种单点仲裁避免了多 Agent 之间因为互相等待、互相扯皮造成的死锁和活锁。我实现控制单元时用过一个简单的评分函数def score(ks, board_state): data_ready all( board_state.is_committed(region) for region in ks.read_regions ) if not data_ready: return -1 return ks.priority * 10 board_state.get_staleness(ks.read_regions)这个函数把“数据是否就绪”作为硬门槛把“优先级”和“数据陈旧度”作为软指标。数据越旧、优先级越高的知识源越靠前这样既能保证高优任务优先处理也不会让低优任务永远拖延。2.4 三种经典的并发启动策略知识源触发不是只能一次一个Blackboard 也支持并发。我从实践角度推荐三种策略复杂度递增串行调度S0每轮只启动一个知识源。简单可靠适合知识源之间存在强依赖、或者并发带来的收益很有限的场景。区域隔离并发S1按写区域做分组同一组内不同知识源如果写区域互不重叠则可以同时启动。这相当于把数据库的分表思想搬到黑板里。适合大多数场景我的合同项目用的就是这种。读写锁并发S2允许“多个读者 单个写者”的完整并发模式。这个最灵活但控制复杂度明显上升适合知识源数量大、成熟度高的大型黑板系统。再往细里说并发启动不是简单的“多线程跑起来”而是要时刻关注并发数对整体吞吐的影响。我专门测过一批 Agent 的并发度——从 1 到 8 逐个加压结果 6 个并发时吞吐量最高再往上提反而因为模型接口限流和内存争抢单 Agent 延迟飙升。所以不要盲目追求高并发要根据你的实际资源压测出一个“甜蜜点”。3. 实战多 Agent 合同信息抽取系统的完整设计3.1 目标与整体运行流程我用一个可复现的“合同信息抽取”案例把上面这些机制串起来。背景是这样我们每周要处理大量合同需要从中抽取关键条款类型、金额、日期并做交叉一致性校验。任务被拆成四个 Agent入口 Agent把 PDF 转文本写入黑板“文档区”。分类 Agent识别每一条合同条款的类型比如违约金、付款条件、保密义务。抽取 Agent从已分类的条款里抽取金额、日期、双方主体等实体。校验 Agent检查抽取结果之间是否存在逻辑矛盾比如“金额”和“付款条件”的语义是否一致。整体流程是入口 Agent 先跑然后分类和抽取两个 Agent 在“分类 - 抽取”这两个区域之间形成一个小流水线校验 Agent 在最后兜底。控制单元全程监督发现分类区数据有更新就触发抽取 Agent发现抽取结果齐了就触发校验 Agent。3.2 黑板 Schema 定义一个清晰的黑板结构是整套系统的地基。我把黑板定义成下面这个结构虽然看着有点像 JSON但在工程实现里可以直接映射到数据库表或内存对象{ doc: { title: , raw_text: , md5: }, clauses: [ { id: c001, content: ..., type: PAYMENT, confidence: 0.92 } ], entities: [ { entity: 甲方, value: XX科技有限公司, region: PARTY, source_clause_id: c001 } ], validations: [ { status: PASS, checker: consistency_validator, message: } ], _control: { doc_parsed: false, clause_classified: false, entity_extracted: false, validation_done: false } }每个字段归属于明确分区clauses归分类 Agent 写entities归抽取 Agent 写_control只允许控制单元写。这样运行到后期即使两个 Agent 同时被启动因为写区域不重叠也不会互相干扰。3.3 各 Agent 的触发条件与写入规则我按“读区域写区域触发条件”三个维度给每个 Agent 定规则。这张表就是整个黑板协作的契约Agent读区域写区域触发条件入口 Agent无doc.*任务创建后立即触发分类 Agentdoc.raw_textclauses[].type,clauses[].confidencedoc.raw_text已提交抽取 Agentclauses[].content,clauses[].typeentities[]至少一条clauses[].type已提交校验 Agentclauses[],entities[]validations[]至少一个entities[]已提交注意一个关键细节抽取 Agent 的写入区域是追加式的向entities列表追加它不修改clauses里已有的数据。这样设计是为了避免两个 Agent 对同一个既有字段产生“读-改-写”竞争。多 Agent 协作里最稳妥的写策略是追加新数据而不是修改旧数据。除非该数据明确归你管。3.4 防冲突的两条硬规则读集校验与版本检查有了分区和权限还不够并发环境下还有个经典问题两个 Agent 同时读到了版本 A 的黑板然后各自基于版本 A 计算结果先后写入不同区域。如果两个区域没有交集没问题但如果它们恰好都要更新“同一份统计字段”后写的一方就会覆盖先写的一方。我采用数据库里“乐观锁”的思路在代码层面加“版本校验”。核心逻辑是每个 Agent 执行前记录自己读取的版本号写回时检查黑板当前版本号是否仍是读取时的版本如果不是则丢弃本次写入重新读最新版再来一次。def safe_write(board, expected_version, region, data): if board.version ! expected_version: raise RetryNeeded( f黑板版本已变化期望 {expected_version}当前 {board.version} ) board.write(region, data) board.version 1 board.record_event(write, region, data)这个过程和你平时处理 Git 冲突本质上一样先拉最新代码改完提交前再检查一下远端有没有被别人 pushed如果被 push 了就 rebase 一下再提交。不管是 Git、数据库并发锁还是多 Agent 黑板核心思想都是**“写入前验证读取集合没有变化”**。这套方案也叫“检查-提交Check-Act”实现成本低工作效率却提升极大。第二条硬规则是“提交可见性”。我反复强调过Agent 只允许读取committed状态的数据。实现上我在黑板对象里封装了读取入口def read_committed(board, region): data board.get(region) if data is None: return None if data.status ! committed: return None return data.value一开始团队有人觉得这条规则太绕多包一层检查没意义。但有一次我们并行跑分类和抽取两个 Agent没有这条规则时抽取 Agent 读到了分类 Agent 正在写入的半截条款里面字段只填了一半confidence 还是默认值 0。要不是加了这层过滤整个实体抽取的结果都会被带偏。从那之后“未提交不可读”成了团队所有 Agent 开发的默认要求。3.5 控制单元循环的代码骨架把控制单元的整个循环写成代码大概是这样的骨架def controller_loop(board, knowledge_sources): while not board.is_solved(): candidates [] for ks in knowledge_sources: if board.has_quota(ks.name) and ks.trigger_check(board): candidates.append(ks) if not candidates: board.wait_for_change(timeout2) continue candidates.sort(keylambda ks: score(ks, board), reverseTrue) # 关键并发决策按写区域分组互不重叠才能并发 groups group_by_write_region_disjoint(candidates) for group in groups: if len(group) 1: execute_ks(group[0]) else: run_concurrently( [lambda ksks: execute_ks(ks) for ks in group], max_workersmin(4, len(group)), ) board.commit_all_changes() return board.build_result()这里的group_by_write_region_disjoint就是区域隔离并发S1 策略的具体实现如果两个候选 Agent 的写区域没有交集就放进同一批并发跑只要写区域有交集宁可串行也不冒险。这个函数给整套系统定义了“并发边界”让“安全”成为第一优先级。4. 我踩过的坑冲突不是靠锁解决是靠约定解决4.1 常见问题速查表我直接整理一张问题排查表这些问题全部来自真实联调过程问题现象根因解决方案两个 Agent 写入同一字段后写覆盖先写写区域未隔离检查知识源声明强制分区写权限Agent 读到半截数据结果异常未提交数据被读取添加committed状态过滤只读已提交控制单元死循环任务卡住知识源触发条件依赖了永远不会产生的数据检查触发条件的每个读区域补上终止条件并发数一高模型接口频频超时没有压测并发阈值对 Agent 执行进行压测找到最优并发数高优先级 Agent 反复抢任务低优先级永远饿死控制单元只按优先级调度给每个知识源加最大执行次数配额撤销一个 Agent 的结果非常困难黑板写入了大量交叉引用数据使用“追加式写入状态标记”支持逻辑撤销两个 Agent 使用了不同版本的公共依赖依赖版本冲突在全局配置中统一锁定依赖版本第一条和第二条是出现频率最高的优先排查。第三条隐蔽性最强需要你认真梳理触发条件里的数据依赖链。4.2 三个容易被忽略的隐性冲突技术层面的并发冲突容易察觉真正麻烦的是两类“语义冲突”。第一类是分类口径不一致分类 Agent 认为某一条是“违约金条款”抽取 Agent 认为它是“赔偿条款”两个都对但同一个东西被写入了两个不同名称。这种冲突锁解决不了只能在黑板里增加“归一化别名表”让两个 Agent 都从黑板读取统一分类标准。第二类是新旧知识源的不一致系统里有一个旧版本 Agent 还在运行同时又有新版本 Agent 上线两者对同一种输入产生不同判断。如果它们写同一个区域就会互相覆盖。我的经验是给知识源加版本号写入的每条数据都带source_version控制单元发现同区域有两个版本在写时直接拒绝旧版本写入。第三类是配置冲突多个 Agent 使用公共配置文件但每个 Agent 有自己的一套参数覆盖逻辑结果同一份文件在 A 看来是生效的在 B 看来是无效的。这个问题和纯软件开发的“配置漂移”一模一样。所以我在系统里把配置也当作一块特殊黑板所有 Agent 必须从这块黑板读配置不允许本地覆盖。4.3 数据回滚与脏写的处理在并发环境中脏写入几乎无法完全避免。真正关键的是“脏写之后怎么办”。我在项目里引入了“写入日志”的概念每个 Agent 写回黑板时都会写一条日志记录【Agent 名写入区域读取版本号写入时间】。一旦校验 Agent 在最终检查中发现矛盾控制单元可以根据日志反查是谁在哪个版本上写了什么精准定位精准撤销而不是整块黑板回滚。回滚策略我有三个原则可以逻辑回滚的尽量用状态标记rejected而不是物理删除。必须物理回滚的按“写区域写入批次”局部回滚。禁止整块清空黑板重来这种粗暴方式会连带毁掉其他 Agent 的无辜成果。这套回滚机制运行了大半年最复杂的一次回滚只影响了 14 条实体记录其他 400 多条数据全部原样保留。4.4 什么时候不要用 Blackboard 模式最后说几句泼冷水的话。Blackboard 模式不是银弹有些情况下用它反而增加复杂度。如果你遇到以下三类场景我建议就不要硬上这套模式任务边界完全固定、步骤永不变更的简单流水线用管道模式干净省事。只有一个主 Agent、几个辅助工具的“单主多辅”结构用工具调用循环就够了黑板属于杀鸡用牛刀。对端到端时延要求极苛刻的场景比如 10 毫秒内要返回结果黑板的分区检查、版本校验、提交过滤每一层都是开销。Blackboard 模式最舒服的区间是“领域复杂、知识源分散、问题边界动态变化、对秒级延迟不敏感”的那类任务。做知识密集型 Agent 系统、复杂文档分析、故障诊断系统时它的威力会非常大值得你花时间掌握。根据我个人的实操经验从这里上手最有效先用一个很小的领域任务比如“多 Agent 新闻资讯摘要”定义好黑板 Schema让每个 Agent 各自实现“读-算-写”再写一个简版控制单元。跑通以后再逐渐加入并发分组、版本校验、回滚日志。等你把这几层地基一个个打好再回头看那些“Agent 互相打架”的问题就发现它们已经不是工程问题了而是设计问题在画架构图的那张纸上就解决了。