☰
LLM上下文管理实战:从滑动窗口到分层记忆的完整指南
2026/10/7 11:04:47 网站建设 项目流程

1. context-mode 到底是什么:从一次“失忆”说起

如果你调过 LLM 的 API,大概率遇到过这种事:模型聊着聊着就忘了你十分钟前叮嘱的事情。比如我曾在做一个客服机器人时,用户第一句说“我叫王小明,用的是 Pro 套餐”,到第十轮追问“那我这个套餐能升级吗”,模型居然一本正经地回复“请问您目前使用的是哪个套餐”。这不是模型变蠢了,而是你根本没有把“王小明”和“Pro 套餐”这两条信息持续喂给它。这个问题的根源,就是上下文(context)管理没做好。

业内管这一整套处理手段叫context-mode,翻译过来就是“上下文模式”。它不是一个具体的函数,也不是某个框架独有的功能,而是一套工程化方案:你决定把哪些信息放进模型的一次调用里、放多少、以什么形式放、放不下了怎么办。说白了,LLM 本身是一个无状态的函数,输入一堆 tokens,输出一堆 tokens,每次调用都是独立的一次“失忆”。而 context-mode 要做的,就是在这层无状态之上,替模型搭出一套“工作记忆”。

这套东西能解决的问题,比表面上看到的要大得多。首先是成本,GPT-4 级别的模型按 token 计费,你每多塞一千个历史字进去,价格就往上跳一截;其次是延迟,输入越长,模型处理越慢,用户那边就是转圈圈;再就是一致性,如果用户信息在中间某轮被丢掉了,后面整个对话就会跑偏。所以 context-mode 并不是“锦上添花”的优化,而是任何要做多轮对话、Agent、RAG 应用的人都绕不开的核心环节。

这篇文章适合谁?正在做 AI 客服、个人助理、文档问答、自动化 Agent 的开发者,或者刚接触 LLM 应用、想把“能跑的 Demo”变成“能用的产品”的工程师。我会从原理讲到设计,再给一套可以抄作业的轻量实现,最后把我踩过的坑一并倒出来。内容不绑定某个具体平台,核心思路换到哪家模型都适用。

2. 上下文模式的 4 种主流设计:从“全塞进去”到“像人一样记忆”

既然要管理上下文,首先得有设计方案。我见过不少人一上来就直接把所有消息全部拼进 prompt,这其实是第一种模式,而且是最原始的一种。下面我把 4 种主流设计逐一拆开讲清楚,包括它们的原理、优缺点和适用边界。

2.1 全量上下文模式:简单直接,但贵得肉疼

全量上下文就是把系统提示词、全部历史对话、检索到的文档、工具返回结果,一股脑全拼成一个 prompt 发给模型。它的优点只有一个:信息无损,模型理论上能看到所有发生过的事。

但它的缺点会在对话轮次增加后迅速暴露。假设一轮对话平均消耗 400 tokens,20 轮就是 8000 tokens。主流模型的上下文窗口虽然已经涨到 128K 甚至 200K,但你的钱包撑不住。我实测过一个简单的 FAQ 机器人,用全量模式跑 50 轮后,单次请求的 input 已经逼近 20K tokens,按当时 GPT-4 的价格算,一次请求光输入成本就接近一块钱人民币。而且越到后面,模型响应越慢,用户体感就是“这个机器人变迟钝了”。

还有一个隐蔽问题:越长的上下文,模型越容易迷失重点。你把 50 轮闲聊和一句关键用户指令都塞进去,模型很可能被中间的大量噪音带偏,反而忽略最重要的信息。这就像让一个人同时读一本 500 页的小说和一张写满重点的便签,他大概率会记住小说情节,而忘了便签上的事。

所以全量模式的适用场景非常有限:对话轮次极少(比如 3 轮以内)、或者单次请求本来就短的工具调用类应用。但凡要做真正的多轮交互,就得换模式。

