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[模型调用]

关键路径如下:

  1. 原始消息或工具结果先成为可追溯的证据;
  2. 记忆策略决定是否写入长期记忆;
  3. 写入事件日志作为事实来源;
  4. 关系库根据事件生成当前状态;
  5. 向量库根据记忆内容生成检索投影;
  6. 生成回复前,同时进行结构化查询和语义召回;
  7. 只有经过权限、时间和冲突处理后的结果,才能进入模型上下文。

关系库:保存当前可查询状态

关系库适合保存:

  • 用户、组织、会话和任务的归属关系;
  • 记忆的当前值;
  • 版本号和有效时间;
  • 同意状态和保留期限;
  • 删除状态;
  • 事件与投影之间的处理进度;
  • 幂等键和唯一约束。

一个简化的数据模型如下:

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_idmemory_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”,后续无法判断这次变化是否来自用户明确指令,还是模型误判。


三、版本:同一个记忆为什么需要多个版本

版本表示某个逻辑记忆在一条可比较的变更序列中的位置。

版本至少有三种用途:

  1. 防止并发更新覆盖;
  2. 判断向量索引是否过期;
  3. 重建、审计和解释状态变化。

乐观并发控制

设当前关系库中的记忆为:

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_idmemory_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

可以执行:

  1. 删除或隔离错误的当前投影;
  2. aggregate_version 顺序读取事件;
  3. 从初始状态依次应用事件;
  4. 得到 version=12 的当前状态;
  5. 重新生成向量任务;
  6. 对比投影哈希、版本和事件末端位置。

事件应用必须满足确定性。例如,同一组事件每次重放都应得到相同状态。若事件处理依赖当前时间、随机数或外部 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)

InMemorySaverMemorySaver 将检查点放在进程内存中,进程重启后数据会丢失,因此只能用于开发或测试。生产环境需要使用持久化实现,例如 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
}

模型提取出的内容不是天然事实。 模型可能把:

我这周在上海出差

错误提取为:

用户居住地是上海

因此,记忆写入器至少需要判断:

  1. 这是用户明确陈述,还是模型推断;
  2. 它是永久事实、临时状态还是任务局部信息;
  3. 是否已有冲突值;
  4. 是否需要用户确认;
  5. 是否允许这个 Agent 保存该类型数据;
  6. 是否超过保留期限。

八、冲突解决:不是简单的“最后写入者获胜”

设同一个逻辑键有两个候选值:

A:city = 杭州,来源为用户明确陈述,version=7
B:city = 上海,来源为模型从“我去上海出差”推断,version=8

如果采用“版本更大者胜出”,会把低可信度的推断当成事实。

更合理的解决过程是先区分:

  • 时间冲突:两个值在不同时间都曾经正确;
  • 来源冲突:用户陈述和第三方数据不一致;
  • 语义冲突:居住地、当前位置和出差地点被错误归并;
  • 并发冲突:两个请求同时更新同一个键。

可以为一个候选事实定义排序函数:

S=wsRs+wcRc+wtRt+wfRfS = w_s R_s + w_c R_c + w_t R_t + w_f R_f

其中:

  • RsR_s:来源可靠性,例如用户明确确认高于模型推断;
  • RcR_c:时间新鲜度;
  • RtR_t:与当前任务的相关性;
  • RfR_f:用户明确确认程度;
  • ws,wc,wt,wfw_s,w_c,w_t,w_f:业务权重。

这个分数只能帮助排序,不能替代硬规则。例如,低权限来源即使分数高,也不能覆盖用户明确设置的安全约束。

更安全的做法是保留多个事实:

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)

这带来两个工程结论:

  1. 不能因为应用数据库设置了短保留期,就假设外部响应链也会自动按同样期限消失;
  2. 不能把外部会话状态当作唯一的隐私删除索引,应用仍需要保存自己的关联 ID、同意记录和删除状态。

如果应用需要严格控制数据生命周期,应在写入前决定:

哪些内容发送给模型
哪些内容只保存在本地
哪些内容允许进入持久化会话
哪些内容只能在一次请求中使用

十一、并发、重试和故障路径

情况一:事件已提交,向量写入失败

状态可能是:

事件日志:version=8
关系库:version=8
向量库:version=7
outbox:pending

