news 2026/10/20 8:03:31

给 AI Agent 装上长期记忆:从上下文工程到记忆分层设计实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
给 AI Agent 装上长期记忆:从上下文工程到记忆分层设计实践

做 Agent 做久了,你一定会被同一件事反复绊倒:用户上个月说过自己对花生过敏,这次推荐餐厅时它照样甩出一份宫保鸡丁;上周明确要求所有回复用中文,隔了一天又开始飙英文。不是模型不聪明,是它每开一个新会话就把你忘得干干净净。Hermes Agent 这个项目要解决的就是这件事——给 AI Agent 装上一层长期记忆,让它从"每次见面都重新认识你"变成"用得越久越懂你"。我把它理解成一个专门负责"记什么、怎么记、什么时候想起来"的中间层,跟具体的对话模型解耦,可以挂在不同能力等级的模型上。这篇内容适合三类人看:正在做对话类 Agent 但被上下文长度折磨的开发者、想让智能助手记住用户偏好的产品同学,以及单纯好奇"机器怎么记住人"的技术爱好者。下面我按项目设计的思路、核心实现细节、能直接跑起来的实操步骤、以及我踩过的坑,一层层拆给你看。

1. Hermes Agent 到底解决什么问题

1.1 从一个真实场景说起:健忘的助手有多难用

我接触过不少做智能助手的团队,大家最开始的做法都差不多:把历史对话全部塞进上下文,能塞多少塞多少。用户聊到第 30 轮,模型前面还记得住,到第 80 轮就开始"失忆",问它"我刚才说的那个预算上限是多少",它要么答错,要么含糊过去。更麻烦的是跨会话场景——今天聊完关掉,明天再打开,一切归零,前面积累的所有信息全部作废。

这件事的痛感其实不在"记不住",而在"信任崩塌"。用户第一次说"我不吃香菜",助手记住了,用户会觉得这玩意儿有点东西;用户第二次说"我不吃香菜",助手还是没记住,用户就会在心里给它降级,从此不再认真交代任何偏好。人跟人之间建立默契靠的就是这种"我说过一次你就懂"的积累,机器也一样。Hermes Agent 的核心价值就在这儿:它不指望模型本身变得更聪明,而是通过一层外置的记忆系统,把用户的偏好、事实、历史决定、操作习惯沉淀下来,在需要的时候精准地捞出来喂给模型。

换个生活化的类比。你有个助理,他记性一般,但随身带一本工作日志,每次跟你开完会就把关键信息记一行,下次开会前翻一遍。这个助理不一定比天才助理聪明,但因为那本日志,他给你干活的效果会超过很多天赋更好但不记笔记的人。Hermes Agent 就是那本日志,外加一套"什么时候翻、翻哪一页、哪几条该划掉"的管理规则。

1.2 为什么"把上下文拉到超长"不是答案

有人会说,现在上下文窗口都做到几十万甚至上百万 token 了,还需要外置记忆吗?我实测下来的结论是:窗口大不等于记性好,而且窗口越大,问题越隐蔽。

第一个问题是成本和延迟。上下文每翻一倍,推理开销大致线性甚至更快地增长,一个每次都要吃进 20 万 token 的请求,响应时间和账单都会变得很难看。日常对话场景里,真正有用的信息可能只有几百字,剩下的全是噪声。

第二个问题是"中间遗忘"。长上下文模型对首尾内容敏感,夹在中间的信息召回率明显下降,你辛辛苦苦塞进去的那句"用户是左撇子",很可能正好落在注意力最弱的区间里,模型压根没当回事。这不是猜测,把关键信息刻意放在不同位置做对照实验,差距能看出来。

第三个问题是不可控性。上下文是一种"全有或全无"的注入方式,你没法精确控制哪条信息在什么时候生效、什么时候失效。用户三个月前说的临时需求,跟昨天说的长期偏好,在模型眼里权重是一样的,这显然不合理。记忆系统要做的事情,恰恰是给不同信息打上不同的权重和生命周期。

注意:外置记忆不是要取代上下文,而是给上下文做"预处理"。它的目标是把 20 万 token 的原始流水,压缩成 800 到 1500 token 的高密度事实,让模型每一次都在"最相关的信息"上做推理。

1.3 Hermes 的记忆分层:四种记忆各管一摊

