☰
大模型上下文管理实战:四种模式与token预算优化
2026/10/7 14:02:07 网站建设 项目流程

1. context-mode是什么:先搞清上下文管理的核心问题

1.1 从一次"失忆"的对话说起

如果你做过大模型应用开发,大概率遇到过这个场景:用户连续追问十几轮之后,AI突然答非所问,或者干脆表示"我不记得你之前说过了"。这时候,懂行的人会告诉你——上下文窗口满了,早期内容被挤掉了。

这个问题的根子,就是context-mode(上下文模式)。说白了,它是决定"每一轮请求里,模型能看到哪些历史信息"的管理策略。我在做AI客服、文档问答这类项目时,几乎每天都要跟它打交道。可以这么说:上下文管理做得怎么样,直接决定了一个AI应用是"看起来很聪明"还是"看起来像个智障"。

1.2 上下文模式的本质:在有限窗口里做最优记忆分配

理解context-mode之前,得先弄清一个关键限制:大模型的输入窗口是有限的。哪怕号称能处理200K token的模型,真把这么多内容全部塞进去,也会面临两个致命问题。

第一个是费用。现在主流大模型API基本按token计费,每多带一轮历史对话,就要多付一份钱。一个日活一万的客服机器人,如果每轮都带全部历史,月底账单能让人心梗。

第二个是注意力稀释。模型对输入中间部分的内容记忆最差,越长的上下文,越容易"忘掉"关键信息。我做过一个测试:把一份50页的产品手册塞进上下文,然后问具体参数,模型给出的答案经常是错的,因为它根本没"注意"到藏在第30页的那行小字。

所以context-mode的核心,不是简单地"多带点历史"或"少带点历史",而是在有限的窗口预算里,决定保留什么、压缩什么、丢弃什么。这个决策逻辑,就是整套上下文管理模式的设计重心。

1.3 这个内容适合谁读

如果你正在做这几类事情,这篇文章会非常对胃口:用API开发聊天机器人、做RAG知识库问答、写Agent类应用,或者只是觉得"我的prompt明明写得很好,但AI用起来还是蠢"。我会从模式原理讲到代码实现,再分享一批实际踩坑记录,帮你避掉那些文档里查不到的雷。

2. 四种主流上下文模式拆解:选型前先看懂原理

2.1 全量上下文模式:最省心的基础方案

所谓全量上下文模式,就是每轮对话都把全部历史消息组装好,一股脑丢给模型。这是最简单、最直观的做法,新手接入API时基本都会先这样写。

它的优势非常明显:实现成本低,模型能看到完整的对话脉络,对用户意图的理解最准确。在对话轮数少、历史总量可控的场景下,比如"10轮以内的闲聊机器人"或"单轮但输入较长的文档分析",全量模式完全够用,效果也最稳。

但它有个硬伤:不可扩展。假设每轮用户说200字、AI回复500字,一轮对话大约消耗700 token,20轮就是14000 token。如果你们的产品要求AI记住30轮以上,或者每次请求还要附加固定的系统指令和参考资料,窗口会很快被塞爆。

更隐蔽的问题在于"垃圾信息累积"。用户中途说的"今天天气不错"、AI回应的"是的呢,请问还有什么可以帮您",这类寒暄内容对当前问题的判断毫无帮助,却实打实占着token预算。全量模式等于把这些噪声全部保留,既不经济,还可能干扰模型对真正关键信息的注意力。

所以我的建议是:全量模式适合项目原型期和低轮次场景,但产品一旦要上线,一定要切换到更精细的管理方式。

2.2 滑动窗口模式:控制成本与延迟的实用派

滑动窗口(Sliding Window)的思路很直接:只保留最近N轮对话,更早的一律丢弃。它像一个永远在移动的取景框,只关注眼前这一片。

实现上就是在组装消息时,对历史消息列表做一次裁剪:

