1. 共享黑板不是“共享文档”,而是多 Agent 协作的交通指挥系统
你有没有试过让 3 个 Python 脚本同时往同一个 JSON 文件里写数据?刚跑起来时一切正常,跑着跑着突然发现:A 写进去的 key 被 B 覆盖了,C 读到的 timestamp 是 5 分钟前的旧值,日志里还夹杂着JSONDecodeError: Expecting value—— 这不是代码 bug,是典型的并发写冲突。而“共享黑板模式(Blackboard)”要解决的,正是这个在多 Agent 系统中反复出现、却常被误认为“加个锁就完事”的深层问题。
它和你熟悉的 Google Docs、腾讯文档那种“多人实时协同编辑”有本质区别:黑板不是为人类设计的协作界面,而是为异构 Agent(可能是规则引擎、LLM 推理模块、数据库查询服务、定时任务调度器)设计的结构化消息中转站与状态仲裁器。它的核心价值不在于“谁能看到最新内容”,而在于“谁能在什么条件下安全地读、写、修改哪一部分,且整个系统不会因局部冲突导致全局死锁或状态撕裂”。
关键词 Blackboard、Multi-Agent、CAS、并发控制、Python 并非随意堆砌——它们共同指向一个工业级落地场景:当你的系统从单体 Agent 演进到由 5+ 个功能解耦、开发团队不同、更新节奏各异的子系统组成的协作网络时,传统 REST API 调用链会迅速变成一张无法维护的蜘蛛网,而数据库直连又会把所有模块耦合在同一个事务边界里。此时,黑板模式提供了一种松耦合、高可观测、可审计、支持渐进式演进的替代方案。
我去年在做一个智能工单分派系统时踩过这个坑:最初用 Redis Hash 存工单状态,三个 Agent(NLP 意图识别、SLA 倒计时监控、自动升级决策)轮询读写同一字段。结果是:意图识别刚把 status 更新为pending_analysis,SLA 监控就基于旧的created_at算出超时并触发升级,而决策 Agent 又因为没看到新状态,重复生成升级工单。这不是逻辑错误,是缺乏状态变更的原子性承诺与读写隔离语义。后来我们重构为黑板模式,把工单状态拆成status、last_updated_by、version三个独立字段,配合 CAS 检查,问题彻底消失。这背后不是简单的“换了个存储”,而是对协作契约的重新定义。
所以,本文不讲抽象理论,也不复现教科书里的“三阶段黑板架构图”。我会带你从零手写一个生产可用的 Python 黑板实现,重点拆解四个真实痛点:
- 为什么用
compare-and-swap(CAS)比lock更适合跨进程 Agent? - 如何让 LLM Agent 和规则引擎 Agent 读取同一份数据时,看到彼此兼容的视图?
- 当一个 Agent 正在写入大段分析报告(比如 2MB 的 JSON),另一个 Agent 同时读取工单基础信息,如何避免阻塞?
- 黑板本身如何自证清白?—— 即,当系统出问题时,你能快速定位是哪个 Agent 在哪个时间点、基于哪个旧版本做了错误修改?
这些,才是你在技术选型会上真正需要拍板的细节。
2. CAS 不是“乐观锁”的别名,而是黑板模式的呼吸节律
很多人把 CAS(Compare-and-Swap)简单理解为“乐观锁的一种实现”,这在单线程内存操作层面没错,但放到黑板模式下,它承担的是更根本的角色:定义 Agent 之间协作的最小可信单元。它不是为了“提高并发性能”,而是为了消除状态歧义——确保每个写入操作都明确声明:“我修改此字段的前提是,它当前的值等于 X”。
我们先看一个反例。假设黑板用 Redis String 存储工单状态:
# ❌ 危险写法:无条件覆盖 redis.set("ticket:123:status", "resolved") # ❌ 更危险:先读后写(race condition 高发区) current = redis.get("ticket:123:status") if current == b"pending": redis.set("ticket:123:status", "in_progress") # 中间可能已被其他 Agent 修改!这种写法在单 Agent 场景下尚可,一旦引入第二个 Agent,就会出现经典的“丢失更新”(Lost Update)。而 CAS 的核心思想是:把“读取-判断-写入”这个三步操作,压缩成一个不可分割的原子指令。Redis 的GETSET或SET ... NX只能解决部分问题,真正的 CAS 需要WATCH/MULTI/EXEC或INCRBY这类带版本校验的命令。
我们手写一个基于 Redis 的 CAS 黑板核心方法:
import redis import json import time from typing import Any, Optional, Dict, Union class Blackboard: def __init__(self, redis_client: redis.Redis): self.redis = redis_client def cas_update(self, key: str, expected_value: Any, new_value: Any, version_field: str = "version") -> bool: """ 原子性CAS更新:仅当key对应value的version字段等于expected_value时,才更新整个value @param key: 黑板键名,如 "ticket:123" @param expected_value: 期望的version值(整数) @param new_value: 待写入的新完整value(dict) @param version_field: 版本字段名,默认"version" @return: True表示更新成功,False表示版本不匹配(冲突) """ pipe = self.redis.pipeline() try: # 1. WATCH 监控key,确保后续操作原子性 pipe.watch(key) # 2. 获取当前值 current_json = pipe.get(key) if current_json is None: # 键不存在,视为初始状态,允许写入(但需初始化version) new_value_with_ver = dict(new_value) new_value_with_ver[version_field] = 1 pipe.multi() pipe.set(key, json.dumps(new_value_with_ver)) pipe.execute() return True # 3. 解析当前值,提取version current_data = json.loads(current_json) current_version = current_data.get(version_field, 0) # 4. 版本校验:只有期望版本匹配,才执行更新 if current_version != expected_value: return False # 版本不一致,拒绝更新 # 5. 构造新值:继承原数据,仅更新version和指定字段 new_data = dict(current_data) new_data.update(new_value) new_data[version_field] = current_version + 1 # 严格递增 # 6. 原子写入 pipe.multi() pipe.set(key, json.dumps(new_data)) pipe.execute() return True except redis.WatchError: # WATCH 失败,说明key在watch后被其他客户端修改,重试或返回失败 return False except Exception as e: # 记录异常,但不抛出,保持接口幂等性 print(f"CAS update failed for {key}: {e}") return False这段代码的关键不在语法,而在其体现的协作哲学:
WATCH不是锁,是“观察承诺”:它不阻止其他 Agent 读写,只是告诉 Redis “如果这个 key 在我 WATCH 之后被改过,就别执行我的 MULTI 命令”。这避免了传统锁带来的阻塞和死锁风险,特别适合 Agent 执行时间不确定(比如 LLM 调用可能耗时数秒)的场景。version字段是契约锚点:它强制每个 Agent 在写入前必须声明“我基于哪个版本做决策”。当 A Agent 读到 version=5 并计算出新状态,B Agent 却在 A 读取后、写入前把 version 改成了 6,那么 A 的 CAS 就会失败。这时 A 不该强行覆盖,而应选择:重试(重新读取 version=6 的状态再计算)、降级(用旧状态做保守决策)、或上报冲突(触发人工审核)。这是黑板模式赋予系统的弹性恢复能力。new_value是增量而非全量:注意new_data.update(new_value)这一行。我们不要求 Agent 提供整个黑板对象,只需提供它关心的字段。这极大降低了 Agent 间的耦合度——NLP Agent 只需提交"intent": "refund",SLA Agent 只需提交"sla_deadline": "2024-06-15T18:00:00Z",它们互不影响。
提示:生产环境务必为
cas_update添加重试机制(如指数退避),因为WatchError是预期中的并发现象,不是错误。我通常设置最多 3 次重试,超过则记录告警并走降级流程。
再对比一下,如果不用 CAS,而用分布式锁(如 RedisSET key value NX EX 30):
# ✅ 锁方式(伪代码) lock_key = f"lock:{key}" if redis.set(lock_key, "1", nx=True, ex=30): # 获取锁 try: current = json.loads(redis.get(key)) # ... 复杂业务逻辑 ... redis.set(key, json.dumps(new_data)) finally: redis.delete(lock_key) # 释放锁 else: raise Exception("Lock acquisition failed") # 或等待重试表面看也能保证原子性,但问题在于:锁的粒度与业务语义脱节。一个工单的status和assignee可能被不同 Agent 独立更新,用一把大锁会强制所有操作串行,严重拖慢吞吐。而 CAS 允许status和assignee在同一 key 下并发更新(只要它们不修改同一个 version 字段),这才是黑板模式“细粒度协作”的精髓。
3. 黑板的“分区视图”设计:让 LLM 和规则引擎读到同一份数据的不同真相
多 Agent 系统最棘手的不是写冲突,而是读一致性。想象这个场景:LLM Agent 正在基于工单全文(含用户原始描述、历史沟通记录、附件 OCR 文本)生成处理建议;与此同时,规则引擎 Agent 只需检查priority字段是否为"critical"就决定是否触发告警。如果两者都读取同一个巨大的 JSON 字符串,会发生什么?
- LLM Agent 读取耗时长(2MB 数据解析+传输),期间规则引擎可能被阻塞;
- 规则引擎 Agent 为了一次简单判断,被迫加载全部无关字段,浪费内存和 CPU;
- 更致命的是:当 LLM 正在写入长达 500 字的
suggestion字段时,规则引擎读到的可能是半截 JSON(如果黑板存储不支持原子写入大对象)。
解决方案不是给每个 Agent 配一个数据库副本,而是设计黑板的分区视图(Partitioned View)。核心思想:同一物理存储,按逻辑维度切分成多个“虚拟视图”,每个视图有独立的读写契约和缓存策略。
我们扩展Blackboard类,增加get_view方法:
class Blackboard: # ... 前面的代码 ... def get_view(self, key: str, view_name: str, fields: Optional[list] = None, timeout: int = 5) -> Dict[str, Any]: """ 获取黑板数据的指定视图 @param key: 主键 @param view_name: 视图名,如 "summary", "full_context", "audit_log" @param fields: 可选,指定只返回哪些字段(用于轻量级视图) @param timeout: 读取超时(秒) @return: 字典,包含请求的字段 """ # 1. 根据view_name确定缓存key和读取策略 cache_key = f"{key}:view:{view_name}" # 2. 尝试从本地缓存(如LRU Cache)读取,避免频繁Redis访问 cached = self._local_cache.get(cache_key) if cached: return cached # 3. 从Redis读取完整数据 start_time = time.time() full_data = self._get_full_data(key, timeout) if not full_data: return {} # 4. 按视图规则裁剪数据 result = {} if view_name == "summary": # 规则引擎视图:只返回关键决策字段,且做类型强校验 result["id"] = str(full_data.get("id", "")) result["priority"] = str(full_data.get("priority", "normal")).lower() result["status"] = str(full_data.get("status", "pending")).lower() result["version"] = int(full_data.get("version", 0)) # 强制添加时间戳,确保视图新鲜度 result["view_fresh_at"] = int(time.time()) elif view_name == "full_context": # LLM视图:返回所有文本相关字段,但过滤掉二进制附件 text_fields = ["title", "description", "history", "suggestion", "notes"] for field in text_fields: if field in full_data and isinstance(full_data[field], (str, list)): result[field] = full_data[field] # 附加元数据 result["blackboard_key"] = key result["view_name"] = view_name elif view_name == "audit_log": # 审计视图:只返回变更历史(需额外存储) audit_data = self.redis.hgetall(f"{key}:audit") result = {k.decode(): v.decode() for k, v in audit_data.items()} # 5. 写入本地缓存(短时效,避免脏读) if view_name in ["summary", "full_context"]: self._local_cache[cache_key] = result return result def _get_full_data(self, key: str, timeout: int) -> Optional[Dict]: """带超时的完整数据读取""" try: data = self.redis.get(key) if data: return json.loads(data) except (json.JSONDecodeError, redis.TimeoutError) as e: print(f"Failed to load full data for {key}: {e}") return None这个设计解决了三个实际问题:
3.1 视图即契约:定义 Agent 的数据权限边界
summary视图强制返回priority为字符串并小写标准化,这意味着规则引擎 Agent永远不必处理Priority、PRIORITY、high、HIGH等多种写法。它只认"critical"、"high"、"normal"、"low"四个值。这消除了因数据格式不一致导致的逻辑分支爆炸。而full_context视图则明确告知 LLM Agent:“你可以放心使用description和history字段,但attachments字段不在这个视图里,你需要单独调用get_attachments()”。
3.2 视图即缓存:降低核心存储压力
_local_cache使用functools.lru_cache或cachetools.TTLCache,为summary视图设置 1 秒 TTL,为full_context设置 30 秒 TTL。这意味着:
- 规则引擎每秒可能调用 100 次
get_view("ticket:123", "summary"),但实际只产生 1 次 Redis 请求; - LLM Agent 加载一次
full_context后,30 秒内所有相关推理都复用同一份数据,避免重复解析大 JSON; - 缓存失效时,
_get_full_data的超时机制防止 Redis 故障拖垮整个 Agent。
3.3 视图即隔离:规避大对象读写干扰
最关键的,full_context视图的裁剪逻辑(只取text_fields)确保了:即使full_data包含一个 10MB 的base64_encoded_image字段,get_view(..., "full_context")返回的result也不会包含它。LLM Agent 的内存占用稳定在几百 KB,而规则引擎 Agent 的summary视图更是控制在 1KB 以内。这从根本上杜绝了“一个 Agent 的大对象操作拖垮所有 Agent”的雪崩效应。
注意:
view_fresh_at字段不是可选的。它让每个 Agent 能自我诊断数据新鲜度。例如,规则引擎发现view_fresh_at是 5 秒前的,而 SLA 倒计时要求毫秒级精度,它就会主动调用get_view(..., "summary", force_refresh=True)绕过缓存。这是黑板模式赋予 Agent 的自主健康感知能力。
4. 黑板的“自审计”能力:当系统出错时,第一行日志就是证据
在生产环境中,多 Agent 系统最令人崩溃的不是报错,而是“一切看起来都正常,但结果错了”。比如:工单被错误标记为resolved,但所有 Agent 的日志都显示“执行成功”。此时,如果没有黑板自身的审计能力,你将陷入大海捞针式的排查:查 Redis 命令日志(通常不开启)、翻每个 Agent 的详细日志(字段分散、时间难对齐)、甚至抓包分析网络请求(成本极高)。
黑板模式的终极优势,是它能把所有状态变更行为,固化为可追溯、可验证、可回放的事件流。这不是事后补救,而是从设计之初就嵌入的基因。
我们为Blackboard添加审计追踪(Audit Trail)功能:
import uuid from datetime import datetime class Blackboard: # ... 前面的代码 ... def _log_audit_event(self, key: str, operation: str, # "cas_update", "view_read", "error" agent_id: str, details: Dict[str, Any]) -> str: """ 记录审计事件到专用Hash结构 @return: 事件唯一ID(用于关联) """ event_id = str(uuid.uuid4()) event_data = { "id": event_id, "timestamp": datetime.utcnow().isoformat(), "key": key, "operation": operation, "agent_id": agent_id, "details": json.dumps(details), "ip": details.get("ip", "unknown") # 可选,由Agent传入 } # 写入Redis Hash,以 key 为 top-level,event_id 为 field audit_hash_key = f"{key}:audit" self.redis.hset(audit_hash_key, event_id, json.dumps(event_data)) # 同时写入全局审计流(便于聚合查询),使用Redis Stream stream_key = "blackboard:audit:stream" self.redis.xadd(stream_key, {"event_id": event_id, "key": key, "operation": operation}) return event_id def cas_update(self, key: str, expected_value: Any, new_value: Any, version_field: str = "version", agent_id: str = "unknown") -> bool: """ 带审计的CAS更新(重载父类方法) """ # 记录前置状态(用于冲突分析) before_state = self._get_full_data(key) start_time = time.time() success = super().cas_update(key, expected_value, new_value, version_field) # 记录结果 end_time = time.time() details = { "expected_version": expected_value, "success": success, "duration_ms": round((end_time - start_time) * 1000, 2), "before_state_version": before_state.get(version_field, 0) if before_state else 0, "agent_id": agent_id } self._log_audit_event( key=key, operation="cas_update", agent_id=agent_id, details=details ) return success def get_audit_history(self, key: str, limit: int = 100) -> list: """ 获取指定key的审计历史(最近N条) @return: 按时间倒序排列的事件列表 """ audit_hash_key = f"{key}:audit" events = self.redis.hvals(audit_hash_key) parsed_events = [] for event_json in events: try: event = json.loads(event_json) # 转换时间戳为datetime便于排序 event["datetime"] = datetime.fromisoformat(event["timestamp"].replace('Z', '+00:00')) parsed_events.append(event) except: continue # 按时间倒序 parsed_events.sort(key=lambda x: x["datetime"], reverse=True) return parsed_events[:limit]这个审计系统不是简单的日志记录,而是构建了一个可编程的状态变更图谱。它的价值体现在三个具体场景:
4.1 冲突根因定位:5 分钟内锁定问题 Agent
假设工单ticket:123的status被错误地从"in_progress"改成了"resolved"。你调用get_audit_history("ticket:123"),得到如下精简结果:
| 时间 | Agent ID | 操作 | 详情 |
|---|---|---|---|
| 2024-06-10 14:22:03 | nlp-agent-7 | cas_update | expected_version=8,success=True,duration_ms=12.4 |
| 2024-06-10 14:22:01 | rule-engine-3 | cas_update | expected_version=7,success=False,duration_ms=8.2 |
| 2024-06-10 14:21:59 | nlp-agent-7 | cas_update | expected_version=7,success=True,duration_ms=15.6 |
立刻可以推断:nlp-agent-7在14:21:59基于 version=7 写入,将 version 升至 8;rule-engine-3在14:22:01尝试基于 version=7 写入,失败(success=False);nlp-agent-7在14:22:03又发起一次写入,这次它读到了 version=8,成功写入。问题出在nlp-agent-7的第二次写入逻辑有缺陷——它不该在第一次成功后,又基于旧状态做二次决策。
没有审计日志,你只能猜测;有了它,证据链清晰得像时间线。
4.2 Agent 行为合规性检查:自动化巡检脚本
你可以写一个每日巡检脚本,扫描所有cas_update事件,统计异常模式:
def audit_compliance_check(): # 1. 查找所有失败率 > 5% 的 Agent agents = get_all_agent_ids() for agent in agents: total = redis.xlen("blackboard:audit:stream", f"agent_id={agent}") failed = redis.xlen("blackboard:audit:stream", f"agent_id={agent} operation=cas_update success=False") if failed / total > 0.05: alert(f"Agent {agent} CAS failure rate high: {failed/total:.1%}") # 2. 查找所有 duration_ms > 1000ms 的慢操作 slow_events = redis.xread({"blackboard:audit:stream": "$"}, count=1000) for event in slow_events: if event.get("duration_ms", 0) > 1000: alert(f"Slow CAS: {event['key']} by {event['agent_id']}, {event['duration_ms']}ms")这相当于给每个 Agent 配备了一个“数字绩效考核表”,无需人工介入,系统自动识别低质量参与者。
4.3 状态回滚与重放:故障恢复的最后防线
当确认某个 Agent 的错误写入污染了黑板状态,你可以用审计日志进行精准回滚:
def rollback_to_version(key: str, target_version: int, agent_id: str = "rollback-bot"): """ 将黑板key回滚到指定version(需确保target_version存在) """ history = blackboard.get_audit_history(key, limit=1000) # 找到target_version对应的事件 target_event = next((e for e in history if e.get("details", {}).get("before_state_version", 0) == target_version), None) if not target_event: raise ValueError(f"Version {target_version} not found in audit history") # 从审计日志中提取该事件发生前的完整状态(需提前存档) # 实际中,可在每次CAS成功后,将before_state存入 backup:{key}:{version} backup_key = f"backup:{key}:{target_version}" restored_data = redis.get(backup_key) if restored_data: blackboard.redis.set(key, restored_data) blackboard._log_audit_event( key=key, operation="rollback", agent_id=agent_id, details={"target_version": target_version, "restored_from_backup": True} ) return True return False虽然生产环境应尽量避免回滚,但这个能力的存在,本身就是对系统鲁棒性的最大保障。它让黑板从一个“状态容器”,升维成一个“可编程的协作时空”。
5. 从原型到生产:部署、监控与 Agent 接入 checklist
写完一个能跑通的黑板类,只是万里长征第一步。真正的挑战在于:如何让它在真实的、由不同团队维护的、运行在不同服务器上的 Agent 网络中,稳定、高效、可运维地工作?以下是我在三个不同规模项目中沉淀下来的实战 checklist,按优先级排序:
5.1 部署层:Redis 不是“开箱即用”,而是需要定制配置
很多团队直接用默认 Redis 配置跑黑板,结果在压测时发现WATCH失败率飙升。这是因为WATCH依赖 Redis 的multi-bulk协议和连接池管理,而默认配置未针对高并发 CAS 优化。
必须调整的 Redis 配置项(redis.conf):
| 配置项 | 推荐值 | 原因 |
|---|---|---|
maxmemory | 明确设置(如2gb) | 防止黑板数据无限增长挤占内存,OOM Kill 进程 |
maxmemory-policy | volatile-lru或allkeys-lru | 确保过期 key 能被自动淘汰,避免手动清理 |
timeout | 0(禁用空闲超时) | Agent 连接池复用连接,超时会导致ConnectionResetError |
tcp-keepalive | 300(5分钟) | 保持长连接,避免防火墙中断 |
hash-max-ziplist-entries | 512 | 提高 Hash 结构(如 audit hash)的内存效率 |
client-output-buffer-limit | normal 0 0 0 | 禁用普通客户端输出缓冲区限制,避免大视图读取被切断 |
提示:在 Kubernetes 环境中,务必为 Redis Pod 设置
resources.limits.memory,并启用redis_exporter暴露指标。重点关注redis_connected_clients(连接数突增预示 Agent 泄漏)、redis_keyspace_hits/redis_keyspace_misses(缓存命中率低于 90% 需优化视图策略)。
5.2 Agent 接入层:统一 SDK 是降低协作成本的唯一途径
禁止让每个 Agent 团队自己实现cas_update。必须提供一个官方 Python SDK,封装所有最佳实践:
# blackboard_sdk/__init__.py from .core import Blackboard from .views import SummaryView, FullContextView # 预定义视图类 from .audit import AuditLogger # 审计工具类 # 提供开箱即用的工厂函数 def get_blackboard_from_env() -> Blackboard: """从环境变量自动构建Blackboard实例""" host = os.getenv("BLACKBOARD_REDIS_HOST", "localhost") port = int(os.getenv("BLACKBOARD_REDIS_PORT", "6379")) db = int(os.getenv("BLACKBOARD_REDIS_DB", "0")) password = os.getenv("BLACKBOARD_REDIS_PASSWORD") return Blackboard(redis.Redis(host=host, port=port, db=db, password=password)) # 提供标准Agent基类 class BaseAgent: def __init__(self, agent_id: str): self.agent_id = agent_id self.blackboard = get_blackboard_from_env() self.audit_logger = AuditLogger(self.agent_id) def safe_cas_update(self, key: str, expected_version: int, updates: dict) -> bool: """带重试和审计的CAS更新""" for attempt in range(3): if self.blackboard.cas_update(key, expected_version, updates, agent_id=self.agent_id): return True time.sleep(0.1 * (2 ** attempt)) # 指数退避 self.audit_logger.log_error(f"CAS failed after 3 attempts for {key}") return False这个 SDK 的价值在于:它把“如何正确使用黑板”这个复杂问题,降维成“调用一个方法”。新加入的 Agent 团队,只需继承BaseAgent,就能获得重试、审计、错误上报等全套能力,无需理解底层 Redis 机制。
5.3 监控层:定义三个黄金指标,胜过一百个图表
不要试图监控所有 Redis 指标。聚焦以下三个与黑板业务语义强相关的黄金指标:
CAS 成功率(CAS Success Rate)
count(cas_update{success="True"}) / (count(cas_update{success="True"}) + count(cas_update{success="False"}))
健康阈值:≥99.5%。低于此值,说明 Agent 间冲突严重,需检查 Agent 逻辑(是否在过期状态上做决策)或黑板数据模型(是否字段耦合过紧)。视图平均延迟(View Latency P95)
histogram_quantile(0.95, sum(rate(blackboard_view_duration_seconds_bucket[1h])) by (le, view_name))
健康阈值:summary < 50ms, full_context < 500ms。延迟飙升往往意味着 Redis 负载过高,或full_context视图裁剪逻辑有性能瓶颈(如正则匹配大文本)。审计事件积压(Audit Stream Lag)
redis_xlen{key="blackboard:audit:stream"} - redis_xread{key="blackboard:audit:stream"}
健康阈值:≤ 1000。积压过多说明审计消费端(如日志分析服务)宕机或处理缓慢,将导致审计能力失效。
这三个指标,应该放在 Grafana 首页最醒目的位置。它们不是技术指标,而是协作健康度的晴雨表。
5.4 最后的经验:黑板模式的“死亡陷阱”
最后分享一个血泪教训:永远不要让黑板成为业务逻辑的“黑洞”。我见过最失败的案例,是把所有业务规则、状态转换条件、甚至第三方 API 调用,都塞进黑板的update_hook(更新钩子)里。结果是:
- 黑板本身变成了一个难以测试、难以调试的“上帝对象”;
- 任何规则变更,都必须重启黑板服务,违背了多 Agent 松耦合的初衷;
- 当黑板出问题时,整个系统瘫痪,失去了“降级”能力。
正确的做法是:黑板只做三件事——存储、校验、通知。
- 存储:提供
cas_update和get_view; - 校验:保证
version严格递增、fields类型安全; - 通知:通过 Redis Pub/Sub 或 Kafka,广播
key_updated事件,由各 Agent 自行订阅和响应。
把业务逻辑留在 Agent 侧,黑板只做它最擅长的事:当好那个沉默、可靠、永远记得自己说过什么的“协作见证者”。
我在实际使用中发现,一个设计良好的黑板系统,其最大的价值往往在上线三个月后才显现——当新加入的第 7 个 Agent 需要接入时,你不再需要开三天的需求对齐会,而只需告诉对方:“用 SDK 的SummaryView读,用cas_update写,其他交给黑板”。那一刻,你才真正体会到,什么是“让复杂性沉入基础设施,让协作变得简单”。