这篇讲我自己项目里的记忆系统设计。实现位置在 RuleTest-Agent 的 Client/agent/memory/(约 3800 行),架构参考了 GAM(General Agentic Memory)论文(arxiv:2511.18423)。我想把它讲清楚——既讲底层原理,也讲我踩过的工程坑,最后再说说怎么把它迁移到问数/客服业务。
一、为什么 Agent 需要记忆系统
LLM 本身无状态——每次调用只看得到本次塞进 prompt 的内容,上一轮聊完就「失忆」。但 Agent 跑长任务会遇到三个硬约束:
- 上下文窗口有限:再大也塞不下整个项目历史,硬塞还会「lost in the middle」(关键信息夹在中间被忽略)。
- 跨会话要延续:今天测了 A 模块,明天接着测,昨天的结论不能丢。
- 重复劳动浪费钱:同一文件被多个 Worker 重复读取,token 全是重复成本。
记忆系统 = 把历史压缩、存盘、按需检索回来塞进 prompt,相当于「外挂大脑」。
二、三个底层概念
1. Embedding(向量嵌入)+ 向量检索
把文字用模型转成定长数字向量(本项目用 all-MiniLM-L6-v2,384 维)。语义相近的文字向量距离近。检索时把 query 转向量,算余弦相似度找最近。
- 优点:懂语义。「心梗」召回「心肌梗死」,「查订单」召回「订单查询接口」。
- 缺点:对精确专有名词、ID、代码符号不灵。
getUserById和getUserByName向量很近,但你要精确匹配。
2. BM25(关键词检索)
传统词频统计算法(TF-IDF 升级版),看 query 词在文档中的频率与稀有度。
- 优点:精确匹配强,专有名词、函数名、错误码场景吊打向量。
- 缺点:完全不懂语义,换同义词就召回不到。
3. 混合检索(Hybrid)——本项目实际做法
两者优势互补。本项目用加权求和:
# 向量分 × 0.6all_page_scores[page_id] += score * vector_weight # vector_weight = 0.6# BM25 分先归一化(÷10 截断到1),再 × 0.4normalized_score = min(score / 10.0, 1.0)all_page_scores[page_id] += normalized_score * bm25_weight # bm25_weight = 0.4这里有个关键的坑:BM25 原始分无上界(可能几十),向量相似度是 0~1,量纲不同不能直接相加,否则 BM25 会淹没向量分。所以代码先把 BM25 分 ÷10 截断到 1 归一化再加权——这是混合检索最常见的坑,我踩过并解决了。
业界还有更优雅的 RRF(Reciprocal Rank Fusion,倒数排名融合):不看分数只看排名,score = Σ 1/(k+rank),天然免疫量纲问题。我当前用的是加权归一化,RRF 是后续的演进方向。
三、GAM 记忆系统实战架构
核心思想:「完整保留 + 轻量索引」分层
GAM 论文精髓一句话:别压缩历史(压缩就丢信息),而是完整存原文 + 单独建轻量索引快速定位。对应三个数据模型:
| 模型 | 角色 | 类比 |
|---|---|---|
| Page | 完整原文片段(≤2000字符,带 tags、向量、归属 plan/phase/worker) | 书的正文页 |
| SessionMemo | LLM 生成的 1-3 句摘要 + 关键实体 + 结果总结 | 书的目录条目 |
| LightweightIndex | 标签→page_id 倒排索引 + Phase 摘要 | 书的索引页 |
检索时先查又小又快的 Memo/索引定位 Page,再捞完整 Page 原文——既快又不丢细节。
双阶段:离线写入 + 在线检索
离线 GAMMemorizer:Worker 执行完一个 session 后触发 process_session():
- 把工作记录喂 LLM,生成一句话
SessionMemo(摘要+关键实体+结果); - 把完整内容切成带重叠的
Page(2000字符/页,200字符重叠,防切断语义); - 自动打标签、存盘、更新索引。
在线 GAMResearcher——最亮的部分,是一个 Deep-Research 循环(不是查一次就用):
Planning(LLM 规划搜索策略:用哪些 query、哪些检索器) ↓Searching(向量 + BM25 + Page-ID 多工具检索 + 加权融合) ↓Reflection(LLM 评估:信息够不够?置信度多少?) ↓ 不够且未到 3 轮 → 带新 query 再循环 ↓ 够了(置信度≥0.7) 或到 3 轮上限 → 整合成连贯上下文给 Worker这就是 Agent 版 RAG——传统 RAG 是「查一次直接用」,这套是「让 LLM 自己判断查得够不够,不够换角度再查」,带反思迭代,质量高很多。
工程成熟度细节:优雅降级 + 性能节流
- 优雅降级(
_init_default_retrievers):三个检索器独立初始化,一个挂了不影响其他。向量检索依赖sentence_transformers,装不上就只用 BM25;全挂了 fallback 到page_store.search_pages()。这呼应了项目里「换被测系统核心代码零改动」的原则——无强依赖、层层兜底。 - 性能节流(
last_cleanup_at):历史多了每次启动全量扫会拖慢 Agent,用时间戳节流避免每次清理。这种细节是真踩过坑才会做的。
四、对照 Claude Code 来理解
| 维度 | Claude Code | RuleTest GAM |
|---|---|---|
| 长期记忆 | CLAUDE.md/MEMORY.md,人工或半自动写规则 | SessionMemo+Page,Worker 跑完 LLM 自动生成 |
| 写入时机 | 手动 # 记一条,或 /memory | session 结束自动 process_session() |
| 检索方式 | 启动时全量塞进上下文 | 按 query 混合检索按需召回 top-k |
| 会话内 | 当前窗口 + 上下文压缩摘要 | Working Memory + 三层结构 |
| 跨会话 | MEMORY.md 索引 + 文件 | LightweightIndex + memo_store |
关键区别:Claude Code 偏「人工策展的规则库」(稳定的长期约定);GAM 偏「自动沉淀的工作记忆」(任务过程中海量中间结果的自动管理)。
还有一个角度:CLAUDE.md 里写的 Working/Collaborative/Global 是按用途分层(当前会话/Worker 间共享/长期知识库);GAM 的 Page/Memo/Index 是按存储粒度分层。两套视角不冲突,因为它们解决的是不同维度的问题。
五、这套东西怎么迁移到业务
我习惯把这套记忆系统的讲法按这个顺序展开:
- 先讲痛点:Agent 跑长任务,上下文窗口装不下历史,跨 Phase 要延续,Worker 间重复读文件浪费 token——所以要做记忆系统。
- 再讲设计哲学:参考 GAM 论文,核心是完整保留原文 + 轻量索引快速定位,不做有损压缩。分两阶段:离线 Worker 跑完 LLM 自动生成摘要和分页;在线检索做了 Deep-Research 循环,让 LLM 自己判断查得够不够,不够换角度再查,最多 3 轮。
- 硬核细节:检索是向量 0.6 + BM25 0.4 加权融合,这里有个坑——BM25 原始分无上界会淹没向量分,我做了归一化截断到 1 再加权。
- 迁移业务:这套直接能用在客服上——长期 FAQ 知识库 + 跨会话用户画像就是 Global Memory,当前多轮对话状态就是 Working Memory,混合检索保证「退款政策」这种精确术语和「我想退货」这种口语都能召回。
读过论文 + 落地过工程 + 能迁移业务——我觉得这正是 AI 应用开发岗最该具备的画像。