Agent 工程体系 · 第 29/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 记忆隐私与删除:同意、保留期、可追溯删除和备份

Agent 的“记忆”不是一个单独的字段,而是一组可能位于不同系统、不同生命周期中的数据:

  • 当前请求中的上下文;
  • 会话消息、工具调用和工具输出;
  • Agent 工作流的中间状态;
  • 跨会话保存的用户偏好、事实和画像;
  • 向量索引、全文索引和缓存;
  • 观测日志、错误报告、提示词记录;
  • 数据库快照、对象存储版本和备份;
  • 已经发送给模型供应商或第三方工具的数据。

因此,“删除用户记忆”不能简化为执行一次 DELETE FROM memories。一个可验证的删除实现,必须同时回答四个问题:

  1. 用户是否同意系统保存这类数据?
  2. 系统应当保存多久,什么时候必须停止使用?
  3. 删除请求如何沿着所有副本和派生数据传播,并留下可审计证据?
  4. 在线数据删除后,备份中的数据如何处理,何时才算完成?

本文将这些问题统一成一个工程模型,并结合 OpenAI Conversation State 与 LangGraph Persistence 的状态语义说明实现边界。OpenAI 的 Conversation State 文档区分了手动传递上下文、通过 previous_response_id 连接响应,以及通过 Conversations API 持久化会话对象;LangGraph 则将线程级短期状态交给 checkpointer,将跨线程长期数据交给 store。二者都说明了一个关键事实:上下文连续性和长期记忆不是同一种持久化对象。(developers.openai.com)

一、先定义“记忆”以及“删除”的对象

1.1 上下文、会话状态与长期记忆

可以把 Agent 一次运行所依赖的数据分成三层。

请求上下文

请求上下文是本次模型调用可见的数据:

system instructions
+ 当前用户输入
+ 被选中的历史消息
+ 工具结果
+ 检索结果
+ 当前运行时状态

它可能根本没有持久化,也可能在请求日志、模型供应商的响应对象或应用日志中留下副本。

会话状态

会话状态用于在多轮交互或中断恢复时继续工作。它通常包含:

{
  "thread_id": "thread-7f...",
  "messages": [],
  "pending_tool_call": null,
  "approval_state": "waiting",
  "workflow_step": "review"
}

OpenAI 的 Responses API 可以通过 previous_response_id 将多个响应串成线程;Conversations API 则把会话表示为具有持久标识符的长期对象,并保存消息、工具调用、工具输出等 item。需要注意,一个可继续访问的会话对象本身就是持久化数据,不能因为应用数据库中没有保存消息,就认为数据没有被保存。(developers.openai.com)

LangGraph 的 checkpointer 保存的是某个 thread_id 下的图状态快照,适合会话连续性、人工介入、故障恢复和时间旅行;store 保存的是应用自行定义的跨线程键值数据,适合用户偏好、事实和共享知识。(docs.langchain.com)

长期记忆

长期记忆是跨会话、跨设备或跨任务复用的数据,例如:

{
  "subject": "user-123",
  "kind": "preference",
  "key": "language",
  "value": "zh-CN",
  "source": "explicit_user_statement",
  "confidence": 1.0,
  "created_at": "2026-08-01T10:00:00Z",
  "expires_at": "2026-11-01T10:00:00Z"
}

长期记忆往往会被复制为:

  • 关系数据库中的结构化记录;
  • 向量数据库中的 embedding;
  • 搜索引擎中的倒排索引;
  • Agent prompt 中的摘要;
  • 缓存中的序列化对象;
  • 评测和调试系统中的样本;
  • 数据仓库中的分析记录。

所以应把一条记忆建模为一个数据对象及其派生对象集合,而不是某张表中的一行。

设用户主体为 uu,一条原始记忆为 mm,其派生数据集合为:

D(m)={m, d1(m), d2(m),,dn(m)}D(m)=\{m,\ d_1(m),\ d_2(m),\ldots,d_n(m)\}

其中:

  • mm:原始文本或结构化记忆;
  • di(m)d_i(m):由它生成的 embedding、摘要、索引、缓存或日志;
  • D(m)D(m):删除请求必须覆盖的完整数据闭包。

删除只覆盖 mm,而不覆盖 D(m)D(m),得到的只是源记录删除,不是记忆删除

1.2 删除的四种含义

工程上经常把以下四个概念混在一起。

停止使用

数据仍然存在,但 Agent 不再把它读入 prompt、检索结果或工具参数。

这可以通过:

UPDATE memories
SET status = 'suppressed'
WHERE subject_id = 'user-123';

实现,但它不是物理删除。

逻辑删除

数据仍可能存在于数据库、索引或备份中,但主业务查询不可见。逻辑删除适合异步删除流程,因为它可以先阻止继续使用,再进行后台清理。

物理删除

从在线数据库、索引、缓存和对象存储中删除数据,使正常操作无法再读到它。

密钥销毁

如果数据使用独立数据密钥加密,销毁该密钥可以使保留在某些介质中的密文不可恢复。这通常称为加密擦除或 crypto-erasure。

