ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从确定性优先到持续回忆:Agent长期记忆检索的工程新思路

从确定性优先到持续回忆:Agent长期记忆检索的工程新思路 做 Agent 的朋友最近应该都遇到过同一个问题聊到一半它把前面的结论忘了你昨天明确告诉它的偏好今天再问就变成“我理解你的意思”哪怕是同一个问题、同一个上下文两次回答还能给出不同的依据。记忆一直是大模型应用从 Demo 走向生产的最大短板但大多数团队的第一反应还是“上向量数据库”“加 RAG”结果召回结果仍然玄学。最近 Hacker News 上出现了一个很有意思的项目CueMap。它把记忆检索的思路从“语义相似优先”改成“确定性优先”核心技术点可以拆成三个词deterministic-first确定性优先、memory retrieval记忆检索、continuous recall持续回忆。这篇文章不打算复述项目说明而是想把背后的设计逻辑讲透为什么相似度检索不够用确定性优先解决了什么用什么结构落地以及它适合哪些场景、不适合哪些场景。读完后你至少能判断自家 Agent 的记忆模块要不要往这个方向改也能照着文中的最小示例自己跑通一套雏形。1. 这篇文章真正要解决的问题先说结论当前大多数 Agent 的“长期记忆”本质上是一个黑盒相似度搜索它对“连续回忆”这件事支持得非常差。所谓连续回忆不只是“记住一条信息”而是指系统可以在很长的时间跨度里用不同的线索稳定地把相关记忆找回来。比如用户三个月前说“我不吃香菜”今天点菜时提到“有什么忌口”一个好记性应该能自动关联并稳定命中。这里有两个关键词关联以及稳定。传统向量检索擅长关联但不擅长稳定。原因在于向量检索是近似搜索它依赖 Embedding 模型把文本映射到高维空间而同一句话在不同模型、不同改写形式下余弦相似度都会波动。你存入一条记忆时用的是用户原话但用户之后提问会换一种说法两个文本的向量距离很可能不够近导致这条记忆根本不会被召回。更麻烦的是向量检索结果缺乏可解释性你很难回答“为什么这次没找到”。CueMap 提倡的 deterministic-first 思路是先把记忆检索拆成两个阶段。第一阶段用完全确定性的规则和索引去匹配比如精确键、结构化字段、时间戳、标签、显式关联第一阶段没有命中时再退回语义检索。这个顺序看起来简单但它带来几个无法忽视的好处可复现、可审计、时延低、不需要昂贵模型参与。本文后面会给出这个思路的完整实现雏形。什么人最应该读这篇文章如果你在做 RAG 问答、Agent 长期记忆、个人知识库、客服机器人或者正在为“记忆不稳定”发愁这篇文章值得从头到尾读完。如果你只是想把 SQLite 里几十条配置塞进 Prompt那 CueMap 的思路对你也可能有启发但你需要的是更轻的解决方案。2. CueMap 是什么把“提示”变成一张可导航的地图先拆名字。Cue 是“提示线索”Map 是“地图”。CueMap 这个名字非常直白它把记忆的检索过程从“大海捞针”变成“按图索骥”。传统向量检索就像是让你在漆黑的仓库里靠嗅觉找东西而 CueMap 的思路是“给仓库装上标签和货架编号先按编号找编号找不到再全场扫描”。从项目描述看CueMap 是一个偏系统设计的项目解决的是大模型长期记忆的提取与召回问题。它没有把重点放在“用什么模型生成记忆”而是放在“怎么样把记忆找回来”。这个切入点值得注意因为多数团队把精力花在记忆的写入和存储上却低估了读取的难度。实际工程里写入是可控的读取是实时的、面向用户的、不可控的。你无法要求用户按你存储时的措辞来提问所以读取策略必须足够鲁棒。如果把 CueMap 和常见方案放在一起对比边界会更清楚方案匹配方式确定性可解释性时延典型成本关键词搜索BM25词项命中等有排序较高高低低向量相似度检索语义空间近似低低中中高Embedding混合检索关键词 向量分值融合中中中中高CueMap 风格确定性索引优先语义兜底高高低到中低到中这里不是要否定向量化检索的价值。语义检索非常适合开放式问题、模糊描述和跨语言场景。但它的缺陷在于当系统需要“用户提出一个线索立即精确召回对应记忆”时向量检索的近似性会成为负担。CueMap 的判断是先给出可信的硬匹配路径再让语义检索处理剩余不确定部分。这个设计背后其实是一个很朴素的工程原则能用确定逻辑解决的就不要依赖概率模型。这个原则在搜索、数据库、规则引擎里已经存在几十年只是到了大模型时代大家反而忘了它。3. 核心概念deterministic-first 到底是什么意思deterministic-first 不是一个严格学术名词而是一条检索优先级策略凡是能被显式键、规则、结构化条件命中的查询永远优先走确定性路径。只有确定性路径失败时才允许模糊匹配登场。举个例子。你往记忆库里写入这样一条记录{ id: mem_000123, content: 用户不喜欢吃香菜, user_id: user_7788, tags: [饮食偏好, 忌口], created_at: 2025-03-17T10:00:00Z, source: conversation_42 }如果用户问“我的忌口是什么”系统可以先用tags精确匹配“忌口”或者用“饮食偏好”作为维度键直接命中mem_000123。这一步完全不需要 Embedding不需要向量距离命中了就是命中了。但用户很可能问的是“我今天出去吃饭有什么注意事项”这句话里没有任何词和“忌口”重叠。此时确定性路径失败系统才调向量检索去匹配语义相关的记忆。于是组成这样的检索链路Query → 规则/键/标签/时间/关联匹配 → 命中则返回 → 未命中 → 语义相似度检索 → 排序返回 TopK → 语义也未命中 → 返回空或通用兜底这是一个“级联式检索”Cascade Retrieval。它的关键价值在于大多数记忆查询在第一个阶段就可以收口。因为现实中的很多检索意图是高度结构化的比如“这个用户的偏好是什么”“这个项目的配置参数是多少”“上次部署是什么时间”这些本来就不该靠向量去猜。deterministic-first 还有一个容易被忽视的收益它是可审计的。生产环境里如果用户投诉“系统记错了”你可以导出命中链路精确复现是哪个键命中了哪条记录。而纯向量检索几乎不具备这个能力你只能复现一个近似分值说不清为什么。当然确定性优先也有代价。它要求你为数据建模提前设计好字段、标签、键结构。这比“一股脑塞进向量库”要麻烦。CueMap 的价值主张是这个前期成本换来的稳定性和可解释性对长期记忆系统来说非常划算。4. 为什么 continuous recall 是硬骨头连续回忆continuous recall这个概念字面意思是“长期持续地把记忆找回来”。它和一般问答检索最大的区别在于时间跨度和线索变化。短期记忆场景里上下文就在 Prompt 里检索压力很小。长期记忆则会遇到三个相互叠加的难题。第一线索漂移。用户三个月前说“我不喜欢太辣的东西”今天可能问“上次那家川菜馆后来去了吗”。两条信息语义上有关系但字面几乎不重叠。靠纯向量检索很容易因为 Embedding 方向偏差而丢失关联。第二记忆衰退。向量检索本质上是“打分排序”不是“精确取出”。即便某条记忆确实存在只要 query 的表示靠近另一个不相关的记忆簇正确记忆的排名就会被挤压出 TopK。这种失败是随机性的这次能召回下次就召回不了很难调试。第三上下文碎片化。长期记忆中一条完整信息经常被拆成多个片段存储比如时间、地点、人物分别写成了三条记忆。如果检索时只命中其中一条Agent 组合出的答案就是残缺的。continuous recall 真正需要的不是“更聪明的相似度计算”而是“一套让记忆可定位的结构”。CueMap 的对应解法是把记忆组织成有线索的地图。每条记忆不是一个孤立的向量而是一个携带确定性关联的节点可以通过多个入口稳定进入。比如“用户 A 不喜欢香菜”这个节点同时挂 tags饮食偏好、participantsuser_7788、event聚餐记录等多个键。任何一个键被请求命中都能把这条记忆拉出来。这种设计很像数据库里的多列索引而不是全表扫描。它没法保证智能但能保证“有路可走”。在长期记忆场景里“有路可走”比“碰运气命中”可靠得多。5. 从概念到结构一个 CueMap 风格系统怎么设计理解了 deterministic-first 之后可以自己设计一套最小系统。本文不提供 CueMap 官方 SDK 的调用代码因为项目本身可能仍在迭代更值得学习的是它的设计模式。下面给出一个兼容 CueMap 思路的最小架构。核心组件有三个MemoryStore记忆存储、CueIndex确定性线索索引、HybridRetriever混合检索器。MemoryStore 负责保存记忆主记录可以用关系型数据库、KV 存储甚至 JSON 文件承载。每条记忆必须有稳定 ID、内容、创建时间和至少一个可索引字段。CueIndex 负责维护确定性入口。它由若干“线索”组成每个线索是一个键值对或多字段组合。常见的线索包括用户 ID、标签、记忆类型、时间范围、业务实体 ID。CueIndex 可以是内存哈希表也可以是数据库索引。HybridRetriever 负责执行级联检索先走 CueIndexmiss 再走向量检索。为了提高质量建议给每条记忆增加一个semantic_embedding字段但不要让向量成为主索引。来看一个数据表设计的例子CREATE TABLE memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, content TEXT NOT NULL, memory_type TEXT NOT NULL, tags TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, metadata TEXT, embedding BLOB ); CREATE INDEX idx_memories_user ON memories(user_id); CREATE INDEX idx_memories_type ON memories(memory_type); CREATE INDEX idx_memories_tags ON memories(tags); CREATE INDEX idx_memories_created ON memories(created_at);这里的 user_id、memory_type、tags 就是确定性线索。在实际业务里你可以根据领域扩展字段比如project_id、device_id、category。字段越贴近业务确定性检索的命中率越高。检索流程可以用下面这段 Python 伪代码表达def retrieve(query, user_id, k5): # 第一阶段确定性线索匹配 candidates cue_index.match( queryquery, user_iduser_id, memory_type_hintextract_type(query), tag_hintextract_tags(query) ) if candidates: return candidates[:k] # 第二阶段语义向量兜底 query_vec embed(query) semantic_hits vector_store.search( query_vec, top_kk, filter{user_id: user_id} ) return semantic_hits这个流程只是一个骨架但已经能看出 deterministic-first 的完整意图。第一阶段命中率高、时延低、结果稳定第二阶段负责处理开放式表达。两者不是互斥关系而是互补关系。6. 完整示例最小可用的 deterministic-first 记忆系统为了让思路可落地这里给出一个可以直接跑起来的最小实现。不需要重型框架只要 Python 3.9 以上版本再加一个 SQLite 数据库即可。该示例不模拟向量检索而是用文件路径形式演示级联逻辑方便你在没有 Embedding 服务时也能启动。项目目录结构如下cue_map_demo/ ├── memory_store.py ├── retriever.py ├── config.yaml └── main.py6.1 记忆存储层新建memory_store.py# 文件路径cue_map_demo/memory_store.py import sqlite3 import json from datetime import datetime, timezone class MemoryStore: def __init__(self, db_path: str memories.db): self.conn sqlite3.connect(db_path) self.conn.row_factory sqlite3.Row self._init_schema() def _init_schema(self): self.conn.execute( CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, content TEXT NOT NULL, memory_type TEXT NOT NULL, tags TEXT, created_at TEXT NOT NULL, metadata TEXT ) ) self.conn.execute( CREATE INDEX IF NOT EXISTS idx_user ON memories(user_id) ) self.conn.execute( CREATE INDEX IF NOT EXISTS idx_type ON memories(memory_type) ) self.conn.commit() def add_memory( self, memory_id: str, user_id: str, content: str, memory_type: str, tags: list, metadata: dict None, ): now datetime.now(timezone.utc).isoformat() self.conn.execute( INSERT INTO memories (id, user_id, content, memory_type, tags, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?) , ( memory_id, user_id, content, memory_type, json.dumps(tags, ensure_asciiFalse), now, json.dumps(metadata or {}, ensure_asciiFalse), ), ) self.conn.commit() def query_by_user_and_type(self, user_id: str, memory_type: str): cursor self.conn.execute( SELECT * FROM memories WHERE user_id ? AND memory_type ?, (user_id, memory_type), ) return [dict(row) for row in cursor.fetchall()] def query_by_tag(self, user_id: str, tag: str): rows [] for row in self.conn.execute( SELECT * FROM memories WHERE user_id ?, (user_id,), ): record dict(row) tags json.loads(record[tags]) if tag in tags: rows.append(record) return rows这个文件的核心是 SQLite 查询。query_by_user_and_type和query_by_tag都是确定性检索不需要任何模型参与。6.2 检索器新建retriever.py# 文件路径cue_map_demo/retriever.py from memory_store import MemoryStore class CueMapRetriever: def __init__(self, store: MemoryStore): self.store store def retrieve(self, query: str, user_id: str): # 第一层关键词规则识别是否存在明确类型线索 type_hint self._extract_type_hint(query) if type_hint: hits self.store.query_by_user_and_type(user_id, type_hint) if hits: return {stage: deterministic, items: hits} # 第二层标签匹配 tag_hint self._extract_tag_hint(query) if tag_hint: hits self.store.query_by_tag(user_id, tag_hint) if hits: return {stage: deterministic, items: hits} # 第三层模拟语义兜底 return {stage: semantic_fallback, items: []} def _extract_type_hint(self, query: str): type_mapping { 偏好: preference, 忌口: preference, 配置: configuration, 部署: deployment, } for keyword, memory_type in type_mapping.items(): if keyword in query: return memory_type return None def _extract_tag_hint(self, query: str): tag_mapping { 香菜: 饮食, 辣: 饮食, 环境: 部署, 端口: 部署, } for keyword, tag in tag_mapping.items(): if keyword in query: return tag return None这里的关键是retrieve方法的三层级联。它先用规则抽取类型线索再用标签抽取两者都失败才进入兜底。实际生产中规则抽取可以替换成更精确的实体识别但优先级顺序不会变。6.3 配置文件新建config.yamlmemory: db_path: memories.db deterministic_first: true fallback_strategy: semantic_vector retriever: type_hint_keywords: 偏好: preference 忌口: preference 配置: configuration 部署: deployment tag_hint_keywords: 香菜: 饮食 辣: 饮食 环境: 部署 端口: 部署这个配置文件的价值在于把规则和代码解耦。后面扩充线索词时不需要改 Python 代码。6.4 入口程序新建main.py# 文件路径cue_map_demo/main.py from memory_store import MemoryStore from retriever import CueMapRetriever def main(): store MemoryStore() store.add_memory( memory_idmem_0001, user_iduser_7788, content用户不喜欢吃香菜点餐时不要加香菜, memory_typepreference, tags[饮食, 忌口], metadata{channel: menu_agent}, ) retriever CueMapRetriever(store) test_queries [ 我的忌口是什么, 今天聚餐有什么注意事项, 帮我看看生产环境的部署配置, ] for query in test_queries: result retriever.retrieve(query, user_iduser_7788) print(fQuery: {query}) print(fStage: {result[stage]}) print(fItems: {result[items]}) print(---) if __name__ __main__: main()运行方式cd cue_map_demo pip install pyyaml python main.py预期输出大致如下Query: 我的忌口是什么 Stage: deterministic Items: [{id: mem_0001, content: 用户不喜欢吃香菜点餐时不要加香菜, ...}] --- Query: 今天聚餐有什么注意事项 Stage: semantic_fallback Items: [] --- Query: 帮我看看生产环境的部署配置 Stage: deterministic Items: [] ---注意第三条查询没有命中因为记忆库中没有配置类型的记录这是符合预期的。如果之后添加相关记录确定性路径就会命中。7. 实际验证与效果评估一个记忆系统不能只看“能不能跑通”还要看检索质量和稳定性。建议从四个维度评估。7.1 命中率在测试集上统计“正确记忆是否出现在 TopK 结果中”。对 deterministic-first 系统来说命中率要拆开统计确定性阶段命中率、语义兜底命中率、整体命中率。如果整体命中率低先看确定性阶段的线索词覆盖率。评估命令可以这样写python main.py output.txt grep Stage: deterministic output.txt | wc -l7.2 可复现性同一个查询连续运行 10 次结果是否一致。纯向量检索受 Embedding 模型和距离计算影响结果可能出现微小波动确定性检索则应该 100% 一致。如果出现不一致说明代码里存在随机逻辑或查询排序不稳定。7.3 时延分别统计确定性阶段和语义兜底阶段的耗时。确定性阶段一般应在毫秒级语义兜底则依赖模型推理。如果检索接口被频繁调用建议对确定性阶段做缓存。7.4 失败模式当返回空结果时系统应该能区分“记忆确实不存在”和“记忆存在但没找到”。理想设计是确定性阶段 miss 后在日志里记录 query、候选键、兜底结果如果语义兜底也 miss再记录一次。这样你可以分析是线索设计问题还是语义模型问题。排查顺序建议如下先确认查询是否经过正确入口。查看日志中Stage字段判断命中了哪一个阶段。如果停在 deterministic 且未命中检查关键词映射表里有没有覆盖此查询。如果进入 semantic_fallback 但结果为空再排查 Embedding 服务和向量库。8. CueMap 思路的适用场景与边界从项目定位来看CueMap 的思路适合很多场景但并不是万能钥匙。下面按“推荐”“谨慎”“不推荐”三类来划边界。推荐场景有三类。第一类是 Agent 长期记忆用户偏好、历史结论、项目状态这些结构化程度较高的信息天然适合确定性线索索引。第二类是个人知识库或个人助理用户经常用碎片化问题触发记忆稳定召回比模糊丰富更重要。第三类是运维和客服领域很多问题本质上是“查配置”“查上次结论”确定性策略能显著降低幻觉风险。谨慎使用的场景是开放域问答。比如“给我推荐一部电影”这类问题用户预期的是发散、多元的结果确定性优先反而会限制探索性。此时更适合把 CueMap 作为过滤层而不是主召回层。不推荐的场景是首轮冷启动和极短对话。如果用户只说过一句话记忆量不足建立索引的意义不大。另一个不推荐的场景是高度自由的知识组织比如用户希望系统自动抽取任意主题的知识点且不提供任何结构化入口此时强行设计确定性索引会非常累。所以准确地说CueMap 不是一个“记忆生成器”它是一套“记忆检索的工程秩序”。它要求你在写入阶段就考虑“以后怎么找到这条记忆”。如果你不想投入这个建模成本它就不适合你如果你愿意它能给你大量回报。9. 常见问题与排查思路问题现象可能原因排查方式解决方案确定性阶段总是不命中关键词映射表覆盖不足查看日志中的关键词解析结果扩充 type_hint / tag_hint 映射同一查询结果两次不同兜底阶段使用了向量检索且无确定性缓存增加缓存并对比两阶段结果优先确认是否存在确定性路径避免过早进入语义阶段检索结果包含无关记忆多用户数据未按 user_id 过滤检查 SQL 是否拼接了 user_id 条件统一在 store 层强制携带 user_id 过滤记忆新增后查不到写入未提交事务或索引未更新查询数据库记录是否存在在写入后检查 commit并刷新 CueIndex语义兜底时延过高每次请求都调用 Embedding 模型检查请求日志、模型推理耗时增加查询缓存或降低兜底触发频率日志里看不到命中链路没有记录 Stage 字段补充检索阶段日志在 retrieve 返回结构中加入 stage 和 matched_key 字段10. 最佳实践与工程建议从 CueMap 的设计思想中可以提炼出一套适用于大多数记忆系统的工程实践。第一写入时就要设计可检索的线索。不要只存“内容”还要存user_id、memory_type、tags、entities、created_at。后续每次检索都会依赖这些字段。这套字段体系应该和产品功能对齐比如“用户偏好”“项目配置”“部署历史”每个类型对应一个明确业务含义。第二检索顺序保持稳定。第一层精确键第二层结构化条件第三层语义兜底。不要随意调换顺序。稳定的顺序也意味着可观测的日志每次查询都能记录落在了哪一层。第三把关键词映射做成配置而不是硬编码。上一节示例里已经演示了config.yaml的做法。业务变化后运营或开发人员可以直接改配置不需要发布代码。第四语义检索永远作为兜底而不是主路径。这样做能减少 Embedding 服务的调用量降低系统成本和不可控性。如果数据量超过十万条还可以考虑在语义检索前增加基于标签的预过滤。第五关注记忆的更新与删除。长期记忆系统的可靠性不仅取决于检索还取决于一致性。用户更正偏好后旧记忆必须立即失效或标记为废弃。建议给每条记忆增加status和superseded_by字段避免新旧版本同时命中。第六设定安全边界。记忆数据往往包含个人偏好、业务敏感信息检索接口必须做好权限校验。任何跨用户查询都不应该出现。生产环境建议最小权限原则数据库账号只开放所需表的读写权限并在 API 层做用户维度隔离。11. 总结与后续学习方向CueMap 这个项目最值得关注的不是它的具体代码而是它提出的一个判断在长期记忆检索里确定性应该优先于概率性。向量检索可以作为兜底但不应成为唯一路径。这个判断在工程上非常扎实因为你一旦把确定性路径建立起来系统的可维护性会明显改善用户对“记忆是否可靠”的信任也会增强。下一步可以从三个方向深入。第一把最小示例扩展成支持向量检索的完整服务用真实 Embedding 模型替换模拟兜底逻辑。第二设计一套记忆生命周期管理机制包含写入、索引、更新、失效和归档让记忆不是一个只增不减的仓库。第三尝试把检索结果反馈到 Prompt 组装层观察不同的记忆召回策略对最终生成质量的影响。最后想提醒一点记忆系统的核心指标不是“记住了多少”而是“需要时是否稳定地想得起来”。CueMap 的 deterministic-first 思路给这个目标提供了一个非常务实的路线。如果你的 Agent 也正被“记忆不稳定”困扰不妨先画出自己的线索地图再考虑向量库选型。建议收藏本文方便后续落地时对照实践。
返回列表