医院 Agent 的长期记忆工程:从“记住用户说过什么”到 3 年可检索、可遗忘、可验证
医院 Agent 的长期记忆工程:从“记住用户说过什么”到 3 年可检索、可遗忘、可验证
本文配套开源项目
openclaw-deepagents。它不是一个“把聊天记录塞进向量库”的玩具方案,而是一套面向医疗场景的长期记忆工程:显式记忆写入、时效失效、混合召回、精排、上下文预算、分层压缩、遗忘机制和验证闭环。
1. 为什么“向量检索 + 聊天记录”不够
很多 Agent 的长期记忆实现是:
聊天记录 → 切块 → embedding → 向量库
用户提问 → 向量检索 topK → 塞进 prompt
这套方案能跑 demo,但放到医院场景会迅速暴露问题:
- 三年前的“喜欢甜咖啡”和昨天的“戒糖了”会被同时召回。
- “对花生过敏”和“想看某部电影”在向量空间里可能分数接近。
- “我对什么过敏”需要精确命中“花生”,纯向量可能被“坚果”“食物不耐受”带偏。
- 用户明确说“把这件事写进记忆”,系统却因为抽取失败没有写。
- 一次号源查询返回几千行,工具结果直接把上下文顶爆。
- 记忆越存越多,最后变成一个无法维护的垃圾场。
所以长期记忆不是一个“检索接口”,而是一套完整的生命周期:
写入 → 抽取 → 时效管理 → 存储 → 召回 → 精排 → 注入 → 压缩 → 遗忘 → 验证
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"
}
这样带来三个能力:
- 当前问题默认只取
active,不会把“喜欢甜咖啡”当成现在的偏好。 - 时间点问题可以按
asOf查询:“2024 年时用户喜欢什么?” - 审计和纠错保留完整历史,不会因为更新而丢失证据。
冲突处理规则:
- 新事实明确更新旧事实 → 旧记录
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。所以:
- 先裁剪再注入:单次工具结果超过预算,完整结果落盘/入库,上下文只放摘要 + 文件路径。
- 保护精确值:裁剪时保留订单号、过敏信息、剂量、阈值、日期、异常指标。
- 按轮预算:同一轮多个工具调用共享 38% 预算,不能每个都塞满。
- 工具结果摘要化:列表类保留
slotDigest+ 命中项;报告类保留结论、异常项、关键数值。
6. 分层压缩:热、温、冷
上下文用到 60%~70% 就开始压缩,不要等 100%。
热层
最近几轮原文保留:
- 用户当前诉求
- 最近的工具调用和结果
- 当前待确认操作
温层
较早的历史做滚动摘要,必须保留四类信息:
- 决策:用户确认了什么、拒绝了什么、改约了什么。
- 调用:调用过哪些工具、目的是什么。
- 结果:工具返回的关键结论、精确数值、日期、ID。
- 当前状态:未完成事项、下一步、待确认卡。
温层不是“把对话变短”,而是“把任务状态保住”。
冷层
更早的历史压成一条高层记忆:
- 主题
- 长期事实
- 关键事件
- 未完成事项
原始消息不删除,落到 sandbox/archive/ 或数据库归档,按需读取。上下文里只保留一个引用地址(例如 /sandbox/archive/xxx.jsonl、数据库记录定位或 archiveRef),不要把原始内容和多个地址一起塞回 prompt。
保护标记
压缩是有损的,丢掉的往往不是废话,而是:
- 订单号/预约号/报告号
- 过敏信息
- 药物剂量
- 检验阈值
- 日期和时间
- 用户明确说“不要忘”的约束
所以压缩前先抽取保护事实,作为不可摘要的 【受保护事实】 块附加在摘要后面。
7. 遗忘:记忆系统必须学会“忘”
记忆越多,检索越差,成本越高。遗忘机制至少包含三类:
7.1 访问衰减
长期没有被召回的记录,权重自然下降:
accessScore = log(1 + accessCount) / log(1 + 20)
再结合 lastAccessedAt 做时间衰减。
7.2 冲突标记
被新事实推翻的记录:
status = supersededvalidTo = 新事实生效时间supersededBy = 新记忆 ID
冲突比例是线上必须监控的指标。冲突过多,说明抽取或更新策略有问题。
7.3 容量淘汰
到达容量上限时,按三条一起清理:
evictScore = 0.4 * (1 - importance)
+ 0.3 * (1 - accessScore)
+ 0.3 * ageScore
优先淘汰:
- 低重要性
- 低访问频次
- 高年龄
保护记忆(过敏、用药、禁忌、精确约束)永不淘汰。
淘汰不是物理删除,而是 archived,保留审计和恢复能力。
8. 验证闭环:记忆到底有没有用
记忆系统最怕“写了很多,但回答还是错”。需要做闭环验证。
8.1 离线评测
业界有 LongMemEval 数据集,核心考察:
- 信息抽取
- 跨会话推理
- 时间推理
- 知识更新
- 该拒答时拒答
自建回归可以模拟:
- 今天告诉 Agent “我对花生过敏”
- 明天换一种问法:“我可以吃花生酱吗?”
- 检查它是否记得、是否答对、是否在信息不足时拒答
- 再说“其实我不过敏了”,检查旧记忆是否失效、新记忆是否生效
8.2 线上指标
至少盯三个:
- 记忆命中率:需要记忆的问题里,有多少召回到了正确记忆。
- 注入后答非所问率:注入记忆后回答反而偏离问题的比例。
- 冲突记忆占比:
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 才能从“记得刚才说过什么”,变成“三年后仍然知道该记住什么、该忽略什么、什么时候该说不知道”。
更多推荐

所有评论(0)