AgentScope第18式·藏忆长记忆
学习 Harness 两层长期记忆
数字人换一个会话就失忆,问题不在模型,而在你敢不敢把聊天记录当成记忆。
[读者] 已经把 AgentScope Java 数字人跑起来、准备让它跨会话记住用户的开发者
[痛点] 每开一个新 Session,上一轮说过的偏好和业务背景全部归零
[现在读] Harness 在 v2.0.3 里已经给出两层记忆的文件约定,接入成本可控
[读完] 能自己落地一套「日志记忆 + 整理后长期事实」的长期记忆闭环
[旧方案] 把完整聊天历史截断后塞回下一轮
|
v
[新需求] 跨会话记住用户偏好与业务事实,而且只记住对未来有价值的部分
|
v
[冲突] 全量塞会撑爆上下文,只留摘要又会丢细节,而摘要本身还会重复和漂移
|
v
[后果] 要么每次会话都从零问一遍用户是谁,要么记忆文件一天天膨胀到没人敢读
我是老李。这个系列走到第 18 式,我们的数字人 Demo 已经能通过 LiveKit 接通实时会话,也能让 AgentScope Java 完成一次推理。功能是齐的,但每次演示到一半,我都要在台上重新解释一遍「我们的产品叫什么」「回答请用中文」。
因为它在会话之间没有记忆。前端一刷新,后端就是一张白纸。更麻烦的是,这件事很容易被低估:很多人以为记忆就是把历史记录存下来,下一轮再拼回去,代码量不大。真接上去才发现,拼回去的上下文会一天比一天厚,最后模型在一堆陈年流水里,找不到最该记住的那一句。
为什么全量历史撑不住跨会话?为什么「只留摘要」听起来更聪明,却更容易把记忆养坏?如果这一次选错了方案,半年后你要面对的是一份没人愿意读、也不敢删的记忆文件吗?
01、故事
上个月在客户现场,运营同学打开 /run/{projectId},对着数字人说了一句:我们是做跨境电商的,以后回答用中文,控制在三句话以内。数字人笑着应了。会议结束,页面关掉。
第二天同一个同学再打开,问「你还记得我的偏好吗」,数字人回了一句非常标准、也非常多余的:您好,请问有什么可以帮您?
会议室里没人说话。我当时第一反应是去看前端有没有把 sessionId 传丢,第二反应是去看后端有没有落库。两边都正常。真正的答案很简单:从架构上看,我们压根没打算让它记住。
这一轮的任务是:让同一套 Web 运行页,在用户第二次打开 /run/{projectId} 的时候,还记得上一次说过的偏好,而且记住的东西要能被不断修正、不能被无限堆高。
变量只有两个:一个日志目录,一个长期事实文件。听上去很轻,但它是整个数字人从「能被演示」走向「能被人用」的分水岭。
旧忆随屏散
新痕落纸间
一问跨会话
方知谁记得
02、问题
旧做法是:会话状态全放前端,后端无状态。运维上很舒服,业务上很瘸。它默认了一个前提——记忆的生命周期等于页面的生命周期。这个前提在单次演示里成立,在任何真实使用里都不成立。
业务影响是可见的:用户每换一次设备、每刷新一次页面,就要重新自我介绍。而数字人的开场白越热情,这种重复就越刺耳。
技术表现有三点:
第一,偏好信息进不了 system prompt,模型每一轮都在用默认人格回答;
第二,即便把偏好写进数据库的会话记录,下一轮读出来的是流水,不是结论;
第三,一旦为了「记得住」把最近 20 轮历史全塞进去,Token 消耗会跟着会话长度线性上涨。
本篇的完成标准只有三条,全部可验证:
- 在 A Session 告诉数字人一条偏好,关闭会话;
- 在全新的 B Session,用新的 sessionId、新的浏览器标签,数字人能正确取回这条偏好;
- 把同一条偏好用不同措辞重复说三次,长期记忆文件的体积不出现线性增长。
第三条才是真正卡人的地方。前两条只要肯堆代码,大概率能过;第三条是在问:你有没有一个会遗忘的机制。
写得下不算
取得出才行
一轮一注入
每日一整理
03、原理
Harness 这套记忆设计的关键,是它把「记忆」拆成了两层,而不是一个文件。官方 v2.0.3 文档 docs/v2/zh/docs/harness/memory.md 给出的就是这个两层结构:一层是按天分片的日志记忆 memory/YYYY-MM-DD.md,一层是整理后的长期事实 MEMORY.md。
日志记忆:按天一个文件,只追加,不做判断。它记录的是「今天发生过什么」。优点是保真、写入便宜、出错好查;缺点是长、吵、不适合每一轮都读进模型。
整理后的长期事实:由日志提炼出来的、稳定的、对未来有用的用户偏好和业务事实。它短、稳、适合常驻上下文。
Memory Flush(记忆刷写):把本轮会话的增量落到当天日志文件里的动作,是一层纯粹的写入。
Memory Consolidation(记忆整理):把日志里的候选事实抽出来,与已有的 MEMORY.md 合并、归一、去重,再写回长期事实文件的动作。它需要一次模型调用,属于「有成本的写」。
节流:因为 Consolidation 有成本,不能每一轮都触发,需要按轮数、日志增量或时间窗口限制频率。
去重:同一件事被不同措辞说出来,必须在整理阶段被合并成一条。
每轮注入:把 MEMORY.md 的内容拼进 system prompt 或等价的上游上下文,让每一轮推理都能看到它。
这里有个反直觉判断:日志记忆是写给人看的,MEMORY.md 才是写给模型的。
如果你把两层搞反——让模型每轮都读日志、把 MEMORY.md 当成给人看的变更记录——整套设计立刻失效。因为日志的价值在于「完整」,而完整对模型来说是噪音;MEMORY.md 的价值在于「精简」,而精简需要人为的选择和删减。
第二个反直觉判断更扎心:Consolidation 里最重要的能力不是「写进去」,是「删掉重复的、合并近义的、淘汰过时的」。 只做追加的整理不是整理,是换了个地方堆积日志。
需要说明的是,Harness 这一层提供的是文件布局、整理入口和生命周期约定,不是一套「自动判断什么该记」的成品记忆引擎。哪些事实值得留下,取决于你给整理器的提示词和策略。这一点需要按当前官方文档核验,不要按类名推测行为。
多写非好事
狠删见真章
摘要有重量
漂移是代价
04、架构
[输入] 一轮用户消息 + sessionId + projectId
|
v
[模块] Harness Memory 层(Flush 写入 / Consolidate 整理 / 注入读取)
|
v
[数据/状态] {agentHome}/memory/YYYY-MM-DD.md、{agentHome}/MEMORY.md
|
v
[处理] 追加日志 → 阈值触发整理 → 归一/去重/覆盖写 → 每轮注入 system prompt
|
v
[输出] 带长期事实的推理上下文 + 可被人查阅的记忆文件
边界:Harness 负责记忆的文件位置与读写时机;Spring Boot 负责在会话生命周期里调用它;LiveKit 完全不参与记忆,它只管 WebRTC 的音频与数据传输。这条边界一定要守住,否则记忆会被塞进媒体链路,后面很难拆。
收益:跨会话连续性;日志保真、事实精简,各司其职;整理失败时最坏情况是长期事实没更新,日志还在,信息不丢。
代价:多一次整理用的模型调用;多一份需要维护和备份的文件状态;每轮注入会占用固定上下文预算。
适用条件:单用户、单项目、相对稳定的偏好与业务事实。这类场景下两层结构的性价比很高。
不适用条件:多租户共享记忆、需要强一致或强审计合规的记忆、需要精确复现原文的场景。这些场景下文件式长期事实会变成负担,应该换成带权限和版本的外部存储。
涉及内部机制时,本篇主要参考两处官方位置:文档 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/harness/memory.md ,以及整理器实现 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryConsolidator.java 。官网入口是 https://java.agentscope.io/ ,选型或版本问题优先看它。
一写落日志
一炼成事实
一注入入脑
一召回见人
05、实战一次
环境与版本:教程基线 AgentScope Java v2.0.3,分支 chapter/18-long-memory,从第 17 式分支继续创建。本章建议提交信息为 feat(ch18): add long term memory consolidation。
先定目录布局。所有记忆都挂在一个 agent home 下面,不散落:
{agentHome}/
├── MEMORY.md # 整理后的长期事实,每轮注入
└── memory/
├── 2025-06-11.md # 当天日志,只追加
└── 2025-06-12.md
下面是本章新增的记忆服务骨架。以下代码为按官方文档整理出的结构示意,方法签名需按 v2.0.3 的 MemoryConsolidator 实际实现核验;未在当前环境实测,运行结果以实际为准。
package com.lifuchun.digitalhuman.memory;
import io.agentscope.harness.agent.memory.MemoryConsolidator;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.util.List;
public final class LongTermMemoryService {
/** 每 N 轮才考虑一次整理,避免每轮触发模型调用。 */
private static final int CONSOLIDATE_EVERY_ROUNDS = 8;
/** 日志累计行数阈值,与轮数阈值共同构成节流条件。 */
private static final int CONSOLIDATE_THRESHOLD_LINES = 120;
private final Path agentHome;
private final MemoryConsolidator consolidator;
private int rounds = 0;
public LongTermMemoryService(Path agentHome, MemoryConsolidator consolidator) {
this.agentHome = agentHome;
this.consolidator = consolidator;
}
/** 每轮结束:先把增量写进当天日志。 */
public void onTurnEnd(List<String> turnMessages) throws IOException {
Path todayLog = agentHome.resolve("memory").resolve(LocalDate.now() + ".md");
Files.createDirectories(todayLog.getParent());
Files.writeString(todayLog, renderTurn(turnMessages), StandardCharsets.UTF_8,
Files.exists(todayLog)
? StandardOpenOption.APPEND
: StandardOpenOption.CREATE);
rounds++;
if (rounds % CONSOLIDATE_EVERY_ROUNDS == 0 && shouldConsolidate(todayLog)) {
consolidate(todayLog);
}
}
private boolean shouldConsolidate(Path todayLog) throws IOException {
return Files.readAllLines(todayLog, StandardCharsets.UTF_8).size()
>= CONSOLIDATE_THRESHOLD_LINES;
}
/** 整理:读旧事实 + 读日志,产出新的完整长期事实,覆盖写。 */
private void consolidate(Path todayLog) throws IOException {
Path longTerm = agentHome.resolve("MEMORY.md");
String existing = Files.exists(longTerm)
? Files.readString(longTerm, StandardCharsets.UTF_8)
: "";
String merged = consolidator.consolidate(
existing, Files.readString(todayLog, StandardCharsets.UTF_8));
Files.writeString(longTerm, merged, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
}
/** 每轮注入:把长期事实交给上游拼进 system prompt。 */
public String loadForPrompt() throws IOException {
Path longTerm = agentHome.resolve("MEMORY.md");
return Files.exists(longTerm)
? Files.readString(longTerm, StandardCharsets.UTF_8)
: "";
}
private String renderTurn(List<String> messages) {
StringBuilder sb = new StringBuilder();
for (String m : messages) {
sb.append("- ").append(m).append(System.lineSeparator());
}
return sb.toString();
}
}
验收步骤(未在当前环境实测,以下为预期结果):
- 启动 Spring Boot,浏览器 A 打开
/run/{projectId}; - 说:「我是做跨境电商的,以后回答用中文,控制在三句话以内。」
- 关闭标签页,等整理阈值被触发;
- 换一个全新浏览器窗口,新的 sessionId,再打开同一个
/run/{projectId}; - 问:「你还记得我的偏好吗?」
预期现象:回答里出现「中文」「三句话以内」,并且不再追问用户是谁。
再看一眼文件,示例输出如下:
# 长期事实
## 用户偏好
- 回答语言:中文
- 回答长度:不超过三句话
## 业务事实
- 业务领域:跨境电商
注意这里只有三条,没有时间戳流水,也没有「用户于 14:03 说了什么」。这就是「只保存对未来有价值的偏好和业务事实」。
先落一行日志
再炼一条事实
跑通一次闭环
再谈记忆规模
06、排查
证据链一:B 会话取不回偏好。
现象:A 会话写完之后,B 会话问「你还记得我的偏好吗」,数字人依然从零开始。
怀疑顺序通常是:模型没遵守提示词 → 日志没写 → 文件写到了别处 → 注入没生效。
错误尝试: 我第一反应是模型不听话,于是去改 system prompt,加了一句「请务必记住用户的偏好」。这是错的。模型没有可能记住它从未见过的内容,记忆是数据注入问题,不是提示词措辞问题。往提示词里加「请记住」,只是在浪费上下文预算。
正确做法是去看文件。检查命令:
ls -l {agentHome}/memory/
cat {agentHome}/MEMORY.md
证据:日志目录里文件是有的,但 MEMORY.md 不存在。
根因:写入路径用 projectId 解析 agent home,读取路径用 agentName 解析。两个不一样的目录,写得进去、读不出来。
修复:把 agent home 的解析收敛成一个单例组件,写和读共用同一个入口,禁止在业务代码里第二次拼接路径。
证据链二:长期事实无限膨胀。
现象:连续三天,同一句「回答用中文」在 MEMORY.md 里以三种措辞各出现一次,文件行数只增不减。
怀疑:去重没生效。
检查:连续三天执行 wc -l {agentHome}/MEMORY.md,并比对内容差异。
证据:三条事实上语义等价,但字符串不相等,例如「回答使用中文」「用户希望用中文回答」「请用中文」。
根因:两个问题叠在一起。一是去重只做了字符串相等判断,语义近义没有被识别;二是整理采用追加模式,每次都在旧内容后面接新内容,没有做「全量重写」。
修复:把整理从追加改成覆盖写,并要求整理器输出完整的长期事实全文。这一条是本篇的核心修正,下一篇的优化全部围绕它展开。
上游贡献判断:
- 是否可在最小 Demo 复现:可以,两条证据链都在 Demo 范围内;
- v2.0.3 是否存在:是按当前文档语义实现时的预期行为,不是崩溃或异常;
- main 是否仍存在:需按官方 main 与 Release 核验;
- 是否已有 Issue/PR:本章写作时未检索到针对该语义的直接 Issue,需现场核验;
- 当前只能记录候选:整理阶段「去重与覆盖」的语义在文档中描述较薄,属于文档缺口候选,未提交任何 Issue 或 PR。
现象先怀疑
证据再定因
错在改提示
根在断点早
07、优化
根因清楚了,V2 围绕三件事改:去重语义、节流策略、注入预算。
修改一:整理改成「全量重写 + 归一」。
原因是语义重复必须靠模型判断,靠字符串比对一定漏。新行为是:整理器每次都接收「已有长期事实 + 今日日志」,输出一份合并后的完整长期事实,覆盖写回文件。
private static final String CONSOLIDATE_PROMPT = """
你是长期记忆整理器。输入是已有的 MEMORY.md 与今天的日志。
规则:
1. 只保留对未来对话有价值的用户偏好、业务事实、稳定约束;
2. 语义相同的事实必须合并成一条,不保留措辞变体;
3. 删除过时、被推翻、一次性的内容;
4. 不保留时间流水,不保留完整聊天原文;
5. 输出完整的 MEMORY.md 全文,不要输出任何解释;
6. 总行数不超过 60 行,超出时优先保留业务事实。
""";
修改二:节流双条件。
原因是一个条件容易被绕开:只看轮数,长对话会过早触发;只看行数,短对话永远不触发。新行为是轮数窗口与日志行数阈值同时满足才整理,并额外限制一天最多整理 N 次。
修改三:注入加预算。
原因是长期事实文件如果失控,会变成每轮的常驻税。新行为是注入前做长度检查,超过预算先触发一次整理压缩,再注入;整理后仍然超限,只注入「用户偏好」与「业务事实」两个小节。
public String loadForPrompt(int maxChars) throws IOException {
String content = loadRaw();
if (content.length() <= maxChars) {
return content;
}
consolidateLatestLog();
content = loadRaw();
return content.length() <= maxChars
? content
: extractCoreSections(content, maxChars);
}
验证方法:把同一条偏好在三个不同会话里用三种措辞各说一遍,观察 MEMORY.md 行数是否保持不变;重新打开 B 会话确认偏好仍然生效。预期结果是行数不增,偏好可召回。
去重靠归一
节流靠阈值
注入要有预算
记忆要有上限
08、演进
[同一输入] 新会话第一句「你还记得我的偏好吗」
|
+--[V1] 无长期记忆:每轮重问一遍用户是谁 —— 代价:演示断裂、体验归零
|
+--[V2] 两层记忆:日志落盘 + 整理后注入 —— 代价:多一次整理调用、多一份文件状态
|
[Trade-off]
得到跨会话连续性;失去无状态带来的简单性;适用边界是「单用户 + 单项目 + 稳定偏好」
正确性:V1 在单次会话内正确,跨会话必然失败;V2 在单用户单项目下正确,多用户共享同一个 agent home 时会串记忆。
稳定性:V1 没有额外失败点;V2 新增了整理失败、文件并发写、路径解析不一致三个新失败点,每个都需要显式处理。
复杂度:V1 是零;V2 引入日志目录、长期事实文件、整理任务、节流策略四样东西,代码量不大,但概念变多了。
成本:V1 的成本是用户体验;V2 的成本是一次整理用的模型调用,以及每轮注入长期事实所带来的固定 Token 占用。
适用范围:V2 适合有稳定偏好的业务型数字人;不适合每次都是陌生人、每次都不需要连续性的场景。
遗留问题:冲突消解策略尚未确定——当用户先说「用中文」后说「用英文」时,是覆盖还是保留两条并标注时间,本篇只做了覆盖,语义上未必永远正确。
旧法全量塞
新法两层分
得的是连续
失的是简单
09、洞见
9.1 记忆的质量上限,由删除决定
反直觉判断:写进去容易,删得掉才是能力。日志可以只追加,因为它的定位是保真记录;MEMORY.md 必须允许被重写,因为它的定位是当前最优结论。一个只增不减的长期事实文件,本质上就是第二份日志。
9.2 整理越频繁,记忆越不准
反直觉判断:把 Consolidation 的频率调高,会先带来「记忆很新」的错觉,随后带来事实漂移。因为每次整理都是一次有损摘要,摘要的摘要会逐层偏离原话。节流在这里不是性能优化,它是记忆正确性的一部分。
9.3 什么时候不该用长期记忆
一次性的客服问答、需要精确复现原始对话的审计场景、多租户共享同一份事实的场景、以及用户本人在意「你凭什么记着我」的合规场景,都不该直接套两层文件记忆。前两类是结构不匹配,第三类是隔离没做,第四类是授权没做。本章能力解决的是「同一用户跨会话的连续体验」,不是「记忆本身的能力上限」。
9.4 每轮注入不是免费的午餐
MEMORY.md 会出现在每一轮请求里。它每长 10 行,就意味着整场对话的每一次推理都要多付这 10 行。所以抑制它膨胀的动机不只是整洁,还有账单。这也是为什么写入侧要设置分类白名单:只保留用户偏好、业务事实、稳定约束,其余一律不进长期事实。
删比写重要
少比多准确
不逢场景用
方是真功夫
10、系统落地
原来有什么:一个能通过 LiveKit 跑通实时会话、能调用 AgentScope Java 完成推理的数字人 Demo,但跨会话无状态。
本篇新增什么:一个按天分片的日志目录、一个整理后的长期事实文件、一个带节流的整理触发器、一个每轮注入的读取入口,以及写入侧的分类白名单。
现在能做什么:在 A Session 说出的偏好,能在全新的 B Session 被正确取回;同一条事实重复出现时,长期事实文件的体积保持稳定,不会线性膨胀。
还缺什么:记忆的检索与分片(目前是全量注入)、事实冲突的消解策略、记忆的可观测(整理是否发生、去重掉了多少)、以及多用户隔离。这四样都不在本章范围,但它们决定了长期记忆能走多远。
下一步如何演进:当 MEMORY.md 逼近预算上限时,从「全量注入」转向「按需检索」,把稳定偏好留在常驻区、把业务事实放进可检索区;同时把整理过程的关键事件打点,让「记忆有没有变好」成为一个可以被观测的问题,而不是一个凭感觉的问题。
开源参与点:发现可作为 Issue/PR 候选的问题:v2.0.3 文档对 Consolidation 阶段「去重与覆盖」的语义描述较薄,最小 Demo 中若观察到追加式整理导致同一偏好以多种措辞重复出现,可先在官方 Discussions 澄清预期语义,确认后在个人 Fork 的主题分支上补文档或补示例,不硬造 Bug。(尚未提交任何 Issue 或 PR)
日志在人间
事实在脑内
一层留给查
一层留给想
11、小结
Q1 → 全量历史塞不回去,是因为上下文预算随会话长度线性上涨,而且流水本身对模型是噪音;
Q2 → 「只留摘要」之所以容易养坏记忆,是因为整理若只追加不重写,近义事实会不断堆积;
Q3 → 选错方案的代价,是一份没人愿意读、也不敢删的记忆文件,以及每轮都在付的注入成本。
状态 → 数字人具备跨会话长期记忆,偏好可在新会话召回,重复事实不再无限膨胀。
[只写日志] -> 保真但吵,取回时全是噪音
[只写事实] -> 干净但脆,细节一丢就找不回
[两层记忆] -> 日志保真、事实精简,各司其职
一写一整理
一注一召回
跨会话不忘
重复不膨胀
12、作业
12.1 理解题:为什么 Harness 要把记忆拆成两层,而不是一个文件?
参考答案:两层的读写特征完全不同。日志是高频追加、低频读取、要求保真;长期事实是低频写入、高频读取、要求精简。合成一个文件,必然要在一件事上妥协:要么牺牲保真,要么牺牲注入效率。拆成两层后,写入路径和注入路径各自最优。
12.2 实战题:在 chapter/18-long-memory 上完成验收,并给出你的节流参数。
参考答案:从第 17 式分支创建 chapter/18-long-memory,接入本篇的 LongTermMemoryService;用「轮数窗口 + 日志行数阈值 + 单日上限」三条件节流,例如每 8 轮且日志累计超过 120 行时整理,一天最多整理 5 次。然后跑 A→B 会话验收,并连续三次用不同措辞重复同一偏好,确认文件行数不增长。建议提交 feat(ch18): add long term memory consolidation。
12.3 排障题:B 会话取不回偏好,你按什么顺序排查?
参考答案:先确认文件是否生成,再确认写入路径与读取路径是否指向同一个 agent home,最后确认注入是否发生在 system prompt 组装之前。不要一开始就去改 system prompt——那是本篇明确点出的错误尝试。排查时优先用 ls、cat、wc -l 拿到文件层证据,再看应用日志。
12.4 架构判断题:请打开官方源码与文档,判断「整理阶段的去重语义」是框架已经保证的能力,还是使用方要自己定义的能力。
参考答案:需要现场核验。请打开 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/harness/memory.md 与 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryConsolidator.java ,对照 v2.0.3 的测试与 Release Notes,确认整理入口究竟提供了哪些默认语义、哪些要由调用方传入。确定结论前不要基于类名推测行为;版本差异一律以固定 tag 为准。
读文档一时
翻源码一世
测得一条链
写清一页文
13、思考
长期记忆这件事,最后考的不是「怎么存」,是「敢不敢删」。日志可以贪心,长期事实必须克制。因为 MEMORY.md 是每一轮都要付钱的常驻上下文,它每多一行,整场对话都在为这一行付费。
回看这一式,真正的冲突从来不是「记不记得住」,而是「记多少」。全量记,模型找不到重点;只记结论,又可能丢掉转折。两层结构给出的答案很朴素:把保真交给日志,把判断交给整理,把预算交给注入。三个环节各自的失败模式不同,所以需要各自的对策。
同样地,「会用框架」和「能参与框架演进」的差别,往往也不在于能不能调通 API,而在于你有没有能力去追问一句——这一步的语义,究竟是框架保证的,还是我自己该定义的。
边界先画清
证据再说话
改一层记忆
见一分克制

浙公网安备 33010602011771号