def build_sliding_window_messages(messages, max_rounds=10): # 假设每条消息是 {"role": "user"/"assistant", "content": "..."} # 至少保留system指令 system_messages = [m for m in messages if m.get("role") == "system"] history = [m for m in messages if m.get("role") != "system"] # 只取最近max_rounds轮的对话(一轮算2条消息) recent = history[-(max_rounds * 2):] return system_messages + recent

这个模式的优点很明显:token消耗稳定可控,请求延迟不会有大的波动,代码实现也简单。但它有一个所有人都绕不开的痛点——切断记忆。用户20轮前提到的重要需求,一旦窗口滑过,模型就再也看不见了。

我见过不少项目就是在这里翻车的。比如一个保险理赔助手,用户在第3轮填了出生日期,第18轮问"按我之前的年龄算保额是多少",滑动窗口模式下模型直接懵了。这种问题不是调prompt能解决的,是数据结构上的缺失。

所以滑动窗口模式适合什么场景呢?对近期信息敏感、对远期信息不敏感的对话,比如技术客服排障(用户最近两步的操作记录最重要)、闲聊陪伴类产品。如果你判断"用户随时可能引用早期信息",那就得考虑摘要模式或检索模式。

2.3 摘要压缩模式:让记忆"提纯"的关键路径

摘要压缩模式(Summarization Mode)解决的是滑动窗口"一刀切"的问题:我不直接删掉老对话,而是把它们浓缩成一段摘要,随请求一起传给模型。这样既保留了长期记忆,又控制了token开销。

它的流程分三步:

  1. 设定一个触发阈值,比如历史消息超过10轮。
  2. 把第1到第N轮对话发送给一个"摘要模型",要求它输出一段包含关键信息的摘要。
  3. 后续请求组装的顺序变成:系统指令 + 历史摘要 + 最近几轮完整对话。

这里有个细节值得注意:摘要不是只做一次,而是滚动更新。每过几轮新对话,就要把旧摘要和新对话合并,再压缩成新摘要。我通常用"二次摘要法":第一次先把每5轮对话压缩成一小段,第二次再把多段小摘要合并成一段话。这样能避免一次性压太多内容导致信息丢失。

我自己实践下来的摘要提示词是:

你是对话摘要引擎。把下面的对话内容压缩为一段不超过200字的摘要。 要求: 1. 保留所有具体数字、日期、姓名、地点等硬信息; 2. 保留用户明确表达的偏好、需求、承诺与情绪倾向; 3. 保留未解决的待办事项; 4. 忽略寒暄、语气词、重复表达。

摘要压缩模式最大的好处,是让模型拥有"真正意义上的长期记忆",而且每次请求的token增长是缓慢的(只增长摘要长度,而不是增长全部历史)。代价也很明显:摘要过程本身有信息损耗,而且需要额外调用一次摘要模型,增加了延迟和费用。

做这类模式时,我强烈建议把摘要结果也持久化存储(存到Redis或者数据库里),而不是每次实时生成。否则每轮请求都要重新压缩一遍历史,成本和速度都吃不消。

2.4 检索增强模式:把外部知识装进上下文

检索增强模式,也就是常说的RAG(Retrieval-Augmented Generation),是目前企业级应用里最热门的上下文管理方案。它的思路和前面几种完全不同:不再"保留对话历史",而是从知识库里按需检索相关内容,拼进当前请求里。

以做AI客服为例。用户问"你们的耳机支持蓝牙5.3吗",传统的全量模式是把整个产品手册塞进上下文,而检索增强模式是先把这个问题做向量化,去知识库里捞出几段最相关的产品参数说明,再把"参数片段 + 最近几轮对话"一起交给模型。

这种模式最大的价值,是打破了上下文窗口的物理限制。你不需要把几十万字的文档全部塞进每次请求,而只需要找到"当前这个问题最需要的那几百字"。同时,知识库可以随时更新,不需要重新训练模型。

但检索增强模式也不是银弹。最典型的翻车案例是"检索不到导致幻觉":知识库切得不够细、向量召回不准确,模型找不到答案,就开始一本正经地编。另外,检索出来的片段之间如果存在逻辑断裂,模型的回答质量也会明显下降。

