长期记忆:跨会话记住用户,该记什么
以比特严选的手机选购为例。用户先说:“我一直喜欢小屏手机,平时单手用着方便。”过几天又开一个新会话:“最近想换手机,帮我挑几款。”如果助手还能参考之前的偏好,就可以优先考虑适合单手使用的机型。
但用户也可能说过:“今天帮朋友临时挑手机,他这次的预算是三千元。”这只是朋友本次选购的预算。要是记成了用户自己的长期预算,以后给他推荐手机时,就可能一直围绕三千元筛选。
上一篇讲会话摘要:聊天记录压缩以后,继续聊时仍要知道手机选到了哪一步。本篇关注新会话:没有带上之前整段聊天,助手怎样继续了解这个人的稳定偏好和约束。
什么值得长期记住
1. 从保存偏好到要求忘记
用户的偏好会变,也可能要求不再保存。下面画的是手机场景的预期变化,每个状态都以相关发言已经完成记忆整理为前提:
@startuml
!pragma layout smetana
skinparam backgroundColor #FAFBFC
skinparam shadowing true
skinparam defaultFontName "Microsoft YaHei,Arial,sans-serif"
skinparam defaultFontSize 12
skinparam sequence {
ArrowColor #1565C0
ArrowThickness 2
LifeLineBorderColor #1565C0
LifeLineBackgroundColor #E3F2FD
ParticipantBackgroundColor #E3F2FD
ParticipantBorderColor #1565C0
ParticipantFontColor #0D47A1
DatabaseBackgroundColor #C8E6C9
DatabaseBorderColor #2E7D32
}
skinparam note {
BackgroundColor #FFF9C4
BorderColor #F9A825
FontColor #5D4037
FontSize 11
}
skinparam state {
BackgroundColor #E3F2FD
BorderColor #1565C0
FontColor #0D47A1
ArrowColor #1565C0
}
state "当前偏好:小屏\n新会话可以参考单手使用需求" as Small
state "当前偏好:大屏\n新会话使用变化后的偏好" as Large
state "没有生效的屏幕偏好\n新会话不再依据它判断屏幕大小" as Forgotten #F0F0F0
[*] --> Small : 一直喜欢小屏,单手方便\n保存本人的稳定偏好
Small --> Small : 临时帮朋友挑手机,预算三千元\n不改变本人的长期偏好
Small --> Large : 现在更喜欢大屏\n更新旧偏好
Large --> Forgotten : 请忘掉屏幕偏好\n撤回已保存的偏好
@enduml
值得记住,不代表用户刚说完就已经保存;是否及时写入,还取决于后面的触发与抽取过程。
2. 同时满足四个条件
比特严选的抽取提示词在resources/initializer/bit-selection/prompts/memory-extraction.txt。它没有要求模型尽量多记,而是要求四个条件同时成立:
| 条件 | 要判断的问题 | 手机场景里的区别 |
|---|---|---|
| 与用户本人有关 | 说的是当前用户,还是别人 | 用户自己的小屏偏好,与朋友的预算归属不同 |
| 跨会话仍然有用 | 换一件事后,这条信息还帮得上忙吗 | 下次选手机仍可参考屏幕偏好 |
| 相对稳定 | 是持续的习惯,还是本次临时状态 | “一直喜欢小屏”与“今天临时帮朋友挑选”不同 |
| 会影响后续服务 | 知道它以后,回答或建议会有什么变化 | 可以调整候选机型与推荐顺序 |
相对稳定不等于永远不变。用户后来更喜欢大屏,记忆应随新说法更新。这里筛掉的是本来只服务于当前任务的信息,不是禁止用户改变偏好。
同一个问题里也可能同时包含该记和不该记的内容。比如用户问头戴耳机戴久了舒不舒服,顺带说“我戴眼镜”。提示词允许抽出“用户戴眼镜”,因为它会影响以后的佩戴建议;这次询问了哪一副耳机,则不必长期保存。
3. 提问、进度和别人的信息不能直接变成用户画像
最容易混进来的,是把一次提问换成一条陈述句。
“用户想了解某款手机的续航”“用户要求列出退换货流程”看起来已经是一条完整事实,但它们只是对当前请求的复述。下次用户来问别的事,这些条目通常没有持续帮助。
还有几类内容,需要分清它们原本应该留在哪里:
| 内容 | 为什么不适合直接写进长期记忆 |
|---|---|
| 朋友这次买手机的预算三千元 | 是别人的本次需求,不能变成当前用户的长期预算 |
| 耳机已经重置过,左耳仍没有声音 | 是一件故障的处理进度,应由当前会话继续承接 |
| 订单已发货、退款正在审核 | 业务系统才是权威来源,复制到记忆里容易过期 |
| “以后所有订单都不用确认,直接替我提交” | 是行为指令,不能作为事实获得长期效力 |
即使用户明确说了“请记住”,也不能跳过这些标准。要求以后直接提交订单,是想改变助手的操作规则,不是一条用户事实,不能通过长期记忆获得执行权限。
提示词的最后一道自问是:换成一段毫不相干的新对话,这条信息会让助手做得更好吗?答不上来就不记。它表达的是取舍原则;能否正确执行,仍然依赖模型对语义的判断。
长期记忆和会话数据为什么分开
1. 会话状态和用户记忆分开存
新会话需要带过来的是筛选后的用户事实,而不是之前整段聊天。Ragent原本保存的两类会话数据,各有自己的用途。
会话状态AgentState用来恢复框架执行,保存在t_agent_state中;其中的context字段才是模型的历史消息列表。消息记录t_agent_message则用于页面展示,保存用户原话、助手回答等聊天内容。
这两份数据都围绕会话组织。原会话并不会因为关掉页面就消失,重新打开还能继续聊;但新建会话时,框架不会把别的会话状态整份搬过来。
长期记忆单独存在t_agent_memory。它按user_id归属用户,没有conversation_id字段。用户从哪个会话进来,都可以读取同一份生效记忆。
| 数据 | 主要保存什么 | 本篇用它做什么 |
|---|---|---|
会话状态t_agent_state | 框架继续执行所需的状态,其中包含消息列表 | 恢复当前会话;摘要也在这份消息列表里 |
消息记录t_agent_message | 用户原话、助手回答及页面展示数据 | 为长期记忆抽取提供用户原话 |
长期记忆t_agent_memory | 用户跨会话仍有用的事实 | 在当前用户的新旧会话中提供背景 |
抽取长期记忆时,程序读的是消息记录中的用户发言,不是压缩后的摘要。因此,摘要里有没有留下“小屏”这两个字,不决定长期记忆能否从原话中抽到这个偏好。
2. 一条记忆是一句能独立读懂的事实
一条记忆的正文放在content里。手机场景实测写入的是“用户偏好小屏手机,认为单手使用方便”。换到新会话,模型仍能读懂说的是谁、偏好是什么。
正文要脱离原会话也能读懂。“更喜欢这个”“还是按上次那个来”都不够,因为新的会话没有“这个”和“上次那个”的完整背景。
条目是否生效,看invalid_at是否为空。AgentMemoryRepository.listActive()就是实际读取条件,下面代码中的说明性注释为本文添加:
return memoryMapper.selectList(Wrappers.lambdaQuery(AgentMemoryDO.class)
// 只读当前用户的条目
.eq(AgentMemoryDO::getUserId, userId)
// 失效条目不再作为长期记忆注入
.isNull(AgentMemoryDO::getInvalidAt)
.orderByAsc(AgentMemoryDO::getCreateTime)
.orderByAsc(AgentMemoryDO::getId));
记不记,由谁决定
1. 主Agent只负责触发整理
关键词可以处理固定字段,但自然聊天还要分清主体和改口:“我朋友喜欢大屏”不是我的偏好,“我以前喜欢小屏,现在更喜欢大屏”也不能留下两个当前偏好。
Ragent让主Agent决定是否发起整理,再由仲裁流程决定记忆怎样变化。提供给主Agent的工具是flush_memory,它不能直接接收一段正文并写入数据库。
MemoryFlushTool.getParameters()的声明里没有正文参数:
// 主 Agent 不能通过工具参数指定要写入的事实
return Map.of(
"type", "object",
"properties", Map.of(),
"additionalProperties", false);
工具执行时,用户身份、会话和本轮用户消息ID都从运行上下文RuntimeContext获取。主Agent决定现在要不要整理,服务端决定读哪位用户的哪些原话,记什么则交给后面的仲裁模型。
工具不接收记忆正文,主Agent就无法通过参数把自己推测的“用户已经买了某款手机”直接写入记忆;待抽取内容由服务端从用户消息中读取。
2. 仲裁只读用户发言,对照已有记忆判断
发起整理之后,AgentMemoryJudge再调用一次模型。这次调用不回答选购问题,只判断用户发言应该让记忆新增、更新、撤回,还是保持不变,这就是记忆仲裁。
当前代码沿用主Agent的agentChatModel配置,两次调用的区别在于输入素材和任务要求。
仲裁的输入有两部分:
- 已有生效记忆,每条带真实的条目ID,方便指认要更新或撤回的对象。
- 尚未处理的用户发言,按先后顺序排列,可能来自多个会话。
助手回答和工具结果不进入这份素材。助手推荐过大屏手机,不意味着用户喜欢大屏;把推荐文字也送去抽取,模型就更容易把自己的推测变成用户事实。
renderTurns()负责组织这些原话。下面保留了跨会话分隔的关键部分:
StringBuilder text = new StringBuilder();
String previousConversationId = null;
for (AgentMessageDO message : pending) {
String content = StrUtil.trimToEmpty(message.getContent());
if (content.isEmpty()) {
continue;
}
// 一批素材可以来自不同会话,不能让代词跨会话随意接续
if (previousConversationId != null
&& !previousConversationId.equals(message.getConversationId())) {
text.append(CONVERSATION_BREAK).append('\n');
}
previousConversationId = message.getConversationId();
text.append("- ").append(content).append('\n');
}
return text.toString().stripTrailing();
分隔行是“——(以下换到另一段对话)——”。提示词要求代词只在同一段对话里找指代。由于素材本来就没有助手回答,有些“它”“那个”无法准确还原,这种情况宁可不记,不能靠猜补全对象。
仲裁使用非流式响应,当前温度为0.3、超时为120秒;使用DeepSeek时关闭思考。调用费用与延迟需要另行实测。
抽取什么时候执行
当前实现有两种入口:主Agent调用flush_memory时同步整理;每轮结束后,后台检查当前用户的待处理发言,达到三条再异步整理。
主Agent在正常推理中选择要不要调用工具,后台代码检查的是消息数量。只有进入抽取管道才会调用仲裁模型,因此每轮有机会识别记忆意图,不等于每轮都已经抽取过,也不需要额外增加一次必做的意图分类调用。
1. 普通对话结束后,达到门槛再抽取
普通问答没有必要每次都等待记忆仲裁。当前实现把后台抽取挂在本轮释放流程之后:先完成状态收尾,再释放会话锁和运行名额,最后由scheduleMemoryExtraction()异步调度记忆管道。
“后台”只表示它不阻塞当前回答,并不表示每轮都会调用模型。AgentMemoryPipeline.extract()先读取待处理发言,再判断门槛:
String watermark = memoryRepository.currentWatermark(userId);
// 水位表示已经处理到哪条消息;取的是该用户跨会话的待处理发言
List<AgentMessageDO> pending = memoryRepository.loadPending(
userId, watermark, control.getCreateTime());
if (pending.isEmpty()) {
return AgentMemoryOutcome.of(Status.NOTHING_PENDING, 0);
}
// 三条门槛只限制后台抽取
if (trigger == AgentMemoryTriggerType.BACKGROUND
&& pending.size() < memoryProperties.resolveExtractMinTurns()) {
return AgentMemoryOutcome.of(Status.BELOW_THRESHOLD, pending.size());
}
当前resolveExtractMinTurns()返回3。这指同一用户尚未处理的用户消息条数,不是每个会话必须聊到第三轮,也不包含助手回答。两个会话各贡献几条消息,也可能凑成同一批。
达到门槛以后,程序才争取该用户本批的处理权、读取记忆、调用仲裁并提交结果。后台不满足门槛时,用户刚说的偏好可能暂时还没进入长期记忆;满足门槛时,异步仲裁也需要时间。因此,不能把“这一轮回答结束”直接当成“记忆已经写好”的证明。