2.2 滑动窗口模式:只留最近 N 轮,成本瞬间可控

滑动窗口是最常见的省钱方案,核心逻辑一句话:只保留最近 N 轮对话,更早的直接丢掉。N 通常根据模型窗口和单轮平均 token 数计算。比如窗口 8K,系统提示词占 1K,每轮平均 500 tokens,那就保留 (8K - 1K) / 500 ≈ 14 轮,取个整数 N=10,留点余量。

这个模式的优点是实现极简单,加一个deque(maxlen=N)就搞定了,而且成本稳定可控,不会随着对话变长而无限膨胀。但它有一个致命伤:早期关键信息会丢失。还是拿客服场景举例,用户在第 2 轮说过“我叫王小明,用的是 Pro 套餐”,到第 12 轮时,这个信息如果已经被挤出去,模型就只能靠猜。

更麻烦的是,滑动窗口丢信息不是线性丢失,而是“连根拔起”。比如用户在第 6 轮提到过一个产品型号,第 10 轮又围绕这个型号问了两个问题,第 15 轮你问“所以你刚才说的那个型号有什么问题?”,模型已经完全不记得了,因为它连那段对话一起丢掉了。

所以滑动窗口适合什么场景?闲聊机器人、一次性问答、或者你确定用户不会在早期留下“必须长期记住”的信息。它适合做“兜底方案”,但不适合做核心方案。

2.3 摘要压缩模式:把旧历史变成“记忆面包”

摘要压缩模式是我在实际项目里用得最多的一种,思路也非常符合直觉:既然旧对话太长,那就用模型把旧内容总结成一小段摘要,保留要点,丢掉细节。比如 20 轮历史对话可能压缩成 300 tokens 的摘要,效果好的时候,核心事实(用户姓名、套餐、诉求)都能保留下来。

具体做法是:设定一个阈值,比如当历史消息总 token 数超过 3000 时,触发一次压缩。把最旧的 2000 tokens 丢给模型,让它总结成“包含用户关键信息、当前诉求、已解决/未解决问题”的摘要,然后把这个摘要放在新的系统提示词附近,再把最近几轮完整对话拼接在后面。

这样做的好处很直观:成本被压下来了,而且记忆保留能力比滑动窗口强不少。但坏处也有三方面。第一,摘要会引入信息损失,模型总结时可能丢掉一些你认为重要、但模型觉得不重要的细节;第二,压缩本身也是一次模型调用,会增加延迟和成本,如果对话很长,可能每隔几轮就要压一次;第三,摘要的“保质期”有限,如果对话跨了多个话题,早期话题的摘要后面可能用不上,但你还得一直带着它。

这里有个经验值:摘要压缩适合“单线长对话”,比如一对一客服、单用户助理。但如果你的场景是多用户、多话题交叉的,纯摘要模式往往不够用,需要往下看第四种。

2.4 分层记忆模式:让 AI 像人一样“想起来”

分层记忆是目前最接近“人脑工作方式”的方案,也是我最近半年在研究的方向。它的核心思想是不再试图把所有信息塞进同一个 prompt,而是把信息按层次存储,每次调用时只检索和当前问题最相关的部分。

你可以把它理解成三层的记忆体系:第一层是工作记忆,也就是当前这一轮用户问题、最近的几轮对话、系统指令,这些必须完整放进去;第二层是短期记忆,比如本次会话中已经压缩过的摘要、上一步的中间结果,可以按需带入;第三层是长期记忆,用户的历史偏好、过往订单、知识库内容,这些平时存在独立的存储里(可以是 Redis、向量数据库、甚至普通数据库),只有当模型判断当前问题需要这些信息时,才通过检索把它们拉回 prompt。

这样做的好处非常明显。首先是成本极低,每次调用的输入长度基本恒定;其次是记忆能力极强,理论上可以记住几个月前的细节,只要检索做得足够好;最后是抗干扰,模型不会因为看到一堆无关历史而迷失方向。