所以RAG对技术栈的要求更高。你需要搭向量数据库、选嵌入模型、设计切分策略、调召回阈值。这也是为什么很多团队最终选择"混合模式":核心业务知识用RAG,对话记忆用摘要压缩,近期对话用滑窗——几个模式协同工作,各干各的活。

我把这四种模式的取舍整理成一个对照表,方便选型时快速参考:

模式长期记忆能力成本/延迟实现复杂度最适合场景
全量模式一般,受窗口限制随对话轮数线性增长极低原型、短对话
滑动窗口差,远古信息丢失恒定低强近期依赖的排障类
摘要压缩强,但存在损耗中,需要额外摘要调用中需要长期记忆的陪伴/顾问类
检索增强受知识库覆盖度影响中,依赖检索链路较高知识密集型的问答/客服

3. 实操:从零实现一个可切换的context-mode管理器

3.1 基础数据结构与token预算计算

动手写代码之前,先把底层的token预算算法搞定。我做上下文管理时,第一步永远不是写消息组装函数,而是先写一个"预算计算器",因为所有模式都是在一笔固定预算里做取舍。

假设你的模型上下文窗口是8000 token,你需要预留这几个部分:

  • 系统指令:固定消耗,比如500 token
  • 输出空间:给模型回复预留的,通常设置max_tokens=1000
  • 安全余量:建议留15%到20%,防止tokenizer估算误差导致超限
  • 剩余部分:才是给对话历史和额外信息用的

写成代码就是:

import tiktoken class ContextBudget: def __init__(self, max_window=8000, max_output=1000, system_prompt="", reserve_ratio=0.15): self.encoder = tiktoken.get_encoding("cl100k_base") self.max_window = max_window self.max_output = max_output self.reserve_ratio = reserve_ratio self.system_tokens = len(self.encoder.encode(system_prompt)) def available_tokens(self): # 窗口总大小 - 系统指令 - 输出空间 - 安全余量 total = self.max_window - self.system_tokens - self.max_output return int(total * (1 - self.reserve_ratio)) def count_tokens(self, messages): # 计算一组消息的token总量,注意tiktoken的per-message格式 total = 0 for msg in messages: total += len(self.encoder.encode(msg.get("content", ""))) total += 4 # 每条消息的元数据开销(role标记等) return total

这里的核心思想是先算可用的、再往里装内容。很多开发者习惯先组装消息再截断,结果系统指令或输出空间被压缩,模型行为变得极其不稳定。我踩过这个坑:有次把系统指令截断了一半,模型直接开始乱答,排查了半天才发现是token预算分配的问题。

3.2 滑动窗口模式的代码实现

基于预算计算器,滑动窗口模式可以写成这样:

class SlidingWindowContext: def __init__(self, budget: ContextBudget, max_rounds=12): self.budget = budget self.max_rounds = max_rounds def build(self, history: list, system_override=None): system = system_override or history[:1] dialog = [m for m in history if m["role"] in ("user", "assistant")] # 方案A:按轮数裁剪 trimmed = dialog[-(self.max_rounds * 2):] # 方案B:按token预算裁剪(更稳) while len(trimmed) > 2 and self.budget.count_tokens(trimmed) > self.budget.available_tokens(): trimmed = trimmed[2:] # 从头丢掉最早的一组问答 return system + trimmed

注意我在代码里写了两种裁剪策略。按轮数裁剪简单,但不同用户输入的message长度差异极大——有人一句话20字,有人一句话500字,固定轮数根本拦不住token溢出。所以我一般推荐先按轮数粗裁,再按token预算精裁,两头都卡住。

这里还有一个隐患:裁剪时只能成对丢弃(user+assistant),不能只丢assistant回复而保留user问题。否则模型会面对一个没有回答半截的问题,容易产生胡编乱造的倾向。成对丢弃虽然会多损失一点信息,但能保住对话的因果完整性。

