先把最近的一个项目复盘写下来,这个主题我觉得特别值得聊。起因是给一个智能体产品做架构梳理,反复碰到了同一个问题:模型能力明明很强,但用户聊着聊着就会被“失忆”打击到。用户前脚刚说完自己在准备某类考试,AI后脚就像初次见面一样,礼貌地重新问一遍。这种体验一旦出现,智能体在用户心中的信任感基本就清零了。
这就要说到大模型记忆工程体系。记忆不是单一技术,而是一套分层协作的工程架构。其中最先要解决、也最容易见效的,就是会话级记忆。你可以把它理解为智能体交互过程中的“临时缓存”——它只负责记住“这一次对话里发生了什么”,不承担跨会话的用户画像,也不负责长期偏好沉淀。它的目标只有一个:让多轮对话连贯、自然、不犯低级错误。
这篇文章我会以会话级记忆为主线,把定位、技术原理、落地实现和常见坑位一次讲透。适合正在搭智能体、做对话产品的开发者,也适合想系统理解大模型记忆分层逻辑的技术负责人。不绕弯子,直接进入正题。
1. 记忆工程全景:会话级记忆的定位与边界
1.1 为什么记忆会变成一门“工程”
先从最朴素的问题说起:大模型不是无所不知吗,为什么非要搞一套记忆体系?
确实,单看一轮问答,模型自身的知识覆盖已经很广。但智能体不等于问答机器人。真正的智能体要连续完成多轮任务,比如帮用户规划一次亲子出行,中间涉及目的地、预算、同行人数、时间窗口、偏好约束等多个变量。这些信息如果每一轮都要用户重复交代,体验会非常糟糕,甚至会让用户产生“这个AI是不是根本没在听”的挫败感。
更麻烦的是,大模型的对话本质上是无状态的。它每一次生成,都是基于你当前给它的输入,它不会天然“记得”你上一轮说了什么。所谓多轮对话,其实是开发者在每次请求时,把历史消息重新拼装起来,一起喂给模型。换句话说,记忆不是模型自带的能力,而是工程上“造”出来的。
这就解释了为什么记忆能被称作“工程”:它不是加一个字段、存一个变量那么简单,而是要解决怎么存、存多久、什么时候清、什么时候换、怎么防止越存越乱等一系列问题。任何一个环节处理不好,对话体验就会出问题。我自己带过的几个项目里,凡是记忆模块设计得草率的,后期几乎都要返工,而且返工成本远高于一开始就认真设计。
1.2 会话级记忆与其他层级的分工
在完整的记忆工程体系里,我倾向于把记忆分三层。表格对比一下会很清楚:
| 层级 | 生命周期 | 核心内容 | 典型载体 |
|---|---|---|---|
| 会话级 | 单次会话期间 | 当前对话的上下文、临时状态、过程变量 | 消息列表、上下文窗口、会话摘要 |
| 用户级 | 跨会话 | 用户偏好、画像、长期事实记忆 | 用户属性表、知识库 |
| 全局级 | 跨用户 | 通用的领域知识、规则、业务约束 | 向量库、规则引擎、知识图谱 |
会话级记忆是三层里最基础、最底层的一环。它像演出时台上的“台词卡”和“布景”,只服务于当下这一场,散场后大部分内容就该清掉。
这种分工不是拍脑袋定的。会话级记忆如果做得太重,把用户所有历史都塞进来,Token成本会爆炸,而且模型在大量噪声信息中会更容易答偏。反过来,如果会话级记忆完全不做,那用户级记忆再完善也没用——你连用户“这次想干什么”都不知道,谈何“记住他是谁”。
所以我会反复强调一个观点:会话级记忆是记忆工程的地基。地基没打好,上层做得再精致也白搭。很多团队一上来就冲向量库、做长期记忆,反而把最基础的会话连贯性忽略了,这是典型的优先级失误。
2. 会话级记忆的技术本质与设计难点
2.1 从LLM的无状态到有状态
要理解会话级记忆,先得把这件事想明白:大模型天生是“无状态”的。
你调用一次模型接口,传进去的是Prompt,出来的是Completion。上一次调用和这一次调用之间,模型不保存任何用户信息。所谓“有状态”,完全靠外部系统(也就是你的代码)帮忙维持。
最常见的做法是消息列表。开发者在后端维护一个数组,把用户每次说的话和AI每次的回复按顺序存下来。新一轮请求时,把整个列表序列化成文本,拼到系统提示词后面,再发给模型。这样模型就能“看起来”记得之前的对话。
我用一个生活类比来解释:这就像你请了一位记忆力极差的接线员,他不是真的记得电话内容,而是你每次打电话,都先把之前所有的通话记录传真给他,他一边翻记录一边接电话。会话级记忆就是那个不断更新的“通话记录本”。
这个机制说起来简单,真正难的是在有限的空间里决定“哪些记录要保留、哪些可以丢弃、压缩到什么程度不会丢关键信息”。这不是一个能靠直觉解决的问题,必须建立量化的管理策略。
2.2 上下文窗口:物理限制与工程应对
会话级记忆面临的第一堵墙,就是上下文窗口的物理上限。
现在的模型虽然上下文窗口越来越大,但窗口再大也是有限的。而且窗口并不是越大越好——窗口里的内容越多,模型对中间信息的关注度反而会下降,这就是业内常说的“Lost in the Middle”现象:模型更容易记住开头和结尾的内容,中段信息容易被忽略。
我实际测过一个场景:在一个很长的对话里,用户在第12轮提到“我儿子今年上小学二年级”,到第30轮时再问“我儿子现在读几年级”,模型会一本正经地给出错误答案。原因不是模型“忘了”,而是中间隔了太多轮普通寒暄和任务细节,这条关键信息在模型眼中已经被淹没了。
所以会话级记忆的设计核心,不是“把越多内容塞进去越好”,而是“在有限空间里让关键信息保持高曝光率”。
这就引出几个必须做的工程动作:
- 设定Token预算,明确给历史对话留多少空间。
- 设计优先级策略,重要消息靠前放或重复放,不重要的消息压缩掉。
- 对超长对话做摘要,把冗余对话浓缩成几句话。
这三个动作缺一不可。如果只做预算不做优先级,那你只是在“删消息”;如果只做摘要不做预算,摘要本身就会失控。它们必须组合使用,才能形成一套完整的会话级记忆管理机制。
2.3 信息更新与覆盖机制
还有一个容易被忽略的技术难点,是消息的覆盖。对话中用户经常会推翻自己之前的说法,比如“不对,我不住那边了,我现在搬到城西”,或者“上次说的那个方案先放一放,我们换个方向”。如果消息列表只是机械地往后追加,模型就会同时看到新旧两种说法,产生冲突。
我见过最典型的错误案例是:用户在第5轮说预算5000,又在第10轮改成8000,结果模型第12轮推荐方案时,还在按5000筛选。原因就是历史里保留了旧值,模型不知道该信哪一个。
正确做法是在存储层引入“状态覆盖”机制:对同一类事实型信息,新消息到达时,旧消息要标记为“已失效”或直接隐藏。这相当于给会话级记忆加了一个“橡皮擦”,让模型每次看到的都是最新且自洽的状态,而不是互相矛盾的时间线。
这个机制做得好不好,直接决定了长对话的稳定性。我后面在实操章节里会给出具体的数据结构设计思路。
3. 实操:从零搭建一个会话级记忆模块
3.1 基础形态:消息列表与滑动窗口
最简单的会话级记忆,就是一段消息列表加一个滑动窗口。
具体做法:在后端维护一个数组,每个元素是一条消息,包含角色(用户/助手/系统)和内容。每次请求前,截取最后N条消息作为历史上下文。
# 会话级记忆:滑动窗口版(简化示例) class SessionMemory: def __init__(self, max_turns=20): self.messages = [] self.max_turns = max_turns def add_message(self, role: str, content: str): self.messages.append({"role": role, "content": content}) # 触发窗口收缩:只保留最近 max_turns 条 if len(self.messages) > self.max_turns: self.messages = self.messages[-self.max_turns:] def build_context(self, system_prompt: str) -> str: parts = [system_prompt] for msg in self.messages: parts.append(f"{msg['role']}: {msg['content']}") return "\n".join(parts)这个方案能解决大概六成的问题,但有两个明显缺陷。
第一个缺陷是“一刀切”丢失。窗口滑动时,早先的消息会被直接删掉。如果用户的某些关键信息是在第3轮透露的,到第30轮它早就被挤出窗口了,AI照样“失忆”。
第二个缺陷是Token不可控。按“轮数”截取窗口其实不科学——用户一句话可能100字,AI一个长回复可能500字,同样20轮,不同的对话占用的Token天差地别。更合理的方式是按Token数量截取,而不是按消息条数。
所以我更推荐按预算截取的做法:
def build_context_budget(self, system_prompt: str, token_budget: int = 3000): """ 从后往前拼接消息,直到逼近 token_budget 返回 (最终上下文, 被裁剪掉的消息条数) """ parts = [system_prompt] used = estimate_tokens(system_prompt) # 简易估算 trimmed = 0 # 倒序访问,最近的优先保留 for msg in reversed(self.messages): line = f"{msg['role']}: {msg['content']}" cost = estimate_tokens(line) if used + cost > token_budget: trimmed += 1 continue parts.append(line) used += cost # 此时 parts 顺序是反的,需要还原 # system_prompt 在首位,其余正序排列 history = parts[1:][::-1] return "\n".join([system_prompt] + history), trimmed这里的estimate_tokens可以简单用字符数除以4粗略估算,也可以接入真正的分词器。核心思路是:从最新的消息往前回溯,直到预算耗尽。最新的优先保留,旧的在必要时丢弃。
3.2 进阶策略:摘要压缩与层级记忆
滑动窗口方案被诟病最多的地方就是“粗暴”。为了保留早期关键信息,业内普遍的做法是引入摘要压缩,也就是“滚动摘要”。
思路:当消息列表超出预算时,不直接删掉最旧的消息,而是先把旧消息丢给模型生成一段简短的摘要,再把摘要作为一条特殊的历史记录,和最近的消息一起组成上下文。
这样上下文里就分了两层:
- 会话摘要:覆盖整个对话的精华信息,比如用户的核心目标、明确的偏好、已经确认的结论。
- 最近消息:保留最近几轮的原始内容,用于处理需要精确复述的细节。
我建议的摘要触发时机是:按消息条数或Token量设置阈值,而不是每轮都做摘要。比如消息超过60条,或者历史Token超过预算的50%,就触发一次摘要。这样可以把摘要成本控制在一个合理的频率上。
class SummaryMemory: def __init__(self, summary_threshold=60, max_recent=40): self.summary = "" self.recent_messages = [] self.summary_threshold = summary_threshold # 消息条数阈值 self.max_recent = max_recent # 翻转时保留的最近消息条数 def add_message(self, role: str, content: str): self.recent_messages.append({"role": role, "content": content}) if len(self.recent_messages) >= self.summary_threshold: self._roll_over() def _roll_over(self): old_messages = self.recent_messages[:-self.max_recent] self.recent_messages = self.recent_messages[-self.max_recent:] # 用模型生成摘要 summarization_prompt = ( "请对以下对话做简洁摘要,保留所有关键的用户意图、偏好、" "已确认结论和尚未完成的任务:\n\n" + "\n".join(f"{m['role']}: {m['content']}" for m in old_messages) ) new_summary = call_llm(summarization_prompt) # 调用模型接口 self.summary = merge_summary(self.summary, new_summary) def build_context(self, system_prompt: str) -> str: parts = [system_prompt] if self.summary: parts.append(f"[对话摘要] {self.summary}") for msg in self.recent_messages: parts.append(f"{msg['role']}: {msg['content']}") return "\n".join(parts)这里有一个我踩过的坑要特别强调:摘要不是一次生成就完事的。随着对话继续,第一版摘要会积累得越来越长,导致后期信息反而放不下。所以要设计“二次摘要”逻辑,即新摘要和旧摘要合并后如果还超长,就再压缩一次。这是一个递归的压缩过程,实现时要注意给摘要生成留出稳定的预算位置,别让摘要本身占掉全部上下文空间。
3.3 Token预算管理与超支预案
会话级记忆工程里,Token预算分配是最容易被忽略、却最影响体验的环节。
我给一个可复用的分配建议,假设单次请求的总预算是B:
- 系统提示词和工具定义:20%
- 会话摘要:10%到15%
- 最近对话消息:50%到60%
- 当前用户输入:15%到20%
- 预留生成空间:5%到10%
预留生成空间很多人会漏掉。如果你把上下文Token填得满满当当,模型回复时空间不够,轻则回复被截断,重则直接报错。所以即使模型支持很大的上下文窗口,也建议不要全部用完。
如果预算真的不够了,按这个优先级降级:
- 先压缩最近消息,把多轮短消息合并成摘要。
- 再压缩会话摘要本身,让它更精炼。
- 最后才考虑丢弃次要消息。
实际工程里还要考虑Token估算误差。不同分词器的估算差异可能达到两倍,所以建议把阈值设置在真实预算的70%左右,留出安全余量。我之前在线上项目里踩过这个坑:自以为算得很准,结果某次用户贴了一大段长文,上下文直接超限,线上服务报错,用户才不会管你是不是预算设置不合理。
3.4 会话级记忆的数据结构落地
讲完策略,说说具体的数据结构。我推荐把会话级记忆分成三个核心字段来管理,而不是简单的一个消息数组。
@dataclass class SessionState: session_id: str summary: str # 滚动摘要,保留高价值信息 facts: dict[str, str] # 关键事实槽位,如 {"预算": "8000", "目的地": "城西"} recent_messages: list[dict] # 最近原始消息,按预算截取 stale_flags: set[int] # 标记已失效/被覆盖的消息索引这个结构的核心价值在facts字段。它相当于一个“状态槽”,专门存储用户明确说过且可能反复引用的事实信息。每次新消息进来时,可以做一次轻量抽取,把“预算”“人数”“时间”“地点”这类高频事实更新到facts里。
这样做的好处是:即使这些事实在原始消息里被挤出窗口,模型仍然可以通过facts字段拿到准确值。我在多个项目里验证过,这个字段对长对话稳定性的提升非常明显。代价是需要多一层抽取逻辑,但对于真正做产品来说,这笔成本值得花。
4. 常见问题与排查技巧实录
4.1 上下文污染:AI“串台”了
这是会话级记忆最常见的坑。多轮对话里,如果上下文里残留了上一轮的不相关内容,或者把多个会话的消息混在一起,模型就会“串台”。
典型表现:用户本来在聊工作周报,突然问了一句“昨天那家餐厅怎么样”,AI如果从历史里翻出一段跟餐厅完全无关的旧话题,就属于典型的历史污染。
排查思路:把你传给模型的完整上下文打印出来,逐段看哪些消息是当前意图不需要的。常见污染源有三个:
- 多个会话共用一个消息列表。
- 系统提示词里塞了过期的临时信息,比如某轮任务明确结束了但状态没有更新。
- 用户口误或修正,模型在历史里保留了“修正前”的错误信息。
解决方案:
- 保证每轮请求前对上下文做“意图相关性过滤”:和当前任务无关的旧消息,该隐藏就隐藏。
- 对已完成的子任务,把细节收敛为一句状态描述,其余内容从原始消息里移除。
- 用户明确撤回或纠正过的信息,要有“覆盖”机制而不是简单追加。
4.2 记忆失真:摘要丢了关键信息
我在做滚动摘要的时候踩过大坑:摘要生成得不够结构化,结果模型把用户的预算底线、时间偏好这些硬约束压缩没了,后面所有推荐全部跑偏。
排查技巧:建议在摘要模板里强制要求模型按字段填写,而不是自由发挥。
比如这样设计摘要格式:
用户核心目标:在3天内安排一次亲子出行 已确认约束:预算1000元内,不带老人,孩子5岁 已完成事项:已筛选3个候选地点 未完成事项:确定出发时间、预订门票 待推荐偏好:亲近自然、车程1小时内把摘要从“自由文本描述”改成“半结构化字段”,信息完整率会显著提升。我试过几个项目,用自由文本摘要时关键约束的保留率可能只有五成,用结构化字段后能提到八成以上,代价是摘要占用的Token稍微多了一点,但性价比非常高。
4.3 性能和成本失控
会话级记忆的另一个隐藏成本,是每次请求都要把完整消息列表发给模型。对话越长,请求越大,费用涨得越快,延迟也越高。
实测下来,一个50轮的对话,光历史消息就可能占用上万Token。如果用户经常发起长会话,这个费用绝对不能忽视。
控制成本的办法,除了摘要压缩,还有几个思路:
- 复用重复文本。比如系统提示词和工具定义是固定的,可以要求服务端做缓存前缀,避免每次重复计费。
- 对“闲聊型”内容降级。识别到对话已经从任务模式切到闲聊,可以缩小历史窗口。
- 延迟摘要。不是每条消息都立刻摘要,而是等消息数量达到阈值再批量处理,降低模型调用频率。
还有一个容易忽略的点:日志和监控。一定要在线上记录每次请求的Token消耗和上下文构造结果,否则你根本不知道是哪一轮对话把成本拉爆的。我见过的项目里,凡是成本失控的,基本都是因为缺了这层监控。
4.4 多会话并发隔离
做智能体产品,肯定不能只有一个会话。用户可能同时开着三个对话框,分别聊工作、生活、学习,这时会话级记忆必须做好隔离。
问题通常出在存储设计上。如果用单一数组存所有消息,忘记按会话ID分组,就会出现A对话的消息跑到B对话去,AI当场“失忆”加“精分”。
最稳妥的结构是在消息列表之外再加一层会话容器:
class MemoryStore: def __init__(self): self.sessions = {} # session_id -> SessionMemory def get_session(self, session_id: str) -> SessionMemory: if session_id not in self.sessions: self.sessions[session_id] = SessionMemory() return self.sessions[session_id]每次请求根据session_id取出对应的记忆对象,读写都只作用在它上面。这个基础设计能堵住大多数隔离漏洞。再配合超时回收机制,比如30分钟无活跃就清理会话缓存,可以避免存储无限膨胀。
我还建议在存储层加一个“会话活跃时间戳”字段,每次读写都刷新。这样清理线程可以扫一遍时间戳,把超过N分钟没有活跃的会话直接回收,避免内存和存储双膨胀。
5. 会话级记忆的扩展方向
5.1 会话级与向量检索的配合
做完整记忆工程,不能只靠滑动窗口和摘要。会话级记忆的天然劣势是“只限本次”,一旦用户第二天回来说“继续上次那个话题”,新会话的临时缓存里什么都没有。
一种务实的扩展,是把会话级记忆和向量检索结合。流程是:每次会话结束或关键节点,把会话摘要和重要消息入库做向量化;新会话启动时,先用用户的“当前意图”去向量库检索相关旧会话片段,把最相关的几条历史信息注入当前上下文。
这个方案比“记住所有历史”现实得多。它不追求把每句话都存下来,而是按需调取。检索出来的片段还可以标注来源会话和时间,让模型知道“这些是之前的背景”,避免与当前会话的消息混淆。
我个人的实践体会是:向量检索的目标候选,不要用整段原始消息,而是用已经做好的会话摘要切片。这样检索的准确率和效率都更高,而且摘要本身已经去噪,注入上下文后干扰更小。
5.2 会话级向用户级记忆的沉淀
会话级记忆的最终价值,不只是服务一次对话,更是为长期记忆提供“养料”。
举个具体的场景:用户在多次会话里反复提到自己偏好某种写作风格、某个工作领域、某些忌口。这些信息本不该只存在某一次会话的临时缓存里,而应该沉淀到用户级记忆,形成跨会话的稳定画像。
实践中的做法是给会话级记忆增加一个“意图分类”动作:会话结束时,把对话里识别出的持久化信息(偏好、身份、长期目标)抽取出来,写入用户级存储;临时性信息(本次任务的中间状态、具体到某个时刻的事务细节)则随会话过期清理。
这样分层配合,会话级记忆负责“演好当下这场戏”,用户级记忆负责“真正记住这个人是谁”。两者是流水线关系,不是替代关系。
5.3 记忆的持久化与会话恢复
还有一类场景容易被忽略,就是用户主动结束会话后,又希望恢复之前的对话继续聊。比如“我昨天让你帮我写的那份方案,今天我想再改改”。
这就是会话级记忆的持久化需求。操作方法是在会话结束后,把消息列表或摘要序列化入库。恢复时根据会话ID把历史拉回来,重新构建上下文。
但这里有个细节:恢复时不一定需要完整拉回所有原始消息。如果之前已经生成了高质量的会话摘要,那恢复时只需加载摘要和最近若干条原始消息,成本低且效果好。这也是为什么我一直建议,会话级记忆要尽早设计“摘要”能力,它会成为后期所有扩展的基础。
顺便提醒一下,序列化格式建议使用JSON或MessagePack,字段里一定要带上结构化版本号。后续改动数据结构时,线上已经存储的旧会话数据需要做兼容迁移,没有版本号会非常痛苦。
最后一次再分享一点我在多个项目里的体感。会话级记忆是最容易快速见效的优化方向,但它也是最容易被轻视的。很多人第一版觉得“反正上下文窗口够大,全部塞进去就行”,等到用户量上来、对话变长,才被成本、延迟、上下文污染等问题反复捶打。
我的建议是,从一开始就把“预算、分层、隔离、摘要”这四个词刻在记忆模块的设计里。预算决定能放多少,分层决定怎么组织,隔离决定不乱串,摘要决定关键信息不丢。四件事看起来简单,真正做好需要你在真实对话里反复调试。
如果你正在搭自己的智能体,不妨花一个下午,把你现在的历史消息管理代码翻出来,试试加一个摘要层,再试试Token预算控制,大概率你马上就能感受到对话质量的提升。这就是会话级记忆的魔力——改动不大,但用户立刻能感知到“这个AI终于记得住事了”。