这四者的强度不同:

停止使用  <  逻辑删除  <  物理删除  <  物理删除 + 密钥销毁\text{停止使用} \;<\; \text{逻辑删除} \;<\; \text{物理删除} \;<\; \text{物理删除 + 密钥销毁}

但它们不是严格替代关系。例如,物理删除数据库记录并不能自动删除备份;密钥销毁也不能消除已经出现在访问日志中的明文。

二、同意不是一个布尔字段

2.1 “同意保存记忆”必须有范围

同意是用户对某种处理目的、数据范围和保存期限的授权,而不是一个永久的:

{"consent": true}

至少需要区分:

维度 示例
主体 哪个用户、组织或租户
目的 提供连续对话、个性化推荐、质量分析
数据类别 称呼、语言偏好、健康信息、财务信息
处理动作 保存、检索、生成 embedding、发送给供应商
期限 30 天、直到撤回、任务结束
范围 某个 Agent、某个租户、全部产品
版本 用户同意时看到的隐私说明版本
时间 生效时间、撤回时间

因此,同意记录可以建模为:

C=(u,p,k,a,τs,τe,v)C=(u,p,k,a,\tau_s,\tau_e,v)

其中:

  • uu:数据主体;
  • pp:处理目的;
  • kk:数据类别;
  • aa:允许的处理动作;
  • τs\tau_s:同意开始时间;
  • τe\tau_e:同意结束时间,可能为空;
  • vv:告知或政策版本。

一条写入操作 ww 只有在以下条件同时成立时才允许:

allow(w)=consent(u,p,k,a,t)necessity(w,p)scope(w,u)\operatorname{allow}(w)= \operatorname{consent}(u,p,k,a,t) \land \operatorname{necessity}(w,p) \land \operatorname{scope}(w,u)

直觉是:即使用户同意保存“偏好”,也不代表系统可以保存所有聊天内容;即使用户同意个性化,也不代表可以把数据用于质量分析或训练。

2.2 撤回同意必须改变写入和读取路径

撤回同意不是只更新设置页面上的开关。至少要改变三个状态:

consent active
    │
    ├── revoke
    ▼
consent revoked
    │
    ├── stop future writes
    ├── suppress reads
    └── enqueue deletion

撤回后,新的记忆写入应失败或降级为仅当前请求使用:

def may_persist(subject_id: str, purpose: str, category: str) -> bool:
    consent = consent_repo.get(subject_id, purpose, category)
    return (
        consent is not None
        and consent.status == "active"
        and consent.expires_at > utcnow()
    )

调用方不能默认写入:

memory = extract_memory(message)

if may_persist(user_id, "personalization", memory.category):
    memory_repo.upsert(memory)

更安全的做法是把许可检查放在存储层或写入服务中,而不是依赖每个 Agent 节点自行遵守:

class MemoryWriter:
    def save(self, memory):
        if not self.policy.can_write(
            subject_id=memory.subject_id,
            purpose=memory.purpose,
            category=memory.category,
        ):
            raise ConsentRequired("memory persistence is not allowed")

        return self.repository.insert(memory)

否则,主 Agent 可能停止写入,但摘要节点、异步 embedding 任务或错误重试队列仍会继续产生副本。

2.3 明确同意、必要处理和默认行为

有些数据是完成当前任务所必需的,例如用户在当前请求中提供的收货地址;另一些数据只是为了未来个性化,例如“用户喜欢简洁回答”。

两者不应共用一个开关:

当前任务所需数据
    └── 仅限本次任务,任务结束后删除或按任务规则保留

可选长期记忆
    └── 单独取得同意,允许查看、修改、撤回和删除

反例是把完整聊天记录自动压缩成“用户画像”,然后以“服务正常运行需要”为理由永久保留。这个设计同时扩大了数据范围、处理目的和保存期限,难以解释每一条数据为什么仍然存在。

三、保留期不是数据库 TTL

3.1 保留期的定义

保留期是数据允许被保存或使用的时间窗口。对一条记忆 mm,可以定义:

retain_until(m)=min(tpurpose-end,tconsent-end,tpolicy-end,tlegal-end)\operatorname{retain\_until}(m) = \min( t_{\text{purpose-end}}, t_{\text{consent-end}}, t_{\text{policy-end}}, t_{\text{legal-end}} )

这里的每个时间点含义不同:

  • tpurpose-endt_{\text{purpose-end}}:处理目的不再需要数据的时间;
  • tconsent-endt_{\text{consent-end}}:同意撤回或授权到期时间;
  • tpolicy-endt_{\text{policy-end}}:系统设定的最大保存期限;
  • tlegal-endt_{\text{legal-end}}:特定合规或争议保全要求结束时间。

这不是法律结论,而是系统设计中的决策模型。具体期限必须由组织的隐私、法务和业务规则确定。

例如:

用户语言偏好:
  consent_end = 2026-12-01
  policy_end  = 2026-11-01
  purpose_end = 2026-10-15

retain_until = 2026-10-15

因为目的结束得最早,所以不能因为同意仍然有效,就继续保留到 12 月。