把记忆当成一个整体来设计,很快就会乱。Hermes Agent 的思路是分层,借鉴认知科学里对记忆的分类,落到工程上我把它拆成四层,每一层的写入策略、存储结构、召回权重都不一样。

工作记忆对应当前会话的短期上下文,生命周期是"这次对话结束就丢",处理的是"我刚才说的那个"这类指代。它不需要持久化,只需要在会话内维持一个滑动的窗口,保证模型能理解上下文指代关系。

情景记忆记录的是"发生过什么":某月某日用户提了一个需求,某次任务失败了,用户当场纠正了助手的某个说法。这类记忆带明确的时间戳和事件属性,价值在于让助手知道"这件事我们之前处理过,当时的结论是什么",避免重复踩坑。

语义记忆记录的是相对恒定的知识:用户的职业、常用语言、技术栈偏好、家庭情况里愿意透露的部分。它不依赖具体时间点,可以长期存在,但当新信息与旧信息冲突时,需要被更新或标记为过期。

程序性记忆记录的是"该怎么做事":用户要求代码注释用中文、要求每周报告按固定模板输出、要求先给结论再给理由。这类记忆最容易被忽略,但它的价值极高,因为它直接决定了输出的格式和风格,是"个性化"最直观的体现。

2. 架构设计与选型取舍

2.1 三层结构:写入管道、存储层、召回层

Hermes Agent 的整体结构就是三段式,中间用清晰的数据契约隔开。

写入管道负责从对话流里"提炼"记忆。它的输入是最近若干轮对话,输出是结构化的候选记忆条目。这一层最容易做烂,因为模型的默认倾向是"什么都记",一旦不加约束,三天之后你的记忆库里全是"用户说了你好"这种废条目。

存储层负责持久化和索引。它不是简单的一张表,而是"结构化字段 + 向量表示"的双轨结构:结构化字段用于精确过滤(比如只看某个用户的、只看某类记忆),向量表示用于模糊语义匹配。两条路最后在召回层合并。

召回层负责在每一轮对话开始前,根据当前用户输入,从库里捞出最该被想起来的那几条,按 token 预算裁剪后注入到系统提示里。召回质量直接决定了用户体感,捞多了浪费 token 还干扰模型,捞少了等于没记。

2.2 存储选型:为什么我最终选了"双轨"而不是纯向量库

一开始我也想过最省事的方案:所有记忆转成一段文本,存进向量库,召回时做一次相似度搜索就完事。跑了大概两周我就放弃了,原因有三个。

第一,精确过滤做不了。"只看这个用户的记忆"、"只要最近 30 天的"、"排除已经被标记为过期的",这些条件在纯向量检索里很难可靠地表达,得靠检索后再过滤,而检索本身已经跑偏了。

第二,冲突消解做不了。用户昨天说预算 50 万,今天说改成 30 万,我需要找到那条旧记录并把它标记为 superseded。纯向量库只能告诉我"这两条很像",但没法保证一定命中,也没法做原子更新。

第三,可解释性差。线上出问题时我需要知道"为什么这条记忆被召回了",结构化字段能让我一眼看到是相似度贡献大还是时间新鲜度贡献大,纯向量给不出这个视角。

最后的方案是:用关系型存储(我图省事用了嵌入式数据库,省掉部署成本)存结构化字段和原始文本,用一列二进制存向量,同时建一个全文索引兜底关键词匹配。数据量在百万条以内,这个组合完全够用,而且单机零依赖,迁移和备份都简单。

注意:不要一上来就上分布式向量集群。绝大多数项目的记忆条目数量级在十万以内,用简单方案把写入和召回逻辑跑通,比提前优化基础设施重要得多。我见过团队花两周搭索引服务,结果核心的抽取质量一塌糊涂。

2.3 写入时机:什么时候该记,什么时候闭嘴

写入策略里最反直觉的一条是:大部分对话轮次不应该产生任何记忆。如果每一轮都写一条,你的库里 90% 是噪声,召回时会被这些噪声稀释,真正的关键信息反而捞不出来。

我用的判断标准是三条,满足任意一条才进入抽取流程。

第一条是含持久性信息:用户交代了偏好、身份、长期约束、重要日期。这类信息的特点是"下次还有用"。相反,"帮我查一下今天的天气"就是一次性的,不该记。

