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

Agent 身份与用户画像:稳定标识、偏好、称呼和可信来源

Agent 要在多轮对话中“记住用户”,至少需要回答四个不同的问题:

  1. 这是哪个主体?
  2. 这次请求属于哪个租户、线程或群聊上下文?
  3. 这个主体有哪些可持久化的偏好和事实?
  4. 这些信息来自哪里,是否足以支持当前决策?

如果把这四个问题都简化为一个 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

大多数单用户应用中,actorsubject 看起来相同,但在代办和委托场景中不相同:

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. 稳定标识的定义

稳定标识是一个在允许的生命周期内,持续指向同一主体的不可歧义标识。

可以形式化为:

resolve(id,t)=sresolve(id, t) = s

其中:

  • id 是稳定标识;
  • t 是时间;
  • s 是主体;
  • 在标识有效期内,解析结果应保持为同一主体。

更严格地说,稳定标识应满足四个条件:

唯一性

不同主体不应共享同一个标识:

sasbid(sa)id(sb)s_a \neq s_b \Rightarrow id(s_a) \neq id(s_b)

否则会发生画像合并和权限泄漏。

持久性

同一主体在不同设备、会话或请求中仍能被解析为同一标识:

st=st+1idt=idt+1s_t = s_{t+1} \Rightarrow id_t = id_{t+1}

这里的“同一主体”必须依据账号系统或身份提供方判断,而不是依据昵称、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"
}

二者的差别可以写成作用域函数:

scope(memory){thread,user,tenant,global}scope(memory) \in \{thread, user, tenant, global\}

通常:

  • 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. 临时指令不能自动成为长期画像

用户说:

今天请叫我王老师。

这句话至少有三种可能解释:

  1. 只对当前回复有效;
  2. 对当前线程有效;
  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"
}

这体现了一个重要边界:

observed behaviorexplicit preference\text{observed behavior} \neq \text{explicit preference}

观察到用户连续三次使用中文,只能形成“可能偏好中文”的候选证据;不能直接声称用户永久偏好中文。


五、称呼:展示属性中的高风险字段

称呼看似简单,实际包含身份、礼貌和隐私风险。

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. 可信来源的定义

可信来源不是简单的“来源字段不为空”,而是来源在某个事实类型和作用域下具有足够的证明能力。

可以定义一个来源评价函数:

T(source,claim,scope)T(source, claim, scope)

其中:

  • 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. 可信度不是永久真值

可信度应随时间、冲突和范围变化:

Cnew=clamp(Colddecay+evidence)C_{new} = clamp(C_{old} \cdot decay + evidence)

这里:

  • C_old 是旧可信度;
  • decay 是时间衰减因子;
  • evidence 是新证据贡献;
  • clamp 将结果限制在 [0,1]

但这只是工程模型,不是统一规范。对于高风险字段,不能只靠分数自动决策。例如:

用户消息:我现在的部门是法务
企业目录:部门仍为财务

系统不应简单比较 0.8 > 0.7 然后自动覆盖。正确做法是:

  1. 将用户陈述保存为候选变更;
  2. 触发身份或组织系统同步;
  3. 在确认前继续使用权威业务字段;
  4. 将冲突记录到审计日志。

七、画像写入:从自然语言到结构化变更

一个可靠的画像写入流程至少包含六步:

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"
}

写入前需要检查:

  1. 当前认证主体是否确实是 user:alice
  2. 是否允许该用户修改自己的称呼;
  3. 是否存在租户级称呼策略;
  4. 是否有同一字段的并发更新;
  5. 是否需要保留旧值作为审计记录;
  6. 是否存在过期时间或删除策略。

示例:处理“今天开会叫我王老师”

更合适的结果是:

{
  "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)

这种做法有三个问题:

  1. 把与当前任务无关的隐私暴露给模型;
  2. 增加模型误用旧信息的机会;
  3. 使提示词内容难以审计和删除。

更好的方式是建立任务相关投影

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
        },
    ],
)

需要特别注意三个边界:

  1. conversation.id 是对话状态标识,不应直接当作用户标识;
  2. previous_response_id 表示响应链路,不表示身份认证;
  3. 即使模型能在连续对话中看到历史消息,也不代表这些消息已经成为经过确认的长期画像。

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 表达式,因此生产实现通常有两种选择:

  1. 将作用域键规范化为非空字段;
  2. 使用生成列或唯一索引。

例如采用规范化键:

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');

恢复方式:

  1. 由身份系统确认两个外部身份属于同一主体;
  2. 创建受控合并任务;
  3. 合并非冲突画像;
  4. 冲突字段进入人工或显式确认流程;
  5. 保留迁移审计记录;
  6. 禁止直接删除旧用户后再猜测合并关系。

故障二:线程串线导致用户画像污染

错误代码:

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 的称呼记录。

可以把它写成不变量:

read(profile,user=B)write(user=A)=read(profile, user=B) \cap write(user=A) = \varnothing

在租户系统中还要验证:

tenant(A)tenant(B)accessible_profile(A)profile(B)=tenant(A) \neq tenant(B) \Rightarrow accessible\_profile(A) \cap profile(B) = \varnothing


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 身份与画像系统,不是让模型拥有一段更长的历史,而是让每条记忆都能回答:

  1. 关于谁?
  2. 适用于哪里?
  3. 由谁、何时、通过什么来源提供?
  4. 当前是否确认、过期或撤销?
  5. 如果用户要求删除,能否找到并清理所有副本?

稳定标识解决“主体是谁”;线程和会话解决“当前上下文是什么”;用户画像解决“跨线程需要保留什么”;称呼和偏好解决“如何个性化表达”;可信来源解决“为什么相信它”。

只有把这些维度分开,Agent 才能在连续对话中保持一致,而不会把一致性变成串线、误认和隐私泄漏。


系列导航与关联阅读

官方资料

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