3.2 每个数据层都要有保留策略

如果只有主表设置 TTL,系统仍可能长期保存数据:

memory row              30 days
embedding index         永久
Redis cache             7 days
application log         180 days
warehouse               2 years
backup                  90 days

此时一条记忆的实际残留时间至少是:

Tresidual=max(Tdatabase,Tindex,Tcache,Tlog,Twarehouse,Tbackup)T_{\text{residual}} = \max(T_{\text{database}}, T_{\text{index}}, T_{\text{cache}}, T_{\text{log}}, T_{\text{warehouse}}, T_{\text{backup}})

如果索引和日志确实包含可恢复的个人数据,最长的那一层决定了实际风险。

保留策略应记录在数据目录中:

memory_type: user_preference
purpose: personalization
sources:
  - conversation_message
  - explicit_profile_update
derived:
  - embedding
  - search_index
retention:
  primary: 90d
  embedding: 90d
  cache: 24h
  audit_payload: metadata_only
backup:
  deletion_mode: expire_by_backup_generation

其中 audit_payload: metadata_only 表示审计日志不保存原始消息、完整 prompt 或工具输出。

3.3 TTL 只负责“到期触发”,不负责完整删除

数据库 TTL 通常只能完成以下动作之一:

  • 删除主记录;
  • 标记记录过期;
  • 触发一个后台事件。

它不能自动知道:

  • 哪个向量对应这条记忆;
  • 哪些摘要引用了它;
  • 哪些缓存键由它生成;
  • 哪些异步任务尚未执行;
  • 哪些备份包含它;
  • 哪些供应商对象仍然存在。

因此,推荐把到期处理建模为删除任务:

retention scanner
    │
    ├── select expired memories
    ├── create deletion request
    ├── suppress reads immediately
    ├── delete derived data
    ├── delete primary data
    ├── propagate to external processors
    └── verify and close request

四、删除的正确模型:撤销可见性,再传播到数据闭包

4.1 删除请求状态机

删除请求本身也需要状态,而不是一个同步 HTTP 请求中完成所有动作。

stateDiagram-v2
    [*] --> Requested
    Requested --> Authorized
    Requested --> Rejected
    Authorized --> Suppressed
    Suppressed --> Propagating
    Propagating --> Verifying
    Propagating --> RetryableFailure
    RetryableFailure --> Propagating
    Verifying --> Completed
    Verifying --> Exception
    Exception --> ManualReview
    ManualReview --> Propagating
    Completed --> [*]

各状态的语义如下:

  • Requested:收到删除请求,但尚未验证主体身份;
  • Authorized:确认请求者有权删除该主体的数据;
  • Suppressed:系统立即停止读取和使用目标数据;
  • Propagating:删除正在向数据库、索引、缓存、队列和外部服务传播;
  • Verifying:检查所有预定义目标是否已经删除或不可读;
  • Completed:达到删除策略定义的完成条件;
  • Exception:某个目标失败,需要人工或补偿处理。

Suppressed 是重要的中间状态。它解决了一个并发问题:

删除请求到达
    │
    ├── 线程 A:删除数据库记录
    └── 线程 B:同时读取旧记忆并写入新摘要

如果读取路径只检查主表是否存在,线程 B 可能在删除过程中重新制造副本。把“禁止读取”放在删除状态上,能够先阻断新副本产生,再执行物理清理。

4.2 数据删除闭包

为每条记忆保存来源和派生关系:

memory_id = m-001

derived:
  embedding_id = e-001
  summary_id   = s-023
  cache_key    = memory:user-123:v7
  index_doc_id = idx-884

可以将依赖关系表示为有向图:

G=(V,E)G=(V,E)

其中:

  • VV:原始记录、派生记录、缓存、日志、备份对象;
  • E=(x,y)E=(x,y):对象 yy 由对象 xx 派生或引用。

删除目标不是单个节点 mm,而是从 mm 出发可达的节点集合:

closure(m)={xmx}\operatorname{closure}(m) = \{x\mid m\leadsto x\}

如果摘要 s-023 是由记忆 m-001 生成的,删除 m-001 时必须至少将 s-023 标记为无效或重新生成;否则 Agent 仍可能从摘要中恢复原始事实。

实际系统中不一定能精确记录所有语义依赖。例如,一个包含十条记忆的摘要可能无法简单地反向映射到其中一条。因此有三种策略:

  1. 精确引用:摘要保存来源 ID,删除时精确失效;
  2. 批次隔离:每批记忆生成独立摘要,删除粒度较粗但可靠;
  3. 重新计算:删除源数据后,从剩余数据重建摘要和索引。

第三种策略成本较高,但适合无法安全判断摘要内容是否仍包含被删除信息的场景。

4.3 删除请求的幂等性

删除任务必须支持重复执行:

def process_deletion(request_id: str) -> None:
    request = deletion_repo.get(request_id)

    if request.status == "completed":
        return

    deletion_repo.mark_suppressed(request.subject_id)

    for target in deletion_targets(request.subject_id):
        delete_target_idempotently(target, request.subject_id)

    verify_result = verify_all_targets(request.subject_id)

    if verify_result.ok:
        deletion_repo.complete(request_id)
    else:
        deletion_repo.record_failure(request_id, verify_result.errors)

