我是从一次特别狼狈的联调开始接触“共享黑板模式(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( name="clause_classifier", read_regions=["doc.raw_text"], write_regions=["doc.clauses[].type"], max_executions=5, priority=10, )这里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.* | 任务创建后立即触发 |
| 分类 Agent | doc.raw_text | clauses[].type,clauses[].confidence | doc.raw_text已提交 |
| 抽取 Agent | clauses[].content,clauses[].type | entities[] | 至少一条clauses[].type已提交 |
| 校验 Agent | clauses[],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(timeout=2) continue candidates.sort(key=lambda ks: score(ks, board), reverse=True) # 关键并发决策:按写区域分组,互不重叠才能并发 groups = group_by_write_region_disjoint(candidates) for group in groups: if len(group) == 1: execute_ks(group[0]) else: run_concurrently( [lambda ks=ks: execute_ks(ks) for ks in group], max_workers=min(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 互相打架”的问题,就发现它们已经不是工程问题了,而是设计问题,在画架构图的那张纸上就解决了。