第二条是含纠正或否定:用户说了"不对"、"不是这样"、"以后别这样"。这类信号极其宝贵,因为它明确指出了当前行为的偏差,属于程序性记忆的第一手材料。

第三条是含决策结论:经过讨论后达成了一个结论、选定了一个方案、确定了一个参数。这类内容在未来被问到"我们当时为什么选 A 不选 B"时能用上。

除了这三条,还有一个兜底机制:会话结束前跑一次"收尾抽取",把整个会话里没有被实时捕获的高价值信息补一遍。实时抽取会漏东西,因为单轮视角看不出哪些信息后面会被反复引用。

2.4 遗忘机制:会忘比记得多更难做

这一点是我做完第一版之后最大的认知修正。我最初的设计是"只增不减",结果三个月后记忆库里堆了八千多条,召回准确率从早期的 80% 多掉到 50% 出头。原因很朴素:旧信息和过时信息在参与竞争,把真正相关的那几条挤下去了。

Hermes 的遗忘不是物理删除,而是三层降权。第一层是时间衰减,每条记忆按类型设定不同的半衰期:偏好类信息衰减慢(几个月量级),临时任务类衰减快(几天量级),事件类居中。第二层是访问频次加成,被反复召回的条目说明它确实有用,权重会往上走。第三层是显式过期,被新事实覆盖的旧条目状态改成 superseded,默认不参与召回,但保留可追溯性,万一用户问"我是不是以前说过这个"还能查出来。

物理删除只在两种情况下发生:用户明确要求删除,以及检测到敏感信息误入。其余的"忘"都是软性的、可逆的降权,这样既控制了噪声,又保留了纠错空间。

3. 核心细节拆解:写入与召回怎么落地

3.1 记忆条目的字段设计

一张表设计得好不好,直接决定后面所有逻辑顺不顺。我最终定下来的字段结构是这样的:

字段类型作用设计理由
id文本主键唯一标识用时间戳加随机后缀,便于排序和排查
scope_id文本归属范围用户维度还是项目维度,决定可见性边界
mem_type枚举记忆类型语义、情景、程序性三类,影响衰减参数
content文本原子事实一句话一条,不要塞整段对话
subject文本主体可选结构化,方便精确匹配
embed_vec二进制向量表示语义检索用,维度按所选模型定
importance整数 1-10重要度抽取时由模型打分,参与召回加权
confidence浮点 0-1置信度用户明说的给高分,推断的给低分
created_at时间戳创建时间时间衰减的基准
last_used_at时间戳最近召回时间频次加成的依据
use_count整数召回次数同上,反映真实有用程度
status枚举状态active、superseded、deleted
source_ref文本来源引用指向原始对话,便于审计

其中content字段我要强调一下:一条记忆只写一个事实。像"用户是后端工程师,喜欢用 Go,讨厌写单元测试"这种,应该拆成三条。理由是拆开之后每条的向量表示更集中,语义检索的准确率明显更高,而且更新时可以只改其中一条,不会牵连其他信息。

3.2 抽取管道:从对话流到原子事实

抽取是整个系统里最需要反复调的一环。我的实现是让一个模型来干这件事,但绝不直接把它输出的自然语言存进去,一定要让它输出结构化 JSON。

提示词的核心约束有四条。第一,只输出原文明确出现过的信息,禁止推测,比如用户说"我今天加班到很晚",不许推断出"用户工作压力大"。第二,一条记忆只包含一个事实,宁多勿合。第三,输出统一的 JSON 数组,每条带类型、重要度、置信度、主体。第四,如果没有值得记的内容,返回空数组——这条最容易被忽略,如果不强调,模型会绞尽脑汁给你编几条出来。

抽取完之后还有一道过滤,我用的是规则加模型的双重判断。规则层面拦掉几类:长度少于 6 个字的、纯寒暄的、包含明显一次性时间词的、包含敏感个人信息的。模型层面再跑一次轻量分类,判断"这条信息在 30 天后还有没有价值",打分为低的直接丢弃。

注意:抽取模型不必跟主对话模型是同一个。抽取是个简单的信息抽取任务,小模型完全够用,而且延迟低、成本低。我一开始用主模型抽,每轮对话多出将近一秒延迟,换成小模型之后体感几乎无差别,成本降了一个数量级。

3.3 去重与冲突消解:记忆库最脏的活

