Agent 工程体系 · 第 29/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 记忆隐私与删除:同意、保留期、可追溯删除和备份
Agent 的“记忆”不是一个单独的字段,而是一组可能位于不同系统、不同生命周期中的数据:
- 当前请求中的上下文;
- 会话消息、工具调用和工具输出;
- Agent 工作流的中间状态;
- 跨会话保存的用户偏好、事实和画像;
- 向量索引、全文索引和缓存;
- 观测日志、错误报告、提示词记录;
- 数据库快照、对象存储版本和备份;
- 已经发送给模型供应商或第三方工具的数据。
因此,“删除用户记忆”不能简化为执行一次 DELETE FROM memories。一个可验证的删除实现,必须同时回答四个问题:
- 用户是否同意系统保存这类数据?
- 系统应当保存多久,什么时候必须停止使用?
- 删除请求如何沿着所有副本和派生数据传播,并留下可审计证据?
- 在线数据删除后,备份中的数据如何处理,何时才算完成?
本文将这些问题统一成一个工程模型,并结合 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 中的摘要;
- 缓存中的序列化对象;
- 评测和调试系统中的样本;
- 数据仓库中的分析记录。
所以应把一条记忆建模为一个数据对象及其派生对象集合,而不是某张表中的一行。
设用户主体为 ,一条原始记忆为 ,其派生数据集合为:
其中:
- :原始文本或结构化记忆;
- :由它生成的 embedding、摘要、索引、缓存或日志;
- :删除请求必须覆盖的完整数据闭包。
删除只覆盖 ,而不覆盖 ,得到的只是源记录删除,不是记忆删除。
1.2 删除的四种含义
工程上经常把以下四个概念混在一起。
停止使用
数据仍然存在,但 Agent 不再把它读入 prompt、检索结果或工具参数。
这可以通过:
UPDATE memories
SET status = 'suppressed'
WHERE subject_id = 'user-123';
实现,但它不是物理删除。
逻辑删除
数据仍可能存在于数据库、索引或备份中,但主业务查询不可见。逻辑删除适合异步删除流程,因为它可以先阻止继续使用,再进行后台清理。
物理删除
从在线数据库、索引、缓存和对象存储中删除数据,使正常操作无法再读到它。
密钥销毁
如果数据使用独立数据密钥加密,销毁该密钥可以使保留在某些介质中的密文不可恢复。这通常称为加密擦除或 crypto-erasure。
这四者的强度不同:
但它们不是严格替代关系。例如,物理删除数据库记录并不能自动删除备份;密钥销毁也不能消除已经出现在访问日志中的明文。
二、同意不是一个布尔字段
2.1 “同意保存记忆”必须有范围
同意是用户对某种处理目的、数据范围和保存期限的授权,而不是一个永久的:
{"consent": true}
至少需要区分:
| 维度 | 示例 |
|---|---|
| 主体 | 哪个用户、组织或租户 |
| 目的 | 提供连续对话、个性化推荐、质量分析 |
| 数据类别 | 称呼、语言偏好、健康信息、财务信息 |
| 处理动作 | 保存、检索、生成 embedding、发送给供应商 |
| 期限 | 30 天、直到撤回、任务结束 |
| 范围 | 某个 Agent、某个租户、全部产品 |
| 版本 | 用户同意时看到的隐私说明版本 |
| 时间 | 生效时间、撤回时间 |
因此,同意记录可以建模为:
其中:
- :数据主体;
- :处理目的;
- :数据类别;
- :允许的处理动作;
- :同意开始时间;
- :同意结束时间,可能为空;
- :告知或政策版本。
一条写入操作 只有在以下条件同时成立时才允许:
直觉是:即使用户同意保存“偏好”,也不代表系统可以保存所有聊天内容;即使用户同意个性化,也不代表可以把数据用于质量分析或训练。
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 保留期的定义
保留期是数据允许被保存或使用的时间窗口。对一条记忆 ,可以定义:
这里的每个时间点含义不同:
- :处理目的不再需要数据的时间;
- :同意撤回或授权到期时间;
- :系统设定的最大保存期限;
- :特定合规或争议保全要求结束时间。
这不是法律结论,而是系统设计中的决策模型。具体期限必须由组织的隐私、法务和业务规则确定。
例如:
用户语言偏好:
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
此时一条记忆的实际残留时间至少是:
如果索引和日志确实包含可恢复的个人数据,最长的那一层决定了实际风险。
保留策略应记录在数据目录中:
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
可以将依赖关系表示为有向图:
其中:
- :原始记录、派生记录、缓存、日志、备份对象;
- :对象 由对象 派生或引用。
删除目标不是单个节点 ,而是从 出发可达的节点集合:
如果摘要 s-023 是由记忆 m-001 生成的,删除 m-001 时必须至少将 s-023 标记为无效或重新生成;否则 Agent 仍可能从摘要中恢复原始事实。
实际系统中不一定能精确记录所有语义依赖。例如,一个包含十条记忆的摘要可能无法简单地反向映射到其中一条。因此有三种策略:
- 精确引用:摘要保存来源 ID,删除时精确失效;
- 批次隔离:每批记忆生成独立摘要,删除粒度较粗但可靠;
- 重新计算:删除源数据后,从剩余数据重建摘要和索引。
第三种策略成本较高,但适合无法安全判断摘要内容是否仍包含被删除信息的场景。
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_ref 和 target_ref 可以使用带密钥的 HMAC,而不是普通哈希:
普通哈希在用户 ID 空间较小时可能被枚举;HMAC 只有持有密钥的审计服务才能稳定关联同一主体。即使如此,HMAC 结果仍可能构成可关联标识,不能把它当作绝对匿名化。
5.2 审计日志也有保留期
“为了证明删除,所以永久保存所有日志”是常见错误。审计记录本身可能包含:
- 用户 ID;
- 请求 IP;
- 工具名称;
- 数据库主键;
- 错误信息;
- 供应商响应内容;
- 原始 prompt 片段。
应把审计事件拆成两类:
删除证明元数据:
request_id
subject_ref
target
timestamp
result
operator
event hash
诊断数据:
原始错误
请求内容
供应商响应
调试上下文
前者可以按审计要求保存;后者应短期保存、脱敏或单独限制访问。删除证明不应包含原始记忆内容,否则系统会为了“证明已删除”而继续保存被删除的数据。
5.3 删除完成的判定条件
不能用“主数据库查不到”作为完成条件。更严格的完成条件可以写成:
其中:
- :在线数据库、缓存、索引、队列、日志等目标集合;
not_readable:正常业务身份无法读取;external_ack:外部处理方已确认删除,或已记录受控失败;backup_policy_satisfied:备份删除、过期或密钥销毁符合预先定义的规则。
如果某个目标没有可验证的删除接口,就不能伪造“已删除”。应记录:
target = vendor_x
result = deletion_not_verifiable
status = exception
然后依据合同、供应商数据处理协议或组织内部政策决定是否允许关闭请求。
六、备份:删除请求最容易遗漏的路径
6.1 为什么在线删除不等于备份删除
备份通常具有与在线数据库不同的性质:
- 为恢复灾难而保留;
- 可能是增量链或不可变对象;
- 删除单条记录可能无法直接修改备份块;
- 备份可能由独立平台或云服务托管;
- 恢复测试会重新产生在线副本。
因此,删除流程至少要区分三种结果:
- 在线系统已经不可读;
- 备份中的数据仍可能存在,但受访问控制和生命周期限制;
- 备份过期、重写或密钥销毁后,数据达到组织定义的最终删除状态。
不能笼统地说“数据已经从所有地方删除”,除非确实能够验证备份介质。
6.2 备份删除的三种工程策略
策略一:等待备份生命周期自然过期
例如:
在线数据:立即删除
每日备份:保留 7 天
月度备份:保留 90 天
删除请求完成时可以记录:
online_deletion = completed
backup_deletion = scheduled_by_expiry
backup_final_date = 2026-11-30
这要求备份平台严格执行生命周期,且在备份恢复时再次应用删除清单。
策略二:按用户或租户使用独立加密密钥
数据密钥按租户或主体隔离:
删除时销毁 ,使备份中的 在没有密钥的情况下不可恢复。
这种策略的边界是:
- 密钥必须确实只服务于该主体或租户;
- 密钥副本、密钥备份和密钥管理日志也要纳入生命周期;
- 已经导出到其他系统的明文不会因密钥销毁而消失;
- 多租户共享文件块时,不能因为删除一个主体而销毁其他主体所需的共享密钥。
策略三:备份恢复后重新执行删除清单
维护删除墓碑:
{
"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]
关键路径如下:
Consent & Policy决定当前数据是否允许保存;Context Builder只读取仍然有效且未被 suppress 的数据;Memory Write Service统一执行写入授权,而不是让每个 Agent 节点自由写库;Memory Extractor产生的原始记忆和 embedding 必须带上来源关系;Deletion Coordinator负责扇出删除任务;Audit Log只保存删除证明元数据;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 用于保存跨线程可复用的数据;
InMemorySaver和InMemoryStore适合示例和测试,不适合作为进程重启后仍需保留数据的生产持久化方案。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 删除延迟指标
删除不是只有成功和失败,还应测量:
其中:
- :在线读取路径全部停止使用的时间;
- :达到组织定义的完整删除条件所需时间。
生产监控至少应包含:
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 是否真正具备删除能力:
如果其中任何一项不成立,系统最多只能声称“删除了某个在线记录”,不能声称“删除了 Agent 的记忆”。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent 记忆存储:关系库、向量库、事件日志、版本和一致性
- 下一篇:Agentic RAG:检索决策、查询分解、重排、迭代和停止
- 延伸:Agent 身份与用户画像:稳定标识、偏好、称呼和可信来源
- 延伸:Agent 数据隐私:最小采集、脱敏、保留、跨境、导出和删除
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论