但代价也很实际:你需要额外写检索逻辑、维护存储、处理向量化,如果项目规模不大,这套东西的工程成本会超过收益。我见过很多团队为了“显得高级”硬上分层记忆,结果检索召回质量很差,反而丢掉了本来用摘要模式就够用的效果。

2.5 混合模式的选型思路:90% 场景的最终答案

上面四种模式不是互斥的,我实际生产环境中用的几乎都是“滑动窗口 + 摘要压缩 + 按需检索”的混合体。基本结构是这样的:最近 5 轮完整对话保留在工作记忆里,更早的对话压缩成摘要常驻在 prompt 中,当用户提到某个历史实体(比如“我之前说的那台设备”)时,触发向量检索,把相关历史片段拉回来插进 prompt。

选型的判断标准我总结成一句话:先看对话轮次要多长,再看信息丢失会造成什么后果。如果只是短会话,滑动窗口足够;如果会话可能超过 10 轮且必须记住用户偏好,就必须上摘要;如果业务还涉及跨会话记忆(比如用户隔天回来继续问),那就只能做分层。

3. 从零实现一个轻量 context-mode 引擎

理论说了那么多,不落地就是空中楼阁。下面我给你一套可以直接抄的轻量实现,不依赖任何特定框架,用 Python 写核心逻辑,存储先用内存和 JSON 文件顶着,后续要换 Redis 或向量库也容易。这套代码我实际跑过,结构清晰,适合作为你项目的第一版地基。

3.1 数据结构与模块设计

先想清楚要管理什么数据。一个完整的 context 应该包含四块内容:

  • system_prompt:系统指令,通常在第一轮固定下来,全量保留。
  • recent_messages:最近 N 轮原始对话,按时间顺序排列。
  • summary:旧对话的压缩摘要,用文本字符串存。
  • memory_store:按需存储关键实体信息(比如用户姓名、订单号、偏好),键值对即可。

对应的模块也分四块:token 计数器、上下文组装器、压缩触发器、记忆管理器。其中 token 计数可以先用近似方法,后面接真实 tokenizer。

我用一个数据类来表示上下文会话:

from dataclasses import dataclass, field from typing import List, Dict, Optional @dataclass class ContextSession: session_id: str system_prompt: str = "" recent_messages: List[Dict] = field(default_factory=list) summary: str = "" memory: Dict[str, str] = field(default_factory=dict) @property def recent_token_count(self) -> int: return sum(count_tokens(msg["content"]) for msg in self.recent_messages)

这里的关键是把“最近对话”和“记忆”分开存储。如果你把用户信息混在 recent_messages 里,一旦滑出窗口就彻底丢了;放进 memory,哪怕对话过期也能通过检索找回来。

3.2 核心逻辑:阈值判断与压缩时机

接下来是最重要的部分:什么时候触发压缩?我的做法是设置两个阈值,一个软阈值、一个硬阈值。

  • 软阈值:recent_messages 的 token 数超过 max_recent_tokens 的 80% 时,就把最早的若干条挪进待压缩区。
  • 硬阈值:recent_messages 的 token 数加上系统提示词和摘要的总 token 数,逼近模型窗口上限的 90% 时,强制触发压缩。

压缩不是把整个 recent 全部一次性压掉,而是只压缩最旧的那一批,保留最近几轮完整对话。代码逻辑如下:

MAX_RECENT_TOKENS = 3000 # 最近对话保留上限 MAX_TOTAL_TOKENS = 7000 # 单次请求总 token 预算(含系统提示词、摘要) SUMMARY_TRIGGER_RATIO = 0.8 def should_compress(session: ContextSession) -> bool: recent_used = session.recent_token_count / MAX_RECENT_TOKENS total_used = ( session.recent_token_count + count_tokens(session.system_prompt) + count_tokens(session.summary) ) / MAX_TOTAL_TOKENS return recent_used >= SUMMARY_TRIGGER_RATIO or total_used >= 0.9 def compress_old_messages(session: ContextSession, llm_func) -> None: # 只取最旧的一半做压缩,保留最近几轮细节 split_idx = len(session.recent_messages) // 2 old_part = session.recent_messages[:split_idx] session.recent_messages = session.recent_messages[split_idx:] prompt = ( "请将以下对话记录压缩成一段摘要,要求保留:" "1) 所有用户提供的个人偏好和实体信息;" "2) 用户当前的核心诉求;" "3) 已经解决的事项和未解决的事项。\n\n" + "\n".join(f"{m['role']}: {m['content']}" for m in old_part) ) session.summary = llm_func(prompt)

这段代码有几个细节值得注意。split_idx 取一半,是为了避免每次压缩都把全部旧消息清空;摘要 prompt 里明确要求保留“个人偏好、实体信息、核心诉求”,这是我试过几十次之后总结出来的关键点,不写清楚,模型就会给你压成一个毫无信息量的“用户与助手进行了对话”。

3.3 上下文组装:把摘要、记忆、最近对话拼接起来

压缩完怎么组装成最终发给模型的 messages?顺序很重要,我踩过坑之后才明白:不是简单把摘要放最前面就完事,而是要考虑模型的注意力分布。

我的组装顺序是:系统提示词 → 用户长期记忆(如果有) → 旧会话摘要 → 最近 N 轮原始对话 → 当前用户问题。理由是:系统提示词离得近,优先级最高;长期记忆和摘要提供背景;最近对话提供即时上下文;当前问题放在最后,因为模型对“越靠后的内容注意力越强”。

def build_messages(session: ContextSession, current_user_input: str) -> List[Dict]: messages = [] if session.system_prompt: messages.append({"role": "system", "content": session.system_prompt}) if session.memory: memory_text = "\n".join(f"{k}: {v}" for k, v in session.memory.items()) messages.append({"role": "system", "content": f"用户已知信息:\n{memory_text}"}) if session.summary: messages.append({"role": "system", "content": f"历史对话摘要:\n{session.summary}"}) messages.extend(session.recent_messages) messages.append({"role": "user", "content": current_user_input}) return messages

这里我把 memory 单独作为一个 system 消息放进去,而不是拼在摘要里,目的是让模型更容易区分“事实”和“历史过程”。实际效果差别挺明显,模型对单独列出的已知信息引用率远高于埋在摘要里的同一句话。

3.4 token 估算的两种方式

上面代码里的count_tokens是个关键函数。生产环境强烈建议用模型的官方 tokenizer,比如 OpenAI 的tiktoken,或者 Hugging Face 的transformers。但如果你只是快速验证逻辑,可以用一个粗略估算:英文按 4 个字符算 1 个 token,中文按 1.5 到 2 个汉字算 1 个 token。这个估算会有误差,但用来做阈值判断足够了,反正你设阈值的时候也会留余量。

def count_tokens(text: str) -> int: if not text: return 0 # 粗略估算:中文字符按 1.8 计,英文按 4 字符 1 token chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') other_chars = len(text) - chinese_chars return int(chinese_chars * 1.8 + other_chars / 4)

如果你要更精确,我建议在本地缓存 tokenizer,不要每次调用都重新加载模型文件,否则性能会非常难看。我用tiktoken实测过,单次计数在毫秒级,加缓存之后基本无感。

3.5 何时接入 RAG / 工具调用:别让检索结果挤爆上下文

context-mode 和 RAG 的边界经常有人搞混。我的理解是:RAG 是往上下文里灌“外部知识”,context-mode 是管理“对话内信息”。两者可以配合,但配合方式有讲究。

