1. 从一次多轮对话的“失忆”说起
前阵子我在做一个基于智能体的客服辅助项目,底层框架选的是字节开源的 DeerFlow。功能跑通得挺顺利,单轮问答、工具调用、流式输出都没问题,但一进入多轮场景就露馅了:用户第一轮说“我上周买的那台咖啡机漏水”,第三轮再问“那台机器保修多久”,智能体完全接不上,像换了个人。这个现象做智能体的同行应该都不陌生——长期记忆没做对。
DeerFlow 是字节跳动开源的一个深度研究类智能体框架,它把规划、检索、工具调用、报告生成串成一条流水线,社区里讨论比较多的还有 deerflow 可观测、deerflow 人机协同、基于 deerflow 智能体进行二次开发这些方向。但真正让一个智能体从“玩具”变成“能用”的,恰恰是长期记忆这一块。我翻了不少 deerflow 2.0 代码详解的文章,也自己啃了一遍源码,发现它的记忆方案设计得挺克制,没有堆一堆向量库和花哨的架构,而是用一套清晰的“短期上下文 + 长期存储 + 检索注入”的组合拳解决问题。
这篇就借一个具体示例,把 DeerFlow 的长期记忆实现方案拆开讲透。我会说清楚它为什么这么设计、核心数据结构长什么样、检索是怎么触发的、二次开发时哪些地方最容易踩坑。适合正在做智能体、准备基于 DeerFlow 做二次开发、或者单纯想搞懂“长期记忆到底怎么落地”的开发者。看完你应该能自己动手把记忆模块接进自己的项目里。
2. 长期记忆到底难在哪:先想清楚三个问题
2.1 为什么不能只靠上下文窗口
很多人第一反应是:现在模型上下文都 128K 甚至更长,把历史对话全塞进去不就行了?我一开始也这么想,实测下来问题有三个。第一是成本,每轮请求都带上几万 token 的历史,token 费用线性上涨,做商业项目根本扛不住。第二是注意力稀释,历史越长,模型对关键信息的召回越差,中间那段经常被“忽略”,这就是常说的 lost in the middle。第三是跨会话,用户今天聊完明天再来,上下文窗口早就清空了,你不可能把昨天的完整对话一直挂着。
所以长期记忆的本质不是“存更多”,而是“在对的时候取出对的那一小块”。这跟人脑很像:你不会记住跟朋友聊过的每一句话,但会记住“他养了只猫叫豆豆”,下次聊到宠物自然就想起来了。
2.2 记忆的粒度:存原文还是存摘要
这是设计长期记忆时第一个要拍板的决策。存原文的优点是信息无损,缺点是检索时噪声大、存储膨胀快;存摘要的优点是紧凑、检索精准,缺点是摘要过程本身会丢信息,而且摘要质量依赖模型。
DeerFlow 的思路是分层的:短期用原文(当前会话的 message 列表),长期用结构化提取后的记忆条目。也就是说,它不会把整段对话原封不动塞进长期库,而是经过一轮“记忆抽取”,把值得长期保留的事实、偏好、结论提炼成条目。这个取舍很关键,后面讲实现时会看到它具体怎么抽。
2.3 检索时机:什么时候该去翻记忆
第三个问题是触发时机。有的方案是每轮都检索,简单但浪费;有的是让模型自己决定要不要查记忆,灵活但不可控。DeerFlow 更偏向后者——把记忆检索做成一个可被规划器调用的能力,而不是无脑前置。这样做的好处是,闲聊类问题不会触发无谓的检索,只有涉及“之前说过”“我的偏好”“上次那个”这类信号时才去翻库。
把这三个问题想清楚,再看 DeerFlow 的实现就不会迷路。它整套方案就是围绕“分层存储、结构化抽取、按需检索”这十二个字展开的。
3. DeerFlow 记忆方案的整体架构拆解
3.1 三层结构:会话态、记忆库、检索器
我把 DeerFlow 的记忆相关代码梳理了一遍,抽象出来是三层。
第一层是会话态(Session State),也就是当前这次对话的 messages,包含用户输入、模型回复、工具调用结果。它是易失的,会话结束就没了,作用范围就是当前这一轮任务。
第二层是长期记忆库(Memory Store),通常落在向量数据库或者带向量索引的关系库里。每条记忆是一个结构化对象,包含内容、类型、时间戳、来源会话 ID、embedding 向量等字段。它是持久的,跨会话共享。
第三层是检索器(Retriever),负责把用户当前的问题转成查询,去记忆库里召回 top-k 相关条目,再拼进 prompt。它是连接前两层的桥梁。
这三层各司其职,好处是解耦。你想换向量库,只动第二层;想改检索策略,只动第三层;会话管理逻辑完全不受影响。二次开发时这一点特别重要,很多人一上来就把三层揉在一起,后面想换组件就得推倒重来。
3.2 为什么用“抽取式”而不是“全量式”
前面提到 DeerFlow 走的是抽取式路线。具体来说,它会在会话的某些节点(比如一轮任务结束、或者消息数达到阈值)触发一次记忆抽取,让模型从最近的对话里提炼出“值得记住的事”,写成结构化条目存进库。
我理解这个设计的动机有两个。一是信噪比,一段十轮的对话里真正值得长期记住的可能就一两句,全量存进去检索时全是干扰。二是可控性,抽取时可以指定类型(事实、偏好、待办等),检索时就能按类型过滤,精度高很多。
代价是抽取本身要花一次模型调用,而且抽取质量直接决定记忆质量。所以抽取的 prompt 设计是这套方案里最需要打磨的地方,后面我会给一个我实测效果不错的模板。
3.3 记忆条目的数据结构长什么样
基于常见实践,一条记忆条目大概包含这些字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 唯一标识,一般用 uuid |
| content | string | 记忆正文,一句话或一小段 |
| memory_type | enum | 事实/偏好/待办/结论等 |
| embedding | vector | content 的向量表示 |
| session_id | string | 来源会话,便于溯源和删除 |
| created_at | timestamp | 创建时间,用于时间衰减 |
| last_access_at | timestamp | 最近被检索时间,用于热度排序 |
| metadata | json | 扩展字段,比如置信度、标签 |
这个结构看着简单,但每个字段都有用。比如 last_access_at,你可以用它做“最近常被想起的记忆优先”,模拟人脑的激活效应。session_id 则是合规和隐私的基础,用户要求删除某次会话的数据时能精准定位。
4. 核心实现细节:记忆的写入、检索与注入
4.1 记忆写入:抽取时机与抽取 Prompt 设计
写入是长期记忆的入口,做不好后面全白搭。DeerFlow 里触发抽取的时机我总结为两类:事件驱动和阈值驱动。事件驱动是指某个任务完成、用户明确说“记住这个”时触发;阈值驱动是指当前会话消息数或 token 数超过设定值(比如 20 条消息)时触发一次。
抽取的 prompt 我改过好几版,最后稳定下来的是这个结构:
你是一个记忆抽取器。请从下面的对话中提取值得长期记住的信息。 只提取以下类型: - 事实:用户的客观信息(如职业、设备、项目背景) - 偏好:用户的喜好和习惯(如喜欢简洁回复、常用 Python) - 待办:用户明确要求后续跟进的事项 - 结论:本次对话得出的重要结论 要求: 1. 每条记忆独立成句,不超过 50 字 2. 不要提取寒暄、闲聊、临时性内容 3. 如果某条信息不确定,标注 confidence 为 low 4. 输出 JSON 数组,每项包含 content 和 memory_type 对话内容: {conversation}这里有几个细节值得说。第一,明确列出类型,模型才知道什么该抽什么不该抽,不然它会把“你好”“谢谢”也存进去。第二,限制长度,太长的记忆检索时匹配度反而低。第三,加 confidence 字段,低置信度的记忆在检索时可以降权,避免错误信息污染。
注意:抽取用的模型不一定要跟主对话模型一样。实测下来,抽取这种结构化任务用小模型(7B 级别)就够,成本能降一个数量级,质量差距不明显。
4.2 向量化与存储:embedding 选型与去重
抽出来的记忆条目要转成向量才能检索。embedding 模型的选择上,中文场景我一般用 bge 系列或者 text-embedding 类的中文优化模型,英文为主就用通用模型。维度上 768 或 1024 都行,维度越高精度略好但存储和检索成本上升,一般 768 是性价比甜点。
存储层用向量库还是关系库加向量扩展,取决于你的规模。几千条记忆用 pgvector 完全够,几十万条以上再考虑专门的向量库。DeerFlow 本身不绑定具体存储,二次开发时这块可以自由替换。
去重是个容易被忽略的点。同一件事用户可能在不同会话里说好几遍,如果每次都存一条,检索时全是重复内容。我的做法是写入前先做一次相似度查询,如果跟已有记忆的余弦相似度超过 0.92,就更新那条记忆的 last_access_at 而不是新增。这个阈值可以调,0.9 到 0.95 之间比较稳。
4.3 检索触发:让规划器决定要不要翻记忆
前面说过 DeerFlow 偏向按需检索。具体实现上,它把“检索长期记忆”包装成一个工具,规划器在拆解任务时如果判断需要历史信息,就调用这个工具。
判断信号我总结了几类:出现“之前”“上次”“我的”“还记得”这类指代词;问题涉及用户个人背景(“我适合用什么”);任务需要延续之前的结论(“接着上次那个方案”)。规划器的 prompt 里可以显式引导这些信号。
这样做的好处是闲聊不触发检索,省成本也省延迟。坏处是可能漏检,用户明明需要历史信息但规划器没识别出来。折中方案是加一个兜底:如果模型回复里出现“我不确定你之前是否提过”这类表述,就强制触发一次检索。
4.4 注入策略:怎么把记忆塞进 Prompt 不喧宾夺主
检索回来的记忆怎么用,也有讲究。直接拼在 system prompt 里是最简单的,但要注意位置和格式。我一般放在 system prompt 的靠后位置,用明确的分隔标记包起来:
以下是与当前问题相关的历史记忆,供参考: <memory> - [事实] 用户使用 Python 做数据分析 - [偏好] 用户喜欢简洁的代码示例 </memory>这样模型能清楚区分“记忆”和“指令”。数量上一般召回 top-3 到 top-5 就够,太多反而干扰。如果召回的条目相关性分数都很低(比如低于 0.6),宁可不注入,避免误导。
5. 一个完整示例:从对话到记忆再回到对话
5.1 场景设定与初始对话
光讲原理太干,我搭一个最小可跑的示例。假设用户在做一个智能家居项目,分两次会话。
第一次会话:
用户:我在用 ESP32 做一个智能浇花系统,土壤湿度传感器读出来的值波动很大。 助手:波动大可能是电源噪声或者采样间隔太短,建议加电容滤波并做多次采样取平均。 用户:好的,我用的是电容式传感器,采样间隔设成 1 秒。 助手:1 秒可以,配合滑动平均滤波效果更好。这段对话结束后,触发记忆抽取。
5.2 记忆抽取的实际输出
按前面的 prompt,模型抽出来的结果大概是这样:
[ {"content": "用户在用 ESP32 做智能浇花系统", "memory_type": "事实", "confidence": "high"}, {"content": "用户使用电容式土壤湿度传感器", "memory_type": "事实", "confidence": "high"}, {"content": "用户将采样间隔设为 1 秒", "memory_type": "事实", "confidence": "medium"} ]注意“土壤湿度值波动大”这条没被抽出来,因为它是临时性问题,已经解决了,不值得长期记。这就是抽取式的好处——自动过滤掉过程性信息。
5.3 第二次会话的检索与注入
三天后第二次会话:
用户:我那套浇花系统现在想加个远程查看功能,用什么方案好?规划器识别到“我那套浇花系统”是回指,触发记忆检索。查询向量化后召回上面三条记忆,注入 prompt。模型于是知道用户用的是 ESP32、电容式传感器,推荐方案时就会考虑这些约束,比如建议用 ESP32 自带的 WiFi 而不是额外加模块。
如果没有长期记忆,模型只能给一个泛泛的“你可以用 WiFi 模块或者 4G 模块”,体验差一大截。这个对比就是长期记忆价值的直接体现。
5.4 关键参数与阈值实测记录
我把这个示例跑了几十轮,记录了几个关键参数的手感:
| 参数 | 取值 | 说明 |
|---|---|---|
| 抽取触发消息数 | 15-20 条 | 太少抽取频繁,太多信息混杂 |
| 去重相似度阈值 | 0.92 | 低于此值新增,高于则更新 |
| 检索 top-k | 3-5 | 超过 5 条噪声明显上升 |
| 相关性过滤阈值 | 0.6 | 低于此值不注入 |
| 记忆条目最大长度 | 50 字 | 过长检索匹配度下降 |
这些值不是金科玉律,但作为起点能省不少调参时间。不同领域要微调,比如法律、医疗这类对事实精度要求高的场景,去重阈值可以提到 0.95,避免把相似但不同的信息合并。
6. 二次开发与可观测:把记忆模块接进自己的项目
6.1 基于 DeerFlow 二次开发的接入点
社区里问得最多的就是“基于 deerflow 智能体进行二次开发”怎么下手。记忆这块的接入点其实很清晰:实现一个 MemoryStore 接口(负责增删查)、一个 Extractor(负责抽取)、一个 Retriever(负责召回),然后在智能体的规划流程里注册检索工具。
我建议先把接口定死,再选实现。比如 MemoryStore 就三个方法:add(entry)、search(query, top_k)、delete(session_id)。这样你后面从 pgvector 换到别的库,业务代码一行不用改。
6.2 可观测性:记忆命中了没有,怎么知道
deerflow 可观测是另一个热词,记忆模块尤其需要。你至少要在日志里记录:本轮是否触发检索、召回了哪几条、相关性分数多少、是否被注入 prompt。没有这些,出了问题你根本不知道是没检索到还是检索到了没用上。
我一般会打这样一条结构化日志:
logger.info("memory_retrieval", extra={ "session_id": sid, "query": query, "hit_count": len(hits), "top_score": hits[0].score if hits else 0, "injected": injected })有了这些数据,你就能算出记忆命中率、平均相关性分数这些指标,判断记忆模块到底有没有在干活。
6.3 流式场景下的记忆处理
如果智能体走的是 SSE 流式输出(封装 sse 流式接口调用逻辑、完成流式消息解析与处理是常见需求),记忆检索要放在流开始之前,不能边流边检索。因为检索结果要拼进 prompt,而 prompt 必须在请求发出前就确定。所以流程是:收到用户消息 → 判断是否检索 → 检索并拼 prompt → 发起流式请求 → 逐块解析输出。这个顺序不能乱。
7. 常见问题与排查技巧实录
7.1 记忆检索不到,先查这四个地方
检索不到是最常见的问题,我按排查顺序列一下。第一,查 embedding 是否用了同一个模型,写入和检索用不同模型是新手最常犯的错,向量空间都不一致,当然搜不到。第二,查查询文本是否太短或太泛,“那个东西”这种查询向量化后没有区分度。第三,查 top-k 和阈值,可能召回了但被阈值过滤了。第四,查记忆库是否真的写进去了,抽取失败或者写入报错都会导致空库。
7.2 记忆污染:错误信息被反复召回
比检索不到更麻烦的是检索到错的。用户随口说的一句“我可能下周去北京”,被抽成“用户下周去北京”存进库,之后每次聊到行程都被召回。解决办法是给抽取加 confidence,低置信度的记忆在检索时降权或者干脆不注入。另外可以加一个“记忆确认”机制,重要的记忆让用户确认一次再入库。
7.3 性能与成本:别让记忆拖慢响应
记忆检索会引入额外延迟,向量检索一般几十毫秒,但如果库很大或者没建好索引,可能到几百毫秒。优化手段有:给向量字段建 HNSW 或 IVF 索引;把检索和主请求并行(如果架构允许);对检索结果做缓存,相同查询短时间内直接返回缓存。成本上,抽取的模型调用是主要开销,用小模型加批量抽取能压下来。
7.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 检索结果为空 | embedding 模型不一致 | 核对写入和检索的模型 |
| 召回内容不相关 | 查询太泛或阈值太低 | 优化查询改写,提高阈值 |
| 重复记忆多 | 去重没做或阈值太高 | 加相似度去重,阈值 0.92 |
| 响应变慢 | 向量索引缺失 | 建 HNSW 索引 |
| 记忆不更新 | 只新增不更新 | 加 last_access_at 更新逻辑 |
| 抽取质量差 | prompt 不明确 | 明确类型和长度限制 |
提示:调试记忆模块时,先把检索结果打印出来人工看,别急着调参数。很多时候问题不在参数,而在抽取出来的内容本身就不对。
8. 我在实际项目里踩过的几个坑
最后分享几个真实踩过的坑,都是文档里不会写的。
第一个坑是时间戳的时区。记忆条目存 UTC,展示时忘了转本地时间,导致“上周”这种相对时间算错,检索时按时间过滤就出问题。统一用 UTC 存、展示层转,这个规矩要一开始就定。
第二个坑是会话 ID 的复用。我一开始用用户 ID 当 session_id,结果同一个用户不同设备的对话混在一起,记忆互相污染。session_id 应该是“一次连续对话”的标识,跟用户 ID 分开,用户 ID 放在 metadata 里。
第三个坑是删除逻辑。用户要求删除数据时,只删了会话表忘了删记忆库,合规上就出问题了。记忆库的删除要跟会话删除绑定,最好做成级联。
第四个坑是抽取频率。我一开始每轮都抽,结果模型调用费用翻倍,而且抽出来一堆碎片。改成任务结束或消息数达标才抽,质量和成本都好了。
这套记忆方案我用了大半年,从最初的多轮失忆到现在能稳定跨会话记住用户背景,中间就是不断调抽取 prompt、调阈值、补可观测。它不复杂,但每个环节都得抠细节。你要是也在做智能体的长期记忆,建议先从最小闭环跑通——能存、能搜、能注入,再逐步加去重、加置信度、加可观测。别一上来就追求完美架构,跑起来比什么都重要。