医院 Agent 的长期记忆工程:从“记住用户说过什么”到 3 年可检索、可遗忘、可验证

本文配套开源项目 openclaw-deepagents。它不是一个“把聊天记录塞进向量库”的玩具方案,而是一套面向医疗场景的长期记忆工程:显式记忆写入、时效失效、混合召回、精排、上下文预算、分层压缩、遗忘机制和验证闭环。

1. 为什么“向量检索 + 聊天记录”不够

很多 Agent 的长期记忆实现是:

聊天记录 → 切块 → embedding → 向量库
用户提问 → 向量检索 topK → 塞进 prompt

这套方案能跑 demo,但放到医院场景会迅速暴露问题:

  1. 三年前的“喜欢甜咖啡”和昨天的“戒糖了”会被同时召回。
  2. “对花生过敏”和“想看某部电影”在向量空间里可能分数接近。
  3. “我对什么过敏”需要精确命中“花生”,纯向量可能被“坚果”“食物不耐受”带偏。
  4. 用户明确说“把这件事写进记忆”,系统却因为抽取失败没有写。
  5. 一次号源查询返回几千行,工具结果直接把上下文顶爆。
  6. 记忆越存越多,最后变成一个无法维护的垃圾场。

所以长期记忆不是一个“检索接口”,而是一套完整的生命周期:

写入 → 抽取 → 时效管理 → 存储 → 召回 → 精排 → 注入 → 压缩 → 遗忘 → 验证

2. 最高优先级:用户显式要求写入,必须写进去

2.1 不用死规则判断意图

“记住”“记一下”“写入记忆”可以有很多说法:

  • “把我不吃香菜这件事记下来”
  • “以后别给我推荐含花生的东西”
  • “帮我记住,我对青霉素过敏”
  • “这个要长期保存”

如果只靠正则匹配“记住”两个字,会漏掉大量语义等价表达,也会误判“我记住了”这种普通回复。

因此,记忆意图判断应该交给模型:

const intent = await classifyMemoryIntent(model, userText);
// { explicit: true, content: "不吃香菜", kind: "preference", importance: 1.0 }

模型只做一件事:判断用户是否在明确要求长期保存某条信息,并抽取要保存的内容。提示词里明确要求:

  • 不要根据单个关键词判断;
  • 要理解“以后”“长期”“别忘了”“写进记忆”等语义;
  • 普通闲聊、一次性问题、对助手的回应,不算显式写入。

2.2 显式写入绕过普通抽取校验

普通事实抽取需要 quote 校验,防止模型幻觉。但用户显式命令是最高优先级:

await memory.rememberExplicit({
  ownerKey,
  text: intent.content,
  kind: intent.kind,
  importance: 1.0,
  source: { conversationId, turnId, at: now },
  forced: true, // 不要求 quote 在原文中出现
});

同时提供 memory_write 工具,让 Agent 在对话中直接调用:

用户:把我不吃香菜写进记忆
Agent:调用 memory_write({ text: "不吃香菜", kind: "preference" })

再加一层“回合结束后”的意图检查作为兜底。这样即使用户说得比较绕,或者模型在对话中忘了调用工具,记忆仍然会被写入。


3. 时效性:旧记忆不是删除,而是“失效”

用户三年前说“喜欢甜咖啡”,半年前说“戒糖了”。

正确做法不是删掉“喜欢甜咖啡”,而是:

{
  "text": "喜欢甜咖啡",
  "validFrom": "2023-05-01T00:00:00Z",
  "validTo": "2026-05-01T00:00:00Z",
  "status": "superseded",
  "supersededBy": "mem_xxx"
}

新记忆:

{
  "text": "戒糖了",
  "validFrom": "2026-05-01T00:00:00Z",
  "status": "active"
}

这样带来三个能力:

  1. 当前问题默认只取 active,不会把“喜欢甜咖啡”当成现在的偏好。
  2. 时间点问题可以按 asOf 查询:“2024 年时用户喜欢什么?”
  3. 审计和纠错保留完整历史,不会因为更新而丢失证据。

冲突处理规则:

  • 新事实明确更新旧事实 → 旧记录 validTo = 新事实生效时间,status = superseded。
  • 新事实与旧事实冲突但不确定谁对 → 新记录 conflicted,旧记录保留,进入人工确认。
  • 用户显式纠正 → 旧记录立即 superseded,新记录 active。

4. 召回:相关性、时效性、重要性,一个都不能少

4.1 先做元数据硬过滤

在算任何分数之前,先过滤:

  • ownerKey:用户/患者/医院隔离,绝不能跨患者召回。
  • kind:过敏、用药、偏好、事件、约束等。
  • status:默认 active;时间旅行查询才带 superseded。
  • 时间范围:validFrom/validTo 与查询区间求交。
  • asOf:查询某个历史时点的有效记忆。