这两件事不做,记忆库半个月就废了。去重相对简单,分两级。第一级是精确去重,把 content 归一化(去掉标点、统一大小写、数字统一格式)之后做哈希比对,命中就直接跳过,只更新一下last_used_at。第二级是语义去重,用向量近邻搜索找出相似度超过阈值的候选,再让模型判断这两条是不是同一个意思,是的话保留信息量更大的那条。

冲突消解要麻烦一些,因为"冲突"和"重复"是两回事。用户上周说预算 50 万,这周说压缩到 30 万,这两条不重复,但后者覆盖前者。我的处理逻辑是:先做同主体同谓词的检索,找到可能冲突的旧条目,然后判断时间顺序和新旧关系——如果新条目明确表达了"改成"、"调整为"、"现在"这类信号,就把旧条目标记成 superseded,并建立一条链接指向新条目,方便追溯。

有一类冲突特别难处理:用户前后说法不一致但又没明说改了。比如一个人先说自己是做前端的,两个月后又说自己在写服务端代码。这时候不能简单覆盖,很可能他只是换了方向或者兼着做。我的做法是两条都留,但在召回时按时间新鲜度加权,新的权重大一些,同时给旧条目降置信度。真要较真,可以让助手在合适的时机自然地问一句,但不要每遇到这种情况就追问,那会显得很烦。

3.4 混合召回:打分公式和参数怎么看

召回是决定体感的一步。我用的打分公式是加权和:

score = 0.45 * 语义相似度 + 0.20 * 时间新鲜度 + 0.15 * 重要度归一化 + 0.10 * 频次加成 + 0.10 * 类型与场景匹配

语义相似度就是当前用户输入跟记忆条目的向量余弦相似度,做一次归一化。时间新鲜度用指数衰减:

freshness = exp(-Δt / tau)

其中Δt是距今的天数,tau是按类型设定的时间常数。我把偏好类设成 180 天,事件类设成 14 天,程序性记忆设成 365 天——因为"用户要求代码注释用中文"这种事,一年后大概率还是成立的。

重要度归一化就是把 1 到 10 的重要度除以 10。频次加成用log(1 + use_count) / log(1 + max_count),取对数是为了防止某条被反复召回的条目一家独大。类型与场景匹配是个小的加权项,比如当前对话在讨论代码,那么程序性记忆加一点分。

这几个权重我是这么调出来的:先拍一组初始值,然后用一批构造好的测试用例做网格搜索。强调一点,权重不需要很精确,差 0.05 对结果影响很小,真正影响大的是那几个阈值——比如语义相似度的最低门槛设多少。

3.5 注入策略:token 预算怎么分配

捞出来的记忆不能一股脑塞进去,得有个预算表。我现在的分配大致是这样:

用途token 预算说明
系统人设与规则600固定不变的部分
长期记忆注入1000-1200本项目核心,动态变化
工具定义800-900按实际工具数量浮动
最近对话3500-4000保证上下文的连贯性
安全余量1000-1300应对长输出和异常情况

记忆部分还有个内部排序规则:程序性记忆优先注入,因为格式和风格类的要求一旦违反,用户感知最强;其次是语义记忆里跟当前话题相关的;情景记忆放最后,只在明显相关时注入。另外我会对注入的记忆做一次"压缩改写",把多条短事实合并成一段通顺的说明文字,比列表堆砌更省 token,模型理解得也更自然。

4. 动手实操:从零把记忆层跑起来

4.1 环境与目录结构

这一版我用最朴素的依赖跑,一个嵌入式数据库加一个嵌入模型调用,不引入任何服务端组件。目录结构如下:

hermes/ memory/ __init__.py store.py # 存储与索引 extractor.py # 抽取管道 retriever.py # 召回与打分 injector.py # 上下文注入 data/ memory.db eval/ cases.jsonl

依赖只有两样:数据库驱动和嵌入模型客户端。嵌入模型我建议选维度在 512 到 1024 之间的,维度太高存储和检索成本上去了,收益不明显;太低则语义区分度不够。

4.2 建表与索引

先建表。核心是 memories 表加一个全文索引。

CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, scope_id TEXT NOT NULL, mem_type TEXT NOT NULL, content TEXT NOT NULL, subject TEXT, embed_vec BLOB, importance INTEGER DEFAULT 5, confidence REAL DEFAULT 0.8, created_at INTEGER NOT NULL, last_used_at INTEGER, use_count INTEGER DEFAULT 0, status TEXT DEFAULT 'active', source_ref TEXT ); CREATE INDEX IF NOT EXISTS idx_scope_status ON memories(scope_id, status); CREATE INDEX IF NOT EXISTS idx_type_time ON memories(mem_type, created_at DESC); CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(content, content='memories', content_rowid='rowid');

这里有个细节值得说一下:scope_id加status的联合索引是必须的,因为几乎每次召回都会带上这两个过滤条件。如果没有这个索引,数据量上万之后查询会明显变慢。全文索引用于兜底关键词匹配,因为向量检索对专有名词、编号、缩写这类内容的匹配经常翻车,比如用户提过某个内部代号,向量相似度可能算不出来,但关键词能精确命中。

4.3 抽取管道代码

抽取部分的核心是提示词约束加结果校验。

import json, time, uuid, hashlib, re EXTRACT_PROMPT = """从下面的对话片段中抽取值得长期记住的信息。 规则: 1. 只抽取对话中明确出现过的信息,禁止推测和补充。 2. 一条记忆只包含一个事实,不要合并。 3. 类型只能是 semantic(长期事实/偏好)、episodic(发生的事件)、procedural(做事的方式要求)。 4. importance 为 1-10,越长期有用分越高;confidence 为 0-1,用户明说的给 0.9 以上。 5. 没有值得记的内容时,返回空数组 []。 输出格式: [{"type":"semantic","content":"...","subject":"...","importance":8,"confidence":0.95}] 对话片段: {dialog} """ def normalize(text: str) -> str: text = re.sub(r"\s+", "", text) text = text.lower() return text def content_hash(text: str) -> str: return hashlib.md5(normalize(text).encode("utf-8")).hexdigest() def extract_memories(dialog: str, call_model) -> list: raw = call_model(EXTRACT_PROMPT.format(dialog=dialog)) try: items = json.loads(raw) except json.JSONDecodeError: return [] cleaned = [] for it in items: content = (it.get("content") or "").strip() if len(content) < 6: continue if it.get("type") not in {"semantic", "episodic", "procedural"}: continue it["content"] = content it["importance"] = max(1, min(10, int(it.get("importance", 5)))) it["confidence"] = max(0.0, min(1.0, float(it.get("confidence", 0.8)))) cleaned.append(it) return cleaned

normalize加哈希这一步看着不起眼,但它挡住了大量重复写入。我实测过,同一批对话里"用户要求用中文回复"这种信息平均会重复出现三到四次,有了这层过滤,重复写入率从 30% 多降到 5% 以内。

4.4 写入流程:去重、冲突检查、落库

写入不是简单 insert,中间要走三道关。

def upsert_memory(conn, scope_id, item, embed_fn, source_ref=None): vec = embed_fn(item["content"]) now = int(time.time()) # 第一道:精确去重 dup = conn.execute( "SELECT id FROM memories WHERE scope_id=? AND status='active'", (scope_id,) ).fetchall() for (mid,) in dup: row = conn.execute( "SELECT content FROM memories WHERE id=?", (mid,) ).fetchone() if row and content_hash(row[0]) == content_hash(item["content"]): conn.execute( "UPDATE memories SET last_used_at=?, use_count=use_count+1 WHERE id=?", (now, mid) ) return mid # 第二道:语义近邻检查冲突 neighbors = conn.execute( "SELECT id, embed_vec, content, subject FROM memories " "WHERE scope_id=? AND status='active' AND mem_type=?", (scope_id, item["type"]) ).fetchall() sims = [(mid, cosine(vec, blob_to_vec(bv)), c, s) for mid, bv, c, s in neighbors] sims.sort(key=lambda x: x[1], reverse=True) for mid, sim, old_content, old_subject in sims[:5]: if sim < 0.86: break if old_subject and old_subject == item.get("subject") \ and is_overwrite(item["content"]): conn.execute( "UPDATE memories SET status='superseded' WHERE id=?", (mid,) ) # 第三道:落库 mid = uuid.uuid4().hex[:16] conn.execute( "INSERT INTO memories (id, scope_id, mem_type, content, subject," " embed_vec, importance, confidence, created_at, status, source_ref)" " VALUES (?,?,?,?,?,?,?,?,?, 'active', ?)", (mid, scope_id, item["type"], item["content"], item.get("subject"), vec_to_blob(vec), item["importance"], item["confidence"], now, source_ref) ) conn.commit() return mid

