让一个 Agent "记住"用户,最朴素的方案是开一个设置页让用户手填:"我是做外贸的""主要客户在德国"。我们的内部 Agent 平台一开始就是这么做的。但上线后很快发现三个问题:

问题 影响
纯手动维护 用户忘了填 = Agent 永远不了解用户
记忆不会自动更新 对话中获得的新信息不会沉淀
终端用户(API-Key 模式)无记忆 平台背后业务方的最终用户,偏好完全无法感知

于是我们给记忆系统加了两条自动化流水线:每次对话后的实时事实提取,以及每天凌晨的定时记忆整理(内部叫"梦境整理",这个隐喻我们很喜欢,下文详述)。这篇文章把整套机制讲清楚:数据模型、提取 Prompt、去重合并、定时任务、Prompt 注入,以及异步化和失败处理这些工程细节。

1. 总体架构

                        每次对话完成后 (doOnComplete)
                              │
                              ▼
                    MemoryExtractionService      ← 实时提取:LLM 从新对话里抽事实
                    (@Async, fire-and-forget)
                              │
                    ┌─────────┴─────────┐
                    ▼                   ▼
                user_profile        end_user_profile
                (内部用户)           (API-Key 终端用户)
                    ▲                   ▲
                    └─────────┬─────────┘
                              │
                         DreamJob                ← 梦境整理:每天凌晨合并/去矛盾/淘汰
                    (@Scheduled cron 3:00)

画像数据在推理时被读出来,拼进 system prompt 的 ## 关于当前用户 一节。整个系统由三个互相解耦的部分组成:

  1. 实时记忆提取:对话结束后异步触发,LLM 从单场对话中抽取关于用户的新事实,与已有事实做向量去重后写回。
  2. 梦境整理(Dream Job):每天凌晨扫描当天有对话的用户,让 LLM 回顾该用户当天全部对话摘要 + 已有全部事实,做合并、去矛盾、淘汰过时——只整理,不新增。
  3. 画像注入:构建 system prompt 时读取 facts 列表注入,内部用户和终端用户双轨并行。

2. 存储模型:双轨画像

平台同时服务两类用户,画像必须双轨:

维度 内部用户(JWT 模式) 终端用户(API-Key 模式)
身份来源 统一身份认证系统,登录后发 JWT 业务方通过 API Key 调用,携带终端用户标识
user_profile end_user_profile
查找键 user_id(主键) (end_user_id, end_sub_user_id) 联合主键
记忆归属 平台内部员工 业务方客户的最终用户
跨空间共享 是(同一 user 在不同空间共用一份画像) 是(同一终端用户标识跨空间共享)

为什么要分两张表而不是加一列 user_type?因为终端用户的身份模型天然是两层:end_user_id 是终端主账号(通常对应一家公司),end_sub_user_id 是具体操作者。公司级记忆和操作者个人记忆分开存,画像面板可以按公司分组、主/子账号分层展示:

┌─ cust_acme ──────────────────── [公司] 2 个画像 · 8 facts ─┐
│  [主] 主账号                      3 facts                  │
│    · 公司做外贸,主要市场在德国                              │
│  [子] op_zhangsan                 5 facts                  │
│    · 负责美国市场                                          │
└────────────────────────────────────────────────────────────┘

两张表的 DDL 几乎对称,核心是 memory 这个 JSON 字段:

CREATE TABLE user_profile (
    user_id     BIGINT PRIMARY KEY,
    nickname    VARCHAR(128),
    avatar      VARCHAR(512),
    preferences JSON,
    memory      JSON,       -- {"facts": ["事实1", "事实2", ...]}
    metadata    JSON
);

