让一个 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 的 ## 关于当前用户 一节。整个系统由三个互相解耦的部分组成:
- 实时记忆提取:对话结束后异步触发,LLM 从单场对话中抽取关于用户的新事实,与已有事实做向量去重后写回。
- 梦境整理(Dream Job):每天凌晨扫描当天有对话的用户,让 LLM 回顾该用户当天全部对话摘要 + 已有全部事实,做合并、去矛盾、淘汰过时——只整理,不新增。
- 画像注入:构建 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 的可编辑列表,手动修改和自动提取的数据同构(都是字符串数组),改完直接生效。
- API:
GET /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 + "}");
验证清单(上线前的自测路径):
- 内部用户对话中提到新信息 →
user_profile.memory.facts[]新增 - 终端用户对话 →
end_user_profile对应记录新增 - 再次提到相同信息 → 不重复添加(向量去重生效)
- 超过 100 条 → 旧事实被淘汰
- 提取失败 → 对话正常完成
- 构造两条矛盾 facts → 手动触发梦境 → 整理为一条
- 无活跃用户的夜晚 → 处理 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、有状态、有来源、有时间戳的结构化记录。factId 是 SHA-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 结构。旧数据的 factId 和 updatedAt 由 SHA-256 + 当前时间填充,status 默认 active。
所有写路径(MemoryExtractionService、DreamJob、deprecateFact)统一输出 V2。无需批量迁移,无需停机。
9.5 新增能力
- 定向删除:
DELETE /api/profile/memory/{factId}→ status 置 deprecated + WorkLogEvent 审计 - 事实溯源:
source字段标识 extraction(对话提取)/ dream(梦境整理)/ api(外部写入) - Prompt 注入只取 active:
getFacts()内部过滤,零对上层影响
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 调用。