这里的is_overwrite判断的是"新内容是否表达了替换意图",我用了一个小的信号词表加一次轻量模型判断:出现"改成"、"调整为"、"现在用"、"不再"这类词,基本可以确定是覆盖关系。相似度阈值 0.86 是我在几百条样本上调出来的,低于这个值基本是不同的事,高于它才需要进一步判断。

注意:status='superseded'的条目不要从库里删掉。用户有时候会问"我之前是不是说过要换方案",保留可追溯性比省那点存储空间重要得多。

4.5 召回与重排

召回分两步走:先粗排拿候选,再精排打分。

import math TAU = {"semantic": 180, "episodic": 14, "procedural": 365} def recall(conn, scope_id, query, embed_fn, top_k=8): qvec = embed_fn(query) rows = conn.execute( "SELECT id, mem_type, content, embed_vec, importance," " created_at, last_used_at, use_count" " FROM memories WHERE scope_id=? AND status='active'", (scope_id,) ).fetchall() now = time.time() scored = [] for (mid, mtype, content, bv, imp, c_at, l_at, u_cnt) in rows: sim = cosine(qvec, blob_to_vec(bv)) if sim < 0.30: continue dt_days = (now - c_at) / 86400.0 fresh = math.exp(-dt_days / TAU.get(mtype, 60)) imp_n = imp / 10.0 freq = math.log1p(u_cnt) / math.log1p(50) type_bonus = 0.08 if mtype == "procedural" else 0.0 score = (0.45 * sim + 0.20 * fresh + 0.15 * imp_n + 0.10 * min(freq, 1.0) + 0.10 * 0.5 + type_bonus) scored.append((score, mid, content, mtype)) scored.sort(reverse=True) picked = scored[:top_k] for _, mid, _, _ in picked: conn.execute( "UPDATE memories SET last_used_at=?, use_count=use_count+1 WHERE id=?", (int(now), mid) ) conn.commit() return [{"content": c, "type": t} for _, _, c, t in picked]

几个参数说明一下。相似度门槛 0.30 定得比较低,是为了保证召回率,宁可粗排多捞一些,靠后面的打分把噪声压下去。类型加成里程序性记忆给了固定加分,因为格式类要求一旦漏掉,用户的体感损失最大。top_k默认 8 条,实际注入时还会按 token 预算做裁剪。

4.6 接进对话主循环

最后一步是把召回结果拼进系统提示。顺序很重要:人设、规则、记忆、对话历史。

def build_system_prompt(base_persona, memories): if not memories: return base_persona proc = [m["content"] for m in memories if m["type"] == "procedural"] facts = [m["content"] for m in memories if m["type"] == "semantic"] events = [m["content"] for m in memories if m["type"] == "episodic"] parts = [base_persona] if proc: parts.append("【做事方式】" + ";".join(proc[:4])) if facts: parts.append("【已知信息】" + ";".join(facts[:6])) if events: parts.append("【相关经历】" + ";".join(events[:3])) return "\n".join(parts)

用分号串成句子而不是列表堆砌,模型对这种自然语言的接受度更好,token 也更省。实测同样的信息量,句子形式比列表形式平均少 15% 到 20% 的 token。

4.7 上线前先看观测数据

不要凭感觉判断记忆系统好不好,要埋点。我埋的是四个数:每轮召回的条数、注入的 token 数、召回平均耗时、被召回的条目里最终被用户"用上"的比例(这个用后验判断,看模型回复是否引用了该记忆)。前三个是工程指标,第四个是效果指标。第四个最难测,我用的近似方法是让一个小模型在回复生成后判断"这条回复是否依赖了注入的某条记忆",准确率一般,但趋势能看出来。

5. 常见问题与排查实录

5.1 记忆污染:模型记了一堆不该记的

这是最高频的问题,表现是记忆库迅速膨胀,里面充斥着"用户说了谢谢"、"用户询问天气"这类条目。根因是抽取提示词约束不够,或者过滤规则太松。