CREATE TABLE end_user_profile (
    end_user_id     VARCHAR(64) NOT NULL COMMENT '终端主账号',
    end_sub_user_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '终端子账号',
    preferences     JSON,
    memory          JSON,
    metadata        JSON,   -- 最近对话 Agent 列表等
    created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (end_user_id, end_sub_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

memory 的 JSON 结构两边完全一致:

{
  "facts": [
    "用户是做外贸的,主要市场在德国",
    "公司产品是储能电池和 BMS",
    "用户偏好邮件沟通,不喜欢电话推销",
    "最近在关注 UL 安全认证"
  ]
}

画像除了 memory 还有两块:preferences(默认模型、温度、语言等,在创建 Agent 时作为默认值生效)和 metadata(最近使用的 Agent / 知识库等使用上下文,由 /api/profile/touch 幂等接口在会话结束时更新)。昵称和头像在登录时从统一身份系统同步到本地表做缓存,避免每次 /me 都调远程。

存储成本可以忽略:每条 fact 平均 50 字符,上限 100 条 ≈ 5KB/人;一万个终端用户也就 50MB,MySQL 单表轻松承载。

3. 实时记忆提取

3.1 触发时机

挂在对话流的 doOnComplete 里,作为收尾副作用链的最后一环:

doOnComplete:
  ① 更新 Redis 会话缓存
  ② 更新会话状态
  ③ 对话消息落库(ClickHouse)
  ④ ★ 触发 MemoryExtractionService.extract()

注意顺序:提取在所有持久化完成之后触发,且是 fire-and-forget——它失败不影响前面任何一步。

3.2 提取 Prompt

提取本身是一次结构化输出的 LLM 调用。Prompt 的关键是把"什么算事实"框死,并把已有事实喂进去避免重复:

System: 你是记忆助手。你的任务是从对话中提取关于用户的新事实。
        这些事实将被 Agent 用于未来对话的个性化。

        规则:
        1. 只提取用户明确表达的信息(偏好、身份、业务、关注点)
        2. 不要提取对话中讨论的临时话题
        3. 已知的事实不要再重复提取:[已知事实列表]
        4. 没有新事实就返回空数组

        返回格式(纯 JSON):
        {"facts": ["事实1", "事实2"]}

规则 2 和 3 是最容易被忽视的两条。不加规则 2,LLM 会把对话主题当事实沉淀下来("用户在问德国关税政策"不是用户事实);不加规则 3,每次对话都会重复提取一遍已知信息,facts 列表很快被淹没。

3.3 去重与合并

LLM 层面挡一遍重复还不够("主要市场在德国"和"客户集中在德国"语义相同但字面不同),落库前再做一次向量去重:

① 从 DB 读取现有 facts[]
② LLM 返回新 facts[]
③ 合并: all = 新 + 旧(新在前)
④ 向量嵌入 + 余弦相似度去重(阈值 0.85)
⑤ 截断到 100 条
⑥ 写回 DB

几个数字的取舍:

  • 阈值 0.85:定高了漏去重,定低了误杀不同侧面的事实。0.85 是实测下来"语义明显重复才合并"的甜点,模糊边界留给梦境整理去处理。
  • 上限 100 条:一方面是注入 Prompt 的 token 成本,另一方面倒逼系统做淘汰——记忆不是越多越好,100 条高质量事实胜过 500 条噪音。新事实在前,截断淘汰的是最旧的。

3.4 调用方式

  • 异步@Async + CompletableFuture,完全脱离请求线程,对话响应延迟零影响。
  • 模型:用轻量模型(如 gpt-4o-mini 一档)。提取是高频低难度任务,没必要上旗舰模型——这是全系统最重要的成本决策之一。
  • 容错:提取失败仅记录 error 日志,对话照常结束。记忆是增强项,永远不该成为可用性瓶颈。

4. 梦境整理:每天凌晨的记忆反刍

4.1 为什么需要它

实时提取是"只见树木"的:每次只看到当前这一场对话,天然有三个缺陷:

局限 说明
碎片化 无法跨会话合并相似事实
矛盾 "偏好电话沟通"和"最近改用邮件"被分别提取,互相矛盾
过时 用户换了市场,旧事实不会自动失效

行业里不少 Agent 产品都有 Dreaming / Nightly Reflection 这类模块,模拟人类睡眠时的记忆整合——白天接收碎片,夜里反刍整理。我们的实现叫 Dream Job。

4.2 基础设施

Spring 的 @Scheduled 足够用,关键是给定时任务一个独立线程池,不和业务线程混用:

// Application.java
@EnableScheduling
@SpringBootApplication
public class Application { ... }

// SchedulerConfig.java
@Bean("schedulerPool")
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
    s.setPoolSize(4);
    s.setThreadNamePrefix("app-cron-");
    return s;
}

触发时间固定在凌晨低峰期:

@Scheduled(cron = "0 0 3 * * ?")   // 每天凌晨 3:00

4.3 处理对象:只处理活跃用户

没必要每晚全量扫所有画像——绝大多数用户当天没有对话,记忆没有任何新输入,整理了也不会变。活跃名单从对话消息表(ClickHouse)里查:

内部用户:  SELECT DISTINCT user_id FROM chat_message WHERE created_at >= today()
终端用户:  SELECT DISTINCT end_user_id, end_sub_user_id FROM chat_message WHERE created_at >= today()

这样每晚实际处理的用户数和当天 DAU 同量级,成本可控。

4.4 梦境 Prompt

梦境整理和实时提取的 Prompt 有一个本质区别:只允许整理,不允许新增。新事实的发现是实时提取的职责,梦境的职责是治理存量:

System: 你是梦境整理助手。你将在深夜回顾今天的对话记忆,
        对用户的 facts 进行整理。

        规则:
        1. 合并语义相同的事实(如 "做外贸" 和 "外贸行业")
        2. 如果多个事实描述同一件事的不同侧面,合并为一条更完整的事实
        3. 如果两条事实明显矛盾(如 "偏好电话" vs "最近改用邮件"),
           保留更新的一条(根据今天对话判断),丢弃旧的
        4. 如果某条事实在今天的对话中被推翻,标记为过时
        5. 不要添加新事实,只整理已有的事实
        6. 保留上限 100 条,超出时淘汰最旧且不重要的

        当前 facts: [ ... ]

        今天的对话摘要:
        对话 1: [摘要]
        对话 2: [摘要]
        ...

        返回整理后的 facts(纯 JSON):
        {
          "facts": ["整理后的事实1", "整理后的事实2", ...],
          "dreamLog": {
            "merged": [["旧事实A", "旧事实B"], "新事实"],
            "outdated": ["已过时的事实1"],
            "unchanged": 85
          }
        }

注意规则 3 消歧矛盾时给了 LLM 一个判据:"根据今天对话判断"。这是把当天对话摘要传进 Prompt 的核心原因——没有上下文,"电话 vs 邮件"谁新谁旧无从判断。

返回结构里的 dreamLog 是给可观测性用的:合并了什么、淘汰了什么、多少条没动,落进 metadata 字段,也汇总进工作日志。

4.5 完整执行流程

① 凌晨 3:00 触发
② 从 ClickHouse 查询当天有对话的用户(内部/终端两批)
③ 对每个用户:
     ├── 从 MySQL 读取 current facts[]
     ├── 从 ClickHouse 读取今天的对话消息(摘要后传给 LLM)
     ├── 调用 LLM(轻量模型,低成本)
     ├── 用 LLM 输出整体替换 facts[]
     └── 记录 dreamLog 到 metadata
④ 完成: 发布 WorkLogEvent "梦境整理完成,处理 N 个用户"

4.6 对话摘要策略

当天对话可能很长,全文传给整理模型 token 成本吃不消。摘要策略:

每场对话:
  user 的首条消息 + LLM 摘要中间部分 + assistant 的关键结论
  → 单场对话摘要控制在 500 字以内

摘要本身也要一次 LLM 调用,所以 Dream Job 的真实成本是"每活跃用户每场对话一次摘要调用 + 每用户一次整理调用"。用轻量模型跑,单晚成本可以忽略;这也是为什么必须限制只处理活跃用户。

4.7 两条流水线的分工

维度 实时记忆提取 梦境整理
时机 每次对话后 每天凌晨
输入 单场对话 当天全部对话摘要 + 已有全部 facts
目的 提取新事实 合并 / 去矛盾 / 淘汰过时
是新增事实吗 否,只整理
用户感知 无感 第二天发现记忆更整洁

两者互补不冲突。实时提取是"写入路径",梦境整理是"压实(compaction)路径"——这个结构和 LSM 树的 memtable + compaction 很像:写入要快、要简单,一致性问题留给后台压实。

5. 画像注入:记忆如何进入推理

推理引擎构建 system prompt 时读取 facts,拼成固定的一节。双轨模式下只是读取来源不同:

// 内部用户(JWT 模式,userId 存在)
var facts = profileAppService.getFacts(userId);

// 终端用户(API-Key 模式,endUserId 存在)
var facts = endUserProfileService.getFacts(endUserId, endSubUserId != null ? endSubUserId : "");

// 注入格式两种模式完全一致
if (facts != null && !facts.isEmpty()) {
    systemPrompt += "\n## 关于当前用户\n- " + String.join("\n- ", facts);
}

注入后的 system prompt 长这样:

...(Agent 自身的系统提示)...

## 关于当前用户
- 用户是做外贸的,主要市场在德国
- 公司产品是储能电池和 BMS
- 用户偏好邮件沟通,不喜欢电话推销

为什么用 Prompt 注入而不是更"高级"的方案(比如每次对话先向量检索相关记忆)?在我们的场景下 100 条上限 ≈ 几百 token,全量注入的代价极小,换来的是零检索延迟、零检索召回问题、实现极简。如果未来放开条数上限,再演进到"全量常驻 + 检索补充"的混合模式不迟——但 100 条以内,注入就是最优解。

除记忆外,画像的 preferences(默认模型、温度)在创建 Agent 时作为默认值生效,metadata 里的最近使用记录用于前端快速恢复上下文——画像模块是"偏好 + 记忆 + 使用上下文"三合一,但进入推理路径的只有 memory.facts。

6. 用户可控性与管理面

记忆是用户数据,必须用户可控:

  • 查看/编辑/删除:设置页提供 facts 的可编辑列表,手动修改和自动提取的数据同构(都是字符串数组),改完直接生效。
  • APIGET /api/profile 读、PUT /api/profile 改、POST /api/profile/touch 更新使用上下文(幂等,内部调用)。
  • 管理员面板:按 end_user_id 分组展示所有终端用户画像(主/子账号分层),仅限配置的管理员可见,不暴露给普通用户和 API-Key 调用方。

梦境任务也有管理入口:手动触发按钮(POST /api/admin/dream,方便调试和验证)+ 执行历史(查工作日志里 module = 'Dream' 的最近记录)。

7. 工程细节汇总

异步化。三条路径全部不阻塞用户请求:实时提取走 @Async,梦境走独立 scheduler 线程池,画像注入是一次本地 DB 主键查询(可加缓存)。用户在任何时刻感知到的对话延迟,和没有记忆系统时一致。

失败处理。原则一句话:记忆系统是增强项,任何环节失败都不能影响对话主流程。实时提取失败只记 error 日志;Dream Job 单用户处理失败跳过该用户继续下一个;LLM 返回非法 JSON 时丢弃本轮整理结果、保留旧 facts(整理是整体替换,宁可不更新也不能写坏)。

可观测。每次梦境执行发布一条工作日志事件,前端系统日志流可见:

WorkLogEvent.publish(eventPublisher, this, "_system", "Dream", "success",
    "梦境整理完成:处理 " + userCount + " 个用户," +
    "合并 " + mergedCount + " 条," +
    "淘汰过时 " + outdatedCount + " 条",
    "{\"merged\":" + mergedCount + ",\"outdated\":" + outdatedCount + ",\"users\":" + userCount + "}");

验证清单(上线前的自测路径):

  1. 内部用户对话中提到新信息 → user_profile.memory.facts[] 新增
  2. 终端用户对话 → end_user_profile 对应记录新增
  3. 再次提到相同信息 → 不重复添加(向量去重生效)
  4. 超过 100 条 → 旧事实被淘汰
  5. 提取失败 → 对话正常完成
  6. 构造两条矛盾 facts → 手动触发梦境 → 整理为一条
  7. 无活跃用户的夜晚 → 处理 0 个用户,正常结束

8. 权衡与已知不足

最后坦白几个设计上的妥协:

  • 梦境是批处理,矛盾记忆最长会存在一天。用户上午说"改用邮件",Agent 下午的回复可能还基于"偏好电话"。实时提取只管新增不管纠正,这是刻意的——在请求路径上做矛盾检测太贵。如果业务对实时性敏感,可以在提取 Prompt 里加一条"如果新事实与某条已知事实矛盾,输出 {replace: [旧事实]}",成本是提取调用变重。
  • 相似度阈值和 100 条上限是拍脑袋 + 实测调出来的,不同业务事实密度不同,这两个参数应该做成可配置。
  • 摘要环节会丢信息。梦境判断"哪条事实被推翻"依赖当天对话摘要,摘要丢失的细节会导致该淘汰的没淘汰。缓解方向是按用户的事实相关度筛选对话片段,而不是均匀压缩全场对话。
  • 跨空间共享记忆是产品决策(同一个用户的偏好不该因切换工作空间而丢失),但意味着记忆没有空间隔离。涉及敏感业务记忆的场景需要重新评估这条。

这套系统上线后,Agent 从"每次见面都是陌生人"变成"越用越懂你",而全部成本只是每次对话后一次轻量模型调用、每晚一批整理调用。记忆系统的性价比之高,让它成为我们看来 Agent 平台里最值得优先建设的能力之一。


9. V2 升级:从扁平字符串到结构化事实状态机

9.1 为什么需要状态

上线几个月后,facts 的纯扁平存储模式暴露了问题:

问题 现状 影响
矛盾不可追溯 梦境把旧事实删了,你不知道它曾经存在过 用户反馈"Agent 忘了 X",你也无法解释为什么
无法定向删除 用户说"帮我忘掉 Y",你要么全删要么全留 没有"单条弃用但保留记录"的能力
无审计 事实从哪来(对话提取 / 梦境整理 / API)、何时修改,全无记录 排查 = 猜

9.2 V2 结构

{
  "version": 2,
  "facts": [
    {
      "factId": "f_a1b2c3d4",
      "content": "用户负责北美市场",
      "status": "active",
      "updatedAt": "2026-07-23T12:00:00",
      "source": "extraction"
    },
    {
      "factId": "f_deadbeef",
      "content": "用户偏好电话沟通",
      "status": "superseded",
      "updatedAt": "2026-07-22T03:15:00",
      "source": "dream"
    }
  ]
}

每条 fact 现在是一个有 ID、有状态、有来源、有时间戳的结构化记录。factIdSHA-256(content) 的前 8 位 hex 字符——确定性生成保证同内容永得同 ID,V1 升级到 V2 无缝。

9.3 状态机

          ┌── 新事实提取 ──▶ active ──── 梦境判断矛盾 ──▶ superseded
          │                      │
          │                      └─── 用户手动删除 ──▶ deprecated
          │
          └──(V1 升级的旧事实默认 active)

三个状态:

  • active:正常使用,注入 Prompt
  • superseded:被梦境判定为矛盾或过时,不再注入但保留记录
  • deprecated:用户通过 DELETE /api/profile/memory/{factId} 手动删除,审计可查

9.4 零停机升级策略

V1 数据不入迁移脚本——读时兼容双格式,写时统一 V2。首次 MemoryExtractionService 或 DreamJob 触发对该用户的写操作时,自动从 {"facts":["str1","str2"]} 升级为 V2 结构。旧数据的 factIdupdatedAt 由 SHA-256 + 当前时间填充,status 默认 active。

所有写路径(MemoryExtractionService、DreamJob、deprecateFact)统一输出 V2。无需批量迁移,无需停机。

9.5 新增能力

  • 定向删除DELETE /api/profile/memory/{factId} → status 置 deprecated + WorkLogEvent 审计
  • 事实溯源source 字段标识 extraction(对话提取)/ dream(梦境整理)/ api(外部写入)
  • Prompt 注入只取 activegetFacts() 内部过滤,零对上层影响

10. 跨会话记忆:Episode Summary 的产出与召回

10.1 问题

facts 是"用户的长期偏好",但缺了另一种记忆——"用户之前跟 Agent 讨论过什么"。比如用户上周和 Agent 聊了一小时 LED 市场调研,下周再开新会话时,Agent 应该知道"这位用户上周关注过 LED"而不只是"他负责北美市场"。

10.2 机制

产出(DreamJob 凌晨 3 点):事实整理完成后,对当日活跃的每个用户,取每条有 ≥3 条消息的会话,调 LLM 生成一段摘要 + 3~5 个主题标签,存入 episode_summary 表。

{"summary": "用户讨论了 LED 灯具在美国市场的产品定位和竞争策略", "topics": ["LED", "US Market", "Product Strategy"]}

注入(PromptBuilder):在构建 system prompt 时,在用户画像后追加 ## 近期会话摘要 章节。取近 7 天的摘要,按时间衰减(0.4)+ 关键词匹配(0.6) 排序,取前 3 条:

- (昨天)用户讨论了 LED 灯具在美国市场的产品定位和竞争策略。主题:LED、US Market、Product Strategy
- (3天前)用户咨询了外贸邮件模板的最佳实践。主题:Email、Template

10.3 为什么不是实时产出

episode summary 没有放在对话结束时(doOnComplete)做,是刻意的:一次对话的价值在结束后才能判断——短到 1-2 条消息的会话不值得摘要,梦境统一在凌晨批处理更经济。如果业务需要"本轮对话摘要立刻可用",可以加一个同步路径,但成本会从"凌晨一批调用"变成"每次对话一次调用"。

这套机制上线后,Agent 在新会话中能主动引用用户最近讨论过的话题,明显提升了"连续性"的体感——而这部分的全部成本只是每天凌晨多跑几轮轻量 LLM 调用。