LLM上下文管理新模式:三种策略平衡Token成本与多轮对话质量
做AI对话项目最让人头疼的往往不是模型本身选得不好而是上下文怎么管。我上个月在重构一个知识库问答机器人时就把上下文这件事做成了独立的模块项目代号就叫context-mode。这个模块不复杂但把之前一锅粥式的消息拼接改成了三种可切换的运行模式直接让token消耗明显降下来多轮对话的稳定性也头一回变得肉眼可见。如果你正在做智能客服、Agent或者任何带多轮记忆的LLM应用这篇文章大概率能给你省几晚上的调试时间。我会把设计思路、模式切换的细节、代码骨架和踩坑记录都放出来照着改就能用。1. 整体设计与思路拆解1.1 为什么要给上下文做模式先聊一个扎心的现状很多人在做LLM应用时对上下文的管理方式就是把所有历史消息一股脑塞给模型。demo没问题一上线就完蛋。因为历史消息会像雪球一样越滚越大token成本直线上升模型注意力被无关信息稀释回答质量越来越差响应延迟也越来越高。我见过最夸张的一次一个客服机器人跑了一个月后单轮请求的token数从初始的1.8K涨到了42K而用户的真实诉求其实十句话就能说清。这里有个核心矛盾上下文太短模型记不住关键信息上下文太长模型找不到关键信息。两边都是坑。于是我想到了一个类比一个靠谱的会议记录员会分场合用不同的记法。开头脑风暴会他会鼓励大家自由发言尽量记全开进度同步会他只记结论和待办做项目复盘时他要提炼核心决策过程和避坑经验而不是把每个人的每句话都翻出来。这就是context-mode的雏形——根据任务场景和会话状态动态决定该把什么上下文、以什么密度、投喂给模型。1.2 三种基础模式的定义与适用场景我把context-mode拆成三种运行模式分别对应三类最常见的对话场景。compact精简模式只保留system prompt、最近N轮对话的结论摘要、以及结构化提取出的关键实体信息。适合高频、短任务、目标明确的场景比如天气查询、订单状态查询、简单的FAQ问答。在这个模式下上下文体积可以压得很小我的实践里通常能控制在4K token以内响应速度非常快。normal标准模式保留最近的原始对话消息同时维护一个滚动更新的摘要块摘要块负责记住更早的信息。适合大多数客服、助手类应用既能保证最近几轮的原始细节不丢失又不会让全文历史占据窗口。这是我最推荐默认使用的模式也是后面代码骨架里我会重点展开的部分。extended增强模式尽量保留完整的对话历史包括中间推理过程、用户修正记录、情绪化表达等。只在需要深度推理、复杂问题拆解、多文档综合对比时使用。这个模式的token开销大我通常把它作为按需开启的选项比如用户在问题里带了综合一下之前所有讨论这类强需求时再切过去。三种模式不是拍脑袋定的而是从成本、质量、延迟三个维度权衡出来的结果。下面这张表是我总结的选型依据可以直接抄维度compactnormalextendedtoken消耗相对normal约40%基准约3倍以上多轮细节保留弱只保留结论强保留最近原始消息极强保留全文单轮响应速度最快快慢大上下文下可能翻倍适用任务类型FAQ、单步查询、意图识别智能客服、通用助手复杂推理、长文档分析、多轮任务规划推荐默认开启按场景手动切默认临时开启1.3 为什么不做全自动无限压缩有人会问既然摘要能压缩为什么不做成完全自动的模式让系统自己决定一切我也试过结论是全自动压缩在复杂场景下非常不可控。摘要是有损的多轮压缩后信息失真会累积而模型对该记什么的判断在长对话后段会明显波动。更关键的是全自动逻辑很难解释——用户问为什么你把之前的讨论丢了你总不能只甩一句模型觉得不重要。所以我的设计原则是自动判断做一半把决策权交给调用方。系统负责建议比如检测到上下文快满了返回一个switch_required信号但最终切不切、切到哪种模式由上层应用或者用户显式指令决定。这样既保留了灵活性也能在出问题时有清晰的日志用于回溯。还有一个容易被忽略的原因模式切换如果发生在对话中途必须有一份协议来保证不丢关键信息。自动模式容易在用户毫无感知的情况下触发切换一旦摘要质量翻车用户根本找不到原因。改为显式或半显式切换后每一条切换记录都对应一次审计事件排障效率提升一个量级。2. 核心细节解析与实操要点2.1 消息缓存与版本化存储context-mode的底层存储我建议不要直接用Python list把消息串起来而是做一个带版本号的环形缓冲。每个消息记录四个字段role、content、created_at、seq_no。seq_no全局递增用于判断消息顺序版本号则记录这条消息是在哪次模式切换之后产生的。为什么用环形缓冲因为无论什么模式都需要对上下文长度设置硬上限。环形缓冲天然支持旧消息滚出配合摘要块就能实现分层记忆。每次模式切换时我会把当前缓冲区整体打包成一个snapshot记录模式、消息范围、摘要版本和token预算存入一个独立的history表。这样做的好处是如果新模式下模型回答质量下降可以快速回滚到切换前的状态不需要重新拼装上下文。这里有个细节很多人会漏掉环形缓冲滚出的旧消息不是直接扔掉而应该先进入待摘要队列或者关键信息抽取队列。否则信息就会在不知不觉中消失你根本不知道模型哪句话是从哪一段记忆里推断出来的。2.2 压缩策略的三条线并行压缩是context-mode的核心动作。我把压缩策略拆成三条线并行执行而不是只依赖某一种手段。第一条线是摘要压缩。定义一个摘要生成器把一段消息列表交给模型让它输出200-400字的结论性摘要。摘要要按信息类型分开记用户诉求、系统动作、关键数据、待办事项。这样后续查询时能精准定位而不是在整段摘要里翻找。第二条线是关键实体与记忆抽取。用一个小模型或规则抽取器把用户提到的订单号、地址、喜好、约束条件等结构化信息抽出来存成独立的memory entity。比如用户说我不喜欢太辣的菜这条要单独存而不是混在摘要里。因为摘要可能被后续压缩覆盖但实体记忆应该跨会话保留。第三条线是语义去重。多轮对话里经常会重复出现类似问题比如用户反复问那退款大概多久到账。如果不做去重模型每次都会把同样的现成答案的上下文再背一遍。我实现了一个轻量级的相似度判断超过阈值就只保留最近一次出现的位置和结果之前重复的细节不再放进上下文。三条线执行完后还要做一次兜底如果压缩后的套餐依然超过当前模式的token预算就强制走滑窗截断从最旧的消息开始丢弃同时生成一条truncation_notice放入meta字段告诉上层这里发生了截断避免模型在无提示的情况下产生幻觉式回答。2.3 切换触发逻辑阈值与意图双通道模式切换的触发条件我设计成双通道硬触发和软触发。硬触发只看token水位。每个session维护一个token计数当一个模式下的消息区占用达到该模式预算的70%时系统返回一个warn信号达到90%时返回switch_required信号。上层应用接到switch_required后就可以决定是否切到更省token的模式。阈值的选择是有讲究的70%是为了预留切换过程本身要消耗的临时token打包快照、生成摘要都要额外调模型90%则是为了避免切换还没完成就在窗口层面被模型截断。软触发看意图和任务特征。我会在每次用户请求进入时跑一个很轻的意图分类可以是规则匹配也可以是小型分类模型判断用户是在做简单查询、复杂分析还是要求回顾整个会话过程。简单查询且当前模式是extended时建议下调模式复杂分析和全文回顾建议上调模式。这些建议同样只是recommendation不会自动执行。手动切换同样是必须支持的给用户一个API参数或者会话指令比如在对话框输入/mode compact。我建议保留手动通道因为自动判断无论如何都有延迟用户自己最清楚他接下来要问什么。把自动建议和手动覆盖放在一起才是完整的触发逻辑。2.4 切换不丢信息的转移协议模式切换最怕的就是丢信息。我设计了一个三步转移协议每次切换都强制执行。第一步是冻结把当前session的所有消息、摘要块、实体记忆、以及当前模式下未完成的token用量全部写入快照。第二步是构建载荷根据目标模式从冻结的数据里生成一个新的上下文套餐这个过程会调用摘要生成器做增量压缩而不是重新从零压缩。第三步是校验对比切换前后关键实体是否完整比如把用户最近提到的三个实体和系统最近承诺的三个动作拉出来逐一核对只要有一处丢失直接触发回滚。这套协议说不上精妙但非常有效。我在实测中发现的绝大多数切换后失忆问题都不是模型变笨了而是切换时没有把关键信息放进新的上下文套餐。有了转移载荷的校验步骤这个问题从偶发变成了几乎不会出现。实操中有个技巧要提醒切换动作本身也是一条系统消息要进入上下文比如system: context mode changed from extended to compact, key info preserved: ...。这条消息成本很低但能显著降低模型在切换后的困惑感尤其是在Agent类应用里模型需要知道自己处于什么状态才能决定接下来怎么行动。3. 实操过程与核心环节实现3.1 模块划分与目录结构我用Python做了个最小实现目录大概长这样context_mode/ ├── __init__.py ├── models.py # 数据模型定义 ├── manager.py # 核心管理逻辑 ├── mode_selector.py # 模式选择建议 ├── compressor.py # 摘要与压缩策略 ├── memory_store.py # 实体记忆存储 └── middleware.py # 与LLM调用链路的集成这个分层逻辑很直接models定义数据结构manager负责状态流转compressor负责做出压缩动作memory_store负责跨模式保留关键实体middleware则把前面所有东西接到真实的LLM请求流程里。我强烈建议把middleware单独拎出来不要在业务代码里直接调用manager否则以后想复用到其他项目会很痛苦。3.2 核心代码骨架先看数据模型。我用dataclass定义了一个ContextPacket它是每次请求真正发给系统的上下文套餐。这个套餐是按请求构建的而不是按会话全局构建的这样每次请求都能根据current_mode灵活调整不需要维护一个庞大的全局对象。from dataclasses import dataclass, field from enum import Enum from typing import List, Optional class ContextMode(str, Enum): COMPACT compact NORMAL normal EXTENDED extended dataclass class Message: role: str # system / user / assistant content: str seq_no: int created_at: float dataclass class SummaryBlock: block_id: str content: str info_types: List[str] # demands / actions / data / todo token_estimate: int version: int dataclass class EntityMemory: key: str value: str updated_at: float dataclass class ContextPacket: mode: ContextMode system_prompt: str summary_blocks: List[SummaryBlock] recent_messages: List[Message] entity_memories: List[EntityMemory] meta: dict field(default_factorydict)再看manager的切换方法。核心是switch_mode它按上一节说的三步协议实现冻结、构建载荷、校验。为了简洁校验部分我用一个函数extract_key_facts来提取可核对的信息实际工程中你可以用规则或者一个很小的模型。import json class ContextManager: def __init__(self): self.mode ContextMode.NORMAL self.messages: List[Message] [] self.summaries: List[SummaryBlock] [] self.entities: List[EntityMemory] [] self.snapshots [] def switch_mode(self, new_mode: ContextMode, compressor): # step1: freeze snapshot { old_mode: self.mode, messages: self.messages, summaries: self.summaries, entities: self.entities, } self.snapshots.append(snapshot) # step2: build payload new_system compressor.refine_system_prompt(snapshot, new_mode) new_summaries compressor.incremental_compress(snapshot, new_mode) new_entities self.entities kept_messages compressor.select_recent_messages( self.messages, new_mode ) # step3: validate old_facts extract_key_facts(snapshot) new_facts extract_key_facts({ summaries: new_summaries, entities: new_entities, messages: kept_messages, }) missing old_facts - new_facts if missing: # automatically rollback self.messages snapshot[messages] self.summaries snapshot[summaries] self.entities snapshot[entities] raise RuntimeError(fswitch failed, missing facts: {missing}) # apply new state self.mode new_mode self.summaries new_summaries self.messages kept_messages self.entities new_entities self.messages.append(Message( rolesystem, seq_noself.next_seq(), contentfcontext mode changed to {new_mode.value}, created_attime.time(), )) return self.build_packet()说句实话switch_mode里的step1到step3没有一行是高深的算法但它能系统性解决我前面说的信息丢失问题。很多项目都栽在切模式就是换个参数的简单认知上结果上线一跑对话稍微长一点就故障频发。3.3 参数计算与阈值选择context-mode里最容易被问的是token预算怎么分我用的是一个四区分配法只针对有128K窗口的主流模型做示范你根据自己的模型窗口按比例缩放即可。假设窗口上限128K我把它切成四块。system区10K放系统提示词和工具定义摘要区20K放增量压缩后的摘要块对话区70K放最近的原始消息预留区28K留给模型输出和切换时临时生成的中间数据。四区合计128K正好占满窗口但实际调用时我会只送前三区内容输出区不送而是靠max_tokens参数来兜底避免模型生成过长内容把窗口撑爆。触发阈值按区计算。当对话区消息量超过70K的70%也就是约49K时系统给warn超过90%也就是约63K时给switch_required。摘要区超过20K时就把最老的摘要块再合并压缩一轮把两段合成一段腾出空间。这个预算分配不是死的我见过很多偏RAG的项目会把system区压到3K把摘要区放到30K因为那类场景需要模型同时记住多个文档片段。关键不在于数字而在于你要显式地给每个区定预算否则上下文增长就是无约束的context-mode再智能也无法兜底。你可以把预算参数放到配置文件里每次调整后跑一轮回归测试比靠感觉随手调要靠谱得多。3.4 与LLM调用链路的整合前端代码调LLM的逻辑我建议通过一个中间件统一接管。这样业务代码不需要感知context-mode的存在只需要声明自己要什么模式剩下的交给中间件。def build_request(user_input: str, context_manager: ContextManager, compressor, llm_client): packet context_manager.build_packet() # append user request message packet.recent_messages.append(Message( roleuser, contentuser_input, seq_nocontext_manager.next_seq() )) # assemble final payload messages [ {role: system, content: packet.system_prompt}, *[{role: mb.role, content: mb.content} for mb in packet.summary_blocks], *[{role: mb.role, content: mb.content} for mb in packet.recent_messages], ] # attach entity memories as compact info if packet.entity_memories: entity_str \n.join( f{e.key}: {e.value} for e in packet.entity_memories ) messages.insert(1, {role: system, content: f[memory] {entity_str}}) response llm_client.chat.completions.create( modelyour-model, messagesmessages, max_tokens4000, ) return response, packet这里有个细节值得注意entity_memories我放在system之后、summary之前并且明确用[memory]标记。这能让模型区分长期记忆和当前对话摘要避免把旧信息和近期信息混在一起。我在对比测试里发现加了这行结构标签后模型对持久性信息的利用率明显提升尤其是客服场景下用户提前说过上次改过地址这类信息时模型不再当成一次性上下文来处理了。调用完成之后中间件还要负责把assistant的回复追加回manager更新token计数然后判断是否需要触发warn信号。整个闭环走完一次请求才算结束。4. 常见问题与排查技巧实录4.1 切换后AI失忆怎么办这是context-mode上线后被问得最多的一个问题。现象是normal切到compact之后模型开始不记得用户十分钟前说的地址了。第一反应是摘要压缩丢了信息但我的排查经验是先别急着怀疑模型先去看切换时生成的transfer payload。把payload里放给模型的所有内容逐条对一遍确认三个位置summary_blocks里有没有包含用户地址变更这件事entity_memories里有没有新增地址实体recent_messages里还有没有保留包含地址的那条原始消息。大多数情况下问题出在我之前说过的那个校验步骤没做——实体提取只关注了订单号这类白名单字段地址不在白名单里于是被漏掉了。修复方式有两种一是扩充实体提取规则把所有可能在下游被引用到的高频实体都纳入白名单二是把校验逻辑从白名单比对升级为摘要比对实体比对双保险。前者成本低见效快后者更稳健适合生产环境。我建议按你的业务字段重要程度分两批处理紧急的先加白名单长期的再沉淀成一套独立的校验函数。4.2 摘要压缩导致信息失真摘要失真是另一个高频问题典型表现是压缩前模型能准确说出用户要求周一发货压缩后变成用户要求尽快发货日期信息被吞了。根因在于摘要生成时没有提示模型保留所有数值、日期、否定词而这些恰恰是对话中最重要的信息。我的解决方法是给摘要生成器加一个元指令提取摘要时把所有数字、日期、价格、人名、否定表达原样保留宁可句子不完整也不要改写关键数值。同时在经过摘要模型之后加一道正则扫描把数字标记出来和原始消息对照缺失的自动触发重新摘要。这道数值保全处理后失真率明显下降。如果你用的是大模型做摘要建议在prompt里加上禁止归纳数字遇到数字必须原样输出这一类硬约束。别觉得这是废话模型不做约束时就是会偷偷把数字改成约数这是生成模型的天性。4.3 模式频繁切换导致对话抖动有一次我在Agent场景里用户连续问两个问题中间被系统建议切换了两次模式结果Agent的行为出现了肉眼可见的混乱先是详尽回答然后又开始言简意赅用户的提问被理解得七零八落。排查后发现是软触发逻辑过于敏感用户每个问题都会跑一次意图分类分类模型在两个标签间反复横跳于是推荐切换的指令也反复横跳。解决思路是引入冷却时间。同一session内相邻两次模式切换之间至少间隔M分钟或者间隔N条消息。我在manager里加了一个last_switch_at字段switch_mode方法进入时先检查时间戳不满足冷却条件就直接拒绝。同时把软触发升级为三次一致才切换比如连续三次意图分类都指向同一个模式才发出recommendation这样能有效避免单次误判带节奏。4.4 避坑技巧速查表把我在实测里沉淀的经验整理成了一张速查表遇到问题直接对照排查现象优先排查项常见根因推荐处理切换后关键信息丢失transfer payload实体白名单覆盖不全扩充白名单增加数值保全扫描摘要后数值被改写摘要原文与原始消息对照摘要模型未保留数字加数值保护prompt定时运转正则核对模式频繁切换意图分类日志软触发过于敏感加入冷却时间三次一致才切换超长上下文仍被截断token预算计算各区预算分配不合理检查summary区与dialog区占比重新分配切换后模型回答风格突变切换记录与system消息模型未感知状态变更在切换时植入系统消息提示当前模式多轮对话情绪信息丢失entity_memories只存了实体没存用户偏好将用户偏好纳入实体记忆持久化这张表建议贴在你项目文档的FAQ位置团队里任何人遇到问题都能先自查不用每次都把你从写码状态里拉出来。5. 实测对比与可复用的观察指标5.1 三种模式在同一批测试集上的表现我用自己项目里300条真实咨询记录做了一次对比测试三种模式各跑一轮。结果在预期之中但有两组数据值得细说。第一组是token消耗normal模式的均耗为9.2Kcompact为3.8Kextended则达到了28.7K。compact在token上的优势规模非常大适合预算敏感的业务。第二组是任务完成度的差异在需要精确复述用户原始信息的任务里compact的准确率明显下滑而extended在复杂分析类任务里质量领先但在简单FAQ场景下它不仅没提升反而因为携带了大量无关历史回答反而跑偏了。这说明了什么问题模式选择没有绝对的好用只有匹配场景的合适。我建议每个项目上线前都准备这么一套自测集里面至少包含20条需要多轮记忆的任务、20条单轮简单任务、20条需要重读历史细节的任务分别跑三种模式把结果存档。以后每次调整context-mode逻辑直接拿自测集回归效率高得不是一点半点。5.2 什么场景该锁死什么模式在真实的业务系统里我不建议完全依赖动态切换。对于一些场景完全可以锁死模式减少不必要的切换和判断开销。比如内部工单系统的自动回复用户诉求明确、流程固定锁compact比如售后客服机器人用户可能要同时说退款、换货、投诉三个诉求锁normal并加强实体记忆再比如招聘场景的简历评审助手需要综合多段候选人对话和历史反馈锁extended。锁死模式还有一个好处模型行为在同一场景内保持一致用户体验稳定。动态切换适合会话内容边界清晰、用户意图变化明显的应用如果拿不准先从锁模式起步跑两周日志后再逐步放开自动切换比一上来就全自动要稳得多。5.3 后续可以扩展的方向context-mode这套东西目前只服务单会话内。往长远看它完全可以跟会话级记忆打通把entity_memories沉淀到用户画像里跨会话复用summary_blocks也可以做成全局知识库让不同session之间共享已经压缩好的结论。我还没在项目里完全落地这部分但已经把结构设计好了未来迁移成本很低。另一个可扩展的方向是把模式选择器和Agent的规划器结合起来。现在的Agent在做任务前可以先让规划器评估任务的复杂度再反过来建议context-mode应该切到哪一档。这个思路我已经在实验环境里跑通了效果还不错。不过内容已经够长了这部分就不展开细说。最后分享一个我个人的实操体会context-mode真正难的不是代码而是你对每一轮对话哪些信息必须被记住的判断。这个判断只能靠业务场景的长期浸泡来积累没有任何框架能替你自动完成。所以我的建议是先跑起来然后盯日志尤其是切换前后模型行为的差异慢慢你会摸清自己的业务到底适合哪种模式组合。上面的代码骨架和排查表可以直接拿去用有任何细节问题欢迎在评论区交流。