幂等性意味着:

  • 删除不存在的记录视为成功;
  • 重复删除同一个索引文档不会报业务错误;
  • 已完成的任务不会重新激活数据;
  • 网络超时后可以安全重试;
  • 同一个删除请求有稳定的 request_id

删除操作不应依赖“上一次执行是否返回 200”。网络可能在服务端已经删除成功后才断开,因此客户端必须通过查询或验证接口确认结果。

五、可追溯删除:既要证明删除,又不能重新泄露数据

5.1 什么是可追溯删除

可追溯删除不是保存一份被删除数据的副本,而是保存足够的元数据,回答:

  • 谁发起了删除;
  • 请求针对哪个主体和范围;
  • 何时收到请求;
  • 删除经过了哪些系统;
  • 每个系统何时确认;
  • 哪些系统失败过;
  • 当前是否达到完成条件;
  • 谁在何时批准了例外。

可以使用如下事件结构:

{
  "event_type": "deletion_target_completed",
  "request_id": "del-20260901-00042",
  "subject_ref": "hmac:user-123",
  "target": "vector_index",
  "target_ref": "hmac:e-001",
  "occurred_at": "2026-09-01T08:15:30Z",
  "result": "deleted",
  "attempt": 2,
  "actor": "deletion-worker-3",
  "previous_event_hash": "..."
}

subject_reftarget_ref 可以使用带密钥的 HMAC,而不是普通哈希:

r=HMACK(original_id)r=\operatorname{HMAC}_K(\text{original\_id})

普通哈希在用户 ID 空间较小时可能被枚举;HMAC 只有持有密钥的审计服务才能稳定关联同一主体。即使如此,HMAC 结果仍可能构成可关联标识,不能把它当作绝对匿名化。

5.2 审计日志也有保留期

“为了证明删除,所以永久保存所有日志”是常见错误。审计记录本身可能包含:

  • 用户 ID;
  • 请求 IP;
  • 工具名称;
  • 数据库主键;
  • 错误信息;
  • 供应商响应内容;
  • 原始 prompt 片段。

应把审计事件拆成两类:

删除证明元数据:
  request_id
  subject_ref
  target
  timestamp
  result
  operator
  event hash

诊断数据:
  原始错误
  请求内容
  供应商响应
  调试上下文

前者可以按审计要求保存;后者应短期保存、脱敏或单独限制访问。删除证明不应包含原始记忆内容,否则系统会为了“证明已删除”而继续保存被删除的数据。

5.3 删除完成的判定条件

不能用“主数据库查不到”作为完成条件。更严格的完成条件可以写成:

complete(u)    tTonlinenot_readable(t,u)external_ack(u)backup_policy_satisfied(u)\operatorname{complete}(u) \iff \bigwedge_{t\in T_{\text{online}}} \operatorname{not\_readable}(t,u) \land \operatorname{external\_ack}(u) \land \operatorname{backup\_policy\_satisfied}(u)

其中:

  • TonlineT_{\text{online}}:在线数据库、缓存、索引、队列、日志等目标集合;
  • not_readable:正常业务身份无法读取;
  • external_ack:外部处理方已确认删除,或已记录受控失败;
  • backup_policy_satisfied:备份删除、过期或密钥销毁符合预先定义的规则。

如果某个目标没有可验证的删除接口,就不能伪造“已删除”。应记录:

target = vendor_x
result = deletion_not_verifiable
status = exception

然后依据合同、供应商数据处理协议或组织内部政策决定是否允许关闭请求。

六、备份:删除请求最容易遗漏的路径

6.1 为什么在线删除不等于备份删除

备份通常具有与在线数据库不同的性质:

  • 为恢复灾难而保留;
  • 可能是增量链或不可变对象;
  • 删除单条记录可能无法直接修改备份块;
  • 备份可能由独立平台或云服务托管;
  • 恢复测试会重新产生在线副本。

因此,删除流程至少要区分三种结果:

  1. 在线系统已经不可读;
  2. 备份中的数据仍可能存在,但受访问控制和生命周期限制;
  3. 备份过期、重写或密钥销毁后,数据达到组织定义的最终删除状态。

不能笼统地说“数据已经从所有地方删除”,除非确实能够验证备份介质。

6.2 备份删除的三种工程策略

策略一:等待备份生命周期自然过期

例如:

在线数据:立即删除
每日备份:保留 7 天
月度备份:保留 90 天

删除请求完成时可以记录:

online_deletion = completed
backup_deletion = scheduled_by_expiry
backup_final_date = 2026-11-30

这要求备份平台严格执行生命周期,且在备份恢复时再次应用删除清单。

策略二:按用户或租户使用独立加密密钥

数据密钥按租户或主体隔离:

C=EncKu(Du)C=\operatorname{Enc}_{K_u}(D_u)

删除时销毁 KuK_u,使备份中的 CC 在没有密钥的情况下不可恢复。

