AI 记忆如何工程化:从简单拼接到智能压缩的演进之路
约 3938 字大约 13 分钟
2026-06-02
为什么 AI 需要记忆
没有记忆的 AI,每次对话都像初次见面。
你告诉它你的名字,下一轮它就忘了;你分享了你的偏好,换个设备它就不认识你了;你和它聊了半天,关掉页面再打开,它又从零开始。这种体验让用户觉得 AI 只是一个"工具",而不是一个"伙伴"。
记忆是 AI 从"一次性问答"走向"持续陪伴"的关键能力。它让用户感觉 AI 真的"认识我"、"了解我"、"记得我"。这种感觉不是靠更大的模型、更快的推理能解决的——它需要一套真正的工程系统来支撑。
记忆实现的演化之路
很多团队第一次做 AI 记忆时,都会经历类似的演化路径:
阶段一:把历史消息全塞进上下文 最简单的做法:每轮对话把所有历史消息都传给模型。看起来能"记住",但很快就会遇到问题——上下文窗口有限,token 成本飙升,延迟越来越长。
阶段二:只保留最近 N 轮 为了控制成本,开始裁剪历史:只保留最近 10 轮、20 轮。成本下来了,但用户发现 AI "记不住"了——上周聊的事情、重要的偏好、达成的约定,全都被裁掉了。
阶段三:尝试把所有历史存下来 意识到"只保留最近几轮"不够,开始把所有历史存到数据库。但问题又来了:存了很多,找不到;找到了,太长了;太长了,模型读不完。
阶段四:开始分层治理 终于意识到,记忆不是一个"存或不存"的问题,而是一个"怎么分层、怎么压缩、怎么检索"的工程问题。短期记忆、长期记忆、记忆画像、Dream 整理,每一层都有自己的职责和策略。
联犀的记忆系统,就是沿着这条路一步步演化出来的。本文将详细讲述这个演化过程中的每一个关键决策,以及背后的工程考量。
第一阶段:上下文窗口不等于记忆
很多人第一次做 AI 应用时,都会把"多传几轮历史消息"当成记忆方案。这种做法当然能在短会话里提升连续性,但它有两个天然问题:
第一,窗口再大也是暂时的。模型这轮看到的信息,下轮可能因为 token 预算被裁掉。
第二,窗口里的内容没有结构。用户的偏好、历史事实、偶发事件和闲聊内容混在一起,平台并不知道哪些应该长期保留。
换句话说,上下文窗口只是运行时输入,不是记忆系统。它更像人短时间内的工作记忆,而不是可以跨场景复用的长期记忆。
如果平台不把这两类东西分开,最后通常会陷入两个极端:要么上下文越来越大,延迟越来越高;要么为了控 token 直接裁掉历史,用户感觉系统永远"记不住我"。
关键决策:承认短期记忆和长期记忆必须是两套机制,而不是一套消息列表的不同长度。
第二阶段:短期记忆的工程化
短期记忆在联犀里更接近运行时结构,而不是一张独立业务表。它主要由最近轮次消息窗口和压缩摘要组成。
它的目标非常明确:
- 保留最近对话连续性
- 控制上下文长度
- 在窗口过长时做压缩,而不是无限追加
这里最关键的设计点,不是"保留多少轮",而是承认短期记忆天然服务于当前会话。它需要足够快、足够轻,而且可以随着对话推进被裁剪或摘要化。
短期记忆因此更像一种为模型输入服务的运行时缓存,而不是最终知识资产。
这类设计的价值在于,系统终于可以把"本轮回答需要的即时上下文"和"值得长期沉淀的事实"分开。前者优先保障当前对话体验,后者则交给更慢、更重、但更稳定的长期记忆链路。
关键决策:短期记忆是运行时缓存,不是持久化资产。
第三阶段:长期记忆的结构化
长期记忆在联犀里围绕 clone 建模,这一点非常重要。
系统不是把所有用户历史抽象成一个公共记忆池,而是围绕特定 clone 存储事实、偏好、事件和摘要。这样做的好处是,记忆天然有归属,也更容易服务于"这个数字分身以后还应该记住什么"。
当前长期记忆的核心类型主要包括:
fact:事实preference:偏好event:事件summary:摘要
这说明联犀并没有把长期记忆理解成一段大文本,而是尝试让记忆具备最基础的语义分类。因为只有这样,后续检索、衰减和重排序才有机会建立在"这是什么信息"之上,而不是对所有历史一视同仁。
更进一步看,长期记忆并不是只存 content。它还会伴随:
keywords:关键词importance:重要性access_count:访问次数- 向量索引 key
这些字段的价值,在于让记忆不再只是被动存档,而是变成一类可检索、可打分、可治理的系统资产。
关键决策:长期记忆必须有语义分类和可检索字段,而不是一段大文本。
第四阶段:记忆画像的压缩治理
记忆画像(ai_memory_profiles.profile_text)是注入 prompt 的关键内容。它的合并策略经历了三个阶段的演进,核心问题是:如何在保持记忆完整性的同时,控制 prompt 长度。
4.1 简单拼接(初始版本)
最初采用纯字符串拼接,无长度限制:
func mergeProfileText(existing, incoming string) string {
// 检查重复后直接拼接
return existing + "\n" + incoming
}问题:画像无限增长,prompt 膨胀至 218KB,导致响应延迟严重。
4.2 LLM 压缩合并
引入 LLM 进行智能合并,限制输出 500 字符:
func (mm *MemoryManager) MergeProfileText(ctx context.Context, existing, incoming string) string {
merged, err := mm.extractor.MergeProfileText(ctx, existing, incoming)
if err != nil {
return existing + "\n" + incoming // 降级为简单拼接
}
return truncateRunes(merged, 500)
}LLM Prompt:
请将以下两条用户画像合并为一条最精简的画像(150字以内,只保留姓名、偏好、重要约定等关键事实,禁止重复表述):
【现有画像】
{existing}
【新增信息】
{incoming}
请输出合并后的画像:效果:prompt 从 218KB 降至 5-6KB,短句延迟从 10.2s 降至 ~700ms。
问题:同步路径调用 LLM 阻塞响应,长句延迟从 7.6s 飙升至 14.2s。
4.3 分层策略(当前方案)
将写入和读取路径分离,采用不同策略:
写入路径:
同步 (saveMemoryAsync) → 简单拼接 + 2000 字符滑动窗口 → 写入 DB
异步 (ExtractAndSave) → LLM 压缩合并 → 写入 DB
读取路径:
DB → BuildPromptProfileSnapshot → 500 字符截断 → 注入 prompt关键设计原则:
- 响应路径零 LLM 调用:同步写入和读取路径不调用 LLM,确保响应延迟可控
- 异步压缩:LLM 压缩由后台异步任务完成,不阻塞用户请求
- 多重兜底:写入有滑动窗口,读取有截断兜底,防止异常情况导致 prompt 过长
效果:
- prompt 稳定在 5-6KB
- 短句延迟 ~700ms
- 长句延迟 ~4.9s
关键决策:同步路径保证响应速度,异步路径保证压缩质量,读取路径兜底截断。
第五阶段:多系统分层治理
当记忆系统与其他系统(Skills、知识库)共存时,新的问题出现了:
- 记忆重复注入导致上下文膨胀
- Skills 工具调用时 LLM 编造 API 路径
- 知识库每轮都硬编码搜索,浪费 token
当前方案通过分层隔离 + 渐进式披露 + 动态注入解决这些问题。
5.1 记忆去重机制
多轮对话去重
同一 session 内,避免重复注入相同记忆:
func (mm *MemoryManager) FilterRecentPromptRecall(sessionID string, results []*SearchResult, rememberLimit int) []*SearchResult {
const recentRecallWindow = 12
// 获取该 session 已注入的记忆
recent := mm.promptRecallCache[sessionID]
seen := make(map[string]struct{}, len(recent))
for _, item := range recent {
seen[item] = struct{}{}
}
// 过滤已注入的内容
filtered := make([]*SearchResult, 0, len(results))
for _, result := range results {
content := strings.TrimSpace(result.Doc.Content)
if _, ok := seen[content]; ok {
continue
}
filtered = append(filtered, result)
}
return filtered
}问候语检测
避免为简单问候注入多余记忆上下文:
func isGreetingQuery(text string) bool {
greetings := []string{"你好", "您好", "hello", "hi", "hey", "在吗"}
lower := strings.ToLower(text)
for _, g := range greetings {
if strings.Contains(lower, g) && len(text) < 20 {
return true
}
}
return false
}动态上下文注入
每轮对话自动检索相关记忆,但避免重复注入:
func (r *svcAgentRuntime) injectDynamicMemoryContext(ctx context.Context, messages []*schema.Message, userText string) []*schema.Message {
// 跳过问候语
if isGreetingQuery(userText) {
return messages
}
// 检索相关记忆
results, err := r.memoryManager.SearchRelevant(ctx, r.cloneID, r.tenantCode, userText)
// 过滤已注入的记忆
results = r.memoryManager.FilterRecentPromptRecall(sessionID, results, maxRelevantMemoryItems)
// 格式化并注入
contextText := formatPromptMemoryContext(results, maxRelevantMemoryItems, maxRelevantMemoryChars)
// ... 注入到 messages 中
}5.2 Skills 渐进式披露
解决 LLM 编造 API 路径的问题,强制执行渐进式披露工作流:
func (t *skillViewTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "skill_view",
Desc: "【强制执行】执行任何 skill 工具前必须先调用本工具获取准确用法。" +
"渐进式披露工作流:" +
"1.从<available_skills>发现目标skill " +
"2.调用skill_view(name)获取完整文档(含准确API路径、参数、示例) " +
"3.基于获取的准确信息执行对应工具。" +
"禁止跳过第2步直接猜测或编造API路径。",
}, nil
}系统提示词强化:
<available_skills>
渐进式披露工作流(强制执行):
1.发现skill:以下列出可用skill的code、名称和简介
2.查阅文档:执行任何skill工具前,必须先调用skill_view(code)获取完整文档
3.执行工具:基于skill_view返回的准确信息调用对应工具
禁止跳过第2步直接猜测或编造API路径。
</available_skills>5.3 知识库工具化
从"固定前置 RAG"升级为"内置 backend tools":
早期问题:
- 每轮都硬编码先搜索知识库,浪费 token
- 复杂问题只能拿一小段切片,无法获取全文
- 模型无法自主决定是否需要搜索
当前方案:
知识库搜索已工具化为 3 个内置工具:
knowledge_search:搜索命中文档与切片knowledge_get_document_content:获取文档全文knowledge_get_chunk_relations:查询切片关联
模型在 ReAct 路径中可以自主决定:
- 是否需要搜索知识库
- 是否需要获取全文
- 是否需要查看关联证据
关键决策:记忆动态注入,Skills 渐进式披露,知识库工具化,三者分层隔离。
第六阶段:延迟治理
当记忆、Skills、知识库都接入后,用户最容易感受到的问题就是"怎么这次想得这么久"。
很多时候,团队会下意识把这个问题归因到模型本身:模型慢、推理慢、网络慢。但一旦系统真正接入会话历史、长期记忆、知识库和工具链路,用户感知到的"慢"通常已经不是单一模型延迟,而是整条"回忆链路"的总成本。
一次回答在真正进入模型推理之前,往往已经做了不少事:
- 加载当前 session 历史
- 组织短期摘要
- 检索长期记忆
- 拼装 memory profile
- 可能还要再做知识搜索
6.1 回忆是一条链,不是一个点
从平台视角看,回忆至少有三层来源:
- 当前会话:最近轮次,决定 AI 能否接得住刚才的话题
- 长期记忆:用户偏好、事实和事件,决定 AI 是否还记得你
- 知识库:外部证据,补充 AI 的知识储备
如果这三层都在一次请求里无差别触发,延迟自然会快速累加。
关键决策:会话和记忆必须分开治理,而不是每次都从一锅混合历史里盲目抽取上下文。
6.2 memory profile 是快速通道
memory profile 不只是一个方便展示给人的总结字段,它还有一个非常现实的作用:降低回忆成本。
如果平台每次都必须从大量细粒度记忆里挑选内容,再让模型自己重新总结用户长期特征,延迟会非常不稳定。memory profile 相当于把一部分高频、稳定、长期有效的用户画像提前压缩出来,供后续会话直接使用。
从延迟角度看,profile 就像回忆链路里的快速通道:它不是替代长期记忆,而是在很多常见问题上提前给出一个更轻的语义入口。
关键决策:memory profile 是降本手段,不是万能总结。
6.3 延迟治理需要"拆账"
一条成熟的 AI 回忆链路,必须能回答:这次到底慢在了哪一段。
延迟一旦不能拆账,团队就只能笼统地说"AI 慢"。而一旦能拆开,就能把问题变成更可执行的工程决策:
- 是 session 历史加载太重
- 还是长期记忆召回链太长
- 是 profile 不够用,导致频繁深入召回
- 还是知识搜索在复杂问题上扩展过多
- 又或者其实是语音首帧和打断恢复导致的体感延迟
关键决策:延迟治理最怕的不是慢,而是不知道为什么慢。
整体架构总结
┌─────────────────────────────────────────────────────────────┐
│ System Prompt │
├─────────────────────────────────────────────────────────────┤
│ PersonaPrompt │
│ <memory>稳定 profile snapshot(500 字符截断)</memory> │
│ Agent System Prompt │
│ Extra System Prompt(物模型等) │
│ <memory_archive_rules>记忆归档规则</memory_archive_rules> │
│ <available_skills>渐进式披露工作流</available_skills> │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Messages │
├─────────────────────────────────────────────────────────────┤
│ [动态注入] 相关记忆上下文(去重后) │
│ 用户消息 │
│ 历史对话 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ReAct Runtime │
├─────────────────────────────────────────────────────────────┤
│ 模型自主决定: │
│ - 是否调用 skill_view 获取文档 │
│ - 是否调用 knowledge_search 搜索知识库 │
│ - 是否调用记忆工具保存关键信息 │
└─────────────────────────────────────────────────────────────┘核心设计原则
- 分层隔离:记忆、Skills、知识库各自独立,不互相干扰
- 动态注入:记忆根据用户输入动态检索,不是每轮都注入
- 去重机制:同一 session 内避免重复注入相同内容
- 渐进式披露:Skills 强制先查文档再执行,避免编造
- 工具化:知识库搜索交给模型自主决定,不硬编码
- 异步压缩:响应路径零 LLM 调用,压缩由后台异步完成
- 多重兜底:写入有滑动窗口,读取有截断兜底
总结
AI 记忆系统真正要解决的,从来不是"把聊天记录存下来",而是如何在"记住什么、怎么想起来、何时该整理"之间持续做取舍。
从简单拼接到智能压缩,从硬编码搜索到工具化决策,从混乱注入到分层治理,每一步演进都是为了让记忆系统既能记住人,又能控制"回忆一次"的成本。
真正成熟的 AI 记忆系统,不是记得越多越好,而是要在"记住什么、怎么想起来、何时该整理"之间持续做取舍。只有当系统既能记住人,又能控制"回忆一次"的成本,记忆系统才不会从体验增益,反过来变成体验负担。
更新日志
2026/6/2 21:42
查看所有更新日志
9c99e-docs(blog): 新增 AI 记忆工程化演进之路技术分享文章于
