Agent 工程体系 · 第 28/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 记忆存储:关系库、向量库、事件日志、版本和一致性
Agent 的“记忆”不是一个单独的数据结构,而是一组具有不同生命周期、访问模式和可靠性要求的状态。
一次对话中的消息、工具调用和中间结果,需要支持按顺序恢复;用户的长期偏好,需要支持按主体查询和更新;历史事实和操作过程,需要能够审计、重放和解释;用于语义召回的向量,则需要支持近似相似度搜索。把这些数据全部塞进一个关系表,或者全部写进向量库,都会在规模、正确性或可删除性上遇到问题。
因此,Agent 记忆存储的核心问题不是“选择哪一种数据库”,而是:
哪一种数据是事实来源,哪一种数据只是索引或投影;一次更新如何产生多个派生结果;并发写入时谁胜出;失败恢复后如何避免重复或丢失;删除请求如何穿透快照、向量、缓存和备份。
一、先区分四类“记忆”
1. 对话上下文不是长期记忆
对话上下文是生成当前回复所需的输入状态。它通常包含:
- 用户消息;
- Assistant 消息;
- 工具调用;
- 工具返回值;
- 系统指令;
- 当前任务的中间状态;
- 尚未完成的人机协作步骤。
它的主要约束是顺序性和可恢复性。例如,Agent 调用工具查询订单后,进程崩溃并重启,系统必须知道工具调用是否已经发出、工具结果是否已经写入,以及下一步应该继续执行还是重新调用。
OpenAI 的 Responses API 可以通过 previous_response_id 串联响应,也可以使用具有持久标识符的 Conversations API 保存消息、工具调用和工具输出。需要注意的是,这类 API 的“状态持久化”解决的是模型交互状态,不等同于业务系统的长期用户画像或事实库。(developers.openai.com)
2. 长期记忆是应用事实
长期记忆是跨对话、跨任务仍然有效的信息,例如:
用户明确偏好:所有代码示例优先使用 Python
用户事实:所在城市为杭州
用户约束:不接受工作日上午的会议
组织事实:订单 123 的退款状态为 approved
长期记忆不能简单等同于“历史聊天记录”。聊天记录是证据,长期记忆是经过提取、确认、更新和授权后的应用状态。
例如:
用户:我最近搬到上海了。
这条消息可以作为一条新证据,但是否立即把 city = 上海 写成长期事实,取决于应用策略:
- 如果用户明确表达了永久变化,可以直接更新;
- 如果语境可能是临时出差,应降低置信度;
- 如果已有
city = 杭州,需要保留旧值的版本或事件; - 如果该字段影响配送、计费或合规流程,可能需要再次确认。
3. 事件日志记录“发生过什么”
事件日志是追加写入的事实记录。它描述的是已经发生的状态变化,而不是当前状态本身。
例如:
MemoryCreated(user-42, preference.language = zh-CN)
MemoryCorrected(user-42, preference.language: zh-CN -> en-US)
MemoryDeleted(user-42, preference.language)
事件日志的优势是:
- 写入模型简单,通常只追加;
- 可以审计谁在何时修改了什么;
- 可以从头重建当前状态;
- 可以重建关系库投影和向量索引;
- 可以定位错误记忆的来源。
事件日志的代价是:读取当前状态需要聚合事件,事件模型必须处理乱序、重复、撤销和版本迁移。
4. 向量是检索索引,不应默认是事实来源
向量是文本、图像或其他内容经过嵌入模型转换后的数值表示,用来进行语义相似度检索。
假设两条记忆分别是:
用户不喜欢太甜的饮料。
用户最近在控制糖分摄入。
它们可能在向量空间中相似,即使数据库中的结构化字段并不相同。
向量检索适合回答:
哪些历史信息可能与“帮我推荐低糖饮品”相关?
但不适合直接回答:
用户当前的糖分限制是多少?
后一个问题需要结构化事实、来源、时间有效性和权限判断。向量命中只能提供候选证据,不能自动成为最终事实。
二、四种存储的职责边界
可以把一次记忆的生命周期表示为:
flowchart LR
A[用户消息或工具结果] --> B[证据记录]
B --> C[记忆提取与策略判断]
C --> D[事件日志]
D --> E[关系库当前投影]
D --> F[向量索引投影]
E --> G[结构化查询]
F --> H[语义召回]
G --> I[上下文组装]
H --> I
I --> J[模型调用]
关键路径如下:
- 原始消息或工具结果先成为可追溯的证据;
- 记忆策略决定是否写入长期记忆;
- 写入事件日志作为事实来源;
- 关系库根据事件生成当前状态;
- 向量库根据记忆内容生成检索投影;
- 生成回复前,同时进行结构化查询和语义召回;
- 只有经过权限、时间和冲突处理后的结果,才能进入模型上下文。
关系库:保存当前可查询状态
关系库适合保存:
- 用户、组织、会话和任务的归属关系;
- 记忆的当前值;
- 版本号和有效时间;
- 同意状态和保留期限;
- 删除状态;
- 事件与投影之间的处理进度;
- 幂等键和唯一约束。
一个简化的数据模型如下:
CREATE TABLE memories (
memory_id UUID PRIMARY KEY,
subject_id TEXT NOT NULL,
namespace TEXT NOT NULL,
memory_key TEXT NOT NULL,
value_json JSONB NOT NULL,
version BIGINT NOT NULL,
source_event_id UUID NOT NULL,
confidence NUMERIC(5, 4),
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
consent_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(subject_id, namespace, memory_key)
);
CREATE INDEX memories_subject_idx
ON memories(subject_id, namespace)
WHERE deleted_at IS NULL;
这里的唯一键表示:对同一个主体、命名空间和逻辑键,当前最多存在一条活动记录。
例如:
subject_id = user-42
namespace = profile
memory_key = language
value_json = {"value": "zh-CN"}
如果同一用户在另一个任务中有临时偏好,不应覆盖这个全局值,而应使用不同命名空间:
namespace = thread:abc123
memory_key = output_format
关系库的优势是事务、约束、条件更新和精确过滤。它可以表达:
SELECT value_json
FROM memories
WHERE subject_id = 'user-42'
AND namespace = 'profile'
AND memory_key IN ('language', 'city')
AND deleted_at IS NULL
AND valid_from <= now()
AND (valid_to IS NULL OR valid_to > now());
这个查询能够保证只读取当前有效且未删除的记忆。向量相似度本身不能提供这样的保证。
向量库:保存可召回的派生索引
向量索引通常至少需要以下字段:
embedding_id
memory_id
subject_id
namespace
embedding_model
embedding_dimension
content
content_hash
memory_version
deleted_at
其中 memory_id 和 memory_version 很重要。它们把向量命中关联回关系库中的事实版本。
检索流程不应是:
向量命中 -> 直接拼接到提示词
更安全的流程是:
向量命中
-> 检查 subject_id 和 namespace
-> 检查 deleted_at
-> 检查 memory_version
-> 回查关系库当前版本
-> 处理有效期和冲突
-> 形成上下文
例如,向量库中仍然命中了旧内容:
memory_id = m-1
memory_version = 2
content = 用户居住在杭州
而关系库当前已经是:
memory_id = m-1
version = 3
value = 用户居住在上海
此时旧向量只能作为历史证据,不能作为当前偏好注入上下文。
向量索引还有一个常见边界:删除和更新通常不是瞬时、全局同步完成的。关系库可以在事务内把记录标记为删除,但向量索引、缓存、备份或异步投影可能仍然存在旧副本。因此,读取侧必须把关系库的删除状态作为最终过滤条件。
事件日志:保存不可变的变化历史
事件表可以设计为:
CREATE TABLE memory_events (
event_id UUID PRIMARY KEY,
subject_id TEXT NOT NULL,
namespace TEXT NOT NULL,
memory_key TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json JSONB NOT NULL,
aggregate_version BIGINT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_type TEXT NOT NULL,
actor_id TEXT,
consent_id TEXT,
UNIQUE(subject_id, namespace, memory_key, aggregate_version)
);
CREATE INDEX memory_events_subject_idx
ON memory_events(subject_id, occurred_at);
对于同一个逻辑记忆,aggregate_version 形成单调递增的版本序列:
版本 1:MemoryCreated language = zh-CN
版本 2:MemoryUpdated language = en-US
版本 3:MemoryDeleted language
事件日志中的 payload_json 应保留足够的上下文,例如来源消息 ID、提取器版本、原始证据摘要和人工确认结果。否则,只保存“值从 A 变成 B”,后续无法判断这次变化是否来自用户明确指令,还是模型误判。
三、版本:同一个记忆为什么需要多个版本
版本表示某个逻辑记忆在一条可比较的变更序列中的位置。
版本至少有三种用途:
- 防止并发更新覆盖;
- 判断向量索引是否过期;
- 重建、审计和解释状态变化。
乐观并发控制
设当前关系库中的记忆为:
value = 杭州
version = 7
两个 Worker 同时读取它们:
Worker A 读取 version=7,准备改成上海
Worker B 读取 version=7,准备改成北京
如果直接执行:
UPDATE memories
SET value_json = ..., version = version + 1
WHERE memory_id = 'm-1';
两个更新都可能成功,最终结果取决于数据库执行顺序,且无法知道哪个更新覆盖了哪个。
正确的乐观锁写法是把预期版本放进条件:
UPDATE memories
SET value_json = '{"value":"上海"}',
version = version + 1,
updated_at = now()
WHERE memory_id = 'm-1'
AND version = 7
AND deleted_at IS NULL;
结果为:
- 影响行数为
1:更新成功,版本变为8; - 影响行数为
0:版本已变化、记录已删除或 ID 不存在,需要重新读取并解决冲突。
第二个 Worker 的更新应使用同样的 version = 7 条件,因此会失败,而不是静默覆盖。
版本更新的因果关系
一次成功更新应形成这样的关系:
事件版本 8
|
+-- 关系库当前投影 version=8
|
+-- 向量索引待生成 version=8
不能出现:
关系库 version=8
向量索引 version=6
但读取代码不知道向量落后
因此,召回结果必须带版本信息,或者由读取逻辑回查关系库确认。
版本不等于时间戳
时间戳和版本解决不同问题:
occurred_at表示事件发生时间;recorded_at表示事件被系统接收的时间;version表示事件在逻辑聚合上的顺序。
分布式系统中的客户端时间可能不可靠,两个事件也可能拥有相同时间戳。用时间戳直接决定胜负容易产生“较晚到达的旧事件覆盖新事件”的问题。
例如:
10:00:00 用户在手机端改为上海
10:00:01 用户在网页端改为北京,但网络延迟
10:00:02 手机端事件才到达服务器
如果按服务器接收时间决定,上海可能覆盖北京;如果按客户端时间决定,又要信任客户端时钟。更稳妥的做法是使用服务端分配的版本、数据库序列或带条件的业务冲突解决规则,并把原始时间保留下来用于解释。
四、一致性:关系库和向量库不可能天然同事务
一致性描述多个副本或投影之间对同一逻辑状态的可见关系。
在这个场景中,至少有:
事件日志
关系库当前投影
向量索引
缓存
会话检查点
备份
关系库事务通常不能同时覆盖外部向量服务和缓存。因此,最常见的工程模型是:
事件日志和当前状态在一个本地事务中提交;向量和其他索引通过异步投影最终一致;读取侧用版本和删除状态保护正确性。
Outbox 模式
一种实用设计是将领域事件和投影任务写入同一个关系库事务:
CREATE TABLE projection_outbox (
outbox_id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
projection_type TEXT NOT NULL,
payload_json JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
更新记忆时:
BEGIN;
-- 1. 写事件
INSERT INTO memory_events (
event_id, subject_id, namespace, memory_key,
event_type, payload_json, aggregate_version,
occurred_at, actor_type
) VALUES (
'00000000-0000-0000-0000-000000000801',
'user-42', 'profile', 'city',
'MemoryUpdated',
'{"old":"杭州","new":"上海"}',
8, now(), 'user'
);
-- 2. 更新当前投影
UPDATE memories
SET value_json = '{"value":"上海"}',
version = 8,
source_event_id = '00000000-0000-0000-0000-000000000801',
updated_at = now()
WHERE subject_id = 'user-42'
AND namespace = 'profile'
AND memory_key = 'city'
AND version = 7;
-- 3. 写向量投影任务
INSERT INTO projection_outbox (
event_id, projection_type, payload_json
) VALUES (
'00000000-0000-0000-0000-000000000801',
'memory_embedding',
'{"memory_id":"m-city","version":8}'
);
COMMIT;
这里的关键不是表名,而是事务边界:
- 事件写成功但当前投影失败:整个事务回滚;
- 当前投影成功但 outbox 写失败:整个事务回滚;
- 三者提交后,向量 Worker 即使稍后才运行,也能从 outbox 发现任务;
- Worker 重试时,依靠
event_id、memory_id + version或幂等键避免重复副作用。
异步投影的中间状态
提交后可能短暂存在:
事件日志:版本 8
关系库:版本 8
向量库:版本 7
这不是错误,只要系统明确承认它是最终一致窗口。
错误的读取方式:
向量召回旧内容
直接作为当前事实
正确的读取方式:
向量召回 memory_id=m-city, version=7
关系库查询当前版本=8
发现不匹配
丢弃旧内容,或重新生成版本 8 的召回结果
如果业务允许短时间内召回不到新记忆,可以返回空结果;如果业务要求强一致,则应先查询关系库,再使用向量只做候选排序。
五、事件日志和当前投影不是二选一
只保存事件日志会导致每次读取都需要重放:
Event 1 -> Event 2 -> Event 3 -> Event 4 -> 当前状态
当一个用户拥有数万条事件时,这会增加读取延迟。只保存当前状态又会失去审计和重建能力。
因此常见设计是:
事件日志 = 权威历史
当前投影 = 面向查询的物化视图
向量索引 = 面向语义召回的物化视图
其中“物化视图”是由其他数据计算出来、为了读取效率而持久化的结果。它可以删除并重建,前提是源数据仍然完整。
重建过程
假设当前投影损坏:
关系库 memories 中 city = 北京,version = 12
事件日志中最后事件实际为 city = 上海,version = 12
可以执行:
- 删除或隔离错误的当前投影;
- 按
aggregate_version顺序读取事件; - 从初始状态依次应用事件;
- 得到 version=12 的当前状态;
- 重新生成向量任务;
- 对比投影哈希、版本和事件末端位置。
事件应用必须满足确定性。例如,同一组事件每次重放都应得到相同状态。若事件处理依赖当前时间、随机数或外部 API,重放结果就可能变化,必须把这些输入记录在事件中。
六、Agent 的会话状态、长期记忆和检查点
Agent 框架中的“状态”经常包含三层:
会话状态:当前对话和任务中间变量
长期记忆:跨会话仍然有效的应用事实
检查点:某一次图执行后的可恢复快照
LangGraph 文档将 checkpointer 定义为线程级图状态的持久化机制,用于对话连续性、人机协作、时间旅行和故障恢复;store 则保存跨线程的应用定义数据,例如用户偏好、事实和共享知识。两者通常同时使用。(docs.langchain.com)
一个最小示例是:
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
checkpointer = InMemorySaver()
store = InMemoryStore()
graph = builder.compile(
checkpointer=checkpointer,
store=store,
)
result = graph.invoke(
{"messages": [{"role": "user", "content": "我叫 Bob"}]},
{"configurable": {"thread_id": "thread-1"}},
)
这里的 thread_id 标识一次线程范围内的图状态。相同线程可以恢复之前的状态,不同线程则不应自动共享全部对话内容。跨线程共享的用户事实应放入 store 或应用自己的长期记忆表,而不是把某个线程的完整检查点当成全局用户记忆。(docs.langchain.com)
InMemorySaver 和 MemorySaver 将检查点放在进程内存中,进程重启后数据会丢失,因此只能用于开发或测试。生产环境需要使用持久化实现,例如 PostgreSQL;长对话还需要清理不断增长的检查点。(docs.langchain.com)
检查点不是事件日志
检查点是某一时刻的快照:
checkpoint(version=42):
messages=[...]
pending_tool_call=...
plan_step=3
它适合快速恢复,但不一定能解释每一个字段是如何变化的。事件日志是变化序列:
ToolCallCreated
ToolCallCompleted
PlanStepAdvanced
MemoryUpdated
如果把检查点当作唯一事实来源,会遇到:
- 快照损坏后难以重建;
- 多个 Worker 并发更新时缺少细粒度冲突信息;
- 无法区分模型推理中间状态和用户确认后的长期事实;
- 删除某条记忆时难以查找所有来源。
七、写入记忆时,必须记录“事实”和“证据”
长期记忆至少应区分:
事实:用户偏好使用中文
证据:用户在消息 msg-918 中明确说“请以后都用中文回答”
来源:user / tool / model_extraction
置信度:0.99
有效时间:永久或具体时间段
授权:consent-123
一个更完整的值结构可以是:
{
"value": "zh-CN",
"claim_type": "user_preference",
"source": {
"kind": "user_message",
"message_id": "msg-918"
},
"confidence": 0.99,
"observed_at": "2026-08-31T10:00:00Z",
"expires_at": null
}
模型提取出的内容不是天然事实。 模型可能把:
我这周在上海出差
错误提取为:
用户居住地是上海
因此,记忆写入器至少需要判断:
- 这是用户明确陈述,还是模型推断;
- 它是永久事实、临时状态还是任务局部信息;
- 是否已有冲突值;
- 是否需要用户确认;
- 是否允许这个 Agent 保存该类型数据;
- 是否超过保留期限。
八、冲突解决:不是简单的“最后写入者获胜”
设同一个逻辑键有两个候选值:
A:city = 杭州,来源为用户明确陈述,version=7
B:city = 上海,来源为模型从“我去上海出差”推断,version=8
如果采用“版本更大者胜出”,会把低可信度的推断当成事实。
更合理的解决过程是先区分:
- 时间冲突:两个值在不同时间都曾经正确;
- 来源冲突:用户陈述和第三方数据不一致;
- 语义冲突:居住地、当前位置和出差地点被错误归并;
- 并发冲突:两个请求同时更新同一个键。
可以为一个候选事实定义排序函数:
其中:
- :来源可靠性,例如用户明确确认高于模型推断;
- :时间新鲜度;
- :与当前任务的相关性;
- :用户明确确认程度;
- :业务权重。
这个分数只能帮助排序,不能替代硬规则。例如,低权限来源即使分数高,也不能覆盖用户明确设置的安全约束。
更安全的做法是保留多个事实:
current_city = 杭州
temporary_trip = 上海
home_address = 杭州
问题往往不是“哪个值覆盖哪个值”,而是数据建模把不同语义错误地压缩成了同一个键。
九、删除、遗忘和可追溯性
删除长期记忆至少涉及:
当前关系库记录
事件日志中的原始内容
向量索引
缓存
会话检查点
搜索索引
备份和灾备副本
下游导出
因此,DELETE FROM memories 只删除当前投影,并不等于数据已经从系统中消失。
逻辑删除和物理删除
逻辑删除是把记录标记为删除:
UPDATE memories
SET deleted_at = now(),
updated_at = now()
WHERE subject_id = 'user-42'
AND namespace = 'profile'
AND memory_key = 'city';
优点是:
- 可审计;
- 可以短期恢复;
- 异步 Worker 仍能读取删除任务;
- 可以防止旧事件重新生成已删除投影。
缺点是:原始内容仍然存在,不能满足所有隐私删除要求。
物理删除会删除或加密销毁实际内容,但如果事件日志、备份和向量索引仍保留原文,删除仍未完成。
一种折中模型是:
事件日志保留事件类型、时间、主体和哈希
敏感 payload 加密保存
删除请求触发密钥销毁或 payload 擦除
投影表和向量索引删除内容
这样仍可证明“发生过删除操作”,但不再保留可恢复的敏感正文。
删除必须是幂等的
删除任务可能执行多次:
DeleteMemory(m-1)
DeleteMemory(m-1)
第二次执行不应报错,也不应重新生成内容。可以使用删除墓碑:
memory_id = m-1
tombstone_version = 9
deleted_at = ...
事件重放时,如果发现新事件版本不高于墓碑版本,则不得恢复该记忆。
备份是删除流程的一部分
如果生产库已经删除,但七天前的备份仍能完整恢复敏感记忆,就需要明确:
- 备份保留多久;
- 删除请求是否等待备份过期;
- 是否有按用户或租户隔离的加密密钥;
- 恢复演练后是否会重新导入已删除数据;
- 恢复流程如何重新执行删除墓碑。
删除的完成条件应是一个可验证的状态,而不是一句“已经删除”。
十、OpenAI 对话状态的保留边界
使用外部模型提供的对话状态时,要区分响应对象、响应链和会话对象的保留语义。
OpenAI 文档说明,Responses API 的响应对象默认保存 30 天,可以通过 store=false 禁用该行为;而附着在 Conversations API 会话中的项目不受这个 30 天 TTL 约束。即使使用 previous_response_id,链中之前的输入 token 仍会计入输入 token 计费。(developers.openai.com)
这带来两个工程结论:
- 不能因为应用数据库设置了短保留期,就假设外部响应链也会自动按同样期限消失;
- 不能把外部会话状态当作唯一的隐私删除索引,应用仍需要保存自己的关联 ID、同意记录和删除状态。
如果应用需要严格控制数据生命周期,应在写入前决定:
哪些内容发送给模型
哪些内容只保存在本地
哪些内容允许进入持久化会话
哪些内容只能在一次请求中使用
十一、并发、重试和故障路径
情况一:事件已提交,向量写入失败
状态可能是:
事件日志:version=8
关系库:version=8
向量库:version=7
outbox:pending
恢复方法:
- Worker 根据 outbox 重试;
- 写入向量时使用
(memory_id, version)幂等键; - 成功后标记 outbox 完成;
- 读取侧在此期间拒绝过期版本。
情况二:向量已写入,Worker 在确认前崩溃
重启后同一个任务再次执行。如果没有幂等约束,可能产生重复向量。若向量记录使用:
UNIQUE(memory_id, memory_version, embedding_model)
则重复写入可以转化为安全的 upsert 或“已存在即成功”。
情况三:模型调用成功,应用在写入响应前崩溃
如果模型调用会产生外部副作用,例如发邮件、下订单或修改工单,不能只依赖重试。需要记录:
operation_id
request_hash
tool_name
tool_arguments
execution_status
provider_request_id
重试前先查询 operation_id 是否已经执行成功。对工具调用而言,幂等性比数据库事务更重要,因为数据库事务通常无法回滚外部系统已经完成的操作。
情况四:两个请求同时追加消息
如果两个请求共享一个会话线程,必须明确是否允许并发追加。简单地用“最后保存的完整消息数组覆盖前一个数组”会丢消息。
更安全的模型是:
每条消息拥有唯一 message_id
每条消息关联 parent_version
提交时检查 parent_version
冲突时返回 conflict,而不是静默覆盖
或者使用追加式事件:
MessageAppended
ToolCallStarted
ToolCallCompleted
AssistantMessageAppended
再由投影生成当前会话视图。
十二、一个可执行的读取策略
下面的 Python 示例展示“向量召回后回查关系库”的核心逻辑。这里用普通字典模拟两个存储,逻辑可直接映射到 PostgreSQL 和实际向量服务。
from dataclasses import dataclass
from typing import Optional
@dataclass
class MemoryRow:
memory_id: str
subject_id: str
version: int
text: str
deleted: bool = False
# 关系库当前投影
relational_db = {
"m-city": MemoryRow(
memory_id="m-city",
subject_id="user-42",
version=8,
text="用户当前所在城市是上海",
)
}
# 向量库返回了过期版本
vector_hits = [
{
"memory_id": "m-city",
"subject_id": "user-42",
"version": 7,
"score": 0.91,
"text": "用户居住在杭州",
}
]
def load_current_memory(
subject_id: str,
hit: dict,
) -> Optional[MemoryRow]:
if hit["subject_id"] != subject_id:
return None
current = relational_db.get(hit["memory_id"])
if current is None or current.deleted:
return None
# 向量只是候选;版本不一致时拒绝使用旧文本
if current.version != hit["version"]:
return None
return current
accepted = [
current
for hit in vector_hits
if (current := load_current_memory("user-42", hit)) is not None
]
print(accepted)
# []
输出为空是正确结果。向量命中了相关主题,但命中版本为 7,关系库当前版本为 8,因此不能把“杭州”放入当前上下文。
如果希望在过期时自动恢复召回,可以将逻辑改为:
- 发现版本不一致;
- 从关系库读取当前文本;
- 判断当前文本是否仍满足语义检索条件;
- 将当前版本重新加入待嵌入队列;
- 本次请求使用关系库当前值,后续请求使用新向量。
十三、常见错误及其表现
把全部聊天记录塞进提示词
表现:
- 上下文不断膨胀;
- 成本和延迟增长;
- 旧信息与新信息同时出现;
- 模型无法判断哪个事实当前有效。
解决方向是把会话上下文、长期事实和检索证据分层,并对长期事实设置有效期、来源和版本。
把向量库当作主数据库
表现:
- 无法可靠执行精确更新;
- 删除后旧内容仍可能被召回;
- 相似文本重复出现;
- 难以判断某条记忆是否已过期;
- 无法保证同一用户的数据隔离。
诊断时检查每个向量记录是否都能回查 memory_id、主体、命名空间和版本。如果不能,向量库已经承担了不适合它的事实管理职责。
只有当前值,没有历史事件
表现:
- 无法解释记忆为何改变;
- 无法恢复误更新前的状态;
- 无法判断模型提取错误来自哪条消息;
- 重建向量索引时缺少输入。
这并不意味着所有系统都必须采用完整事件溯源。低风险应用可以只保留审计记录和当前状态,但必须承认:没有事件或证据,就无法实现强可追溯重建。
用“最后写入”解决所有冲突
表现:
- 延迟到达的旧请求覆盖新事实;
- 模型推断覆盖用户明确确认;
- 临时地点覆盖永久地址;
- 多端并发更新结果不稳定。
至少需要版本条件、来源等级和业务语义。对于不能自动判定的冲突,应保留候选值或请求用户确认。
只删除关系库记录
表现:
- 删除后仍能从向量召回;
- 缓存继续返回旧记忆;
- 备份恢复后数据重新出现;
- 审计系统无法证明删除范围。
删除流程应围绕统一的 memory_id 或数据主体索引所有副本,并为异步删除、备份过期和恢复后的再清理定义状态。
十四、如何选择最小架构
可以按数据要求选择,而不是按数据库流行度选择:
| 数据 | 首选存储 | 原因 |
|---|---|---|
| 当前用户偏好 | 关系库 | 精确读取、条件更新、版本控制 |
| 对话消息和工具调用 | 会话存储或事件日志 | 顺序恢复、重放和审计 |
| Agent 图执行中间状态 | Checkpointer | 按线程恢复、暂停和继续 |
| 跨线程用户事实 | Store 或关系库 | 长期共享、命名空间隔离 |
| 语义相关历史片段 | 向量库 | 近似相似度检索 |
| 删除和更新历史 | 事件日志 | 可追溯、可重建 |
| 向量和搜索索引 | 异步投影 | 可重建,不承担主事实职责 |
小型系统可以从一个 PostgreSQL 开始:
PostgreSQL:
memories
memory_events
projection_outbox
conversation_items
之后再为语义检索增加向量索引。只有当检索规模、延迟或运维边界确实需要时,才拆出独立向量服务。拆分存储并不会自动提升一致性,反而会增加投影、重试、删除和监控的复杂度。
结语:把记忆设计成“事实、投影和证据”的系统
一个可靠的 Agent 记忆系统通常遵循这样的因果链:
证据
-> 经过策略判断的记忆事件
-> 关系库当前投影
-> 向量和搜索索引
-> 受版本、权限和有效期约束的上下文
-> 模型调用
关系库负责当前状态和精确约束;事件日志负责变化历史和重建;向量库负责语义召回;检查点负责线程级恢复;版本负责并发和派生索引校验;一致性机制负责把多个副本之间的中间状态变得可解释、可恢复。
真正的长期记忆不是“保存得越多越好”,而是能够回答:
这条信息是什么?
从哪里来的?
当前是否有效?
谁可以读取?
哪个版本是最新的?
如果它被删除,所有副本是否都会停止返回?
当这些问题都能由数据模型和状态转换明确回答时,Agent 的记忆才从提示词技巧变成了可维护的工程系统。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent 会话隔离:用户、租户、线程、群聊成员和上下文串线
- 下一篇:Agent 记忆隐私与删除:同意、保留期、可追溯删除和备份
- 延伸:Agent 长期记忆:写入策略、检索、更新、冲突和遗忘
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论