3.3 摘要压缩模式的实现要点

摘要模式的实现框架我也不藏着。它的核心是一个"摘要管理器",维护"当前摘要 + 未压缩的最近对话",并决定什么时候触发压缩:

class SummaryContext: def __init__(self, budget: ContextBudget, compress_after_rounds=6): self.budget = budget self.compress_after_rounds = compress_after_rounds self.summary = "" # 已经压缩过的历史摘要 self.recent_history = [] # 尚未压缩的近期对话 def add_message(self, msg): self.recent_history.append(msg) if len(self.recent_history) >= self.compress_after_rounds * 2: self._compress() def _compress(self): # 旧摘要 + 新对话 → 新摘要 # 这里调用一次LLM完成压缩 combined = f"已有摘要:{self.summary}\n\n新增对话:{self.recent_history}" new_summary = call_summary_llm(combined, max_length=200) self.summary = new_summary self.recent_history = [] def build(self): return [{"role": "system", "content": f"对话历史摘要:{self.summary}"}] + self.recent_history

注意这个实现和我在第2.3节说的"二次摘要法"有些差异——先简单滚动压缩,如果摘要本身超过了200字,再触发二次压缩。实际项目中建议把这两层都做上,防止摘要越滚越长。

摘要模式的姨妈期问题(我自己给它起的名字)在于摘要内容的时效性。如果用户在最近几轮里改口了("之前说要黑色,现在改成白色"),摘要里还留着"用户喜欢黑色",模型就容易犯矛盾。处理办法是在压缩提示词里加一句"如果新对话与旧摘要存在冲突,以新对话为准,并在摘要中标注变更"。

3.4 检索增强模式的嵌入与召回

做RAG模式的上下文管理,核心就一句话:把"对话历史管理"和"知识检索结果"分开组装。

class RagContext: def __init__(self, budget: ContextBudget, vector_store, embed_fn): self.budget = budget self.vector_store = vector_store self.embed_fn = embed_fn def build(self, query: str, history: list): # 1. 向量化用户当前问题 query_vec = self.embed_fn(query) # 2. 从知识库召回topk相关片段 retrieved = self.vector_store.search(query_vec, top_k=3) # 3. 用预算分配:知识片段优先,历史对话次要 parts = [] # 根据实际情况设置比例,一般知识片段占大头 knowledge_limit = int(self.budget.available_tokens() * 0.6) for doc in retrieved: if self.budget.count_tokens(parts, doc) < knowledge_limit: parts.append(doc) # 4. 剩余预算给近期对话 history_limit = self.budget.available_tokens() - knowledge_limit recent = history[-4:] while recent and self.budget.count_tokens(recent) > history_limit: recent.pop(0) return [ {"role": "system", "content": "你是基于知识库回答问题的助手..."}, {"role": "user", "content": f"参考信息:\n{format_docs(parts)}\n\n历史对话:\n{format_history(recent)}\n\n当前问题:{query}"} ]

这里有个非常重要的经验:知识片段必须带引用来源。也就是每段从知识库召回的内容,后面要跟着出自哪个文档、哪个章节。这不仅能帮模型理清依据,更能在后续排查时帮你定位"模型答错了,到底是知识库没收录,还是召回的片段不对"。

召回数量也不是越多越好。我之前测试过top_k从3调到8,回答质量不升反降——大量无关片段混进来,反而干扰了模型对重点信息的注意力。现在业界比较共识的做法是:先做粗召回取top20,做rerank精排后只留top3,效果比直接取top8好得多。

4. 实践中的坑:上下文模式切换与稳定性问题排查

4.1 模式切换时的上下文断裂

很多项目会遇到一个尴尬时刻:全量模式跑得好好的,为了省钱切到滑动窗口,结果用户立刻发现"客服失忆了"。

这不是切换本身的问题,而是没有做切换缓冲。比如用户说"之前你不是推荐过某某产品吗",滑动窗口模式下这句历史已经被裁掉了,模型当然答非所问。