硬过滤的价值是:先把“不相关的整批数据”干掉,后面再谈语义。

4.2 混合召回:关键词 + 向量 + 图

纯向量的问题:

  • 人名、药名、型号、订单号、检查编号容易被“语义化”掉;
  • “花生”可能召回“坚果”“食物过敏”,但不一定精确命中“花生”;
  • 否定句和更新句容易被混在一起。

所以采用三路召回:

关键词 BM25 ─┐
向量余弦   ─┼─→ RRF 融合 → 粗召回 top50
图谱扩展   ─┘

关键词侧:

  • 中文按字 bigram + 英文/数字 token;
  • 人名、药名、型号、编号保留原样 token;
  • BM25 打分,保证精确命中。

向量侧:

  • 使用 BGE-M3 / 同等级中文 embedding;
  • 语义相似度负责“说法不同但意思相近”的召回。

图谱侧:

  • 用实体(疾病、药品、过敏原、检查项目)建立记忆之间的边;
  • 对粗召回种子做 1 跳扩展;
  • supersedes 边用于追踪“新事实替换了哪条旧事实”。

融合使用 RRF(Reciprocal Rank Fusion):

score = Σ 1 / (60 + rank_i)

RRF 不依赖不同路的分数尺度,适合关键词、向量、图三路合并。

4.3 粗召回 + 精排

粗召回几十条,再精排 topK。精排分数:

final = 0.55 * relevance
      + 0.20 * recency
      + 0.20 * importance
      + 0.05 * access
      + protectedBoost

医疗场景的权重解释:

  • 相关性 55%:答非所问是第一位的问题。
  • 重要性 20%:过敏、用药、禁忌、阈值必须压过“想看某电影”。
  • 时效性 20%:昨天的事实和三年前的事实不能等权。
  • 访问频率 5%:长期不被召回的记忆自然降权,服务于遗忘。

时效性用指数衰减:

recency = 0.5 ^ (ageDays / halfLifeDays)

不同记忆类型给不同半衰期:

类型半衰期
过敏/禁忌/长期用药3650 天
疾病史/重要事件1095 天
偏好365 天
普通情节摘要180 天

保护标记:

  • 过敏、用药、订单号、检查号、阈值、剂量、日期等命中保护规则;
  • 保护记忆在精排中加 protectedBoost;
  • 注入时优先保留,容量淘汰时永不淘汰。

4.4 注入预算

召回结果不能无限塞。按 token 预算选择:

memoryBudget = usableContext * 16%

先放保护/高重要性记忆,再按精排分数补足到预算。每条记忆带上:

  • 类型
  • 生效时间/失效时间
  • 重要性
  • 来源会话/轮次
  • 是否已失效

让模型知道“这条记忆是什么时候的”,而不是把三年前的事实当现在。


5. 上下文预算:先分钱,再花钱

不要把“上下文管理”理解成“超了再摘要”。正确做法是先划预算:

系统提示词   16%
对话历史     30%
工具返回     38%
召回注入     16%

工具返回是最容易失控的部分。一次号源查询可能几千行,一次报告查询可能几万 token。所以:

  1. 先裁剪再注入:单次工具结果超过预算,完整结果落盘/入库,上下文只放摘要 + 文件路径。
  2. 保护精确值:裁剪时保留订单号、过敏信息、剂量、阈值、日期、异常指标。
  3. 按轮预算:同一轮多个工具调用共享 38% 预算,不能每个都塞满。
  4. 工具结果摘要化:列表类保留 slotDigest + 命中项;报告类保留结论、异常项、关键数值。

6. 分层压缩:热、温、冷

上下文用到 60%~70% 就开始压缩,不要等 100%。

热层

最近几轮原文保留:

  • 用户当前诉求
  • 最近的工具调用和结果
  • 当前待确认操作

温层

较早的历史做滚动摘要,必须保留四类信息:

  1. 决策:用户确认了什么、拒绝了什么、改约了什么。
  2. 调用:调用过哪些工具、目的是什么。
  3. 结果:工具返回的关键结论、精确数值、日期、ID。
  4. 当前状态:未完成事项、下一步、待确认卡。

温层不是“把对话变短”,而是“把任务状态保住”。

冷层

更早的历史压成一条高层记忆:

  • 主题
  • 长期事实
  • 关键事件
  • 未完成事项

原始消息不删除,落到 sandbox/archive/ 或数据库归档,按需读取。上下文里只保留一个引用地址(例如 /sandbox/archive/xxx.jsonl、数据库记录定位或 archiveRef),不要把原始内容和多个地址一起塞回 prompt。

保护标记

压缩是有损的,丢掉的往往不是废话,而是:

  • 订单号/预约号/报告号
  • 过敏信息
  • 药物剂量
  • 检验阈值
  • 日期和时间
  • 用户明确说“不要忘”的约束

