大模型本身不会记住上一次 API 调用。聊天应用之所以能连续对话,是系统从数据库读取历史消息,再把其中一部分重新放进本轮 Prompt。
这意味着“记忆”不是一个神秘能力,而是一套存储、选择、裁剪和拼装机制。Dify 的 TokenBufferMemory 展示了典型的短期对话记忆实现。
一、先区分三种记忆
| 类型 | 保存位置 | 生命周期 | 用途 |
|---|---|---|---|
| 对话历史 | 数据库 | 长期持久化 | 展示、审计和重新构建上下文 |
| 短期记忆 | 本轮 Prompt | 单次模型调用 | 让模型理解最近对话 |
| 长期语义记忆 | 独立记忆库或向量索引 | 跨会话 | 保存用户偏好和重要事实 |
Dify 的 TokenBufferMemory 主要解决前两者之间的转换:从持久化 Message 中选择一段可放入模型上下文的历史。它不是自动提炼用户偏好的长期记忆系统。
二、一次对话如何进入模型
2.1 数据流
flowchart LR
DB[("Conversation / Message DB")] --> Load["读取当前线程消息"]
Load --> Convert["转换 User / Assistant / File"]
Convert --> Count["计算 Token"]
Count --> Prune["从最旧消息开始裁剪"]
Prune --> Prompt["System + History + Context + Query"]
Prompt --> Model["模型调用"]
Model --> Save["保存新回答"]
Save --> DB
模型每一轮看到的内容通常包括:
1 | System Prompt |
上下文窗口是这些内容共同使用的预算,不是全部留给历史消息。
2.2 Conversation 与 Message
- Conversation 表示一次会话;
- Message 表示一轮用户问题与助手回答;
- MessageFile 保存该轮关联的图片或文件;
- Workflow Run 关联高级 Chatflow/Workflow 的执行记录。
数据库可以保存完整历史,但每次模型调用只选择其中一部分。
2.3 线程而不只是会话
会话可能存在重新生成、分支回答或父消息关系。如果简单按时间读取全部消息,模型可能同时看到互相冲突的分支。
Dify 会从最近消息提取所属线程,只保留通向当前分支的历史。通用设计是:
1 | 当前消息 → parent_id → 上一条消息 → ... → 会话起点 |
展示历史可以是树,送入模型的历史必须是一条有序路径。
三、TokenBufferMemory 如何工作
3.1 读取与转换
核心流程可以简化为:
1 | messages = load_recent_messages(conversation_id, limit=500) |
这里有四个关键点:
- 先限制数据库读取条数,避免无限扫描;
- 只取当前对话分支;
- 用户和助手消息保持配对顺序;
- 超出预算时从最旧内容开始移除。
3.2 为什么按 Token 而不是字符
模型限制的是 Token 数,不是字符数。中文、英文、代码和 JSON 的分词比例不同,同样 1000 个字符可能占用不同 Token。
Token 估算还必须尽量与实际模型 tokenizer 一致。估少了会触发模型上下文超限,估多了则会过早丢弃历史。
3.3 预算如何计算
1 | 历史预算 = 模型上下文上限 |
Prompt Transform 会先计算固定消息使用量,再把剩余 Token 交给 Memory。不能先塞满历史,再希望其他内容还有空间。
3.4 裁剪的最小单位
直接 pop(0) 可能移除一条 User Message,却留下对应 Assistant Message。更稳妥的设计以“一轮对话”或完整工具调用组为单位裁剪:
1 | User → Assistant(tool_call) → Tool(result) → Assistant |
工具调用和结果必须共同保留,否则模型看到不完整协议可能报错。
四、多模态历史
历史消息不只有文本,还可能包含图片、文件和工具生成内容。
4.1 文件处理
Dify 会批量读取 MessageFile,分别构建用户和助手消息,避免每条消息单独查询文件造成 N+1 问题。
多模态记忆需要考虑:
- 当前模型是否支持该文件类型;
- 文件是否仍存在且用户仍有权限;
- 图片使用低、中、高哪种 detail;
- 文件 URL 是否过期;
- 重复发送同一图片的 Token/费用;
- 历史文件是否需要转成文字摘要。
4.2 不应永久重放大文件
用户第一轮上传一份 PDF,不代表以后每轮都要把原文件重新发给模型。更合理的方式是:
1 | 文件 → 解析/检索 → 保存引用或摘要 → 后续按需召回 |
短期记忆保存“这份文件是谁、讨论到了哪里”,原始内容交给文件系统或 RAG。
五、上下文拼装顺序
5.1 Prompt Transform 的职责
Dify 的 Simple/Advanced Prompt Transform 将模板、变量、历史、RAG Context、文件和当前 Query 组装成标准消息。
flowchart TB
Template["Prompt Template"] --> Transform["Prompt Transform"]
Variables["Workflow Variables"] --> Transform
Memory["TokenBufferMemory"] --> Transform
RAG["Retrieved Context"] --> Transform
Files["Current Files"] --> Transform
Query["Current Query"] --> Transform
Transform --> Messages["Prompt Messages"]
推荐顺序:
- 稳定 System Instruction;
- 必要业务背景或 RAG Context;
- 裁剪后的对话历史;
- 当前用户问题和文件。
具体模型可能有不同最佳实践,但安全指令不能被历史中的用户文本覆盖。
5.2 历史不是事实来源
助手以前说过的话可能是幻觉。将其放回 Prompt 会让错误在后续轮次中不断强化。
因此:
- 业务状态重新查询权威系统;
- 关键事实使用 RAG 或数据库;
- 历史只用于理解上下文和指代;
- 用户纠正后应优先采用新信息;
- 不把旧助手回答当成系统规则。
六、从窗口记忆到长期记忆
长会话仅靠“保留最近 N 个 Token”会遗忘早期重要信息。常见扩展有三种:
6.1 摘要记忆
定期把旧对话压缩成摘要:
1 | 旧消息 → 摘要模型 → 结构化会话摘要 → 与最近消息一起进入 Prompt |
优点是简单;缺点是摘要错误会累积,且细节不可恢复。
6.2 语义记忆
把重要片段向量化,按当前问题检索。适合长周期事实,但需要解决提取、去重、更新和遗忘。
6.3 结构化用户画像
将偏好保存为明确字段:
1 | { |
结构化数据可修改、审计和删除,通常比让模型自由生成“记忆文本”更可靠。
长期记忆必须允许用户查看、纠正和删除,并设置租户、用户和应用作用域。
七、性能、安全与排障
7.1 性能
- 数据库查询设置上限;
- MessageFile 批量加载,避免 N+1;
- Token 计算可缓存,但缓存键包含模型和消息版本;
- 长历史使用摘要或检索,不每轮全量读取;
- 不在数据库事务中等待模型调用。
7.2 安全
- 查询 Conversation 时校验租户、应用和用户;
- 历史文件重新检查访问权限;
- 日志与 Trace 对消息内容脱敏;
- 用户删除会话时同步清理文件和长期记忆;
- 不把其他会话或其他分支拼入当前 Prompt;
- 对历史中的 Prompt Injection 保持不可信数据边界。
7.3 常见问题
| 现象 | 可能原因 |
|---|---|
| 模型忘记早期内容 | 历史超出 Token 预算被裁剪 |
| 上下文很短仍超限 | RAG、工具定义或文件占用大量 Token |
| 对话出现冲突 | 错误合并了不同消息分支 |
| 每轮越来越慢 | 历史查询、文件 N+1 或 Token 重算 |
| 模型重复错误事实 | 把旧助手回答当成权威记忆 |
| 文件历史报错 | 文件过期、权限变化或模型不支持 |
八、总结
Dify 的对话记忆可以归纳为:
- 数据库保存完整 Conversation 和 Message;
- TokenBufferMemory 只构建当前模型调用需要的短期窗口;
- 当前线程决定消息路径,不能混入其他分支;
- Prompt Transform 计算剩余预算并拼装历史;
- 超限时从最旧轮次开始裁剪;
- 多模态历史需要文件权限、模型能力和成本控制;
- 对话历史不是权威事实库;
- 长期记忆需要摘要、语义检索或结构化画像;
- 所有记忆都必须具备作用域、可修改和可删除能力。
模型并没有真正“记住”用户。系统每轮选择让模型看到什么,才决定了应用表现出的记忆能力。