排查方法很简单,随机抽 50 条导出来人工看一眼,算一下有效记忆的比例。如果低于 70%,说明抽取环节出了问题。我常用的三板斧:一是把"没有值得记的内容时返回空数组"这句在提示词里加重强调,甚至放到规则最前面;二是加一条硬规则,内容长度低于一定字数直接丢;三是加一次二分类过滤,问模型"这条信息在 30 天后是否仍可能有用",结果为否就丢弃。

还有一个隐蔽的污染源是模型的自问自答。有时候助手在自己的回复里说了一句"您似乎更喜欢简洁的回复",抽取管道会把这句话当成用户偏好记下来,实际上用户从来没这么说过。解决办法是在抽取时明确标注哪些内容来自用户、哪些来自助手,只允许从用户侧抽取偏好类信息。

5.2 召回错人错事:捞出来的记忆不相关

这种问题的表现是助手突然说起一件完全无关的旧事,让用户觉得很突兀。常见原因有三个。

第一个是相似度门槛太低,噪声条目被捞进来了。我会去看被召回条目的实际相似度分数分布,如果大量集中在 0.3 到 0.4 之间,说明门槛该往上调了,或者该加一层模型相关性判断。

第二个是记忆条目内容太长,向量表示被稀释。比如一条记忆把三件事写在一起,它的向量落在三个语义的中间地带,跟任何一个具体问题的相似度都不高,但也不低,就容易被误召回。拆分成原子事实能解决这个问题。

第三个是跨范围串了。多用户场景下如果scope_id过滤没做严,A 用户的偏好被召回到 B 用户的会话里,这是严重问题。我的做法是在存储层就做硬隔离,召回查询里scope_id是必填参数,不给默认值,避免调用方漏传。

5.3 延迟与成本失控

记忆系统引入的额外开销主要有三块:抽取调用、嵌入计算、召回打分。数据量小时还好,上万条之后召回打分这一步会变成瓶颈,因为要在应用层遍历所有候选算余弦相似度。

优化路径是先加索引再加缓存。索引方面,先用数据库层的过滤条件把候选集压缩到几百条以内,比如按scope_id、status、时间范围过滤,然后再算向量相似度。缓存方面,把常用的嵌入结果缓存起来,同一段文本不要重复算。

另外抽取调用一定要异步化,不要阻塞对话主流程。我的做法是把对话片段丢进队列,后台慢慢抽,用户感知不到延迟。代价是刚说完的话可能要过几秒才能被记住,但这在绝大多数场景下无所谓。

5.4 常见问题速查表

现象可能原因排查动作处理方式
记忆库条目爆炸抽取约束太松抽样统计有效比例收紧提示词,加长度与相关性过滤
助手提起无关旧事相似度门槛过低看召回分数分布提高门槛,增加模型相关性判断
新信息不生效旧条目未被覆盖查同主体条目状态补强覆盖判断规则,标记 superseded
回复风格不守规矩程序性记忆未召回查召回结果里的类型分布提高程序性记忆权重,强制注入
响应变慢召回全量遍历统计召回耗时加数据库过滤条件,加嵌入缓存
多用户串信息scope 过滤缺失检查查询参数存储层强制隔离,参数不设默认值

注意:排查问题时一定要先看"数据层"再看"模型层"。我见过好几次以为是模型不听话,结果查下来是记忆压根没写进去,或者写进去了但 scope_id 写错了。数据是对的,问题才轮得到模型。

6. 怎么验证"长了记性"确实有用

6.1 离线回放评测集

不能靠感觉说"好像记住了"。我构建评测集的方法是造多轮对话脚本,每条脚本包含一个信息埋点、若干轮无关对话作为干扰、最后是一个需要用到埋点信息的提问。比如第 3 轮用户说"我下周三要出差",中间穿插 15 轮关于代码的讨论,最后一轮问"我下周有什么安排",看助手能不能答出来。

评测集我控制在 100 到 200 条,覆盖四类场景:单条事实召回、多条事实组合、冲突更新后的正确取值、程序性要求的持续遵守。每条都标注标准答案,用规则加模型判分结合的方式评估。

6.2 几个我实际在看的指标

指标定义我的及格线
召回命中率需要的信息出现在注入内容里的比例90% 以上
答案正确率最终回复包含正确信息的比例80% 以上
误召回率注入了不相关记忆的轮次占比15% 以下
平均注入 token每轮记忆部分占用的 token1200 以内
平均召回耗时从查询到返回结果200 毫秒以内
记忆有效率抽样中真正有用的条目占比70% 以上

