Agent 工程体系 · 第 26/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 身份与用户画像:稳定标识、偏好、称呼和可信来源
Agent 要在多轮对话中“记住用户”,至少需要回答四个不同的问题:
- 这是哪个主体?
- 这次请求属于哪个租户、线程或群聊上下文?
- 这个主体有哪些可持久化的偏好和事实?
- 这些信息来自哪里,是否足以支持当前决策?
如果把这四个问题都简化为一个 user_id,系统迟早会出现严重错误:把同一设备上的不同用户混为一谈,把群聊中的成员偏好写入群体画像,把用户随口说的话当成永久事实,或者在账号合并后继续沿用旧身份。
本文建立一套面向 Agent 的身份与画像模型,重点讨论:
- Agent 身份、用户身份、会话身份和授权主体的区别;
- 稳定标识的语义、生成、迁移、合并与撤销;
- 用户画像、偏好、事实、称呼和临时上下文的边界;
- “用户说过”与“系统确认过”的可信来源差异;
- 画像写入、读取、冲突解决和并发更新;
- OpenAI Responses/Conversations 与 LangGraph Persistence 中的状态边界;
- 多用户、租户、线程、群聊和子图场景中的串线风险;
- 隐私、同意、保留期、可追溯删除与备份删除;
- 生产环境中的诊断、验证和故障恢复。
一、先建立对象模型:身份不是一个字段
1. Agent 身份
Agent 身份是 Agent 作为一个软件实体对外表现的稳定属性集合。它通常包括:
{
"agent_id": "agent:expense-assistant:v3",
"agent_version": "3.4.1",
"tenant_id": "tenant:acme",
"capabilities": [
"read_expenses",
"create_expense_draft"
],
"policy_version": "policy-2026-08-17"
}
这里的 agent_id 不是用户身份,也不是某次运行的请求 ID。
一个 Agent 可以有多个运行实例:
agent:expense-assistant:v3
├── run:01J...
├── run:01K...
└── run:01L...
这些运行实例可以并发处理不同用户的请求,但它们共享的是 Agent 的产品定义、工具能力和策略版本,而不是用户私有记忆。
因此,下面两种数据必须分开:
| 数据 | 所属对象 | 示例 |
|---|---|---|
| Agent 名称 | Agent | “报销助手” |
| Agent 工具权限 | Agent 或租户策略 | 可读取报销单 |
| 当前请求 ID | 运行实例 | run:01J... |
| 用户姓名 | 用户主体 | “张三” |
| 用户称呼偏好 | 用户画像 | “请叫我小张” |
| 当前问题 | 线程 | “这张发票能报销吗?” |
关键因果关系是:Agent 的身份决定“它是谁、能做什么”,用户身份决定“它正在为谁工作”,线程身份决定“当前工作发生在哪个连续上下文中”。
如果把三者混合,权限和记忆都会失去边界。
2. 用户身份与授权主体
用户身份是系统认为正在与 Agent 交互的主体标识。这个主体不一定是一个自然人,也可能是:
- 企业账号;
- 服务账号;
- 机器人;
- 代表某个部门的共享身份;
- 群聊中的一个成员;
- 外部系统委托的调用方。
而授权主体是当前请求实际携带权限的主体。例如:
登录账号 Alice
↓
请求授权主体 principal:alice
↓
代表租户 tenant:acme
↓
访问用户画像 subject:alice
大多数单用户应用中,actor 和 subject 看起来相同,但在代办和委托场景中不相同:
actor = assistant-service
subject = user:alice
on_behalf_of = tenant:acme
例如,企业报销 Agent 可能由服务账号调用,但它不能因此把所有请求都归属于服务账号;它必须从经过认证的请求上下文中确定具体用户和租户。
建议在内部请求对象中显式保存这些维度:
{
"request_id": "req:01J...",
"agent_id": "agent:expense-assistant:v3",
"actor_id": "service:expense-agent",
"subject_user_id": "user:alice",
"tenant_id": "tenant:acme",
"channel_id": "channel:feishu",
"conversation_id": "conv:01J...",
"thread_id": "thread:01J...",
"message_id": "msg:01J..."
}
这不是字段越多越好,而是因为每个字段回答的问题不同:
actor_id:谁发起了系统动作?subject_user_id:动作代表谁?tenant_id:数据隔离边界在哪里?conversation_id:哪条长期对话?thread_id:哪条具体工作流或线程?message_id:哪条原始证据?
二、稳定标识:同一个主体必须能被可靠识别
1. 稳定标识的定义
稳定标识是一个在允许的生命周期内,持续指向同一主体的不可歧义标识。
可以形式化为:
其中:
id是稳定标识;t是时间;s是主体;- 在标识有效期内,解析结果应保持为同一主体。
更严格地说,稳定标识应满足四个条件:
唯一性
不同主体不应共享同一个标识:
否则会发生画像合并和权限泄漏。
持久性
同一主体在不同设备、会话或请求中仍能被解析为同一标识:
这里的“同一主体”必须依据账号系统或身份提供方判断,而不是依据昵称、IP 或浏览器指纹判断。
不可变性
稳定标识本身不应因为展示属性改变而改变:
user_id = user:7f3c...
display_name: Alice → Alice Wang
preferred_address: Alice → 王老师
用户名、邮箱、昵称、头像和称呼都可能变化;它们不是稳定标识。
可撤销性
当账号被删除、合并、去标识化或租户关系解除时,系统必须能停止继续解析该标识,或将其映射到受控状态:
active → suspended → deleted
“稳定”不等于“永远有效”,而是指在生命周期内不因普通属性变化而漂移。
2. 哪些字段不能作为稳定标识
以下字段通常不能单独作为稳定身份:
| 字段 | 不能作为稳定标识的原因 |
|---|---|
| 昵称 | 可重复、可修改 |
| 显示名 | 同名普遍存在 |
| 邮箱 | 可变更、可能被回收或共享 |
| 手机号 | 可换号、可能重新分配 |
| IP 地址 | 代表网络位置,不代表用户 |
| Cookie | 只能代表浏览器或设备,且可清除 |
| 设备指纹 | 不稳定,且存在隐私风险 |
| 群昵称 | 只在特定群聊中有效 |
| 头像 | 可更换,可能重复 |
| Access Token | 代表一次授权凭证,不应直接作为画像主键 |
比较合理的结构是:
内部主键:user_id = user:random-uuid
外部映射:
oidc:issuer-a / sub:abc123 → user:random-uuid
feishu / open_id:ou_xxx → user:random-uuid
email:alice@example.com → user:random-uuid
其中外部身份映射允许变化,内部用户主键保持不变。
如果使用哈希生成标识,也必须明确哈希的输入边界和密钥策略。例如:
user_id = HMAC-SHA256(server_secret, issuer + "\x00" + subject)
不能简单使用:
sha256(email)
因为邮箱空间有限,离线枚举容易恢复原文;同时未带发行方的 sub 可能在不同身份系统中碰撞。
3. 标识解析必须依赖认证上下文
一个安全的解析过程应类似:
请求进入
↓
验证令牌签名、发行方、受众和过期时间
↓
读取 issuer + subject
↓
查找 external_identity_mapping
↓
得到内部 user_id
↓
校验 user_id 与 tenant_id 的归属关系
↓
构造 AgentRequestContext
伪代码:
def resolve_request_context(token: str, tenant_id: str) -> RequestContext:
claims = verify_jwt(
token,
expected_issuer="https://id.example.com",
expected_audience="expense-agent",
)
external_key = (claims["iss"], claims["sub"])
user_id = identity_mapping.lookup(external_key)
if user_id is None:
raise AuthenticationError("unknown external identity")
if not membership_store.is_member(user_id, tenant_id):
raise AuthorizationError("user is not a member of tenant")
return RequestContext(
actor_id=f"principal:{claims['sub']}",
subject_user_id=user_id,
tenant_id=tenant_id,
)
这里的关键是:Agent 不应通过自然语言自行猜测用户身份。
例如用户说:
我是 Alice,帮我查看公司的报销记录。
这句话可以作为对话内容,但不能替代已经验证的身份凭证。否则任何人都能冒充 Alice。
三、身份、会话、线程和消息:四个作用域必须分离
1. 会话状态不等于用户画像
会话状态是为了完成当前连续交互而保存的信息:
{
"thread_id": "thread:123",
"current_task": "审核 2026-08-31 的报销单",
"selected_expense_id": "expense:456",
"pending_confirmation": true
}
用户画像是跨线程仍可能成立的持久化信息:
{
"user_id": "user:alice",
"preferred_language": "zh-CN",
"preferred_address": "小张",
"default_currency": "CNY"
}
二者的差别可以写成作用域函数:
通常:
thread:当前任务和对话连续性;user:某个用户跨线程的偏好和事实;tenant:企业或团队共享知识;global:产品级配置和公共规则。
如果把线程状态直接写进用户画像:
thread: “这次称呼我为王总”
可能被错误地解释为永久偏好。
如果把用户画像直接放进线程状态,则换线程、换设备或重启后可能丢失一致性。
2. 群聊需要成员作用域
群聊中至少存在三个主体:
群聊主体:group:g1
消息发送者:user:alice
被讨论对象:user:bob
例如 Alice 在群里说:
Bob 喜欢周五下午开会。
这句话的证据主体是 Alice,陈述对象是 Bob,作用域可能是当前群聊,而不是 Bob 的全局用户画像。
应区分:
{
"fact": "Bob prefers Friday afternoon meetings",
"subject_user_id": "user:bob",
"source_actor_id": "user:alice",
"scope": "group:team-1",
"confidence": 0.58,
"status": "unverified"
}
只有在 Bob 自己确认,或者企业目录、日历系统等可信系统提供证据后,才可以考虑提升为更广作用域。
3. 线程 ID 不能替代用户 ID
线程是一次连续工作流的身份,不是用户身份:
user:alice
├── thread:tax-2026
├── thread:travel-2026
└── thread:weekly-report
同一用户可以有多个线程;一个群聊线程也可能包含多个用户。
LangGraph 的持久化模型明确区分了线程级检查点和跨线程 Store:checkpointer 保存单线程的图状态,store 保存可跨线程访问的应用数据,例如用户偏好和事实。调用图时需要通过 thread_id 选择线程。(docs.langchain.com)
因此,下面的设计是危险的:
profile_key = config["configurable"]["thread_id"]
因为它会把用户画像错误地绑定到线程。
更合理的设计是:
thread_id = "thread:01J..."
user_id = "user:01H..."
thread_state = checkpointer.load(thread_id)
user_profile = store.get(("users", user_id), "profile")
四、用户画像:不是“关于用户的一切”
1. 用户画像的定义
用户画像是围绕某个主体、在特定作用域内、经过结构化管理的事实、偏好、称呼和约束集合。
它至少应包含:
{
"subject_user_id": "user:alice",
"scope": {
"tenant_id": "tenant:acme",
"channel_id": null
},
"attributes": {
"preferred_language": "zh-CN",
"preferred_address": "小张",
"timezone": "Asia/Shanghai"
},
"version": 12,
"updated_at": "2026-08-31T08:00:00Z"
}
画像不是模型自行生成的一段长文本,而应尽量保留结构化属性。原因是结构化数据可以:
- 单字段更新;
- 单字段删除;
- 检查来源;
- 比较版本;
- 应用权限;
- 进行审计;
- 在提示词中按需投影。
2. 画像中的四类数据
偏好
偏好是用户希望系统在多个可行方案之间优先采用的选择。
例如:
{
"key": "response.detail_level",
"value": "concise"
}
偏好通常具有以下特征:
- 可改变;
- 不一定是事实;
- 适用于特定任务或通道;
- 可能存在例外。
“我一般喜欢简洁回答”不是绝对约束。更准确的表示是:
{
"key": "response.detail_level",
"value": "concise",
"scope": "user",
"exceptions": [
"security_review",
"legal_analysis"
]
}
事实
事实是系统认为关于用户或业务对象成立的命题,例如:
{
"key": "department",
"value": "finance"
}
事实与偏好不能混为一谈。部门可能来自企业目录,可信度较高;“我喜欢蓝色”则来自用户陈述,属于偏好。
称呼
称呼是 Agent 在输出中引用用户时使用的称谓,例如:
{
"key": "preferred_address",
"value": "小张"
}
称呼属于语言呈现策略,不等于真实姓名,也不等于账号显示名。
约束
约束是用户或组织对 Agent 行为施加的限制,例如:
{
"key": "notification.quiet_hours",
"value": {
"start": "22:00",
"end": "08:00",
"timezone": "Asia/Shanghai"
}
}
约束可能来自用户,也可能来自租户政策。租户政策通常优先于用户偏好:
组织禁止外发邮件
>
用户偏好“自动发送报告”
3. 临时指令不能自动成为长期画像
用户说:
今天请叫我王老师。
这句话至少有三种可能解释:
- 只对当前回复有效;
- 对当前线程有效;
- 希望永久修改称呼。
如果没有明确持久化意图,安全的默认策略是:
{
"key": "preferred_address",
"value": "王老师",
"scope": "thread",
"expires_at": "2026-09-01T23:59:59+08:00"
}
只有用户明确说:
以后都这样称呼我。
才适合创建用户级记忆:
{
"key": "preferred_address",
"value": "王老师",
"scope": "user",
"consent": "explicit",
"source": "user_statement"
}
这体现了一个重要边界:
观察到用户连续三次使用中文,只能形成“可能偏好中文”的候选证据;不能直接声称用户永久偏好中文。
五、称呼:展示属性中的高风险字段
称呼看似简单,实际包含身份、礼貌和隐私风险。
1. 显示名、真实姓名和首选称呼不同
例如:
{
"legal_name": "张三",
"display_name": "ZhangSan",
"preferred_address": "小张",
"pronunciation": "zhāng sān"
}
四者不能互相替代:
legal_name:可能用于合同、账务和合规流程;display_name:界面展示名称;preferred_address:用户希望 Agent 使用的称呼;pronunciation:语音场景中的发音提示。
Agent 在普通对话中应优先使用 preferred_address,但在法律、财务、身份核验等流程中,应使用业务系统要求的正式字段,而不是用户画像中的称呼。
2. 称呼写入应有确认阈值
可将称呼更新分为三种来源:
| 来源 | 示例 | 默认处理 |
|---|---|---|
| 明确请求 | “以后叫我小张” | 可写入用户级画像 |
| 当前请求 | “这次叫我王老师” | 写入线程级状态 |
| 模型推断 | 用户签名为“张老师” | 仅作候选,不自动写入 |
推荐的数据结构:
{
"key": "preferred_address",
"value": "小张",
"scope": "user",
"source": {
"type": "user_statement",
"message_id": "msg:01J...",
"actor_id": "user:alice"
},
"confidence": 1.0,
"consent": "explicit",
"created_at": "2026-09-01T02:00:00Z",
"updated_at": "2026-09-01T02:00:00Z"
}
如果来源是模型推断:
{
"value": "张老师",
"confidence": 0.42,
"status": "candidate",
"requires_confirmation": true
}
模型不能因为自己生成了“张老师”这个称呼,就把生成结果当作用户事实。那会形成自证循环:
模型猜测称呼
↓
写入画像
↓
下次读取画像
↓
模型认为画像已证实
六、可信来源:画像值必须携带证据和作用域
1. 可信来源的定义
可信来源不是简单的“来源字段不为空”,而是来源在某个事实类型和作用域下具有足够的证明能力。
可以定义一个来源评价函数:
其中:
source:来源;claim:要证明的命题;scope:命题适用的范围;T:该来源对该命题的可信程度。
同一个来源对不同命题的可信度不同:
| 来源 | 用户姓名 | 部门 | 喜欢的称呼 | 当前意图 |
|---|---|---|---|---|
| 身份提供方 | 高 | 低 | 低 | 低 |
| 企业目录 | 高 | 高 | 低 | 低 |
| 用户明确陈述 | 中到高 | 中 | 高 | 高 |
| 当前消息上下文 | 低 | 低 | 中 | 高 |
| 模型推断 | 低 | 低 | 低 | 中 |
| 其他群成员陈述 | 低 | 低 | 低到中 | 中 |
“可信”必须和命题绑定。用户本人可以可靠地表达自己的称呼偏好,但不能仅凭一句话改变企业目录中的部门字段,也不能绕过权限授予。
2. 画像记录应保存来源链
推荐的记录格式:
{
"subject_user_id": "user:alice",
"key": "preferred_language",
"value": "zh-CN",
"scope": "user",
"source": {
"type": "explicit_user_statement",
"message_id": "msg:1001",
"thread_id": "thread:2001",
"actor_id": "user:alice",
"observed_at": "2026-08-31T09:30:00Z"
},
"confidence": 0.98,
"status": "confirmed",
"consent": "implicit_for_current_task",
"expires_at": null,
"version": 3
}
至少需要区分:
- 谁说的:
actor_id; - 关于谁:
subject_user_id; - 在哪里说的:
thread_id、群聊或租户; - 什么时候说的:
observed_at; - 原文证据是什么:
message_id; - 允许保存多久:
expires_at; - 当前是否确认:
status; - 当前版本是多少:
version。
没有来源链的画像无法回答:
为什么系统认为我是小张?
也无法支持:
删除这条记忆后,系统还能从哪里恢复它?
3. 可信度不是永久真值
可信度应随时间、冲突和范围变化:
这里:
C_old是旧可信度;decay是时间衰减因子;evidence是新证据贡献;clamp将结果限制在[0,1]。
但这只是工程模型,不是统一规范。对于高风险字段,不能只靠分数自动决策。例如:
用户消息:我现在的部门是法务
企业目录:部门仍为财务
系统不应简单比较 0.8 > 0.7 然后自动覆盖。正确做法是:
- 将用户陈述保存为候选变更;
- 触发身份或组织系统同步;
- 在确认前继续使用权威业务字段;
- 将冲突记录到审计日志。
七、画像写入:从自然语言到结构化变更
一个可靠的画像写入流程至少包含六步:
flowchart TD
A[收到用户消息] --> B[识别候选事实或偏好]
B --> C[确定主体与作用域]
C --> D[评估是否具有持久化意图]
D --> E{是否需要确认}
E -- 是 --> F[询问用户或等待权威系统确认]
E -- 否 --> G[校验来源与权限]
F --> G
G --> H[生成带版本和来源的变更]
H --> I[并发条件写入]
I --> J[记录审计事件]
示例:处理“以后叫我小周”
输入:
以后叫我小周。
抽取结果:
{
"operation": "upsert",
"subject_user_id": "user:alice",
"key": "preferred_address",
"value": "小周",
"scope": "user",
"persistence_intent": "explicit",
"source_message_id": "msg:1001"
}
写入前需要检查:
- 当前认证主体是否确实是
user:alice; - 是否允许该用户修改自己的称呼;
- 是否存在租户级称呼策略;
- 是否有同一字段的并发更新;
- 是否需要保留旧值作为审计记录;
- 是否存在过期时间或删除策略。
示例:处理“今天开会叫我王老师”
更合适的结果是:
{
"operation": "upsert",
"subject_user_id": "user:alice",
"key": "preferred_address",
"value": "王老师",
"scope": "thread",
"thread_id": "thread:meeting-1",
"expires_at": "2026-09-01T23:59:59+08:00",
"source_message_id": "msg:1002"
}
不能因为两句话都包含“叫我”,就把它们写成相同作用域。
八、并发更新:画像是共享可变状态
用户画像通常会被多个请求同时读取和写入:
设备 A:以后叫我小张
设备 B:以后叫我张老师
设备 C:管理员同步正式姓名
如果使用“读—改—写”而没有版本控制,可能出现丢失更新:
初始值:Alice
A 读取 Alice
B 读取 Alice
A 写入 小张
B 写入 张老师
最终值:张老师
A 的更新无声丢失
1. 乐观并发控制
可以使用版本号:
UPDATE user_profile_attributes
SET value = :new_value,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE subject_user_id = :user_id
AND attr_key = :key
AND version = :expected_version;
如果影响行数为 0,说明版本已变化,需要重新读取并解决冲突。
完整示例:
def update_preference(user_id, key, value, expected_version, source):
updated = db.execute(
"""
UPDATE user_profile_attributes
SET value_json = :value,
version = version + 1,
source_json = :source,
updated_at = CURRENT_TIMESTAMP
WHERE subject_user_id = :user_id
AND attr_key = :key
AND version = :expected_version
""",
{
"user_id": user_id,
"key": key,
"value": json.dumps(value, ensure_ascii=False),
"source": json.dumps(source, ensure_ascii=False),
"expected_version": expected_version,
},
)
if updated.rowcount != 1:
raise ConcurrentUpdate("profile changed after read")
return "updated"
这里的版本控制只能保证“检测到并发”,不能决定谁正确。冲突解决仍需依据:
- 来源优先级;
- 作用域;
- 时间;
- 是否显式确认;
- 是否涉及高风险属性。
2. 不要用最后写入者永远获胜
“最后写入者获胜”只适合某些低风险偏好,且仍应保留审计轨迹。
例如:
用户明确设置称呼:小张
模型推断称呼:张老师
即使模型推断发生得更晚,也不应覆盖明确设置。
一个简单的优先级排序可以是:
组织政策 / 权威业务系统
>
用户明确确认
>
用户当前消息
>
可靠外部系统
>
历史行为统计
>
模型推断
这不是跨行业的强制规范,而是常见实现中的决策策略。实际系统必须针对字段定义规则:财务账号、健康信息、称呼偏好和推荐口味不应共用一套覆盖逻辑。
九、读取画像:只投影当前任务需要的信息
读取用户画像不是把所有字段拼进系统提示词:
system_prompt += json.dumps(all_profile_data)
这种做法有三个问题:
- 把与当前任务无关的隐私暴露给模型;
- 增加模型误用旧信息的机会;
- 使提示词内容难以审计和删除。
更好的方式是建立任务相关投影:
def project_profile(profile, task):
if task.type == "translation":
return {
"preferred_language": profile.get("preferred_language"),
"terminology": profile.get("terminology"),
}
if task.type == "calendar_scheduling":
return {
"timezone": profile.get("timezone"),
"working_hours": profile.get("working_hours"),
"preferred_address": profile.get("preferred_address"),
}
return {}
投影后的内容还应明确标注状态:
用户画像:
- 首选称呼:小张(用户明确确认,用户级,2026-08-31)
- 时区:Asia/Shanghai(账户设置)
- 语言:zh-CN(当前线程推断,未确认)
模型需要知道:
- 哪些是确定值;
- 哪些是候选值;
- 哪些只在当前线程有效;
- 哪些不能用于权限判断。
十、OpenAI 对话状态:连续对话不是用户画像
OpenAI 文档区分了几种对话状态管理方式:
- 手动把历史消息传入请求;
- 使用
previous_response_id链接前后响应; - 使用 Conversations API 保存一个具有持久标识的会话对象。Conversations API 中可以保存消息、工具调用、工具输出等项目,并可跨会话、设备或任务继续使用。(developers.openai.com)
一个最小的 Python 示例:
from openai import OpenAI
client = OpenAI()
conversation = client.conversations.create()
first = client.responses.create(
model="gpt-5.6",
conversation=conversation.id,
input=[
{
"role": "user",
"content": "以后请用简洁的中文回答我。"
}
],
)
second = client.responses.create(
model="gpt-5.6",
conversation=conversation.id,
input=[
{
"role": "user",
"content": "解释什么是幂等性。"
}
],
)
print(second.output_text)
这个示例建立的是:
conversation.id → 连续对话状态
它没有自动建立:
conversation.id → 永久用户画像
如果要实现用户级画像,应用仍需要自己维护:
user_id = authenticated_request.subject_user_id
profile = profile_store.load(user_id)
response = client.responses.create(
model="gpt-5.6",
conversation=conversation.id,
input=[
{
"role": "system",
"content": render_profile_projection(profile)
},
{
"role": "user",
"content": user_message
},
],
)
需要特别注意三个边界:
conversation.id是对话状态标识,不应直接当作用户标识;previous_response_id表示响应链路,不表示身份认证;- 即使模型能在连续对话中看到历史消息,也不代表这些消息已经成为经过确认的长期画像。
OpenAI 文档还说明,响应对象默认保存 30 天,可通过 store=false 禁用;附着到 Conversations 的项目不受该 30 天 TTL 约束。使用 previous_response_id 时,链中的历史输入 token 仍会作为输入计费。(developers.openai.com)
因此,系统需要单独设计:
模型响应保留策略
对话对象保留策略
应用用户画像保留策略
审计日志保留策略
备份保留策略
它们不是同一个生命周期。
十一、LangGraph 持久化:checkpointer 与 store 的边界
LangGraph 的常见持久化模型是:
checkpointer → 当前 thread 的图状态快照
store → 跨 thread 的应用级持久数据
官方文档将 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,
)
thread_config = {
"configurable": {
"thread_id": "thread:expense:001"
}
}
result = graph.invoke(
{
"messages": [
{
"role": "user",
"content": "我叫小张。"
}
]
},
thread_config,
)
这里的 thread_id 只解决当前图执行的线程连续性。用户级画像应使用独立键空间,例如:
namespace = ("tenant:acme", "users", "user:alice")
key = "profile"
store.put(
namespace,
key,
{
"preferred_address": "小张",
"source": {
"type": "explicit_user_statement",
"message_id": "msg:1001",
},
},
)
profile = store.get(namespace, key)
生产环境不能把内存型 InMemorySaver 当作持久化数据库。官方文档指出,内存保存器在进程重启后会丢失;生产环境应使用持久化 checkpointer,例如 PostgreSQL,开发环境可以使用 SQLite。长对话还会使检查点不断增长,需要配置保留和清理策略。(docs.langchain.com)
此外,子图拥有独立检查点命名空间时,父图不一定能立即看到子图状态变化。需要跨图共享的数据,应考虑通过 Store 传递,或明确配置写入父图检查点。(docs.langchain.com)
十二、一个可落地的数据模型
下面的 PostgreSQL 表结构展示了身份、画像和来源如何分开:
CREATE TABLE users (
user_id UUID PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('active', 'suspended', 'deleted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE external_identities (
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(user_id),
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, subject)
);
CREATE TABLE profile_attributes (
user_id UUID NOT NULL REFERENCES users(user_id),
attr_key TEXT NOT NULL,
value_json JSONB NOT NULL,
scope_type TEXT NOT NULL CHECK (
scope_type IN ('user', 'tenant', 'channel', 'thread')
),
tenant_id UUID,
channel_id TEXT,
thread_id TEXT,
source_type TEXT NOT NULL,
source_message_id TEXT,
source_actor_id UUID,
confidence NUMERIC(4,3),
status TEXT NOT NULL CHECK (
status IN ('candidate', 'confirmed', 'revoked', 'expired')
),
version BIGINT NOT NULL DEFAULT 1,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (
user_id,
attr_key,
scope_type,
COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(channel_id, ''),
COALESCE(thread_id, '')
)
);
实际 PostgreSQL 不允许直接在普通主键定义中使用这种 COALESCE 表达式,因此生产实现通常有两种选择:
- 将作用域键规范化为非空字段;
- 使用生成列或唯一索引。
例如采用规范化键:
CREATE TABLE profile_records (
user_id UUID NOT NULL REFERENCES users(user_id),
scope_key TEXT NOT NULL,
attr_key TEXT NOT NULL,
value_json JSONB NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
source_json JSONB NOT NULL,
status TEXT NOT NULL,
expires_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, scope_key, attr_key)
);
作用域键可以被规范化为:
user:global
tenant:acme
channel:feishu:group:g1
thread:thread:123
这里的重点不是表结构本身,而是三个不可省略的维度:
subject + scope + attribute
只有 user_id + attr_key,无法表达“这个偏好只在某个租户或线程内有效”。
十三、故障路径:身份和画像错误通常如何发生
故障一:登录邮箱变化导致新画像
旧邮箱 alice@old.example → user:001
新邮箱 alice@new.example → 新建 user:002
表现:
- 用户发现 Agent “失忆”;
- 旧画像仍存在;
- 账号删除请求只删除了新画像;
- 权限和记忆出现分裂。
诊断:
SELECT issuer, subject, user_id
FROM external_identities
WHERE user_id IN ('user-001', 'user-002');
恢复方式:
- 由身份系统确认两个外部身份属于同一主体;
- 创建受控合并任务;
- 合并非冲突画像;
- 冲突字段进入人工或显式确认流程;
- 保留迁移审计记录;
- 禁止直接删除旧用户后再猜测合并关系。
故障二:线程串线导致用户画像污染
错误代码:
profile_key = f"profile:{thread_id}"
当用户重开线程后,画像消失;当线程被共享时,其他成员可能读取到错误数据。
正确代码:
profile_key = f"profile:{tenant_id}:{subject_user_id}"
thread_key = f"thread:{tenant_id}:{thread_id}"
诊断日志应同时打印:
request_id
actor_id
subject_user_id
tenant_id
conversation_id
thread_id
profile_key
如果日志只有 thread_id 和模型响应,通常无法判断是身份解析错误、线程选择错误,还是画像存储键错误。
故障三:群聊中把他人评价写入目标用户画像
输入:
Alice:Bob 不喜欢早会。
错误写入:
{
"user_id": "user:bob",
"preference": "dislikes_morning_meetings",
"status": "confirmed"
}
正确写入:
{
"subject_user_id": "user:bob",
"source_actor_id": "user:alice",
"scope": "group:team-1",
"status": "candidate",
"requires_confirmation": true
}
如果 Agent 需要据此安排会议,应先说:
Alice 提到 Bob 可能不喜欢早会,但这条偏好尚未由 Bob 确认。是否仍按此信息安排?
这样可以把事实不确定性暴露给用户,而不是隐藏在模型提示词中。
十四、删除、撤销与备份:画像不是删一行就结束
用户画像的删除至少涉及四个层次:
在线画像主库
→ 搜索索引和向量库
→ 缓存与派生快照
→ 备份和灾备副本
还可能涉及:
- 模型请求日志;
- Agent 运行追踪;
- 审计事件;
- 导出的数据文件;
- 人工审核队列;
- 任务消息队列。
因此删除请求应生成一个可追踪的删除工作单:
{
"deletion_request_id": "del:01J...",
"subject_user_id": "user:alice",
"requested_at": "2026-09-01T03:00:00Z",
"requested_by": "user:alice",
"targets": [
"profile_records",
"vector_index",
"cache",
"conversation_projection",
"backups"
],
"status": "accepted"
}
删除状态应区分:
requested
→ authorized
→ primary_deleted
→ derived_deleted
→ cache_invalidated
→ backup_expiry_scheduled
→ verified
撤销与删除也不同:
- 撤销:这条事实以后不再使用,但可能保留审计记录;
- 删除:从允许删除的存储和派生物中移除;
- 过期:到期后自动停止生效;
- 去标识化:保留统计价值,但移除可关联身份的字段。
对高风险画像,推荐把“当前有效值”和“历史审计事件”分离:
profile_records:可被 Agent 读取的当前状态
profile_audit_events:变更和删除审计
审计事件本身也可能含有个人数据,不能因为叫“日志”就无限期保留。
十五、验证方法:把身份错误当作可测试的系统属性
1. 隔离性测试
创建两个用户和两个线程:
user:A → thread:A1
user:B → thread:B1
先让 A 设定:
以后叫我小 A。
再让 B 提问:
你应该怎么称呼我?
期望结果不是“模型大概率答对”,而是:
B 不应看到 A 的称呼记录。
可以把它写成不变量:
在租户系统中还要验证:
2. 来源可追溯测试
对每条可持久化画像,检查:
assert record.subject_user_id is not None
assert record.source.type is not None
assert record.source.message_id or record.source.system_record_id
assert record.scope is not None
assert record.status in {"candidate", "confirmed", "revoked", "expired"}
如果记录没有来源,系统应拒绝把它标记为 confirmed。
3. 删除验证
删除用户画像后,不仅检查主表:
SELECT COUNT(*)
FROM profile_records
WHERE user_id = :user_id;
还要检查:
- Store;
- 向量检索索引;
- Redis 缓存;
- 对话上下文投影;
- 任务队列;
- 备份删除计划;
- 数据导出文件;
- 观测系统中的敏感字段。
验证的目标不是“当前请求查不到”,而是:
任何新请求都不会重新召回已删除画像;
任何异步任务都不会重新写回已删除画像。
因此删除完成后通常需要放置墓碑或版本标记:
{
"user_id": "user:alice",
"profile_generation": 8,
"status": "deleted"
}
异步写入任务携带旧代数 7 时,应被拒绝,避免删除后旧消息重新恢复画像。
十六、规范保证、常见实现和经验建议
需要明确区分三种陈述。
规范保证
规范保证是平台或框架明确承诺的行为。例如:
- OpenAI 文档说明,Conversations API 使用具有持久标识的会话对象保存对话项目;
- LangGraph 文档说明,checkpointer 面向线程级图状态,store 面向跨线程应用数据;
- OpenAI 文档说明,
previous_response_id用于串联响应形成线程化对话。(developers.openai.com)
这些保证只说明平台如何保存和传递状态,不等于平台自动完成用户身份认证、画像治理或隐私删除。
常见实现
常见实现包括:
- 用内部 UUID 作为用户主键;
- 用
(issuer, subject)映射外部身份; - 用
thread_id管理短期对话状态; - 用用户级 Store 保存长期偏好;
- 给画像值附加来源、版本和过期时间;
- 用乐观锁检测并发更新。
这些实现可以组合,但不是所有系统都必须完全照搬。
经验建议
经验建议是基于故障成本的工程选择:
- 默认不把模型推断直接写入长期画像;
- 当前消息中的临时指令默认限制在当前线程;
- 群聊中的第三方评价默认标为未确认;
- 高风险字段优先使用权威业务系统;
- 画像投影应按任务最小化;
- 删除应覆盖派生缓存、索引、队列和备份生命周期。
结语:Agent 的“记住”必须能回答五个问题
一个合格的 Agent 身份与画像系统,不是让模型拥有一段更长的历史,而是让每条记忆都能回答:
- 关于谁?
- 适用于哪里?
- 由谁、何时、通过什么来源提供?
- 当前是否确认、过期或撤销?
- 如果用户要求删除,能否找到并清理所有副本?
稳定标识解决“主体是谁”;线程和会话解决“当前上下文是什么”;用户画像解决“跨线程需要保留什么”;称呼和偏好解决“如何个性化表达”;可信来源解决“为什么相信它”。
只有把这些维度分开,Agent 才能在连续对话中保持一致,而不会把一致性变成串线、误认和隐私泄漏。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent 语义、情景与程序记忆:数据模型、用途和混用风险
- 下一篇:Agent 会话隔离:用户、租户、线程、群聊成员和上下文串线
- 延伸:Agent 记忆隐私与删除:同意、保留期、可追溯删除和备份
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论