我认为长期记忆真正麻烦的地方,不是把一句话存进数据库,而是下一次再出现相关信息时,应该怎么更新已有的记忆。
"用户喜欢看电影"和"用户现在不喜欢看电影了"在向量空间里可能高度相似,语义上却互斥;"用户叫张三"和"用户的名字是张三"同样相似,但完全等价。前者应该替换旧状态,后者应该去重。自然语言里的这种关系既写不成唯一索引,也不是字符串比较能解决的。
所以长期记忆不能只是追加,新事实进来以后,至少要先找到相关旧记忆,再判断它属于新增、修正、删除还是重复。
语义判断适合交给模型,但模型不能直接决定数据库怎么改。它可能引用不存在的记忆 ID,也可能输出互相冲突的操作。从读取候选到最终写回,中间还隔着模型调用和向量生成,整个过程可能持续数秒,已经不是一个靠数据库事务就能保护的短临界区。
我把这部分的职责拆得很明确:模型负责判断应该如何变化,程序负责校验并执行这些变化。
并不是所有上下文都值得进入长期记忆。比如,用户在当前对话里说"把它调高两度",这句话依赖前文,离开这次对话后通常就失去意义;而"用户喜欢喝咖啡"描述的是相对稳定的个人偏好,之后的多次对话都可能用到。前者需要保留的是会话连续性,后者需要维护的是跨会话事实。两者的生命周期、存储方式和失败代价都不同,因此系统分成工作窗口、聊天归档和长期记忆三部分。
三类数据的边界
| 数据 | 解决的问题 | 当前实现 |
|---|---|---|
| 短期记忆(工作窗口) | 保持当前对话连贯,理解"它""刚才那个"等上下文 | Redis 滑动消息窗口 |
| 聊天归档 | 保存完整原始对话,提供历史记录和事实来源 | PostgreSQL |
| 长期记忆 | 跨会话维护关于用户的独立事实 | PostgreSQL + pgvector |
工作窗口保存的是当前会话状态;聊天归档保存完整历史,适合追溯和后续处理;长期记忆只留下经过抽取和合并的事实,例如职业、偏好、习惯、经历和计划。
这也划清了长期记忆和 RAG 的边界:RAG 更多回答"外部资料里有什么",长期记忆维护的是"用户过去表达过什么"。两者都可能用向量检索,但数据的生命周期和一致性要求不同。
短期记忆:存在哪里和保留多少是两个问题
讨论对话窗口时,很容易把 Redis 和 Token 窗口放在一起比较,其实这是两个维度。
Redis 解决的是状态存在哪里 ;按消息数还是 Token 裁剪,解决的是窗口能保留多少内容。
这里选择 Redis,不是因为性能,而是因为会话状态需要跨请求、跨进程保存。
窗口裁剪目前使用 Spring AI 的 MessageWindowChatMemory,最多保留 20 条消息,按消息数淘汰而不是按 Token。
| 裁剪方式 | 优势 | 代价 |
|---|---|---|
| 固定消息数 | 保留完整消息,实现和排查简单,不依赖分词器 | 总 Token 不可预测,长消息可能撑爆上下文 |
| 固定 Token 数 | 能直接控制输入规模和成本 | 长消息可能迅速挤掉历史,不同模型分词口径也不同 |
| 时间窗口 | 生命周期清晰 | 无法限制活跃会话无限增长 |
| 摘要窗口 | 少量 Token 可以保留更长历史 | 会损失细节,还需要额外模型调用和更新策略 |
Token 上限不是万能的,如果单条回答本身已经很长,仍然需要截断、拒绝、分段或摘要,这是单靠淘汰旧历史解决不了的。
还需要把输入窗口和输出预算分开,工作窗口约束的是下一轮会带入多少历史,而当前回答可以生成多长的内容,取决于模型最大输出 Token 等生成参数。
我的项目目前以语音交互为主,单轮通常较短,所以我更希望保留最近若干轮完整交互,而不是让窗口随着分词估算变化,因此暂时选择固定消息数。
它并不是更优,只是更符合当前场景。文本 Agent 完全可能生成很长的回答,所以固定消息数无法提供上下文硬保护。更完整的做法应该是按完整消息或问答轮次裁剪,同时再限制总 Token、单条消息和模型输出。不过目前项目以语音交互为主,单轮通常较短,这类混合护栏对当前场景并不是必需的。
工作窗口其实可以拆成三个彼此独立的问题:Redis 是存储介质 ,maxMessages 是容量策略 ,TTL 是生命周期规则 。除此之外,还必须明确会话状态的隔离作用域:不同用户、不同通道和不同会话之间的上下文不能互相混用。
读和写是两条链路
整体链路可以简化成下面这样:
#mermaid-svg-OKhXXY4uiqN8CChs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OKhXXY4uiqN8CChs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OKhXXY4uiqN8CChs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OKhXXY4uiqN8CChs .error-icon{fill:#552222;}#mermaid-svg-OKhXXY4uiqN8CChs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OKhXXY4uiqN8CChs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OKhXXY4uiqN8CChs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OKhXXY4uiqN8CChs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OKhXXY4uiqN8CChs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OKhXXY4uiqN8CChs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OKhXXY4uiqN8CChs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OKhXXY4uiqN8CChs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OKhXXY4uiqN8CChs .marker.cross{stroke:#333333;}#mermaid-svg-OKhXXY4uiqN8CChs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OKhXXY4uiqN8CChs p{margin:0;}#mermaid-svg-OKhXXY4uiqN8CChs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OKhXXY4uiqN8CChs .cluster-label text{fill:#333;}#mermaid-svg-OKhXXY4uiqN8CChs .cluster-label span{color:#333;}#mermaid-svg-OKhXXY4uiqN8CChs .cluster-label span p{background-color:transparent;}#mermaid-svg-OKhXXY4uiqN8CChs .label text,#mermaid-svg-OKhXXY4uiqN8CChs span{fill:#333;color:#333;}#mermaid-svg-OKhXXY4uiqN8CChs .node rect,#mermaid-svg-OKhXXY4uiqN8CChs .node circle,#mermaid-svg-OKhXXY4uiqN8CChs .node ellipse,#mermaid-svg-OKhXXY4uiqN8CChs .node polygon,#mermaid-svg-OKhXXY4uiqN8CChs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OKhXXY4uiqN8CChs .rough-node .label text,#mermaid-svg-OKhXXY4uiqN8CChs .node .label text,#mermaid-svg-OKhXXY4uiqN8CChs .image-shape .label,#mermaid-svg-OKhXXY4uiqN8CChs .icon-shape .label{text-anchor:middle;}#mermaid-svg-OKhXXY4uiqN8CChs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OKhXXY4uiqN8CChs .rough-node .label,#mermaid-svg-OKhXXY4uiqN8CChs .node .label,#mermaid-svg-OKhXXY4uiqN8CChs .image-shape .label,#mermaid-svg-OKhXXY4uiqN8CChs .icon-shape .label{text-align:center;}#mermaid-svg-OKhXXY4uiqN8CChs .node.clickable{cursor:pointer;}#mermaid-svg-OKhXXY4uiqN8CChs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OKhXXY4uiqN8CChs .arrowheadPath{fill:#333333;}#mermaid-svg-OKhXXY4uiqN8CChs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OKhXXY4uiqN8CChs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OKhXXY4uiqN8CChs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OKhXXY4uiqN8CChs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OKhXXY4uiqN8CChs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OKhXXY4uiqN8CChs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OKhXXY4uiqN8CChs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OKhXXY4uiqN8CChs .cluster text{fill:#333;}#mermaid-svg-OKhXXY4uiqN8CChs .cluster span{color:#333;}#mermaid-svg-OKhXXY4uiqN8CChs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OKhXXY4uiqN8CChs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OKhXXY4uiqN8CChs rect.text{fill:none;stroke-width:0;}#mermaid-svg-OKhXXY4uiqN8CChs .icon-shape,#mermaid-svg-OKhXXY4uiqN8CChs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OKhXXY4uiqN8CChs .icon-shape p,#mermaid-svg-OKhXXY4uiqN8CChs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OKhXXY4uiqN8CChs .icon-shape .label rect,#mermaid-svg-OKhXXY4uiqN8CChs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OKhXXY4uiqN8CChs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OKhXXY4uiqN8CChs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OKhXXY4uiqN8CChs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 语音会话结束后读取
语音或文本对话
工作窗口 Redis
聊天归档 PostgreSQL
自动事实抽取
用户明确要求记住
统一变更决策
逐条事实向量检索候选
校验操作并准备向量
事务应用整批变更
用户记忆表 user_memories
常驻记忆注入
Recall 按需召回
本轮模型上下文
读取侧分成两种方式:常驻注入和按需召回。常驻注入用于提供每轮对话都可能需要的用户信息,避免完全依赖模型主动搜索;按需召回则用于查询当前对话中暂时用不到、但在特定问题下可能相关的历史记忆。
写入侧则尽量收敛成一条路径,不论事实来自会话结束后的自动提取,还是用户明确表示需要"记住"的需求,最后都经过候选检索、变更判决和落库。
这里参考了 Mem0 把"事实抽取"和"记忆更新"拆开的思路:先从对话得到独立事实,再结合已有记忆判断 ADD、UPDATE、DELETE 或 NOOP。这个拆分是我在设计时参考 Mem0 得到的启发,当然具体的数据结构、写入流程、并发控制和读取策略,需要结合项目的实际场景重新设计。
这个拆分很重要,因为"用户说了什么"和"数据库应该怎样变化"本来就是两个问题。让一次模型调用同时负责事实抽取、历史比较和冲突处理,职责会过重,也更难约束。
在这条主干上,我又根据实际遇到的问题做了几处调整:变更判断从逐条处理改成批量处理,避免相关事实彼此割裂;自动提取按顺序执行,减少并发写入带来的重复和冲突;同一场对话中的重复确认只计一次;记忆层级则由程序侧统一处理,不再交给模型决定。
长期记忆怎么写进去
先从对话中抽事实
原始对话不适合直接作为长期记忆,因为可能有寒暄、追问、助手回复,以及大量只对当前任务有用的信息。
比如:
用户:我以前挺喜欢看电影的,不过现在已经不喜欢了,最近更喜欢看动漫。
助手:明白了,之后给你推荐内容时,我会考虑现在的偏好。
最终需要的只是:
{
"facts": [
"用户现在不喜欢看电影",
"用户喜欢看动漫"
]
}
这一阶段只做事实抽取,不读取历史,也不决定记忆层级。保存范围也有限,只提取用户明确表达、未来仍可能有用的信息,不从助手的话里反推用户属性,也不把一次性请求长期保存。
再找真正相关的旧记忆
有了新事实以后,下一步是找语义上真正相关的历史。
假设"用户喜欢看电影"已经保存了一年,它可能正是"用户现在不喜欢看电影"需要更新的目标;如果按更新时间取候选,优先拿到的可能只是最近更新过的其他娱乐偏好。
每条新事实都会生成向量,分别执行语义检索,再按 ID 合并候选集。当前每条事实最多取 5 条,相似度低于 0.8 的不参与比较。
事实 A ──语义检索──┐
├── 去重后的候选记忆 ── 统一变更决策
事实 B ──语义检索──┘
新事实生成的向量既用于候选检索,也可以在最终 ADD 时直接写入,避免重复调用 Embedding。只有 UPDATE 后文本发生变化,才重新生成向量。
记忆写入只要求相关旧事实进入候选,而不是让向量检索直接决定最终结果。Top5 会整批交给模型判断哪一条和新事实说的是同一件事,因此最终决策阶段会结合事实内容、候选记忆和操作类型一起判断,真正危险的是本该更新的旧事实根本没有被召回。
我这里也确实踩过一次召回相关的坑,记忆表最初使用 ivfflat,而且索引建在空表上。IVFFlat 建索引时需要根据已有数据形成聚类中心,空表无法形成合理的聚类,再加上默认 probes=1 只扫描一个列表,召回会随着后续数据写入悄悄退化。后来通过一次数据库迁移重建索引,把它换成了 HNSW。
这个问题在记忆系统里后果很严重:旧事实没召回,后面的判决就会认为"没有相关记忆",最终执行 ADD,于是数据库里留下两条互相矛盾的状态。
所以在长期记忆里,召回质量其实属于写入正确性的一部分。
模型只负责判断,不直接改数据库
候选找出来以后,模型需要给出四种操作:
ADD:没有等价旧记忆,新增。UPDATE:同一件事,但新内容更准确或完整。DELETE:旧事实已经不成立。NOOP:已有内容已经表达准确,不需要变化。
例如:
已有记忆:
- [id=5] 用户喜欢看电影
新事实:
- 用户现在不喜欢看电影
- 用户喜欢看动漫
可以得到:
{
"operations": [
{"action": "DELETE", "id": 5},
{"action": "ADD", "content": "用户喜欢看动漫"}
]
}
这里必须能够表达删除。
但模型给出的只是变更意图。真正执行前,程序还会检查:
- UPDATE、DELETE、NOOP 的 ID 必须属于本轮候选。
- ADD 和 UPDATE 必须有非空内容。
- 未知操作或格式错误直接丢弃。
- 单条 operation 校验不通过不影响同批其他合法操作;整体 JSON 无法解析则整批放弃。
尤其是候选 ID。模型生成的 ID 不能直接信任,否则一次幻觉就可能修改与本次事实无关,甚至不属于当前用户的记录。
为什么改成整批判决
早期实现是先抽取一批事实,再逐条调用模型去重。假设提取出 N 条事实,一轮会产生 1 + N 次模型调用,而且每次只能看到一条新事实。
改成批量以后,一次提取固定是两次调用:一次抽取,一次判决。
减少调用次数当然有收益,但更重要的是,模型可以同时看到本批所有事实。
例如第一次写入时同时抽到:
用户叫张三
用户的名字是张三
数据库里还没有任何候选。如果按单条处理,两条都可能直接 ADD;批量判决则能够在第一次写入时就完成事实之间的消歧。
代价也存在:提示词更长,而且一次判决出错会影响整批。所以最后还是靠逐条 operation 校验控制影响范围。
目前只有"单条事实且没有候选"这种确定性场景可以直接 ADD。只要一批里有多条事实,就仍然保留批量判决。
主动记忆和自动提取必须走同一条写入路径
长期记忆目前有两个入口。
一个是自动提取,语音会话结束以后,从完整对话里抽事实;不足 4 条消息的短对话直接跳过。文本通道没有明确的结束事件,因此每轮 Agent 执行结束后都会从用户问题和最终回答中提取。
另一个是显式记忆,用户明确说"请记住我现在更喜欢看动漫"时,可以直接调用 remember,跳过事实抽取,但不能跳过冲突判断。
remember 最早其实承担的是主要写入职责,由模型自主判断什么时候应该记。真机测试下来,qwen-plus 的自主触发率只有约 17%,说明不能依赖通用模型稳定地主动写记忆。
所以后来把它降成了显式入口,日常对话中的信息交给自动抽取。
这里还出现过另一个问题:remember 曾经直接 INSERT,只做逐字查重。这样用户说"我现在不喜欢看电影了"时,"我喜欢看电影"不会被删掉,因为两句话字符串不同。
这实际上暴露了一个问题:冲突消解不能属于某一个接口,它必须成为所有长期记忆写入方共同遵守的协议。
所以现在不论信息来自自动提取还是显式 remember,最终都进入同一套候选召回和变更判决流程。以后如果再增加批量导入或第三方同步,也应该从同一个入口进入。
一批相关操作要一起成功
模型给出的多个操作往往不是彼此独立的。
"搬到深圳"可能对应:
DELETE 广州
ADD 深圳
如果 DELETE 已经成功,ADD 却因为 Embedding 服务失败没有完成,数据库反而失去了这部分记忆。
因此我会先在事务外准备所有需要的向量,因为 Embedding 是外部网络请求,如果放进数据库事务,连接和锁的占用时间会被不可控的网络延迟拉长。
完整过程是:
模型判决
→ 校验操作
→ 事务外准备向量
→ 开启事务
→ 应用 ADD / UPDATE / DELETE / NOOP
→ 提交
外部调用只要失败,这批变更就不执行。
两个正确流程,也可能一起写错
长期记忆写入还有一个数据库事务很难直接解决的问题:读取候选和最终提交之间隔得太久。
两个任务可能出现:
数据库 任务 B 任务 A 数据库 任务 B 任务 A #mermaid-svg-ptQmYCw9I8ePEtK2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ptQmYCw9I8ePEtK2 .error-icon{fill:#552222;}#mermaid-svg-ptQmYCw9I8ePEtK2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ptQmYCw9I8ePEtK2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ptQmYCw9I8ePEtK2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ptQmYCw9I8ePEtK2 .marker.cross{stroke:#333333;}#mermaid-svg-ptQmYCw9I8ePEtK2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ptQmYCw9I8ePEtK2 p{margin:0;}#mermaid-svg-ptQmYCw9I8ePEtK2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ptQmYCw9I8ePEtK2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ptQmYCw9I8ePEtK2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ptQmYCw9I8ePEtK2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ptQmYCw9I8ePEtK2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ptQmYCw9I8ePEtK2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ptQmYCw9I8ePEtK2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ptQmYCw9I8ePEtK2 .sequenceNumber{fill:white;}#mermaid-svg-ptQmYCw9I8ePEtK2 #sequencenumber{fill:#333;}#mermaid-svg-ptQmYCw9I8ePEtK2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ptQmYCw9I8ePEtK2 .messageText{fill:#333;stroke:none;}#mermaid-svg-ptQmYCw9I8ePEtK2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ptQmYCw9I8ePEtK2 .labelText,#mermaid-svg-ptQmYCw9I8ePEtK2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ptQmYCw9I8ePEtK2 .loopText,#mermaid-svg-ptQmYCw9I8ePEtK2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ptQmYCw9I8ePEtK2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ptQmYCw9I8ePEtK2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ptQmYCw9I8ePEtK2 .noteText,#mermaid-svg-ptQmYCw9I8ePEtK2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ptQmYCw9I8ePEtK2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ptQmYCw9I8ePEtK2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ptQmYCw9I8ePEtK2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ptQmYCw9I8ePEtK2 .actorPopupMenu{position:absolute;}#mermaid-svg-ptQmYCw9I8ePEtK2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ptQmYCw9I8ePEtK2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ptQmYCw9I8ePEtK2 .actor-man circle,#mermaid-svg-ptQmYCw9I8ePEtK2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ptQmYCw9I8ePEtK2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 读取候选 没有相关记忆 读取候选 没有相关记忆 判定 ADD 并写入 判定 ADD 并再次写入
两个任务单独看都完全正确,组合起来却写出了重复数据。
而自然语言语义等价又很难建立数据库唯一约束。
所以我最后把自动提取任务放进单线程执行器。保护"读取候选---模型判决---最终写入"这一整个区间,不让另一项自动提取从中间穿过去。
不过这还不是完整解,显式 remember 仍然同步执行,没有和后台提取进入同一个互斥区间,所以两者重叠时仍然存在竞争。
如果以后需要提高吞吐,可以按 userId 对所有写入统一排队。同一用户串行,不同用户并行;多实例部署则还需要进一步增加跨实例协调。
记忆层级不适合让模型一次决定
长期记忆也不是每条都应该每轮注入。
目前分成 PROFILE 和 EPISODIC 两层。它们是两个不同的读取策略:PROFILE 优先进入每轮上下文,EPISODIC 更多作为长期历史保留。
最初我让模型在抽取时顺便判断层级,后来把这件事拿掉了。
原因很简单:一次对话提供的证据太少,很难判断一条事实是不是长期稳定,而且两种误判的代价不对称。
把重要事实暂时放进 EPISODIC,以后还可以召回,也可以继续积累证据;把偶然信息误放进 PROFILE,却会长期占用每轮上下文和 Token 预算。
所以自动抽取的新记忆统一从 EPISODIC 开始。PROFILE 目前只有两个入口:
- 用户明确要求长期记住。
- 同一事实在不同对话中累计确认 3 次。
UPDATE 和 NOOP 都可以算作一次确认,但同一轮对话最多贡献一次,否则用户在同一场对话里把一句话重复三遍就能升级,这显然不符合"跨会话稳定存在"的要求。
确认次数、层级晋升和最近确认会话会在同一条 SQL 里完成,避免读后更新产生并发计数丢失。
常驻注入和按需召回不能只留一个
记忆写进去了,并不代表模型真的会用。
如果所有长期记忆都只能通过工具搜索,模型首先必须意识到"这里可能存在历史信息",然后主动发起 Recall,然而真实案例中很多普通对话根本不会触发这个判断。
所以每轮请求还会注入一部分常驻记忆,当前优先 PROFILE,同层级按更新时间排序,最多 50 条、2000 Token。这个过程只查数据库,不调用 Embedding。
常驻注入负责那些"模型即使没有主动搜索也应该知道"的内容;Recall 则负责长期历史,例如"我上次说准备去哪旅行"。
写入候选检索设置了 0.8 相似度阈值,而读取 Recall 不做固定阈值过滤,只把分数一起交给模型。
因为写入侧的候选会整批进入变更判决,无关候选太多不仅浪费 Token,还会增加判断干扰,所以需要一定过滤。
读取侧只是提供参考材料,多几条边缘结果通常由模型根据当前语境自己判断,因此没必要提前静默丢掉。
不过 0.8 目前仍然只是经验值,没有评测证明 0.75 或 0.85 会更差。而它过滤掉的恰好可能是代价最高的那类失败,本来应该更新,却因为没进入候选而被重新 ADD。所以这仍然是当前实现里需要补评测的一项。
语音和文本的会话生命周期不同
工作窗口还有一个容易被忽略的问题:什么叫"一次会话结束"。
语音比较简单,一次唤醒就是一段完整会话,进入监听状态时生成新的会话 ID,结束后清理窗口,再异步提取长期记忆。
文本没有可靠的结束事件,用户可能几分钟后回来,也可能第二天继续,所以服务端无法从一次 HTTP 响应判断这段对话已经结束。
因此文本窗口使用独立的 24 小时 TTL,并且每次写入都会刷新。它表达的是空闲多久算结束,而不是会话最多只能存在多久。
两个通道各自维护 ChatMemory 和 TTL,但隔离原则一致:最终作用域必须包含通道、用户 ID 和会话 ID。
框架的 ChatMemory 接口只接收一个字符串作为会话标识,本身并不知道这个会话属于哪个用户,所以需要把用户身份显式编码进存储键里。
失败语义和可观测性
记忆不是对话主链路,所以失败时不能让整个助手失效。
目前的处理大致是:
- 自动提取失败:放弃本批,不影响已经返回的对话。
- 常驻记忆读取失败:本轮不注入。
- Recall 失败:返回暂时不可用。
- 提示词资源启动时校验,避免路径错误长期静默。
- 异步线程继续传播 trace 上下文,方便定位模型调用。
普通日志不会直接记录记忆正文和查询词,因为长期记忆可能包含位置、健康、偏好等个人信息,如果正文跟着普通日志复制一份,日志系统本身就成了第二个用户数据存储。
普通日志只留下用户 ID、动作类型和内容长度;模型原始判决只有解析失败时才会进入 DEBUG。
总结
写到最后,我反而觉得向量数据库只是长期记忆里比较基础的一环。
真正决定系统是否可靠的,是几个更基础的问题:写入是否经过统一的冲突处理,旧事实能否被稳定召回,模型输出是否受到程序约束,相关变更能否原子完成,以及并发写入时状态是否一致。
有些是 trade-off。比如,把逐条判决改成批量判决,虽然提示词更长、单次失败影响更大,但减少了模型调用次数,也能在同一批事实之间直接完成去重和消歧。
但统一写入入口、限制模型只能操作候选 ID、整批原子提交,并不是优化,而是必要约束。缺少这些约束,系统可能长期看起来正常,却会在状态变化、模型幻觉或并发写入后,悄悄留下互相矛盾的事实。
所以,长期记忆本质上更像一个状态管理问题:对话提供新的观察,模型提出状态变化的意图,程序负责验证和执行,数据库保存当前仍然成立的事实及其来源。
做到这一步,系统才不只是保存历史文本,而是在持续维护关于用户的有效事实。
本文首发于个人博客:长期记忆系统的设计与实现