给 LLM 聊天系统做的一套记忆调度模块(MMU)。核心目标只有一个:在有限的上下文里,让 LLM 用最少的 token 拿到该记住的东西。
陪伴系统里,每条用户消息原本都会全量搜索 Obsidian 记忆库,100% 触发冷搜索,没有分层、没有缓存。上下文越长,每轮固定开销越大,还容易把无关记忆塞给 LLM 造成幻觉。
于是把记忆调度抽成独立模块,做了三层缓存「热层 TLB / _fileCache / 冷搜(memory-search)」。
用户消息 → 分类(classify, 纯正则 0 token)
├─ 热层 TLB 命中 → 短 context(几十字符),跳过冷搜
├─热层 miss →内部 _fileCache 缓存 命中 →省读盘时间
└─ 热层 miss → 内部 _fileCache 缓存 miss →冷搜(memory-search)
测试结果 ════ 模拟 2 小时对话(120 条消息)════
三层命中分布: 热层命中 : 17 次 (14%) | 平均 198 字符 零读取 : 49 次 (41%) | 平均 183 字符 冷搜 : 54 次 (45%) | 平均 520 字符
冷搜触发率: 45% 实际总 context: 40384 字符 假设每条都冷搜: 62400 字符 token 节省: 35%
存 LLM 从对话里实时提炼的「活跃事实」,一条一个 key/value(如 job_status = 在投 AI agent 岗)。命中就跳过磁盘搜索。
测试结果真正省 token :热层命中(44 字符 vs 冷搜 335,省 87%)和零读取(ack/pushback 不搜任何层)。
全程纯正则、0 token,按「从最省到最贵」排序决策,把「要不要搜索」从 LLM 手里抢过来:
ack(纯应答)→ pushback(抵触)→ question(疑问)→ 热层命中 → 活跃话题 → new_topic
判定「纯应答」时,与其枚举「好的好的」「是的是的」「哈哈哈哈」各种重复形式,不如先压缩重复再匹配基础词:
function _collapseRepeats(s) {
s = s.replace(/(..+?)\1+/g, '$1'); // 多字符重复:是的是的 → 是的
s = s.replace(/(.)\1+/g, '$1'); // 单字符重复:hhhh → h,!!! → !
return s;
}一处解决一类问题——以后加 ack 词只加基础形式,重复的自动被压缩处理。
中文 bigram 模糊匹配会误命中「什么」「干嘛」「这个」这类无意义词。加停用词表过滤,避免正则泛匹配搜出一堆无关记忆。
最初是「热/温/冷」三层。温层(_warmCache)想当热层和冷层之间的缓存,但落地后发现它三个都不沾:
- 不省 token——缓存的内容还得原样喂给 LLM,context 长度和冷搜一样
- 不省 I/O——memory-search
_fileCache功能与温层冲突,冷搜第二次也不读盘 - 失效策略打架——温层用 30min 固定 TTL,
_fileCache用 mtime 感知数据源变化。文件更新后 30min 内,温层还会返回旧 snippet(过期数据)
根因:memory-search.js中的_fileCache与info-broker.js中的_warmCache功能定位冲突,且_fileCache表现优于_warmCache。最终决定将二者合并为_fileCache。
但_warmCache有一个_fileCache没有的功能:根据TTL选择 延续话题cont 还是 new_topic,暂时先不做。