这种策略的边界是:

  • 密钥必须确实只服务于该主体或租户;
  • 密钥副本、密钥备份和密钥管理日志也要纳入生命周期;
  • 已经导出到其他系统的明文不会因密钥销毁而消失;
  • 多租户共享文件块时,不能因为删除一个主体而销毁其他主体所需的共享密钥。

策略三:备份恢复后重新执行删除清单

维护删除墓碑:

{
  "subject_ref": "hmac:user-123",
  "deleted_at": "2026-09-01T08:15:30Z",
  "scope": ["memory", "conversation", "embedding", "profile"]
}

恢复备份时:

restore backup
    │
    ├── load deletion tombstones
    ├── suppress deleted subjects
    ├── remove matching primary rows
    ├── rebuild indexes
    └── expose restored service

删除墓碑自身不能包含被删除数据,只保存执行恢复清理所需的最小标识。

6.3 备份与可追溯性的冲突

备份需要可靠恢复,删除需要不可恢复。两者的冲突不能通过一句“备份也会删除”解决,而要明确系统承诺:

删除请求完成
≠ 所有备份块瞬间被改写

删除请求完成
= 在线路径不可读
  + 外部传播已完成或进入受控例外
  + 备份残留受到访问控制
  + 有明确的最终过期/销毁时间
  + 恢复流程会重放删除墓碑

这是一种可验证的操作定义,而不是对底层介质作无法证明的绝对承诺。

七、组件、状态和数据流设计

一个典型的 Agent 记忆系统可以这样组织:

flowchart LR
    U[用户] --> API[Agent API]
    API --> P[Consent & Policy]
    P --> C[Context Builder]
    C --> M[模型供应商]
    M --> R[Response / Tool Result]

    R --> S[Memory Extractor]
    S --> W[Memory Write Service]

    W --> DB[(Primary DB)]
    W --> V[(Vector Index)]
    W --> X[(Search Index)]
    W --> Q[Async Queue]

    DB --> CH[Checkpoint / Conversation State]
    DB --> ST[Long-term Store]

    D[Deletion Request] --> DS[Deletion Coordinator]
    DS --> DB
    DS --> V
    DS --> X
    DS --> Q
    DS --> CA[Cache]
    DS --> EP[External Provider]
    DS --> AU[Audit Log]
    DS --> BT[Backup Tombstone]

关键路径如下:

  1. Consent & Policy 决定当前数据是否允许保存;
  2. Context Builder 只读取仍然有效且未被 suppress 的数据;
  3. Memory Write Service 统一执行写入授权,而不是让每个 Agent 节点自由写库;
  4. Memory Extractor 产生的原始记忆和 embedding 必须带上来源关系;
  5. Deletion Coordinator 负责扇出删除任务;
  6. Audit Log 只保存删除证明元数据;
  7. Backup Tombstone 保证灾难恢复后不会让已删除数据重新出现。

7.1 OpenAI 会话状态的边界

如果使用 previous_response_id 连接响应,应用保存的可能只是最后一个响应 ID,但此前响应仍然属于链路上下文。OpenAI 文档明确说明,Responses API 的响应对象默认保存 30 天,可以通过 store: false 禁用该行为;而附着到 Conversation 的 response,其 items 持久化时不受该 30 天 TTL 限制。(developers.openai.com)

这会产生一个工程判断:

只保存 previous_response_id
    不代表
只保存了一个无意义的指针

如果该 ID 能继续解析出历史上下文,它就是对持久化会话状态的引用。删除设计必须明确:

  • 是否使用 Responses 的默认保存行为;
  • 是否使用 store: false
  • 是否使用 Conversations API;
  • Conversation 对象和其中 items 的生命周期;
  • 用户删除时,应用是否还会继续传递旧 ID;
  • 供应商侧对象如何由组织策略处理。

不要把 OpenAI 文档中的默认 TTL 自动当成整个 Agent 系统的保留策略。它只描述特定 API 对象的保存行为,并不覆盖应用数据库、日志、向量库、缓存和备份。

7.2 LangGraph checkpointer 与 store 的边界

LangGraph 中,checkpointer 和 store 的数据范围不同:

checkpointer = InMemorySaver()
store = InMemoryStore()

graph = builder.compile(
    checkpointer=checkpointer,
    store=store,
)

graph.invoke(
    {"messages": [{"role": "user", "content": "我喜欢简洁回答"}]},
    {"configurable": {"thread_id": "thread-1"}},
)

该示例中:

  • thread_id 用于定位线程级图状态;
  • checkpointer 保存该线程的状态快照;
  • store 用于保存跨线程可复用的数据;
  • InMemorySaverInMemoryStore 适合示例和测试,不适合作为进程重启后仍需保留数据的生产持久化方案。LangGraph 文档也指出,内存 checkpointer 在进程重启后会丢失,并提醒长期对话中的 checkpoint 可能无限增长,需要设置清理或保留策略。(docs.langchain.com)

因此,删除一个用户时不能只清空 store:

必须同时检查:
  user 的所有 thread_id
  每个 thread 的 checkpoints
  store 中跨线程记忆
  subgraph 的独立 checkpoint namespace
  外部向量和搜索索引