召回命中率和答案正确率之间的差距很值得关注。如果前者高后者低,说明信息捞出来了但模型没用上,这时候要调的是注入方式(放在提示词里的位置、表述方式),而不是召回逻辑。

6.3 上线后做小流量对照

离线指标再好也得线上验证。我的做法是切 10% 的流量开记忆功能,另外 90% 关掉,对比几组行为指标:用户主动纠正的次数、会话轮次长度、用户回访率。其中"用户主动纠正次数"是最灵敏的指标,记忆做得好,纠正次数会明显下降,因为助手不再犯重复错误。

这里有个坑要提醒:不要一开始就全量开。记忆系统出问题的表现形式是"助手说了奇怪的话",用户很难归因到记忆上,只会觉得这个产品有问题。小流量跑两周,把明显的召回错误清掉再放量,风险小很多。

我自己跑下来最大的体会是,这套系统里最有价值的不是向量检索那部分,反而是看起来最土的去重和冲突消解。检索算法的优化带来的提升是线性的,而记忆库干净与否带来的差异是数量级的——一个干净的三千条记忆库,效果远好过一个脏乱的三万条。所以如果只让我保留一个模块,我留抽取管道里的过滤逻辑,那才是决定成败的地方。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 8:03:26

JMeter XPath提取器:从XML/HTML响应中精准提取数据全攻略

做接口测试、性能测试这么些年&#xff0c;我经常在社区里看到有人在问&#xff1a;响应体是XML或者HTML&#xff0c;到底怎么取数据&#xff1f;有人张口就是正则&#xff0c;有人直接上JSONPath&#xff0c;结果对着一段XML手足无措。其实JMeter早就内置了一个专门干这事的组…

作者头像 李华
网站建设 2026/10/20 8:03:19

Matlab/Simulink三相异步电机本体建模:从dq方程到性能仿真

前段时间调三相异步电机仿真时翻了车&#xff1a;用现成模块搭的模型跑起来很快&#xff0c;Scope里波形也“像那么回事”&#xff0c;可一旦被人追问“启动转矩为什么是这个形状”“电流包络线的衰减时间由什么决定”&#xff0c;我发现自己根本解释不了。后来逼着自己从电压方…

作者头像 李华
网站建设 2026/10/20 8:03:16

单片机毕设实战:一卡通、升旗、共享设备、激光打靶设计与调试

1. 选题方向与需求判定先说个比较现实的现象&#xff0c;每年到了毕设季&#xff0c;总有一批学生对着题目列表发愁&#xff0c;觉得“校园一卡通”“自动升旗”“共享设备”“激光打靶”这类题目看起来平平无奇&#xff0c;甚至觉得是同质化的东西。实际上&#xff0c;这些题目…

作者头像 李华
网站建设 2026/10/20 8:03:14

一百多套小程序源码怎么用?从分类索引到跑通最小闭环的实战方法

简介&#xff1a;这是一份面向小程序开发初学者与希望补齐全栈能力的开发者整理的实战源码合集&#xff0c;收录100余套微信小程序完整项目&#xff0c;每套均包含前端展示与后台管理两部分&#xff0c;可用于对照学习页面布局、交互逻辑与接口设计&#xff0c;也适合作为课程设…

作者头像 李华
网站建设 2026/10/20 8:00:23

vtkBoxWidget交互控制实战:从状态机到裁剪与ROI框选

做三维可视化窗口的时候&#xff0c;最常遇到的需求之一&#xff0c;就是让用户在场景里手动框出一个长方体区域。前几年我接到一个需求&#xff1a;某医学影像项目里需要医生在三维体绘制结果上手动选择一个感兴趣区域&#xff0c;用来做后续的裁剪分析。一开始我照着“自己写…

作者头像 李华
网站建设 2026/10/20 7:58:56

同步电机与构网型变流器频率稳定性仿真:Simulink建模与参数整定

做电力系统仿真最怕的不是模型跑不出来&#xff0c;而是模型跑出来了、波形画了一大堆&#xff0c;结果连“这个系统到底为什么会稳/为什么会失稳”都说不清楚。这几年只要聊到新能源高渗透率&#xff0c;构网型变流器&#xff08;Grid-Forming Converter&#xff0c;GFM&#…

作者头像 李华