恢复方法:

  1. Worker 根据 outbox 重试;
  2. 写入向量时使用 (memory_id, version) 幂等键;
  3. 成功后标记 outbox 完成;
  4. 读取侧在此期间拒绝过期版本。

情况二:向量已写入,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,因此不能把“杭州”放入当前上下文。

如果希望在过期时自动恢复召回,可以将逻辑改为:

  1. 发现版本不一致;
  2. 从关系库读取当前文本;
  3. 判断当前文本是否仍满足语义检索条件;
  4. 将当前版本重新加入待嵌入队列;
  5. 本次请求使用关系库当前值,后续请求使用新向量。

十三、常见错误及其表现

把全部聊天记录塞进提示词

表现:

  • 上下文不断膨胀;
  • 成本和延迟增长;
  • 旧信息与新信息同时出现;
  • 模型无法判断哪个事实当前有效。

解决方向是把会话上下文、长期事实和检索证据分层,并对长期事实设置有效期、来源和版本。

把向量库当作主数据库

表现:

  • 无法可靠执行精确更新;
  • 删除后旧内容仍可能被召回;
  • 相似文本重复出现;
  • 难以判断某条记忆是否已过期;
  • 无法保证同一用户的数据隔离。

诊断时检查每个向量记录是否都能回查 memory_id、主体、命名空间和版本。如果不能,向量库已经承担了不适合它的事实管理职责。

只有当前值,没有历史事件

表现:

  • 无法解释记忆为何改变;
  • 无法恢复误更新前的状态;
  • 无法判断模型提取错误来自哪条消息;
  • 重建向量索引时缺少输入。

这并不意味着所有系统都必须采用完整事件溯源。低风险应用可以只保留审计记录和当前状态,但必须承认:没有事件或证据,就无法实现强可追溯重建。

用“最后写入”解决所有冲突

表现:

  • 延迟到达的旧请求覆盖新事实;
  • 模型推断覆盖用户明确确认;
  • 临时地点覆盖永久地址;
  • 多端并发更新结果不稳定。

至少需要版本条件、来源等级和业务语义。对于不能自动判定的冲突,应保留候选值或请求用户确认。

只删除关系库记录

表现:

  • 删除后仍能从向量召回;
  • 缓存继续返回旧记忆;
  • 备份恢复后数据重新出现;
  • 审计系统无法证明删除范围。

删除流程应围绕统一的 memory_id 或数据主体索引所有副本,并为异步删除、备份过期和恢复后的再清理定义状态。


十四、如何选择最小架构

可以按数据要求选择,而不是按数据库流行度选择:

数据 首选存储 原因
当前用户偏好 关系库 精确读取、条件更新、版本控制
对话消息和工具调用 会话存储或事件日志 顺序恢复、重放和审计
Agent 图执行中间状态 Checkpointer 按线程恢复、暂停和继续
跨线程用户事实 Store 或关系库 长期共享、命名空间隔离
语义相关历史片段 向量库 近似相似度检索
删除和更新历史 事件日志 可追溯、可重建
向量和搜索索引 异步投影 可重建,不承担主事实职责

小型系统可以从一个 PostgreSQL 开始:

PostgreSQL:
  memories
  memory_events
  projection_outbox
  conversation_items

之后再为语义检索增加向量索引。只有当检索规模、延迟或运维边界确实需要时,才拆出独立向量服务。拆分存储并不会自动提升一致性,反而会增加投影、重试、删除和监控的复杂度。


结语:把记忆设计成“事实、投影和证据”的系统

一个可靠的 Agent 记忆系统通常遵循这样的因果链:

证据
  -> 经过策略判断的记忆事件
  -> 关系库当前投影
  -> 向量和搜索索引
  -> 受版本、权限和有效期约束的上下文
  -> 模型调用

关系库负责当前状态和精确约束;事件日志负责变化历史和重建;向量库负责语义召回;检查点负责线程级恢复;版本负责并发和派生索引校验;一致性机制负责把多个副本之间的中间状态变得可解释、可恢复。

真正的长期记忆不是“保存得越多越好”,而是能够回答:

这条信息是什么?
从哪里来的?
当前是否有效?
谁可以读取?
哪个版本是最新的?
如果它被删除,所有副本是否都会停止返回?

当这些问题都能由数据模型和状态转换明确回答时,Agent 的记忆才从提示词技巧变成了可维护的工程系统。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。