LLM上下文管理:四种context-mode模式选型与落地实践 接手context-mode这个模块的时候我们刚上线半年的对话机器人还经常被用户吐槽“记性太差”。不是模型不行而是我们根本不知道该把什么样的上下文喂给模型、喂多少、按什么顺序喂。这个模块后来成了整个系统的核心借着整理它的机会今天把从选型到落地的完整思路写出来。如果你也在做自己带记忆的 LLM 应用或者只是想知道context-mode到底是什么、怎么用这篇应该能给你省不少事。再说清楚一点context-mode不是一个官方名词而是我在实践里对一类问题的统称——当模型无状态、接口只认一段 prompt 时我们如何组织上下文才能让对话不“断电”。它涉及历史消息怎么留、怎么截、怎么压缩也涉及业务知识怎么以最合适的形式进到模型视野里。往下我会先讲原理和选型再给一套可以直接抄的代码最后把我在生产环境踩过的坑挨个列出来。1. context-mode 到底是什么解决什么问题1.1 无状态模型的“失忆症”语言模型本身是不记事的。每次调用接口模型看到的只是你这次传进去的那段文本上一轮聊了什么它一概不知。这就像你每次找同一个同事问问题都得把前因后果重讲一遍同事虽然专业但完全没有关于你的长期记忆。所以应用层的context-mode本质上就是替模型做“外挂记忆”。把用户之前说的话、系统给过的指令、查到的资料按一定策略组装成一段当前时刻最该出现的上下文。模型看起来“记得”你其实是我们在每次请求前把该记的东西重新塞给了它。这里有个容易混淆的点很多人一上来就想要一个能记住“所有历史”的模式。先不说技术上做不做得到单是成本就受不了。context-mode解决的不是“全记住”而是“在有限的 token 预算内记住最该记住的”。这是整篇文章反复出现的主线。1.2 context-mode 的三层结构选材、组织、裁剪我搭这个模块时把问题拆成了三层选材、组织、裁剪。选材哪些内容应该进上下文历史对话用户画像文档片段工具返回结果组织这些内容按什么顺序排列system 放哪、user 最近的输入放哪、参考资料插在哪裁剪总长度超了怎么办是丢旧的、还是总结旧的、还是临时检索把这三层想清楚context-mode就不神秘了。它不是什么玄学就是一个“带着预算约束的文本调度系统”。选材决定质量下限组织决定模型能不能正确理解优先级裁剪决定系统能不能长期跑下去。后面章节的每一种模式其实都是在这三层里做不同的取舍。2. 四种主流上下文模式怎么选不踩坑2.1 全量直通模式最简单也最“贵族”。把从第一条用户消息到最后一条 assistant 回复完整拼进 prompt。适合刚起步的 demo、内部工具以及对话轮数极少且每轮内容都很短的场景。优点是没有任何信息损失模型看到的是原汁原味的对话史缺点是 token 会随对话轮数线性增长。聊到第 20 轮可能已经顶到模型的上限。即便没到上限长 prompt 也会带来两个隐性代价首字延迟变高、注意力分散到无关历史里反而影响回复质量。我用全量直通模式时一般会设一个硬性阈值比如 3 轮以内走这条路。超过就直接切下一档。别指望“阈值前全量、阈值后压缩”的切换过程用户能感知到——只要把摘要写得好用户根本不会发现历史被悄悄“折叠”了。2.2 滑动窗口模式社区里最常见的做法只保留最近 N 轮对话更早的一律丢。类似微信聊天窗口只显示最近 20 条消息。好处是长度可控实现简单token 占用稳定在一个区间内。坏处也很明显如果用户在第 1 轮提到“我叫小王预算 5 万”第 10 轮问“按刚才的预算继续”模型已经不知道“刚才”指的是多少。早期版本经常被用户骂“没记性”多半就是窗口开得太小。滑动窗口适合“短时任务型”场景比如一次客服工单、一轮代码调试。真正需要长期记忆的产品它只适合当最外层的兜底策略不适合当主力。2.3 摘要压缩模式当对话超过一定长度时把更早的历史交给模型总结成一段摘要替换掉原始对话最近几轮保留原文。这是我在生产环境用得最多、性价比最高的模式。好处是 token 基本可控同时能把“用户叫小王、预算是 5 万”这类关键信息带进后续对话。坏处是摘要会丢细节而且万一摘要里漏了关键结论后面整个对话都会沿着错误方向走。为了缓解这个问题我会在总结 prompt 里明确要求“保留所有具体数字、人名、结论句、待办事项丢弃寒暄和过程性描述”。宁可在摘要里多留几个关键词也不要压成一句漂亮的废话。2.4 检索增强的 RAG 模式如果场景不是长对话而是“从几千页文档里回答问题”那 history 类模式都不管用。这时要用 RAG先把用户问题向量化从知识库里检索最相关的片段再把片段拼进 prompt。它的本质是把“所有资料都塞进上下文”改成“只塞和当前问题相关的资料”。RAG 模式的上下文结构通常是这样System 指令 检索到的 N 条文档片段 用户当前问题。文档片段要标注来源编号并让模型在回答时引用编号。这块我在第 4 章会专门讲一个容易翻车的点检索片段越多不一定越好超过一定数量反而会带偏模型。2.5 模式选型对照表模式核心思路优点缺点适合场景全量直通完整保留历史零信息损失token 线性增长短对话、临时 demo滑动窗口只留最近 N 轮实现简单、开销稳定丢失早期关键信息工单类、任务型短对话摘要压缩历史总结近期原文兼顾信息与成本摘要可能失真多轮客服、AI 助手RAG 检索按需取片段可处理海量知识检索质量影响大文档问答、知识库助手实际项目里很少只用一种模式。我的做法是组合系统指令 长期事实记忆 近 N 轮原文 更早历史摘要 按需检索的文档片段最后由一个 token 预算控制器决定每部分能占多少额度。3. 实操从零写一个 ContextManager3.1 设计前先定两件事预算和保留区动手写代码前先把两个数字定下来max_tokens和reserve_tokens。max_tokens是模型的输入上限。比如模型上下文窗口是 8000会给输出预留 1000那输入上限就是 7000。reserve_tokens则是给“这轮之后用户还可能追加输入”留的缓冲我通常留 800 到 1000。真正用于历史上下文的是max_tokens - reserve_tokens。这两个数字决定ContextManager什么时候触发压缩。如果每次请求都在超限边缘试探接口会频繁报错用户体验很差如果留得太多又浪费了模型的记忆能力。经验值是让历史内容稳定占总预算的 70% 左右触发压缩后恢复到 50% 以下这样不会每次都触发压缩。3.2 核心数据结构与 API先用一个Message数据结构表示单条消息角色除了system/user/assistant我还建议加一个meta字段存放时间戳、来源、是否已压缩等辅助信息后面排查问题会特别有用。from dataclasses import dataclass, field from typing import List, Optional, Callable dataclass class Message: role: str # system / user / assistant content: str meta: dict field(default_factorydict) # 记录时间、来源、压缩标记等ContextManager的核心接口只有三个add、build_prompt、_compress。add负责把新消息追加进历史build_prompt把当前历史渲染成接口需要的消息列表_compress在长度超限时对最老的消息做处理。class ContextManager: def __init__( self, max_tokens: int 7000, reserve_tokens: int 1000, summarize_fn: Optional[Callable] None ): self.history: List[Message] [] self.max_tokens max_tokens self.reserve_tokens reserve_tokens self.summarize_fn summarize_fn or self._default_summarize def add(self, message: Message) - None: self.history.append(message) if self._estimate_tokens(self.history) self.max_tokens - self.reserve_tokens: self._compress() def build_prompt(self) - List[dict]: return [{role: m.role, content: m.content} for m in self.history] def _estimate_tokens(self, messages: List[Message]) - int: # 生产环境建议用真实 tokenizer这里用“字符数/24”做粗略估计 total 0 for m in messages: total len(m.content) // 2 4 return total注意_estimate_tokens的粗略估算并不精确。中文一个字通常在 1 到 1.5 个 token 之间英文一个词约 1.3 个 token。生产环境应该调用模型服务返回的usage.prompt_tokens字段做闭环统计或者接入对应 tokenizer。我写这个函数只是为了在本地快速跑通逻辑。3.3 压缩策略先裁后压压缩不能“每次超限就从头把全部历史压一遍”那样会反复磨损关键信息。我采用的策略是“先裁后压”先尝试裁掉最老的一部分非系统消息如果还不够再把更早的部分做摘要。def _compress(self) - None: # 找到第一条非 system 的消息作为可压缩区域的起点 start 0 for i, m in enumerate(self.history): if m.role ! system: start i break # 至少保留最近两轮全文 keep_from max(start, len(self.history) - 4) to_summarize self.history[start:keep_from] if not to_summarize: # 实在没得可压缩只能把最近比较长的 assistant 消息先裁掉一些 self.history self.history[-4:] return raw \n.join(f{m.role}: {m.content} for m in to_summarize) summary self.summarize_fn(raw) self.history ( self.history[:start] [Message(roleuser, contentf[历史摘要] {summary}, meta{compressed: True})] self.history[keep_from:] )这段逻辑里有几个关键决定值得说一下。第一system消息永远不参与压缩它包含模型的人格、规则和输出格式要求一旦被藏进摘要里后面所有回复都会变形。第二keep_from保证最近几轮始终保留原文因为用户正在进行的对话最相关。第三压缩后的摘要用user角色放回去比用system更不容易影响模型对“当前指令”的感知。_default_summarize是摘要函数的兜底实现实际项目里会接模型接口。摘要 prompt 我会写成这样def _default_summarize(self, raw: str) - str: prompt ( 把下面的对话压缩成 200 字以内的中文摘要。 必须保留具体数字、人名、结论、用户需求、待办事项。 丢掉寒暄、语气词、重复表达。\n\n f{raw} ) # 伪代码resp chat_completion([{role: user, content: prompt}]) # return resp[choices][0][message][content] return raw[:200] # 本地测试时的占位实现3.4 日志和观测没有监控的上下文模式都是盲调ContextManager上线后最容易出问题的不是代码逻辑而是“看不见”。你根本不知道每次请求裁剪了多少、压缩了什么、还剩多少预算。所以我强烈建议给_compress加结构化日志import logging logger logging.getLogger(context_mode) # 在 _compress 里记录 # 压缩前 token 数、压缩后 token 数、删了多少条、摘要前内容截断、节省的 token logger.info( compress before%d after%d dropped%d saved%d, before_tokens, after_tokens, dropped_count, saved_tokens )这些日志能直接暴露问题如果某天saved_tokens突然变大说明用户聊得太长如果摘要后的content丢了数字说明摘要模型或者 prompt 出了问题。我在调优阶段会额外给每条Message的meta打上compressed: True标记线上出问题时可以回溯“这条摘要到底是什么时候、由谁生成的”。4. 我踩过的五个坑排查实录4.1 总是报 context_length_exceeded第一次上线时我把max_tokens设成模型名义窗口大小没给输出留预算。结果只要对话稍长接口就报上下文超限。排查代码才发现模型窗口包含输入和输出两部分输出也要占 token。正确做法是给输出留足空间并且每次请求后从返回结果的usage里拿prompt_tokens和本地的粗略估计做比对。这样就能知道本地估算偏了多少再决定要不要把触发压缩的阈值调低。我还加了一个“预压缩”逻辑如果估算值已经超过预算的 85%在用户发送新消息前就先行压缩而不是等新消息进来后被动处理。4.2 压缩完模型突然“失忆”现象很典型对话前 10 轮用户说过喜欢简洁风格第 11 轮开始模型变得啰嗦。查日志发现摘要压缩时第 1 轮提到的“用户偏好”被当成寒暄丢掉了。这是摘要压缩模式的固有风险。我的处理方式有两个一是所有“用户偏好、硬性限制、待办事项”类信息在进入ContextManager时就额外抽取一份放到meta里压缩时把这份结构化信息单独留在上下文中二是摘要 prompt 里明确要求“保留所有约束和偏好”并在压缩后人工抽查一批摘要。抽查几轮你就会发现模型默认会把“我今天想做个蛋糕”这种信息压缩成“用户想做蛋糕”但会丢掉“不要加花生”这种关键限制。4.3 system 指令被误杀早期版本为了省 token我让 summary 把整个历史包括 system 都压进去。结果界面风格全乱模型开始把“你是客服助手”理解成“你是对话历史里的一句话”。后来我把 system 消息单独存在一个固定字段里构建 prompt 时永远放在最前面并且从不参与压缩。这里不是技术难是设计取舍系统指令的成本比重很小让它常驻是性价比最高的选择。4.4 摘要反复套娃在很长的对话场景里第一次压缩生成“摘要A”第二次压缩时把“摘要A后续内容”再压成“摘要B”第三次再压。几轮之后摘要里的信息密度会逐渐衰减甚至出现前后两版摘要互相矛盾的情况。我的做法是摘要内容不再参与下一次压缩。也就是说一旦某段历史被压缩成摘要它就被标记为frozen后面的压缩只在未压缩的新消息区域里进行。如果新消息区域也超限就创建第二条独立摘要而不是把所有摘要再合并压一遍。等对话结束或者进入下一轮会话时再考虑把多条摘要做一次总合。4.5 RAG 内容注入太多反而跑题接入文档问答后我以为多塞几篇文档能让答案更准确结果经常出现模型把无关文档里的信息也编进回答的情况。后来发现问题是top_k设成了 10检索出的第二段和第四段根本和问题无关但模型会“礼貌地”把它们都当成依据。调整方案先把top_k降到 3 到 5同时检索前先对问题做一次意图粗分类只在确定的类别范围内检索。另外在 prompt 里明确写“只依据有明确来源编号的内容作答无法回答时直接说明”。这些规则的效果立竿见影跑题率明显下降。5. 从 context-mode 走向记忆系统5.1 区分上下文与记忆调试久了我越来越觉得context-mode和“记忆系统”不是一回事。上下文是“每次请求时组装的临时信息”记忆是“长期稳定沉淀下来的事实”。比如用户的工作单位、常用偏好、历史订单这些属于记忆不应该每次都靠压缩历史来恢复。更好的结构是把记忆库独立出来用一条system 消息或一个memory字段承载用户的核心画像每次构建上下文时都会带进去。而history部分只承载当前会话的动态过程。这样即使历史消息全被压缩掉模型也不会丢掉用户最基础的信息。5.2 事前提取关键事实如果想让记忆更稳定可以在每次对话结束后单独跑一个“记忆提取”步骤把对话里值得记住的事实抽成结构化条目存进数据库。下次对话开始前从库里检索相关条目拼进上下文。我在项目里用的记忆条目格式是(实体, 属性, 值, 更新时间)比如(用户, 预算, 5万, 2025-06-01)。检索时用当前用户的问题去匹配实体匹配到的条目直接生成一句“用户最近的预算上限是5万”。这比把所有历史都塞给模型要省太多 token而且信息更精准。5.3 上线后的持续优化context-mode不是一次做完就完事的模块。我每两周会做一次“上下文审计”随机抽 50 个真实请求检查每次请求里上下文的组成比例、压缩触发率、用户反馈差评率。如果某类场景的压缩触发率特别高说明对话流程可能需要拆分成多个子任务而不是让一个巨型上下文硬扛。还有一个更直接的优化方向把“用户主动要求的保留信息”和“系统自动压缩的历史”做关联分析。比如用户有 3 次说“上次说的那个方案”如果这三次都发生在摘要压缩之后那就说明摘要里的关键信息残留不足需要调整摘要生成策略。这类反馈闭环才是context-mode真正能持续发挥价值的地方。这段时间反复调context-mode我最大的感受是它没有银弹任何模式都是取舍。全量直通扛不住长期对话滑动窗口省了 token 丢了关键信息摘要压缩要防失真RAG 要防检索噪声。真正能稳定跑下去的系统一定是多种模式组合并时刻盯着 token 预算和日志反馈来微调。把我上面这些经验直接拿过去用至少能少踩一半的坑剩下的一半就得靠你自己在真实流量里慢慢试出来了。