我的处理方案是"渐进式切换":新用户直接用滑动窗口模式;老用户保留一份全局摘要(用摘要模式生成),对话前先注入摘要,再叠加滑窗。这样既省token,又不会让老用户体验断崖式下降。具体操作是在切换前跑一次离线脚本,把每个活跃会话的历史,批量压缩成摘要存起来。

4.2 token估算不准导致的截断

tiktoken和模型实际的tokenizer之间,绝大多数情况下是一致或接近的,但有一个特例:某些模型的tokenizer版本更新后,对代码、特殊符号的编码方式变了。如果你之前是在旧模型上用cl100k_base估算,换到新模型后token预算计算就会产生偏差。

排查这个问题,我建议在日志里加一个字段:每次请求记录"估算token"和"实际token消耗"。实际消耗可以从API返回的usage. total_tokens里拿到。连续跟踪几天,你会发现偏差比例基本稳定,把这个系数写进预算公式里就行。比如我的经验是,中文对话场景下,tiktoken的估算值通常比实际值高5%到8%,这个余量可以适当从reserve_ratio里扣除。

4.3 摘要失真与信息丢失

摘要压缩模式最头疼的问题就是"压没了关键信息"。尤其用户提到的具体数字,摘要模型经常觉得"不重要"就给丢了,但下游任务恰恰需要这个数字。

解决思路分两层。第一层是在摘要提示词里强调"数字、编号、日期必须原样保留",大部分时候有效。第二层是"关键信息旁路":在对话过程中,单独把用户提到的实体信息(电话、订单号、地址、偏好设置等)抽取出来,存成结构化的"用户画像字段",每次组装消息时直接注入,不依赖摘要。这样即使摘要漏了,画像字段也能兜底。

4.4 不同模型对上下文格式的敏感度

最后这个坑最隐蔽:同一个context-mode代码,换一个模型,效果就变了。

我踩过一次很典型的例子。GPT-4时代,把历史摘要放在system字段里,效果很好。但换了某个开源模型后,同样把摘要放system里,模型经常忽略摘要内容,只盯着最后几句对话。后来排查发现,这个模型的system指令遵循能力较弱,对system字段里的长文本不敏感。

后面我把摘要从system挪到user字段的最前端,用"对话历史摘要:xxx"的格式和当前问题拼在一起,效果立刻正常了。所以结论是:上下文模式不能只做一套就通用,每个模型都要做单独的格式适配测试。低成本的做法是准备一个"上下文体检集":包含5类典型问题(引用早期信息类、多跳推理类、指令遵循类、知识检索类、追问类),换模型后先跑一遍体检,再决定消息格式怎么组织。

5. 一些实操感受

写了这么多,最后说点我自己的体会。

context-mode这个东西,听起来是个具体的功能,其实是一整套预算思维。每次组装请求之前,你都要先想清楚:这笔token预算,系统指令占多少、知识片段占多少、历史对话占多少、模型输出留多少。预算定好了,模式选型才有意义,不然就是瞎调。

我个人的建议是,新项目不要一上来就追求最复杂的RAG+摘要+滑窗混合架构。先用全量模式把业务逻辑验证清楚,再根据用户真实对话中出现的问题(成本失控、记忆丢失、知识不足),一步步加对应模式。很多团队开局就上大而全的架构,结果每个环节都跑不稳,出了问题都不知道该查哪一环。

另外,不管用哪种模式,日志记录一定要做两层:一层记组装给模型的完整消息,一层记模型的完整输出。这样将来用户投诉"AI怎么胡说八道"时,你能立刻复盘出到底哪一步出了问题——是历史没带上,是摘要压丢了信息,还是知识库压根没这内容。省下的排查时间,远大于记日志带来的那点存储成本。

上下文管理这条路,越往后做越觉得细节多。但只要把预算、模式、日志这三件事抓好,大部分问题都能在可控范围内解决。希望这篇东西能帮你少踩几个坑。

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

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

立即咨询