大模型本身不会记住上一次 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
2
3
4
5
6
System Prompt
+ 对话历史
+ RAG 上下文
+ 当前用户问题
+ 工具定义
+ 输出预留空间

上下文窗口是这些内容共同使用的预算,不是全部留给历史消息。

2.2 Conversation 与 Message

  • Conversation 表示一次会话;
  • Message 表示一轮用户问题与助手回答;
  • MessageFile 保存该轮关联的图片或文件;
  • Workflow Run 关联高级 Chatflow/Workflow 的执行记录。

数据库可以保存完整历史,但每次模型调用只选择其中一部分。

2.3 线程而不只是会话

会话可能存在重新生成、分支回答或父消息关系。如果简单按时间读取全部消息,模型可能同时看到互相冲突的分支。

Dify 会从最近消息提取所属线程,只保留通向当前分支的历史。通用设计是:

1
当前消息 → parent_id → 上一条消息 → ... → 会话起点

展示历史可以是树,送入模型的历史必须是一条有序路径。


三、TokenBufferMemory 如何工作

3.1 读取与转换

核心流程可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
messages = load_recent_messages(conversation_id, limit=500)
messages = extract_current_thread(messages)
prompt_messages = []

for message in reversed(messages):
prompt_messages.append(UserMessage(message.query, message.user_files))
prompt_messages.append(AssistantMessage(message.answer, message.assistant_files))

while count_tokens(prompt_messages) > max_token_limit:
prompt_messages.pop(0)

return prompt_messages

这里有四个关键点:

  1. 先限制数据库读取条数,避免无限扫描;
  2. 只取当前对话分支;
  3. 用户和助手消息保持配对顺序;
  4. 超出预算时从最旧内容开始移除。

3.2 为什么按 Token 而不是字符

模型限制的是 Token 数,不是字符数。中文、英文、代码和 JSON 的分词比例不同,同样 1000 个字符可能占用不同 Token。

Token 估算还必须尽量与实际模型 tokenizer 一致。估少了会触发模型上下文超限,估多了则会过早丢弃历史。

3.3 预算如何计算

1
2
3
4
5
6
7
历史预算 = 模型上下文上限
- System Prompt
- 当前问题
- RAG 上下文
- 工具定义
- 最大输出预留
- 安全余量

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"]

推荐顺序:

  1. 稳定 System Instruction;
  2. 必要业务背景或 RAG Context;
  3. 裁剪后的对话历史;
  4. 当前用户问题和文件。

具体模型可能有不同最佳实践,但安全指令不能被历史中的用户文本覆盖。

5.2 历史不是事实来源

助手以前说过的话可能是幻觉。将其放回 Prompt 会让错误在后续轮次中不断强化。

因此:

  • 业务状态重新查询权威系统;
  • 关键事实使用 RAG 或数据库;
  • 历史只用于理解上下文和指代;
  • 用户纠正后应优先采用新信息;
  • 不把旧助手回答当成系统规则。

六、从窗口记忆到长期记忆

长会话仅靠“保留最近 N 个 Token”会遗忘早期重要信息。常见扩展有三种:

6.1 摘要记忆

定期把旧对话压缩成摘要:

1
旧消息 → 摘要模型 → 结构化会话摘要 → 与最近消息一起进入 Prompt

优点是简单;缺点是摘要错误会累积,且细节不可恢复。

6.2 语义记忆

把重要片段向量化,按当前问题检索。适合长周期事实,但需要解决提取、去重、更新和遗忘。

6.3 结构化用户画像

将偏好保存为明确字段:

1
2
3
4
{
"preferred_language": "zh-Hans",
"technical_level": "backend_engineer"
}

结构化数据可修改、审计和删除,通常比让模型自由生成“记忆文本”更可靠。

长期记忆必须允许用户查看、纠正和删除,并设置租户、用户和应用作用域。


七、性能、安全与排障

7.1 性能

  • 数据库查询设置上限;
  • MessageFile 批量加载,避免 N+1;
  • Token 计算可缓存,但缓存键包含模型和消息版本;
  • 长历史使用摘要或检索,不每轮全量读取;
  • 不在数据库事务中等待模型调用。

7.2 安全

  • 查询 Conversation 时校验租户、应用和用户;
  • 历史文件重新检查访问权限;
  • 日志与 Trace 对消息内容脱敏;
  • 用户删除会话时同步清理文件和长期记忆;
  • 不把其他会话或其他分支拼入当前 Prompt;
  • 对历史中的 Prompt Injection 保持不可信数据边界。

7.3 常见问题

现象 可能原因
模型忘记早期内容 历史超出 Token 预算被裁剪
上下文很短仍超限 RAG、工具定义或文件占用大量 Token
对话出现冲突 错误合并了不同消息分支
每轮越来越慢 历史查询、文件 N+1 或 Token 重算
模型重复错误事实 把旧助手回答当成权威记忆
文件历史报错 文件过期、权限变化或模型不支持

八、总结

Dify 的对话记忆可以归纳为:

  1. 数据库保存完整 Conversation 和 Message;
  2. TokenBufferMemory 只构建当前模型调用需要的短期窗口;
  3. 当前线程决定消息路径,不能混入其他分支;
  4. Prompt Transform 计算剩余预算并拼装历史;
  5. 超限时从最旧轮次开始裁剪;
  6. 多模态历史需要文件权限、模型能力和成本控制;
  7. 对话历史不是权威事实库;
  8. 长期记忆需要摘要、语义检索或结构化画像;
  9. 所有记忆都必须具备作用域、可修改和可删除能力。

模型并没有真正“记住”用户。系统每轮选择让模型看到什么,才决定了应用表现出的记忆能力。