从一次全量上下文翻车说起
去年我们给一家跨境电商做客服 Agent,用户平均交互跨度超过三个月。团队最初方案很朴素:把全部历史对话塞进 prompt。GPT-4-turbo 的 128k 窗口看起来够用。上线两周后问题集中爆发------用户半年前提到的退货原因被当成当前诉求,系统反复追问已经确认过的订单号,而真正重要的"这个用户对某品牌过敏"这条三个月前的信息,被淹没在几十条物流查询记录里。全量上下文不是记忆,它只是没有筛选的档案堆。
LongMemEval 基准的数据印证了这一点:该基准的长对话上下文平均约 115000 tokens,全量上下文基线在 DMR 上大约 94.4%,而会话摘要基线只有约 78.6%。更关键的是,这类研究反复观察到同一个现象:当关键证据被埋在十万级 token 的历史里,模型的准确率会明显下滑,长上下文并不等于长记忆。窗口装得下,注意力装不住,这是两个问题。
三类记忆与四段流水线
记忆层的第一步不是写检索代码,而是想清楚要存什么。Tulving 在 1972 年正式区分了情景记忆与语义记忆:情景记忆指向个人经历的事件及其时空关系,语义记忆则是关于词语、概念和规则的组织化知识。CoALA 框架在这个二分法上补上了程序记忆------隐式的 LLM 权重知识和显式的代码、技能、工作流。落到工程上,这三类记忆的存储位置、更新频率和检索方式完全不同。
| 记忆类型 | 认知科学来源 | 工程载体 | 典型更新频率 | 检索触发条件 |
|---|---|---|---|---|
| 语义记忆 | Tulving 1972 | 结构化事实表、知识图谱节点 | 低频,事实级写入 | 实体或属性匹配 |
| 情景记忆 | Tulving 1972 | 事件日志、会话摘要、向量库 | 高频,每轮对话追加 | 时间窗口加语义相似 |
| 程序记忆 | CoALA 2024 | 版本化技能文件、prompt 模板、工具定义 | 极低频,人工或 CI 审批 | 意图识别后显式调用 |
四段式流水线把这三类记忆串起来。抽取阶段从原始会话中识别值得长期保存的信号,整合阶段去重、消解冲突、建立关联,存储阶段按记忆类型路由到不同后端,检索阶段根据当前任务组合向量相似度和图遍历。这个顺序不能颠倒------先有写入质量,才有检索质量。
| 阶段 | 核心动作 | 失败模式 | 可观测指标 |
|---|---|---|---|
| 抽取 | 实体识别、事实抽取、意图分类 | 噪声写入、漏抽关键事实 | 抽取精确率、写入量日环比 |
| 整合 | 去重、冲突消解、时效标记 | 旧事实未失效、矛盾并存 | 冲突检出率、合并延迟 |
| 存储 | 按类型路由、建立索引、版本化 | 存储膨胀、索引漂移 | 单用户记忆条数、存储成本 |
| 检索 | 混合召回、重排序、预算控制 | 召回不足、token 超预算 | 命中率、检索延迟 p95 |
向量与图的取舍不是二选一
向量库适合语义记忆的模糊匹配。用户说"上次那个蓝色的包",向量检索能召回"蓝色手提包"的实体记录。但向量库的致命缺陷是关系盲区------它不知道"用户 A 的过敏品牌"和"用户 A 三个月前投诉的批次号"之间存在因果链。一份 2026 年的上下文工程行业调查显示,54% 的受访者表示孤立的向量存储限制了 Agent 的实际效果。同一份调查还显示 98% 的团队认为上下文是 Agent 的核心瓶颈,但只有 2% 报告 Agent 能可靠跨系统导航。
图数据库补的是关系那部分。Zep 的 Graphiti 用时间感知知识图谱同时维护事件时间和摄入时间两条时间线,新事实覆盖旧事实时不删除历史,而是标记失效区间。这让"用户在上个季度用的是哪个支付方式"这类时间切片查询成为可能。
| 维度 | 向量检索 | 图检索 | 混合方案 |
|---|---|---|---|
| 匹配方式 | 语义相似度 | 关系路径遍历 | 向量召回后图扩展 |
| 时间语义 | 弱,依赖元数据过滤 | 原生双时间轴,边级有效期 | 向量给候选,图给时序校验 |
| 写入成本 | 低,embedding 加索引 | 高,需实体解析和边维护 | 中等,向量快写加图异步补全 |
| 适合记忆类型 | 语义记忆、情景摘要 | 语义记忆中的关系事实 | 语义加情景联合查询 |
| 代表实现 | 通用向量库 | Zep Graphiti、HippoRAG | Mem0g |
HippoRAG 的层次化检索走了另一条路:把开放知识图谱和 Personalized PageRank 结合起来,用图遍历做多跳召回,弥补向量检索在"需要串联多个事实"场景下的不足。MemGPT 则从操作系统借来分页隐喻,让 Agent 在有限主上下文和外部存储之间主动换入换出信息,而不是被动等待全量注入。这两种思路的共同点是:把检索从一次性的向量查询升级为有预算意识的多步过程。
写一条记忆到底要经过什么
抽取不是把整段对话存下来。NVIDIA 的 NemoClaw 记忆驱动 Agent 把证据和判断做了物理分离:知识层用 Markdown 自模型存人员、项目、优先级和工作模式,SQLite 账本存义务、排名、更正和审计事件。这个设计的关键在于,Markdown 页面写的是"这个协作者偏好 Slack"这样的衍生判断,而原始消息作为证据保留在会话记录里,永不覆盖。
下面的 Python 示例展示一个最小抽取写入与混合检索流程。依赖仅为标准库和一个假设的 embedding 函数,重点看控制流而不是具体实现。
ini
import hashlib
import time
class MemoryEntry:
def __init__(self, kind, key, value, source_msg_id, confidence=1.0):
self.kind = kind
self.key = key
self.value = value
self.source_msg_id = source_msg_id
self.confidence = confidence
self.created_at = time.time()
self.superseded_by = None
def fingerprint(self):
raw = f"{self.kind}:{self.key}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def extract_and_integrate(raw_message, existing_store):
candidates = extract_facts(raw_message)
written = []
for cand in candidates:
fp = cand.fingerprint()
old = existing_store.get(fp)
if old and old.value == cand.value:
old.confidence = min(1.0, old.confidence + 0.1)
written.append(old)
continue
if old and old.value != cand.value:
old.superseded_by = cand.fingerprint()
cand.confidence = 0.7
existing_store[fp] = cand
written.append(cand)
return written
def hybrid_retrieve(query, store, budget=4096):
vector_hits = embedding_search(query, list(store.values()))
graph_hits = graph_expand(query, store) if needs_relations(query) else []
merged = rerank(vector_hits + graph_hits, query)
selected, used = [], 0
for m in merged:
est = estimate_tokens(m.value)
if used + est > budget:
break
selected.append(m)
used += est
return selected
def extract_facts(raw_message):
return []
这个示例里有两个值得注意的控制点。第一,冲突消解发生在写入路径上,不是检索时的事后过滤。旧条目的 superseded_by 字段保留历史链路,新条目携带较低的初始置信度。第二,检索有 token 预算上限,超预算的候选被截断而不是全部塞入。Mem0 的工程数据显示,结构化记忆相对全量上下文有大约 90% 的 token 节省和 91% 的延迟下降,单用户可管理上万条记忆,更新延迟低于 100 毫秒。这个量级的收益不是靠更好的 embedding 模型拿到的,是靠写入路径上的筛选和检索路径上的预算纪律换来的。
账本不只是审计,是判断的锚点
NemoClaw 把 SQLite 账本单独拎出来,不是为了合规摆样子。当 Agent 说"这个任务应该优先处理",这个判断的依据可能来自三处:用户上周口头说了一句优先级、系统根据截止日期自动排名、或者 Agent 从历史模式中推断。如果这三条依据混在同一个向量库里,出错时你无法定位是证据错了、整合规则错了、还是模型在最终决策时偏离了。
SQL 账本的结构应该区分事实记录和决策记录:
sql
CREATE TABLE evidence (
id INTEGER PRIMARY KEY,
source_type TEXT NOT NULL,
source_ref TEXT NOT NULL,
raw_content TEXT NOT NULL,
ingested_at REAL NOT NULL
);
CREATE TABLE derived_judgment (
id INTEGER PRIMARY KEY,
evidence_ids TEXT NOT NULL,
judgment_type TEXT NOT NULL,
value TEXT NOT NULL,
valid_from REAL NOT NULL,
valid_until REAL,
confidence REAL DEFAULT 0.5,
corrected_by INTEGER REFERENCES evidence(id)
);
CREATE INDEX idx_judgment_valid ON derived_judgment(valid_from, valid_until);
CREATE INDEX idx_evidence_source ON evidence(source_type, source_ref);
derived_judgment 表的 evidence_ids 字段指向产生这条判断的证据行,corrected_by 指向用户纠正行为对应的证据。当 Agent 的输出出现偏差时,排查路径是确定的:先查判断行,顺着 evidence_ids 回溯源消息,确认是抽取错误还是模型在检索后的推理中偏离了证据。
NemoClaw 在 Agent Memory Benchmark 上的数据说明了这种分层的价值:整体准确率从 82.8% 提升到 90.9%,变化事实的跟踪从 60.0% 提升到 100.0%。100% 不是模型变聪明了,是时序事实的版本管理把"用户换工作了"和"用户上一份工作是什么"变成了两个独立可查的记录,而不是一个不断被覆盖的文本片段。
去重、冲突与遗忘的工程边界
去重不能只靠 embedding 相似度。两条记忆余弦相似度 0.95,但一条说"用户对花生过敏",另一条说"用户对花生不过敏",语义去重会把它们合并成灾难。工程上需要一条硬规则:negation guard------检测否定词后,相似度再高也不自动合并,而是进入人工审核队列。
冲突消解的策略取决于记忆类型。对于时序事实,Zep 的方案是双时间轴加边级有效期:新事实到来时不给旧事实打删除标记,而是写入 valid_until 时间戳,历史查询仍然可以通过时间点回溯。对于用户偏好,Amazon Bedrock AgentCore Memory 的做法是语义去重加冲突更新:同一偏好的不同表述被合并为一条记录,取值以最新写入为准,但保留变更历史。
遗忘有两个层面。存储层面的遗忘是删除或归档低频访问的记忆条目,防止存储膨胀。检索层面的遗忘是置信度衰减:一条语义记忆如果长时间未被检索命中,其默认置信度逐步下调,在排序中自然靠后。但这套机制必须保留手动豁免通道,否则"用户三个月前说的过敏信息"会因为没被频繁检索而沉底,这正好是记忆系统最该避免的失败模式。Mem0 的后台整合任务把重复记忆合并、过时事实标记为 superseded 而不删除,是当前工程上比较务实的折中。
最小可行落地顺序
不要从图数据库开始。从一张 SQLite 事实表加一个向量索引开始,把写入路径的抽取和冲突消解跑通,用一小批真实会话验证检索命中率。等事实表稳定了两到三周,再引入图结构处理关系查询。程序记忆单独放在 Git 仓库里版本化,不要塞进向量库。
检查清单:每条记忆有指向原始消息的 source_id;冲突消解在写入路径完成而非检索时事后过滤;检索有 token 预算上限;否定检测阻止语义去重误合并;时序事实有 valid_from 和 valid_until;用户纠正触发 evidence 记录而非直接改写判断值;每周有一个后台任务扫描未命中检索的记忆条目的置信度衰减。