一条让我睡不着觉的用户反馈
Gym-Agent 上线两周后,收到一条反馈,用户原话:"我上周不是问过你凌晨能不能订场吗?怎么又问我要订哪个场馆?"
我第一反应是去查 Redis——当然什么都没有。RedisChatMemory 配的是 5 轮窗口,别说上周,用户昨天聊的内容都随会话一起蒸发干净了。对用户来说,"上周的我"和"今天的我"是同一个 Agent;对我来说,它们是两个毫无关系的 memoryId。
那一刻我意识到,用户对"记住我"的预期,和 ChatMemory 能给的能力之间,隔着一条鸿沟。
ChatMemory 到底是什么、不是什么
先把 ChatMemory 的能力边界说清楚,LangChain4j 给我的东西是真实的:
- 会话内上下文:按 memoryId 隔离,窗口内多轮对话模型都能接住;
- 指代消解的原料:因为窗口里有原文,"这个场馆""时间改成下午"这类指代,模型接得住。
在 Gym-Agent 里我们把这套用到极致:@SystemMessage 里写死了多轮规则("用户说'这个场地'时指代上一轮查询结果中的场地"),窗口从 5 轮调到 8 轮——再往上调,单次请求的 token 开销就压不住了,而场馆预约这个场景的信息密度又低,8 轮对话里有效信息可能就 3 句,剩下的全是"好的""谢谢""帮我看看"这种寒暄,纯粹是花钱让模型读废话。
这暴露了 ChatMemory 的三个结构性局限:
- 窗口截断:只有最近 N 轮,窗口外的信息物理性消失;
- 只有原文,没有提炼:存的是对话流水,不是"用户是谁、他要什么"这种可复用的知识;
- 生命周期等于会话:会话结束,记忆归零。这对场馆预约还能忍(今天订场和上周订场没什么连续性),对绝大多数长周期场景是致命的。
Job-Agent:记忆从"上下文"变成"资产"
做 Job-Agent 的时候,记忆问题绕不过去了。求职是以周为单位的长周期过程:用户第一周说"期望薪资 15-20k,base 只考虑杭州",第三周改口"其实上海也可以,薪资好商量"。如果 Agent 只靠会话记忆,用户每次来都要重新自我介绍一遍;更糟的是,它可能拿着三周前的旧偏好,给一个刚改了期望的用户推一堆不匹配的岗位。
所以这一次,我们把记忆当成核心资产来设计,而不是对话的附属品。整个 memory/ 包最后长这样(类名都是真实的):
memory/
├── AgentMemoryCandidate.java // 记忆候选(还没入库的)
├── AgentMemoryLlmExtractor.java // LLM 通道:从对话里捞语义记忆
├── AgentMemoryRuleExtractor.java // 规则通道:正则/关键词抓硬信息
├── AgentMemoryActionClassifier.java // 动作分类:CREATE/UPDATE/DELETE/IGNORE
├── AgentMemoryActionDecision.java // 动作决策
└── AgentMemoryWritePolicy.java // 写入策略:能不能写、怎么写
设计思路是一条五段式管道:对话流 → 提取 → 分类 → 策略 → 入库。逐段说。
第一段:双通道提取。 记忆候选从两个通道产生:
AgentMemoryRuleExtractor(规则通道):正则 + 关键词,抓硬信息——城市、薪资区间、工作年限、学历。快、免费、零幻觉,但只会抓"明说"的。AgentMemoryLlmExtractor(LLM 通道):拿一个小参数模型,从最近几轮对话里捞软信息——"用户在准备 Java 后端方向""用户对大小周比较敏感""用户似乎在犹豫要不要去上海"。规则抓不住的语义交给模型,产出统一是AgentMemoryCandidate(候选),带置信度和来源对话轮次。
为什么不全用 LLM 通道?贵、慢,而且它会"热心过头"——见下一个坑。
第二段:动作分类。 候选不能直接 append 进库。AgentMemoryActionClassifier 先判断这条候选对应什么动作:
- CREATE:全新偏好,如第一次提到"期望城市";
- UPDATE:和已有记忆冲突,如"15-20k" → "20k 以下不考虑";
- DELETE:用户明确否定,如"我不想去外包了"要删掉相关偏好;
- IGNORE:吐槽、玩笑、假设句("要是我也有五年经验就好了"不是事实)。
这一步是整个系统里 LLM 判断压力最大的环节,它本质是在做一次"对话理解",做错的代价是记忆库被污染。
第三段:写入策略。 AgentMemoryWritePolicy 是最后一道闸门:置信度低于阈值的候选挂起(攒够多次出现再转正);来源对话轮次太边缘的丢弃;带情绪色彩的降权。策略层全是确定性代码,不放任何模型——记忆库的写入门槛必须是确定性的,这是我们从"记忆污染事故"里换来的铁律(下面细说)。
第四段:冲突检测与版本历史。 这是设计里我最得意的一块。记忆更新不做覆盖,做版本链:每条记忆有版本历史,"期望薪资"从 v1(15-20k,10月8日,来自某轮对话)演进到 v2(20k+,10月29日)。好处有两个:一是能回答"用户为什么拒了那个 offer"这类回溯问题(当时他的薪资预期就是 15k,那个 offer 给 12k);二是出了污染事故可以回滚到上一个版本。
第五段:存储与注入。 MySQL 打底持久化,Redis 做热缓存。注入的时候不做全量塞入——用户记忆可能有几十条,全塞进 system prompt 既是 token 浪费也会稀释注意力,而是按当前对话意图检索相关记忆(对,记忆的注入本身也是个检索问题)。注入格式上,给每条记忆标注"(用户于10月提及)",让模型知道这是背景知识而不是当前指令。
三个真实事故
事故一:记忆污染。 用户某天情绪低落,说了句"唉,感觉找不到工作,不想找了"。LLM 提取通道忠实地把它捞出来:{求职状态: 已放弃}。之后两周,Agent 给他的所有回复都带着"既然您已经放弃求职……"的基调,岗位推荐停了,模拟面试也劝他"注意休息"。用户差点流失。修复:情绪类陈述单独建类,默认不产生状态记忆;以及 IGNORE 分类门槛大幅调低——宁可漏记,不可错记。记忆系统的信任和 Agent 的信任一样,一次污染,清零重建。
事故二:提取器幻觉。 LLM 通道偶尔会"脑补"出对话里根本没有的偏好——大概是小模型的推理能力撑不住提取任务。修复很土但有效:提取模型 temperature 调 0,prompt 里强制要求每条候选必须附带原文引用,校验不到原文的候选直接丢弃。
事故三:记忆过期。 用户已经拿到 offer 入职了,Agent 还在按"求职中"的状态每天推岗位。这是记忆只有"写入"没有"生命周期"的问题。修复是把记忆和任务状态机联动:求职状态机的每次流转(投递中 → 面试中 → 已入职)都触发对应记忆的批量失效。
回头看
如果用一句话总结这一篇:**ChatMemory 解决的是"这个会话说了什么",记忆系统解决的是"这个用户是谁"。**前者框架给你现成的,后者只能自己搓,而且搓的时候要想清楚——记忆不是缓存,是有版本、有生命周期、可回滚的资产。
下一篇回到硬核话题:RAG 从 Demo 到生产,那些调参和中文分词的坑。