LangGraph 文档特别指出,子图可能拥有自己的 checkpoint namespace;跨图边界共享的数据应通过 Store 或明确配置写入父 checkpoint。这个边界也意味着删除协调器不能只扫描主图的状态表。(docs.langchain.com)

八、一个可执行的关系数据库模型

下面的 PostgreSQL 结构展示核心关系。它不是某个框架的内置 schema,而是用于说明删除语义的最小实现。

CREATE TABLE consent (
    subject_id      text        NOT NULL,
    purpose         text        NOT NULL,
    category        text        NOT NULL,
    status          text        NOT NULL
                    CHECK (status IN ('active', 'revoked', 'expired')),
    policy_version  text        NOT NULL,
    granted_at      timestamptz NOT NULL,
    revoked_at      timestamptz,
    expires_at      timestamptz,
    PRIMARY KEY (subject_id, purpose, category)
);

CREATE TABLE memories (
    memory_id       uuid        PRIMARY KEY,
    subject_id      text        NOT NULL,
    purpose         text        NOT NULL,
    category        text        NOT NULL,
    content         jsonb       NOT NULL,
    status          text        NOT NULL
                    CHECK (status IN ('active', 'suppressed', 'deleted')),
    source_ref      text,
    created_at      timestamptz NOT NULL,
    expires_at      timestamptz NOT NULL,
    deleted_at      timestamptz
);

CREATE INDEX memories_subject_idx
    ON memories(subject_id, status);

CREATE INDEX memories_expiry_idx
    ON memories(expires_at)
    WHERE status = 'active';

CREATE TABLE memory_derivatives (
    memory_id       uuid        NOT NULL REFERENCES memories(memory_id),
    derivative_type text        NOT NULL,
    derivative_ref  text        NOT NULL,
    status          text        NOT NULL
                    CHECK (status IN ('active', 'deleted')),
    deleted_at      timestamptz,
    PRIMARY KEY (memory_id, derivative_type, derivative_ref)
);

CREATE TABLE deletion_requests (
    request_id      uuid        PRIMARY KEY,
    subject_id      text        NOT NULL,
    scope           jsonb       NOT NULL,
    status          text        NOT NULL
                    CHECK (status IN (
                        'requested', 'authorized', 'suppressed',
                        'propagating', 'verifying', 'completed',
                        'exception'
                    )),
    requested_at    timestamptz NOT NULL,
    completed_at    timestamptz
);

CREATE TABLE deletion_events (
    event_id        bigserial   PRIMARY KEY,
    request_id      uuid        NOT NULL REFERENCES deletion_requests(request_id),
    target          text        NOT NULL,
    target_ref      text,
    result          text        NOT NULL,
    attempt         integer     NOT NULL,
    occurred_at     timestamptz NOT NULL,
    previous_hash   text,
    event_hash      text        NOT NULL
);

8.1 写入时的授权检查

BEGIN;

SELECT 1
FROM consent
WHERE subject_id = 'user-123'
  AND purpose = 'personalization'
  AND category = 'preference'
  AND status = 'active'
  AND (expires_at IS NULL OR expires_at > now())
FOR SHARE;

-- 应用层确认上述查询返回一行后,才允许写入
INSERT INTO memories (
    memory_id, subject_id, purpose, category,
    content, status, source_ref,
    created_at, expires_at
)
VALUES (
    gen_random_uuid(),
    'user-123',
    'personalization',
    'preference',
    '{"language":"zh-CN"}',
    'active',
    'message-abc',
    now(),
    now() + interval '90 days'
);

COMMIT;

这里的 expires_at 不是由用户任意传入,而应由策略服务计算。否则客户端可以伪造一个远期时间,把本应保存 30 天的数据写成 10 年。

8.2 删除请求的第一阶段:立即抑制

BEGIN;

INSERT INTO deletion_requests (
    request_id, subject_id, scope, status, requested_at
)
VALUES (
    gen_random_uuid(),
    'user-123',
    '{"memory":true,"conversation":true,"derivatives":true}',
    'authorized',
    now()
)
RETURNING request_id;

UPDATE memories
SET status = 'suppressed'
WHERE subject_id = 'user-123'
  AND status = 'active';

UPDATE deletion_requests
SET status = 'suppressed'
WHERE request_id = :request_id;

COMMIT;

事务提交后,读取路径必须排除 suppressed

SELECT memory_id, content
FROM memories
WHERE subject_id = :subject_id
  AND status = 'active'
  AND expires_at > now();

这样,即使后台物理删除尚未完成,新的 Agent 请求也不会继续使用待删除记忆。

8.3 删除派生对象

SELECT memory_id, derivative_type, derivative_ref
FROM memory_derivatives
WHERE memory_id IN (
    SELECT memory_id
    FROM memories
    WHERE subject_id = :subject_id
);

应用根据 derivative_type 调用对应删除器:

DELETERS = {
    "embedding": vector_index.delete,
    "search_document": search_index.delete,
    "cache": cache.delete,
}