我见过最蠢的写法是:每轮对话都把 RAG 检索出的 10 段文档全放进 prompt,结果上下文被塞爆,模型被文档噪音淹没。正确的做法是先用记忆管理器判断用户问题是否涉及外部知识,如果涉及,再做检索,而且只把 top 2-3 段结果拼进当前这一轮的 prompt。这样既保证了知识可用,又不会让上下文无限膨胀。

4. 四种模式实测对比:数据说话

光讲理论容易让人觉得“都是纸上谈兵”,所以我专门搭了个测试环境,把四种模式跑了一遍真实数据。场景设计是 20 轮客服对话,里面包含三类关键信息:用户个人偏好(姓名、套餐)、一次售后诉求(换货)、一次情绪表达(不满发货速度)。

4.1 测试指标与结果

我记录了三类指标:关键信息保留率(对话结束时,模型能否准确回答“用户姓名、套餐、换货进度”三个问题)、总 token 消耗(20 轮累计消耗的 input tokens)、单轮平均延迟(模拟真实调用时的响应时间)。

模式关键信息保留率总 token 消耗(约)单轮平均延迟实现复杂度
全量上下文100%85,000高(逐渐变慢)无
滑动窗口(N=8)33%38,000低极低
摘要压缩100%41,000中(压缩时有峰值)低
分层记忆100%32,000低-中高

这个结果很有说服力。全量模式虽然保留率满分,但 token 消耗是分层记忆的 2.6 倍,而且延迟随着轮次增加越来越高,用户体验极差。滑动窗口 token 控制得不错,但关键信息保留率只有 33%,名字和套餐全丢了,换货进度也断了一半。摘要压缩和分层记忆都能做到 100% 保留,但分层记忆的 token 消耗更低,因为每次只检索需要的部分,而不是带着整份摘要。

4.2 结合实际场景选型:别盲目追求最复杂的方案

上面这个结果容易给人一个误导:分层记忆最牛逼,你们都用它。但实际工程里真不是这样。如果你的对话平均只有 5 轮,滑动窗口完全够用,上分层记忆纯属给自己找麻烦——要维护向量库、写召回逻辑、处理相似度阈值,这些成本可能比省下的 token 钱还贵。

我建议的选型标准是这样的:

  • 对话轮次短(≤ 5 轮)且无跨会话需求:滑动窗口,N=6 就够。
  • 对话轮次长(> 10 轮)但只在一个会话内:滑动窗口 + 摘要压缩,阈值设为摘要保留最近 5 轮。
  • 涉及跨会话记忆(用户隔天回来继续问):在上一套基础上加一层 memory_store,存用户实体信息。
  • 对话本身依赖大量外部知识(客服、专业顾问):分层记忆配合 RAG,但只在必要时触发检索。

4.3 成本估算示例:一个月的账单差异

假设你有一个日活 1000 人的客服机器人,每人每天平均聊 30 轮,用 GPT-4 级别的模型(假设输入价格 $0.03 / 1K tokens)。全量模式的日均 token 消耗约 1,000 × 30 × 1,200(平均每轮输入) ≈ 36M tokens,日均成本约 $1,080。改成混合模式(摘要 + 滑动窗口 + 记忆)后,日均消耗降到约 1,000 × 30 × 400 ≈ 12M tokens,日均成本 $360。一个月下来差了两万多人民币。这不是小数目,值得你在设计阶段就认真对待。

5. 常见问题与排查技巧实录

这部分是我这些年攒下来的实战经验,每一行都是踩过坑换来的。按出现频率从高到低排。

5.1 上下文溢出:模型报 “context length exceeded”

这是最容易遇到的问题,而且多半发生在对话第 30 轮左右,因为很多人只在第二十轮的时候想到要压缩上下文,结果压缩完还是超额。我排查这类问题有个固定套路:

第一步,看日志里触顶时实际的 total tokens 是多少,别猜。第二步,检查系统提示词和摘要是否被重复拼接——我见过有人每轮都重新加载一份相同的摘要,导致 token 数翻倍。第三步,调整阈值:模型窗口标称 8K,实际跑 7K 就可能报错,因为有些框架会在 messages 之外再加特殊 token。我的经验是把硬阈值设在窗口上限的 85%,而不是 90%。

5.2 用户信息被重复注入:context 越长,模型越“健忘”

有一次我测试一个带 user memory 的机器人,发现模型总是把用户当前地址和一个月前的地址搞混。查日志才发现,问题出在 memory_store 里的信息没做版本管理,旧地址和新地址一起塞给了模型。模型看到两个矛盾信息,自然会困惑。

解决办法:memory_store 里的每一项都要带时间戳,组装 prompt 时只取最新版本。如果确实存在历史值需要保留,那就加上“用户曾在 X 时间使用过该地址,现地址为 Y”这样的显式说明,让模型明白哪个是当前值,哪个是历史值。

5.3 摘要压缩后信息丢失:模型“总结”得太笼统

摘要压缩最常见的问题是模型把“王小明,Pro 套餐”这种关键信息压成了“用户是会员”。我一开始以为是模型笨,后来发现是我的摘要 prompt 写得太随意。经过反复调优,现在我的压缩指令里会专门追加一句:“如果对话中出现人名、地名、产品型号、订单编号、金额、时间、偏好,必须原样保留在摘要中。”加完这一句,信息保留率立刻从 70% 涨到 95% 以上。

5.4 召回相关性差:分层记忆成了“摆设”

如果你上了分层记忆,发现召回的内容驴唇不对马嘴,大概率不是检索算法的问题,而是切分维度不对。我之前的项目按“时间窗口”切分记忆,把一天的对话切成一大块,结果检索时相似度计算总被无关内容干扰。改成按“话题轮次”切分,每个话题单独存一段之后,相关性显著提升。另外还有一个细节:检索召回的内容不要直接丢进 prompt,最好先用一个小模型(或者规则)过滤一遍,只保留与当前问题明显相关的片段。

5.5 成本监控:别让模型悄悄吃掉你的预算

最后说一个容易被忽略的事情:加日志。每次调用 LLM 时,把 input tokens、output tokens、触发压缩的次数、检索的次数全部记录下来。这样你月底看账单时,才能知道钱花在哪了。我现在每个项目都会在代码里加一个简单的计数器:

class TokenUsageTracker: def __init__(self): self.total_input = 0 self.total_output = 0 self.compress_calls = 0 def log_call(self, input_tokens, output_tokens): self.total_input += input_tokens self.total_output += output_tokens def log_compress(self): self.compress_calls += 1

这个 tracker 不复杂,但它能帮你回答两个关键问题:压缩次数是不是太频繁?检索是不是成了每次请求的固定开销?看到数据之后,你自然知道下一步该调哪里。

最后分享一个自己沉淀的小技巧

说了这么多,我觉得 context-mode 最核心的心法就一句话:别把模型当成一个会记住一切的数据库,它只是一个每次拿着你递给它的笔记本来做推理的实习生。你的工作不是让模型“记住”,而是设计一套机制,把该记的东西在合适的时候递给它,把不该记的东西果断拿掉。

我自己的项目里,现在一直保留着一个习惯:每轮对话结束后,顺手把 memory_store 里新增或变更的实体打印到日志里。这看起来是个笨功夫,但它帮我发现过很多次“模型自己编造用户信息”的问题——当 memory 里的信息和用户实际输入矛盾时,一眼就能看出来。

如果你准备在自己的项目里落地 context-mode,不用一开始就搞得很复杂。先用滑动窗口跑起来,加上 token 监控,再逐步接入摘要压缩,最后根据业务是否真的需要跨会话记忆,决定要不要上分层存储。每加一层,都要有明确的数据指标来证明它值得——这样你的系统既不会显得简陋,也不会背上不必要的工程包袱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询