基于OceanBase与PowerMem的多Agent记忆管理技术笔记
多 Agent 协作最难的地方,是让它们只知道自己该知道的。
Agent 的记忆体系,一直是个很有意思的话题。
前两天 WAIC 期间,OceanBase 开源社区主办了一场 Workshop 午餐会,和大家聊了不少 Agent 记忆的话题。
我也从多 Agent 的角度分享了一个基于 PowerMem + seekdb 搭建的小游戏——AI 情报局,希望大家能从中更直观地观察和理解多 Agent 如何管理与隔离记忆。
先亮出小游戏的技术体系:PowerMem[1] 管理记忆,seekdb[2] 负责检索与落库,LangChain 的 DeepAgents[3] 组织受限的角色表达,角色语言层则接入 StepFun[4](阶跃星辰) 的 step-3.7-flash 模型。
至于不同角色能查哪些记忆,由应用层的检索策略和 MemoryGateway 统一控制。
麻雀虽小五脏俱全,先看看这个游戏怎么玩。
欢迎大家关注 OceanBase 社区公众号 “老纪的技术唠嗑局”。在这里,我们会持续为大家更新与 #AI 和 #Data 相关的技术内容~
一、怎么玩?
玩法其实很简单,用户的身份是局长,当前场上有三个 Agent 角色:侦探、线人、嫌疑人。
侦探、线人和嫌疑人都有各自的私有记忆,默认不知道其他角色的秘密。
用户可以向某个 Agent 耳语,将消息写入其私有记忆;也可以审问某个角色,看他是否知道某条信息。
如果想让所有 Agent 都知道一条消息,用户可以把某个 Agent 的私有记忆公开到公告板,形成公开情报。此后,这条记忆就成为所有 Agent 的共有记忆。
界面上会有记忆透视面板,可以把检索范围、命中和未命中直接摊开:
操作端和大屏靠后端事件同步;模型不可用时也能走证据模式,把知道与不知道对照完。
以上就是界面分布和各模块的作用。开始游戏前,需要先选择剧本并完成初始化,为每个 Agent 加载初始记忆。
选择完任务剧本后,就可以开始审问角色 Agent。比如先问侦探:“保险箱的密码是多少?”此时,侦探 Agent 无法检索到相关信息,因为保险箱密码属于线人的私有记忆。
为了让侦探也知道保险箱密码,可以把线人关于该密码的私有记忆公开到公告板,变成共享记忆。
此时再问侦探:“保险箱的密码是多少?”,他已经可以正确回答。
用户还可以分别向不同角色耳语,测试这条私有消息是否会被其他角色检索到。
玩法简单,但它要展示和验证的核心只有一件事:看清 谁知道什么。
下面拆解游戏后台的 Agent 记忆设计。
二、玩法背后的技术怎么接上
小游戏里的分工很清楚:seekdb 负责存储与检索,PowerMem 提供带 user_id、agent_id 和元数据的记忆读写能力,应用层负责决定本次允许访问哪些记忆空间;DeepAgents 基于已批准的证据组织回答,LLM 只负责把证据表达成角色口吻。
2.1 开局与耳语:秘密只写给目标角色
当我们开始加载剧本或者用户向某个 Agent 耳语时,系统要保证两件事:
-
第一是不同案件的数据不能串,一局案件对应一个
case_id,映射成 PowerMem 的user_id,用来隔离不同对局 -
第二是写入的记忆只能落到目标 Agent,一个角色对应一个
agent_id(侦探、线人、嫌疑人、公告板),用来隔离不同记忆空间
# case_id 映射 PowerMem user_id
def to_user_id(case_id: str) -> str:
return f"case:{case_id}"
# 通过传入 case_id 与 agent_id 写入私有 Agent 记忆
def write_private(self, case_id, agent_id, content, *, topic, kind, created_by):
return self._write(
case_id,
agent_id,
content,
{
"case_id": case_id,
"visibility": "private",
"owner_agent_id": agent_id.value,
"topic": topic,
"kind": kind,
"created_by": created_by,
"is_demo_safe": True,
},
)
底层通过 PowerMem Python SDK 直连 seekdb。
PowerMem 存储多 Agent 记忆时,可以采用两种客户端封装方式:
- 第一种是只使用一个 PowerMem 客户端,每次读写时传入不同的
agent_id,区分不同 Agent 的记忆:
from powermem import create_memory
memory = create_memory(config=settings.powermem_config())
memory.add(
content,
user_id=to_user_id(case_id),
agent_id="informant",
metadata=metadata,
infer=False,
)
memory.search(
query,
user_id=to_user_id(case_id),
agent_id="detective",
limit=20,
)
- 第二种是这个小游戏采用的写法:按角色懒加载并缓存客户端。某个角色第一次被读写时,系统调用
create_memory(agent_id=...)创建该角色的记忆客户端。之后的记忆读写都通过该客户端访问对应角色空间。create_memory里的agent_id,本质上是给这个客户端设置默认角色。
from powermem import create_memory
# 按角色缓存客户端:detective / informant / suspect / bulletin_board
memory = create_memory(config=settings.powermem_config(), agent_id=agent_id.value)
memory.add(
content,
user_id=to_user_id(case_id),
agent_id=agent_id.value,
metadata=metadata,
infer=False,
)
在这个 Demo 中,两种方式最终都依赖 user_id 与 agent_id 过滤数据,差别主要在应用封装:
单客户端 agent_id 区分角色 | 每角色一个客户端 | |
|---|---|---|
| 靠什么隔离 | 调用参数里的 user_id + agent_id |
调用参数里的 user_id + agent_id |
| 代码心智 | 一个记忆引擎,按标签过滤 | 每个角色有自己的记忆入口 |
| 防呆 | 某次忘传或传错 agent_id,更容易串角色 |
客户端绑了默认角色,漏传时也会落到该角色 |
| 开销 | 更轻 | 多几个 SDK 对象,但底层仍是同一套 seekdb |
这个游戏选择第二种方式,是为了让角色边界在代码里更显式,也更贴合游戏叙事。三个角色和公告板各有入口,但真正切开记忆的仍是 user_id 和 agent_id 两个过滤条件。
从玩法看,耳语就是向某个角色写入私有消息。向线人耳语后,带锁的记忆卡只会出现在其私有区,其他角色和公告板既看不到,也检索不到。
2.2 角色审问,先定可见范围再决定怎么说
审问角色时,系统只搜索该角色的私有记忆和公告板中的公开记忆。这个可见性策略固定在应用层:
# 在私有记忆和公开记忆中进行搜索
ask(role, question) = search(case, role, question) + search(case, bulletin_board, question)
业务层先按上面的公式各搜一次,合并去重后生成 RetrievalTrace,再交给作答层:
private_cards = self.gateway.search_space(case_id, agent_id, question)
public_cards = self.gateway.search_space(case_id, AgentId.BULLETIN_BOARD, question)
cards = []
seen = set()
for card in [*private_cards, *public_cards]:
if card.id not in seen:
seen.add(card.id)
cards.append(card)
trace = RetrievalTrace(
request_id=request_id,
query=question,
searched_scopes=[agent_id, AgentId.BULLETIN_BOARD],
hit_cards=cards,
duration_ms=...,
mode=self.settings.demo_mode,
)
同一句保险箱密码是多少,策略不变,变的是可见记忆。
开场问侦探时,公告板为空,侦探私有区也没有密码卡。因此,hit_cards 为空,侦探会回答不知道。换成询问线人时,检索策略仍是“线人私有区 + 公告板”,但线人私有区有密码卡,命中后进入作答层。
这里还有一个工程细节:业务代码不直接调用 PowerMem SDK,而是统一经过记忆适配层 MemoryGateway。整条链路中,只有它能访问 PowerMem / seekdb。业务层决定本次搜索哪些空间,Gateway 再把每次底层检索限定在某个 case 和某个 agent 上。模型层只能看到筛选后的卡片。
def search_space(self, case_id, agent_id, query):
result = self._memory(agent_id).search(
query,
user_id=to_user_id(case_id),
agent_id=agent_id.value,
limit=20,
)
return [card_from_result(item, owner=agent_id) for item in _result_items(result)]
按角色缓存客户端只是让边界更显式,真正参与过滤的仍是 user_id(对应案件)和 agent_id(对应角色)。这里需要区分两个概念:
- PowerMem / seekdb 提供记忆存储、检索和过滤能力,但不会替应用自动判断“某个角色本次应该查哪些空间”。
- 当前 Demo 实现的是应用级逻辑隔离:API 只接受预先定义的目标角色,不能提交任意搜索空间;业务层只允许“当前角色私有区 + 公告板”,
MemoryGateway再统一补齐案件与角色过滤条件。即使问题里出现提示词注入,LLM 也拿不到其他角色的私有卡片。
因此,隔离不能只写在 Prompt 里,也不能散落在各个业务调用中,而要收口到唯一接触 PowerMem 的边界上。若用于真正的多租户生产系统,还应继续叠加独立凭证、数据库权限或租户级资源隔离;这些不属于当前小游戏已经证明的范围。
记忆搜索完成后,系统进入作答阶段。这里使用 DeepAgents 框架作为无工具的角色运行时,再调用 StepFun 的 step-3.7-flash 模型,把筛选后的证据组织成角色口吻。两者都不参与检索范围决策。
agent = create_deep_agent(
model=model,
tools=[],
subagents=[],
system_prompt=f"You speak only as the {role.value} role in the AI Intelligence Bureau demo.",
name=f"ai-intel-bureau-{role.value}",
)
这说明:记忆层决定能不能知道,模型层只决定怎么说。
2.3 公开到公告板:共享是复制,不是解开全局锁
当局长决定公开某条私有记忆时,系统会把它写入公告板空间,并记录 source_agent_id(来自哪个 Agent)和 source_memory_id(来自哪条记忆):
def write_public(self, case_id, source):
return self._write(
case_id,
AgentId.BULLETIN_BOARD,
f"【公开】{source.content}(来源:{source.owner_agent_id.value})",
{
"case_id": case_id,
"visibility": "public",
"topic": source.topic,
"kind": "public",
"source_agent_id": source.owner_agent_id.value,
"source_memory_id": source.id,
"created_by": "operator",
"is_demo_safe": True,
},
)
公开前还会做归属校验:卡片必须属于当前案件、当前来源角色,且可见性为 private。同一张源卡重复点击时,系统通过幂等处理避免生成重复副本。
因此,协作并非默认全员共享,而是在主动公开后才发生。
2.4 公开副本如何保持一致
“复制到公告板”解决了共享边界问题,但也带来一个新的工程问题:如何保证同一张私有卡不会被重复公开?
当前实现没有只依赖进程内锁,而是在本地状态库中建立发布记录,以 (case_id, source_memory_id) 作为唯一键。第一次公开先写入 pending 预留记录,再向公告板写副本,成功后补上 public_card_id 并把状态改为 ready。两个服务实例同时公开同一张源卡时,只有一个请求能拿到预留权;另一个请求会复用已经完成的结果,或在发布仍进行时提示稍后重试。
如果公告板已经写入成功,但进程在本地记录完成前中断,重试时会根据 source_memory_id 查找已有副本并完成对账。若后续步骤失败,系统会删除已写入的公共卡;删除也失败时,则登记清理任务。这是一个小型 Saga,用来缩小“远端已有副本、本地却不知道”的不一致窗口。
不过,这里的“一致”是发布操作幂等,不是私有原件与公开副本的双向实时同步。当前语义更接近一次快照发布:公开后,公告板副本独立存在,私有原件不会被解锁。若未来允许修改或撤回源记忆,还需要增加 source_version、内容哈希、撤回状态或重新发布规则,明确旧副本如何失效。
2.5 再次审问,公开之后知道了
私有记忆公开后,侦探便能检索到这条信息。检索策略仍是“侦探私有区 + 公告板”,变化只在于公告板新增了线人公开的信息。命中来自公共区,并非线人私有原件被打开。
所以模型负责怎么说,记忆层负责能不能知道。
| 层次 | 在游戏里负责什么 |
|---|---|
| seekdb | 向量、元数据与案件相关数据的持久化与检索底座 |
| PowerMem | 基于 user_id 与 agent_id 读写和检索记忆,并保存 metadata 合约 |
| DeepAgents | 在无工具约束下,把已批准证据组织成角色回答 |
| LLM | 提供角色措辞所用的 LLM,可降级、不参与边界决策 |
2.6 记忆透视,把证据放到主舞台上
还有一件事也很关键:如何证明每一句回答都来自真实记忆、有迹可循,而不是模型“演得像”?
这就是记忆透视的作用。每次审问都会产生检索范围、命中情况、耗时和当前模式,面板会同步展示这些信息。无论命中还是未命中,观众都能看到。
证据链成立,游戏的说服力才成立。
2.7 最小测试矩阵:不要只测“能搜到”
要证明隔离成立,不能只测试线人能否搜到自己的记忆,还要同时验证“该命中的命中、该拒绝的拒绝、公开后来源不变”。当前源码中的核心测试可以归纳为下面这组矩阵:
| 场景 | 操作 | 预期结果 | 验证边界 | PowerMem 的作用 | SeekDB 的作用 |
|---|---|---|---|---|---|
| 同案件、同角色 | 询问线人保险箱密码 | 命中线人私有卡 | 正向检索 | 携带当前 user_id、agent_id 发起记忆搜索 |
在对应案件和角色范围内完成向量检索 |
| 同案件、跨角色 | 询问侦探保险箱密码 | 公开前不命中 | 角色隔离 | 分别查询侦探私有区与公告板,不查询线人空间 | 只在传入的两个空间中返回匹配卡 |
| 跨案件公开 | 用案件 A 的卡在案件 B 发布 | 请求被拒绝 | 案件隔离 | 通过案件 B 的 user_id 查找源卡,无法取得案件 A 的卡 |
按 user_id 与 agent_id 限定读取范围 |
| 提示词注入 | 要求侦探忽略规则并读取线人秘密 | 搜索范围仍只有侦探与公告板 | 模型不能扩权 | 只接收应用预先确定的检索范围,LLM 无权改写参数 | 执行侦探与公告板两次受限检索,不理解也不执行越权指令 |
| 正常公开 | 公开线人私有卡后再次询问侦探 | 只命中公告板副本 | 显式共享 | 把带来源元数据的副本写入公告板空间 | 持久化公共副本,并在公告板检索时返回它 |
| 重复公开 | 连续两次公开同一源卡 | 公告板只有一个副本 | 单实例幂等 | 首次写入后复用已登记的公共卡 | 保存最终公共副本;幂等判断由应用状态库负责 |
| 并发公开 | 两个服务实例同时发布同一源卡 | 返回同一公共卡,仅一次为首次发布 | 跨实例幂等 | 只由取得发布预留权的请求执行公共写入 | 保存唯一公共副本;不承担跨实例抢锁 |
| 大屏访问 | 未公开前读取舞台快照与事件 | 不出现私有正文和源私有 ID | 输出面隔离 | 业务层只投影允许公开的记忆字段 | 作为存储底座,不直接向大屏暴露查询入口 |
这张表比单个成功用例更重要。它把存储过滤、应用授权、模型输入和对外展示四条边界分开验证,也说明了 PowerMem 与 SeekDB 的职责边界:PowerMem 负责组织记忆读写调用和上下文参数,SeekDB 负责在指定范围内存储与检索;至于角色能访问哪些空间,以及发布操作如何幂等,仍由应用层决定。这样可以避免把“模型这次没说出来”误当成“系统已经隔离”。
三、从源码启动游戏
原理说完了,接下来把游戏跑起来体验一下。小游戏的代码仓库[5]在: https://github.com/knqiufan/AIIntelBureau。
3.1 必要环境与配置
这是一个前后端分离项目,可以使用 Docker 启动,也可以在本地手动运行。本地启动需要 Python 3.11+、Node.js 20+、可用的 seekdb(远端 OceanBase 或本机嵌入式),以及 LLM 和嵌入模型服务。角色语言层可选;未配置 LLM 时,也能通过 DEMO_MODE=degrade 运行隔离主路径。
获取到源码后先复制配置入口:
cd docs/my/demo/ai_intel_bureau
copy .env.example .env
项目内置 PowerMem SDK,并通过它直连 seekdb。.env 里的主要配置如下:
| 配置项 | 作用 |
|---|---|
DEMO_MODE |
未配置 LLM 时设为 degrade,配置完成后设为 full,启用角色 LLM |
seekdb_MODE |
oceanbase 远端直连,embedded 本机落库 |
seekdb_HOST / PORT / USER / PASSWORD / DATABASE |
远端 seekdb 连接信息, USER 常带租户或集群后缀 |
seekdb_PATH |
嵌入式模式下的本地数据目录 |
EMBEDDING_API_KEY / MODEL / DIMENSIONS |
嵌入服务,模型名和维度按实际服务填写 |
EMBEDDING_BASE_URL |
OpenAI 兼容嵌入地址,例如硅基流动填 https://api.siliconflow.cn/v1 |
LLM_API_KEY / BASE_URL / MODEL |
LLM 模型配置,默认 StepFun step-3.7-flash |
DEMO_ACCESS_KEY |
公网展示时再设,给操作端和大屏加活动口令 |
详细配置见源码中的 .env.example 和 docs/runbook.md。
3.2 怎么启动
可以先预检配置和远端连通性。
cd docs/my/demo/ai_intel_bureau/backend
python -m app.preflight --strict
python -m app.preflight --check-remote
本地开发时,分别启动后端和前端。
# 终端 1
cd docs/my/demo/ai_intel_bureau/backend
python -m uvicorn app.main:app --reload --port 8000
# 终端 2
cd docs/my/demo/ai_intel_bureau/web
npm ci
npm run dev
打开 http://localhost:5173 即可进入操作端玩起来啦。
四、总结
AI 情报局这个小游戏要验证的其实就一件事,看清谁知道什么。
侦探不知道保险箱密码,不是模型在装傻,而是检索范围里本来就没有。线人能回答,是因为命中了私有记忆。公开之后侦探才知道,是因为公告板多了一张带来源的副本,并非私有原件被全局解锁。这组对照之所以成立,依赖三点:
-
默认各记各的。隔离写在
user_id与agent_id的检索过滤里,不是靠提示词约定。 -
共享必须显式发生。公开是复制到公告板,并保留来源,方便追溯。
-
证据要给观众看见。记忆透视把检索范围和命中摊开,知道与不知道都能核验。
多 Agent 记忆不能只靠 Prompt 约束模型行为。
只有把应用授权、存储过滤、显式共享和证据核验落实到清晰的工程边界中,协作才会既可控又可解释。
PowerMem: https://github.com/oceanbase/powermem
[2]seekdb: https://github.com/oceanbase/seekdb
[3]DeepAgents: https://github.com/langchain-ai/deepagents
[4]StepFun: https://platform.stepfun.com/
[5]小游戏的代码仓库: https://github.com/knqiufan/AIIntelBureau
往期内容推荐
了解更多
立即试用 OceanBase 企业版,体验国产数据库能力立即试用 OceanBase 企业版,体验国产数据库能力
浙公网安备 33010602011771号