for row in derivative_rows:
    deleter = DELETERS[row.derivative_type]
    try:
        deleter(row.derivative_ref)
        audit.success(
            request_id=request_id,
            target=row.derivative_type,
            target_ref=hmac_ref(row.derivative_ref),
        )
    except RetryableError as exc:
        audit.failure(
            request_id=request_id,
            target=row.derivative_type,
            target_ref=hmac_ref(row.derivative_ref),
            error_class=type(exc).__name__,
        )
        raise

错误日志只记录错误类别和请求 ID,不直接记录向量内容、原始文本或完整供应商响应。

九、并发、队列和故障路径

9.1 删除与写入竞争

设两个并发事务:

T1:用户删除
T2:Agent 自动提取记忆

如果 T2 在 T1 的 suppress 之前读取到旧消息,并在 T1 完成后写入新记忆,就会出现“删除后复活”。

一种解决方案是写入时检查主体删除代:

CREATE TABLE subject_guard (
    subject_id      text PRIMARY KEY,
    deletion_epoch  bigint NOT NULL DEFAULT 0
);

删除开始时递增:

UPDATE subject_guard
SET deletion_epoch = deletion_epoch + 1
WHERE subject_id = 'user-123';

异步任务携带创建时的 epoch:

{
  "subject_id": "user-123",
  "deletion_epoch": 4,
  "memory_payload": {}
}

写入前验证:

SELECT deletion_epoch
FROM subject_guard
WHERE subject_id = :subject_id;

若当前 epoch 不等于任务中的 epoch,则拒绝写入。删除完成后还可以保持主体处于 deletion_locked 状态,直到所有旧任务过期或被撤销。

9.2 队列中的明文

删除请求通常会遗漏消息队列:

Kafka topic: memory-extraction
Celery retry: embedding-generation
dead-letter queue: failed-tool-output

如果队列消息中包含原始用户内容,删除数据库记录并不能删除尚未消费的消息。

更稳妥的设计是队列只传引用:

{
  "job_id": "job-001",
  "subject_id": "user-123",
  "memory_id": "m-001",
  "generation": 7
}

消费者从受控存储读取数据,并在处理前检查:

if deletion_guard.is_suppressed(subject_id):
    return JobResult.skipped("subject is suppressed")

if generation != deletion_guard.current_generation(subject_id):
    return JobResult.skipped("stale generation")

如果业务必须把明文放入队列,则消息保留期、死信队列、重试队列和消费日志都要纳入删除闭包。

9.3 失败重试不能重新激活数据

常见错误是删除任务失败后,重试逻辑重新执行“恢复记忆”流程:

except Exception:
    memory.status = "active"  # 错误:为了重试而恢复可见性

删除任务失败时应保持:

active -> suppressed -> retrying

而不是:

active -> suppressed -> active -> retrying

只要删除请求尚未明确取消,系统就不应将目标记忆恢复为可读状态。

十、常见误解与反例

误解一:删除用户画像即可

反例:

删除 profile 表
保留 conversation 表
保留 embedding
保留 summary
保留日志中的 prompt

用户画像虽然消失,但 Agent 仍可以从历史会话、摘要或向量检索中恢复同一事实。正确做法是按数据来源和派生关系建立删除闭包。

误解二:向量不可逆,所以不需要删除

Embedding 不是天然匿名数据。它可能被用于相似性检索,也可能与用户、文档或时间信息关联。即使无法直接还原原文,仍可能影响 Agent 输出。因此 embedding 是否纳入删除范围,不应取决于“能否完美反向解码”,而应取决于它是否仍代表或支持使用该主体的数据。

误解三:设置 store: false 就没有持久化

store: false 影响的是特定 Response 对象的保存行为;它不自动清理应用日志、数据库、缓存、向量索引或第三方工具中的副本。OpenAI 文档同时说明,Conversation 对象及其中 items 的保存语义不同于普通 Response 的 30 天默认保存。(developers.openai.com)

误解四:LangGraph 的 thread 删除等于用户记忆删除

thread_id 通常代表一次会话线程,而用户长期记忆可能位于跨线程 Store;多个线程还可能属于同一个用户。删除一个线程不能推出删除该用户的所有长期数据。LangGraph 对 checkpointer 和 store 的范围区分正是为了表达这种差异。(docs.langchain.com)

误解五:审计日志必须保留原文

删除证明需要的是事件、时间、目标和结果,不是被删除的 prompt。保存原文会制造新的泄露面,也会让删除流程自相矛盾。

误解六:备份会自动跟随主库删除

大多数备份系统不是逐行同步的在线数据库。备份可能在删除请求之后仍包含旧数据。系统必须明确采用自然过期、独立密钥销毁、删除墓碑重放或组合策略,并测试恢复流程。

十一、诊断与验证

11.1 删除验证矩阵

为每种数据存储定义验证动作:

目标 删除动作 验证动作
主数据库 更新为 suppressed,再物理删除 按主体和 memory ID 查询不到
向量库 删除 vector ID 或 namespace 相似性检索不返回目标
搜索索引 删除文档并刷新索引 关键词和 ID 查询均无结果
缓存 删除 key,等待传播 读取 miss,不能回源恢复旧值
队列 撤销任务、清理死信 消费者跳过旧 generation
checkpoint 删除线程状态或其引用 新运行无法载入被删状态
store 删除跨线程 key 其他线程无法检索
外部供应商 调用其删除机制或记录例外 获取确认或受控失败证据
备份 到期、销毁密钥或墓碑重放 恢复演练后目标不可见

“查询不到”仍然要定义查询身份和查询范围。管理员直接访问底层存储、备份恢复环境或灾备账号,可能看到业务账号无法看到的数据;验证报告必须记录测试身份、时间、版本和目标环境。

11.2 负向测试

删除测试不能只验证“删除前能读、删除后读不到”,还要验证删除后的反向路径:

1. 删除前写入一条独特字符串;
2. 生成 embedding、摘要、缓存和 checkpoint;
3. 发起删除;
4. 删除后继续发送相似问题;
5. 检查 Agent 是否复述该字符串;
6. 重启服务;
7. 从备份恢复到隔离环境;
8. 重放删除墓碑;
9. 再次检查所有查询路径。

例如使用唯一探针:

WR-PRIVACY-PROBE-20260901-ALPHA-7391

如果删除后模型仍然输出该探针,诊断顺序应是:

主数据库
→ store
→ checkpoint / conversation
→ vector index
→ search index
→ cache
→ prompt cache
→ queue / dead-letter
→ logs / evaluation dataset
→ external provider

不能因为主表为空,就直接把问题归因于模型“记住了”。

11.3 删除延迟指标

删除不是只有成功和失败,还应测量:

Lonline=tall online targets suppressedtrequestL_{\text{online}}=t_{\text{all online targets suppressed}}-t_{\text{request}}

Lcomplete=tcompletiontrequestL_{\text{complete}}=t_{\text{completion}}-t_{\text{request}}

其中:

  • LonlineL_{\text{online}}:在线读取路径全部停止使用的时间;
  • LcompleteL_{\text{complete}}:达到组织定义的完整删除条件所需时间。

生产监控至少应包含:

deletion_requests_total
deletion_requests_completed_total
deletion_requests_exception_total
deletion_target_retry_total
deletion_online_latency
deletion_completion_latency
stale_job_skipped_total
post_deletion_probe_failure_total

如果 post_deletion_probe_failure_total 大于零,优先暂停相关记忆写入和检索,而不是继续扩大数据副本。

十二、生产取舍:精确删除、隔离删除与加密擦除

不同系统很难同时获得最低成本、最低延迟和最强删除保证。

精确删除

每个派生对象都有来源引用,删除可定位到最小粒度。

优点:

  • 删除范围精确;
  • 不影响其他用户;
  • 审计容易解释。

代价:

  • 需要维护依赖图;
  • 所有异步任务都必须传递来源 ID;
  • 摘要和聚合结果的引用关系复杂。

租户或用户级隔离

按用户、租户或数据域建立独立 namespace:

vector namespace: tenant-001/user-123
cache namespace: user-123
encryption key: key-user-123

优点是删除和权限边界清楚,缺点是 namespace、索引分片和密钥数量增加,跨用户共享知识也更复杂。

加密擦除

适合无法及时重写全部备份或对象存储的场景,但依赖密钥隔离和严格的密钥生命周期。它通常应作为备份策略的一部分,而不是用来替代在线数据库、缓存和索引的删除。

实际系统往往采用组合方案:

在线主库:逻辑抑制 + 物理删除
向量/搜索:按来源 ID 删除
缓存:主动失效 + 短 TTL
队列:generation 拒绝旧任务
日志:最小化字段 + 独立保留期
备份:生命周期过期 + 恢复时重放墓碑
外部供应商:供应商删除确认或受控例外

结语:删除能力必须在写入之前设计

Agent 记忆隐私的核心不是“增加一个删除按钮”,而是让数据从产生之日起就具备:

  • 明确的数据主体;
  • 明确的处理目的;
  • 可验证的同意范围;
  • 独立的保留期限;
  • 原始数据与派生数据的来源关系;
  • 可阻断读取和写入的状态;
  • 可重试、幂等的删除任务;
  • 不泄露原文的审计证据;
  • 明确的备份删除或不可恢复策略;
  • 可执行的恢复后删除流程。

可以用下面的条件检验一个 Agent 是否真正具备删除能力:

deletable(m)    identifiable(m)scoped(m)derivations_known(m)writes_blockable(m)deletion_idempotent(m)verification_available(m)backup_policy_defined(m)\text{deletable}(m) \iff \text{identifiable}(m) \land \text{scoped}(m) \land \text{derivations\_known}(m) \land \text{writes\_blockable}(m) \land \text{deletion\_idempotent}(m) \land \text{verification\_available}(m) \land \text{backup\_policy\_defined}(m)

如果其中任何一项不成立,系统最多只能声称“删除了某个在线记录”,不能声称“删除了 Agent 的记忆”。


系列导航与关联阅读

官方资料

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