所以压缩前先抽取保护事实,作为不可摘要的 【受保护事实】 块附加在摘要后面。


7. 遗忘:记忆系统必须学会“忘”

记忆越多,检索越差,成本越高。遗忘机制至少包含三类:

7.1 访问衰减

长期没有被召回的记录,权重自然下降:

accessScore = log(1 + accessCount) / log(1 + 20)

再结合 lastAccessedAt 做时间衰减。

7.2 冲突标记

被新事实推翻的记录:

  • status = superseded
  • validTo = 新事实生效时间
  • supersededBy = 新记忆 ID

冲突比例是线上必须监控的指标。冲突过多,说明抽取或更新策略有问题。

7.3 容量淘汰

到达容量上限时,按三条一起清理:

evictScore = 0.4 * (1 - importance)
           + 0.3 * (1 - accessScore)
           + 0.3 * ageScore

优先淘汰:

  • 低重要性
  • 低访问频次
  • 高年龄

保护记忆(过敏、用药、禁忌、精确约束)永不淘汰。

淘汰不是物理删除,而是 archived,保留审计和恢复能力。


8. 验证闭环:记忆到底有没有用

记忆系统最怕“写了很多,但回答还是错”。需要做闭环验证。

8.1 离线评测

业界有 LongMemEval 数据集,核心考察:

  1. 信息抽取
  2. 跨会话推理
  3. 时间推理
  4. 知识更新
  5. 该拒答时拒答

自建回归可以模拟:

  • 今天告诉 Agent “我对花生过敏”
  • 明天换一种问法:“我可以吃花生酱吗?”
  • 检查它是否记得、是否答对、是否在信息不足时拒答
  • 再说“其实我不过敏了”,检查旧记忆是否失效、新记忆是否生效

8.2 线上指标

至少盯三个:

  1. 记忆命中率:需要记忆的问题里,有多少召回到了正确记忆。
  2. 注入后答非所问率:注入记忆后回答反而偏离问题的比例。
  3. 冲突记忆占比:conflicted + superseded 占全部记忆的比例。

再加两个工程指标:

  • 注入 token 占比
  • 记忆写入失败率

9. 工程实现

配套代码结构:

src/memory/longterm/
  types.ts        记忆数据模型
  store.ts        文件持久化(生产可替换 Postgres + pgvector)
  embedding.ts    BGE-M3 / 本地哈希兜底
  tokenize.ts     中文 bigram、BM25、保护事实抽取
  retrieval.ts    硬过滤 + 混合召回 + RRF + 精排
  writer.ts       LLM 意图判断、事实抽取、时效更新
  compression.ts  上下文预算、热温冷分层压缩、保护块
  forgetting.ts   访问衰减、冲突、容量淘汰
  service.ts      对外门面
  check.ts        回归验证

9.1 记忆记录

interface MemoryRecord {
  id: string;
  ownerKey: string;
  kind: "allergy" | "medication" | "preference" | "event" | "constraint" | "fact";
  text: string;
  quote?: string;
  validFrom: string;
  validTo?: string;
  status: "active" | "superseded" | "conflicted" | "archived";
  supersededBy?: string;
  importance: number;
  confidence: number;
  protected: boolean;
  accessCount: number;
  lastAccessedAt?: string;
  embedding?: number[];
  source: { conversationId?: string; turnId?: string; at: string };
}

9.2 召回接口

const memories = await memory.retrieve({
  ownerKey,
  query: "我对什么过敏",
  topK: 8,
  kinds: ["allergy", "medication", "constraint"],
  asOf: new Date().toISOString(),
});

9.3 显式写入

await memory.rememberExplicit({
  ownerKey,
  text: "不吃香菜",
  kind: "preference",
  importance: 1.0,
  forced: true,
});

9.4 分层压缩

const { messages, layers } = await compressLayers({
  messages,
  model,
  budget: getContextBudget(),
});
// layers: { hot: 12, warm: 1, cold: 1, protectedFacts: [...] }

10. 结论

长期记忆不是“向量库 + topK”,而是一套完整的工程系统:

  • 显式写入必须最高优先级,靠语义判断而不是死规则;
  • 旧事实靠 validTo 失效,而不是删除;
  • 召回要同时考虑相关性、时效性、重要性;
  • 工程上先硬过滤,再关键词 + 向量 + 图混合召回,最后精排;
  • 上下文先划预算,工具结果先裁剪,历史分层压缩;
  • 关键约束和精确值要保护,不能被摘要吃掉;
  • 记忆要会遗忘,冲突要标记,容量要淘汰;
  • 最后用 LongMemEval 类场景和线上三项指标做验证闭环。

做到这些,Agent 才能从“记得刚才说过什么”,变成“三年后仍然知道该记住什么、该忽略什么、什么时候该说不知道”。

Logo

更多推荐