“黑板”模式:分布式 Agent 群落的状态同步与语义冲突
What(本文讲什么)
当多 agent 跨机器运行时,“共享知识”会变成系统级问题:你需要让 A 的发现尽快被 B 用到,同时又要避免“过期事实”和“冲突事实”污染推理。
这篇文章用“黑板(blackboard)”作为架构切入,但重点不是名词,而是三个工程对象:
- 事件流:谁在什么时候发布了什么事实(可重放)。
- 状态视图:当前系统认为的“最新事实是什么”(可查询)。
- 冲突处理:并发更新与语义冲突如何收敛(CRDT/LWW/因果)。
并且把同步层的风险写清楚:超时、重试、幂等、并发、回滚、隔离、权限、审计、观测、降级。
Problem(要解决的工程问题)
同步层最容易出事故的点有四个:
- 重复投递:网络抖动或 consumer 重启导致消息被重放(幂等)。
- 冲突写入:两个 agent 同时写同一条“事实”,内容不同(并发)。
- 过期污染:旧事实长期存在,被检索召回后误导推理(降级)。
- 不可复盘:你不知道当前事实从哪里来、谁写的、是否验证过(审计、观测)。
如果你只用一个“共享向量库”堆事实,它很快会变成“共享污染源”。
Principle(黑板系统拆分:events + state + conflict)
1) events:把同步变成事件流(可重放)
事件流记录“发生过什么”。它的价值是:
- 可回放:出事故后能重建当时状态(审计)。
- 可聚合:能统计延迟、丢失、重复、冲突率(观测)。
- 可治理:能做超时、重试、限流与降级(超时、重试、降级)。
Redis Streams 提供了持久化消息流与 consumer group 语义,是常见的实现基座之一。需要注意:这类系统常见语义是“至少一次投递”,这意味着你必须在消费端做幂等与去重。
参考: https://redis.io/docs/latest/develop/data-types/streams/
2) state:把事件物化成状态视图(materialized view)
state 记录“当前真相是什么”。它通常是:
- 热状态(例如 Redis hash / SQL 表):用于实时读取。
- 长期语义记忆(例如向量库):用于检索召回。
关键点是:向量库不应该直接被当作“真相源”。它更像“检索索引”。真相源必须有版本、来源与验证状态(审计)。
3) conflict:并发冲突要有形式化工具
“语义冲突”不是玄学,它是并发更新的一种表现。CRDT 提供了一套形式化工具,用于在弱一致性环境下处理并发更新冲突(例如 LWW、集合型 CRDT)。
参考入口:
- CRDT 资源: https://crdt.tech/
工程上常见的策略组合是:
- 对“事实”用 LWW(Last Write Wins)+ 来源可信度。
- 对“集合”用 OR-Set/观察者集合,保留并发加入/删除语义。
- 对“配置”用版本号与因果 id,禁止倒退。
Usage(怎么用:一个可落地的黑板同步架构)
1) 事实模型:每条事实必须可审计
最小字段建议:
fact_id:稳定 id(可 hash)。author_agent_id:谁写的。created_at:写入时间。causal_id:因果链 id(可选,但强烈建议)。verified:是否被物理验证过(审计)。ttl_seconds:过期策略(资源释放、降级)。idempotency_key:用于去重(幂等)。
示例:
{
"fact_id": "fact:auth_module_path",
"value": "/pkg/security",
"author_agent_id": "agent-A",
"created_at": 1770000000,
"causal_id": "c-123",
"verified": false,
"ttl_seconds": 86400,
"idempotency_key": "idem-fact-auth_module_path-c-123"
}
2) 写入路径:先写事件,再物化状态
建议的写入顺序是:
- 写入 streams(事件流)。
- 由 materializer 消费 streams 并更新 state view。
- 将高重要度事实写入语义索引(向量库),但必须带来源与版本。
这样你就能保证:
- state 可重建(审计)。
- 语义索引可清理(TTL)且不会成为唯一真相源(降级)。
3) 幂等与重复投递:同步层的硬门槛
因为至少一次投递是常态,你必须:
- 每条事件带
idempotency_key(幂等)。 - 消费端做去重(例如用短期 dedup set)。
- 对副作用(写状态)写 WAL,确保重放不会重复提交(审计)。
4) 冲突处理:LWW 不是万金油
LWW 很常见,但有三个前提你要写清楚:
- 时钟与时间戳可靠性(分布式时钟漂移)。
- 谁更可信(来源可信度)。
- 是否允许倒退(例如路径迁移,倒退就是 bug)。
因此更实用的做法是:
- 事实带“验证状态”,未验证事实只能作为候选。
- 冲突触发 verify 工单,让 agent 进行物理验证并写入“校正事实”(审计)。
5) 观测:没有指标,你不知道同步是否在收敛
最少需要这些指标:
- event 延迟分布(P50/P95)。
- 重复投递率(幂等命中率)。
- 冲突率(同 fact_id 不同 value 的比例)。
- verify 工单触发率与成功率。
- 过期清理次数与索引大小增长趋势(资源释放)。
Design(设计取舍:什么时候要强一致)
黑板模式偏 AP(可用性与分区容错),适合“推理辅助事实”。但对“副作用提交记录”这类东西,不能只靠最终一致性:
- 提交记录必须写 WAL,并且要有明确的提交语义(审计)。
- 回滚/补偿必须可追溯(回滚、审计)。
换句话说:推理事实可以弱一致,副作用事实必须更强一致。
Pitfall(常见坑与防错)
- 把向量库当真相源:冲突与过期会快速污染推理(降级)。
- 没有幂等:重放会制造重复更新(幂等、重试)。
- 没有 TTL:旧事实常驻,越跑越偏(资源释放)。
- 没有审计字段:发生冲突无法归因(审计、观测)。
Debug(同步故障排查)
排查顺序建议:
- 是否重放:查看 idempotency_key 去重命中率。
- 是否冲突:统计同 fact_id 的多 value 冲突率。
- 是否过期污染:查看 TTL 是否生效,索引是否增长异常。
- 是否验证缺失:verify 工单是否在关键冲突上被触发与闭环。
事件去重(一个最小可行的幂等消费器)
当你使用“至少一次投递”的消息流时,消费端必须去重。下面给一个最小去重思路(伪代码):
class DedupConsumer:
"""
去重消费器:
对每条事件使用 idempotency_key 去重,避免重复更新状态视图。
"""
def __init__(self, dedup_store):
self._dedup = dedup_store # 例如 redis set + ttl
async def handle(self, event):
key = event["idempotency_key"]
if await self._dedup.exists(key):
return
await self._dedup.put(key, ttl_seconds=3600)
await self._materialize(event)
关键点:
- 去重窗口必须覆盖你的重放窗口。
- 对关键事实的物化写入仍建议写 WAL,避免去重 store 丢失导致重复提交(幂等、审计)。
一个冲突例子:为什么“只靠时间戳”会翻车
如果你用 LWW,只靠 created_at 决定胜负,会遇到:
- 时钟漂移:不同机器时间不同。
- 因果倒置:晚到的消息可能是早发生的事实。
因此更稳妥的做法是:
- 引入因果 id(例如基于事件流偏移量、或逻辑时钟)。
- 对关键冲突触发 verify 工单,用物理验证写入“校正事实”(审计)。
什么时候用 CRDT,什么时候不用(决策框架)
你可以用两个问题做决策:
- 我是否需要在分区时继续写入?(AP 倾向)
- 我能否容忍短期不一致?(最终一致可接受)
如果答案是“是/是”,CRDT 或 LWW + verify 往往是可行路线。
如果答案是“否”,尤其是涉及副作用提交记录、余额、权限策略等,应该优先强一致(或至少强提交语义 + 审计链),否则回滚成本会吞掉系统预算(回滚、降级)。
Source(资料来源)
- Redis Streams: https://redis.io/docs/latest/develop/data-types/streams/
- Blackboard pattern: https://www.martinfowler.com/articles/patterns-of-distributed-systems/blackboard.html
- CRDT resources: https://crdt.tech/