首页 / 资讯中心 / 文章详情

LLM上下文管理攻略:context-mode策略与实战解析

LLM上下文管理攻略:context-mode策略与实战解析 ★ FEATURED ARTICLE
做LLM应用开发的人几乎都会在某个深夜突然被一个问题卡住上下文到底该怎么管我最早入坑时天真地以为把聊天历史一股脑塞给模型就完事了结果第8轮对话直接给我报“token limit exceeded”再往后做RAG和Agent问题更严重——不是超限就是模型“失忆”。后来折腾了很多方案才发现圈子里默认的那个“context-mode”其实是一整套关于上下文窗口的分配和管理策略远不止“截取最近N条消息”那么简单。这篇文章我就把这块积累的东西完整整理一遍。主要聊清楚context-mode是什么、它在解决什么问题、主流的几种实现策略分别适合什么场景然后给出一套可以直接抄作业的轻量级实现方案最后说说我实际踩过的坑和排查思路。无论你是在调Chat接口做聊天机器人、搞知识库问答、还是做Agent多轮规划这篇都能帮你少走不少弯路。1. 拆解context-mode它不是开关是一套策略1.1 一句话理解它context-mode翻译过来是“上下文模式”说白了就是决定“哪部分历史信息能进入模型视野、以什么形态进入”的规则集合。我更喜欢把它理解成一个内部记忆团队的管理员模型上下文窗口像一块有限的“工作记忆内存”你需要在“保留哪些信息”“压缩哪些信息”“扔掉哪些信息”之间做取舍。这个取舍策略就是context-mode。最早接触这个概念是在一些开源框架的配置项里像LangChain的chat_history处理方式、一些Agent框架里标注的ContextMode.Window、ContextMode.Summary它们都指向同一个核心命题在token预算有限的情况下如何最大化信息的有效密度。1.2 为什么“全塞进去”是灾难很多人觉得上下文窗口不是越来越大吗从4k到32k到128k全塞进去不就行了但实际跑过就知道这条路走不通原因有三条成本不是线性的是叠加的。大部分商业模型的计费方式是输入token 输出token都算钱。每轮对话你都把完整历史重发一遍意味着多轮之后每一轮请求的输入token都在涨。做一个简单的乘法假设单轮平均输入2000 token进行50轮最后一轮你如果全量携带历史输入就是大约10万token——这个成本会快速失控。我在实际业务里测算过不做任何压缩的全量历史方案对话超过20轮后单次请求的费用是前5轮的20倍以上这在生产环境根本扛不住。模型注意力是有限的。现代Transformer架构的注意力机制虽然能处理长序列但大量研究表明模型对中间位置的上下文利用效率显著低于开头和结尾。这个现象大家叫它“Lost in the Middle”。上下文塞得越长越容易发生你明明在20轮前告诉过模型自己的名字它后面还是叫你“用户”或者一处用户偏好被埋在长对话中段模型就把它彻底忽略了。我实测过一轮长对话测试把一段关键约束放在2万token的中段位置模型准确遵循率比放在开头低了将近30个百分点。延迟和稳定性同样受影响。token数量直接推高预填充时间首字延迟从几百毫秒上涨到几秒对实时交互类的产品体验是致命的。另外输入越长单次请求失败的概率也越高网络层面多一分不稳定因素。所以context-mode真正要解决的是在一个有限的“记忆容量”里怎么放进最有价值的信息。1.3 五种主流模式对比根据信息取舍方式的不同我把实际见到的context-mode策略归纳为五类先给你一个总览表模式核心思路优点缺点适用场景全量模式所有历史原样保留信息无损成本高、易超限、长上下文注意力衰减token预算充足的短会话滑动窗口模式只保留最近N轮实现简单、成本稳定早期信息完全丢失闲聊、临时会话、日志类场景摘要压缩模式把早期历史提炼为摘要信息密度高、成本可控摘要会丢失细节、有压缩损耗多轮客服、长会话、Agent规划混合模式摘要兜底最近窗口保细节兼顾长期记忆与短期细节实现复杂度高生产级对话系统我比较推荐这个检索增强模式从向量库中按语义召回相关内容理论上支持无限历史有检索失败风险、依赖embedding质量RAG问答、知识库场景、超长会话这五种模式对应的核心代码权重分配我会在后面实操部分展开。先记住一句话没有最优的模式只有最匹配当前场景的模式。2. 策略设计三个关键参数决定context-mode的生死2.1 预算分配上下文空间到底怎么分无论选哪种模式第一步都是先定预算。所谓预算就是给模型上下文窗口里各类内容分配的token上限。我习惯把上下文空间看成一张分区表选混合模式时固定分四份系统指令区固定的角色设定、行为规则占用最少通常512 token以内。摘要区早期对话压缩后的高密度信息约占总预算的20%-30%。最近窗口区最近N轮对话的原始文本占总预算的40%-50%。检索区如果开了检索模式动态召回的相关片段占总预算的10%-20%。举个具体例子。假设我用的是8k上下文的模型硬上限是8000 token但我会在代码里把max_context_tokens设为7500留出500 token的缓冲。这500 token是给模型生成回复时的输出空间预留的不然请求还没发出去模型就已经被打到天花板了。分区时这样算系统指令初始化时嵌入大约300-500 token摘要区max_tokens × 25%即约1875 token最近窗口区max_tokens × 45%即约3375 token检索区仅当开检索模式时启用约937 token动态预留剩下的几百token做弹性空间应对偶尔超长的消息。这个比例不是拍脑袋定的是我的一个经验总结摘要区太小压缩就会反复触发并且丢失大量信息摘要区太大又会挤压最近的对话细节导致模型对近期意图感知变弱。25%是多次测试下来比较平衡的水位线。2.2 压缩触发时机别等满员了才行动什么时候触发摘要压缩很多人写代码时简单粗暴上下文满了才压缩。这不是好做法。我推荐的策略是设置一个压缩阈值水位线通常为预算的75%-80%。为什么因为压缩操作本身需要消耗token——你要先把历史文本发送给模型生成摘要这一步的输出也要算钱、也要占用上下文。如果你等到100%满员才压缩很可能请求在压缩执行前就已经超限了或者压缩过程本身就爆了。我自己在代码里这样设定compression_threshold_ratio 0.8也就是当当前token数超过max_context_tokens的80%时启动一次压缩。动手压缩时我还会加一个压缩后最低保留比例的判断逻辑如果压缩完的结果仍然超过当前token数的90%说明这轮对话的信息量太大摘要压缩已经无法解决问题了这时需要走“强制裁剪窗口”的兜底分支直接丢弃最旧的非关键消息。2.3 检索的top_k怎么选如果你走检索增强模式一个万能的top_k参数背后其实藏着不少门道。选多少取决于三件事单个片段的平均token数、检索区的预算上限、你期望的语义覆盖率。假设你的检索区预算是1000 token你的知识库切片平均每段250 token那么top_k理想值是4。但这里有个反直觉的点top_k越大虽然覆盖的语义面更广但引入低相关内容的概率也越高反而拉低回答质量。我的经验是分两步走。先按相似度召回top 20候选然后用一个轻量的重排器或者干脆用LLM本身做零样本重排选top 4-6真正核心的片段进入上下文。追求召回数量之前先保证每一条召回都足够精准。另外时间相关性也很重要用户刚提过的东西比三天前他随口说过的话优先级要高得多。所以在检索打分时我会在向量相似度里叠加一个时间衰减权重final_score similarity_score time_bonus time_bonus recency_decay_factor / (1 hours_since_event)太远的历史基本只剩下很低的加成近期信息则能顺利进入检索区。3. 实操手写一个轻量级context-mode管理模块3.1 基础架构设计这一段我直接给你一套可运行的代码思路不要单纯用框架封装好的黑盒我们要能自己掌控核心逻辑。模块化设计上拆成三个类MessageStore消息的原始存储负责记录所有对话历史以及对应的时间戳。ContextManager核心调度器负责token估算、预算分配、触发压缩、组装最终prompt。Compressor压缩器封装摘要生成和检索召回的逻辑。核心接口对外就三个add_message()、build_prompt()、maybe_compress()。3.2 核心代码实现先上核心代码用Python写一个简化但完整可跑的版本。注意这里我假设你已经在环境变量里配好了OPENAI_API_KEY和OPENAI_BASE_URL。import os import json from typing import List, Dict, Optional # 省去自行实现tokenizer的麻烦直接调openai的tiktoken做估算 import tiktoken class MessageStore: def __init__(self): self.messages: List[Dict] [] self.msg_id 0 def add(self, role: str, content: str) - Dict: record { id: self.msg_id, role: role, content: content, timestamp: self.msg_id, # 简化处理用递增序号代替时间 } self.msg_id 1 self.messages.append(record) return record def get_all(self) - List[Dict]: return self.messages这里消息记录同时保存了角色、内容、序号和时间戳。时间戳对后续检索模式的时间衰减很有用。接着是压缩器负责把早期对话浓缩成摘要。class Compressor: def __init__(self, modelgpt-4o-mini, max_summary_tokens1800): self.model model self.max_summary_tokens max_summary_tokens def summarize(self, messages: List[Dict]) - str: if not messages: return prompt ( 请将以下对话压缩为一段高信息密度的摘要 保留所有关键事实、用户偏好、未完成的事项和重要上下文。\n\n json.dumps(messages, ensure_asciiFalse) ) response call_llm([ {role: system, content: 你是对话摘要引擎。}, {role: user, content: prompt} ], max_tokensself.max_summary_tokens) return responsecall_llm是一个通用调用函数你可以用自己的SDK或者HTTP请求实现核心诉求就是能拿到大模型返回文本。再接下来是核心的ContextManager。class ContextManager: def __init__( self, mode: str hybrid, model: str gpt-4o-mini, max_context_tokens: int 7500, system_prompt: str , ): self.mode mode self.model model self.max_context_tokens max_context_tokens self.system_prompt system_prompt self.store MessageStore() self.compressor Compressor(modelmodel) self.summary self.summary_token_budget int(max_context_tokens * 0.25) self.recent_window_budget int(max_context_tokens * 0.45) self.compression_threshold int(max_context_tokens * 0.8) self.encoding tiktoken.encoding_for_model(model) def _count_tokens(self, text: str) - int: return len(self.encoding.encode(text)) def add_and_check(self, role: str, content: str): self.store.add(role, content) if self._current_usage() self.compression_threshold: self.maybe_compress() def _current_usage(self) - int: sys_tokens self._count_tokens(self.system_prompt) summ_tokens self._count_tokens(self.summary) recent self.store.get_all() recent_tokens self._count_tokens(json.dumps(recent, ensure_asciiFalse)) return sys_tokens summ_tokens recent_tokens def maybe_compress(self): messages self.store.get_all() if len(messages) 2: return old_messages messages[:-2] # 最新两轮保留原文 self.summary self.compressor.summarize(old_messages) # 压缩后原始早期消息就可以从主存储里移除改挂到归档区 self.store.messages messages[-2:] # 关键一步同步压缩摘要长度到预算内 if self._count_tokens(self.summary) self.summary_token_budget: self.summary self.summary[: int(self.summary_token_budget * 2.5)] def build_prompt(self) - List[Dict]: prompt_messages [] if self.system_prompt: prompt_messages.append({role: system, content: self.system_prompt}) if self.summary: prompt_messages.append({role: system, content: 历史摘要 self.summary}) recent_messages self.store.get_all() for msg in recent_messages: prompt_messages.append({role: msg[role], content: msg[content]}) return prompt_messages def retrieve_from_archive(self, query: str, top_k: int 4): # 检索逻辑简化实现按关键词包含度返回 archived get_archived_messages() scored [] for msg in archived: score self._similarity(query, msg[content]) time_bonus 1.0 / (1 (self.store.msg_id - msg[id])) scored.append((score time_bonus, msg)) scored.sort(keylambda x: x[0], reverseTrue) return [m for _, m in scored[:top_k]]这段代码里最核心的部分是maybe_compress触发时把除最近两轮外的所有消息打包喂给摘要模型生成摘要然后清空早期原始存储。这个“最近两轮”就是窗口模式的窗口大小你可以根据自己的对话频率调整成5轮甚至10轮没有固定标准。3.3 接入大模型的完整示例拿上面这个模块接一个真实的对话循环大约长这样cm ContextManager( modehybrid, system_prompt你是一位耐心的技术客服助理回答简洁准确。 ) while True: user_input input(用户: ) if user_input.strip() exit: break cm.add_and_check(user, user_input) prompt cm.build_prompt() # 这里假设call_llm就是你的模型调用封装 answer call_llm(prompt) print(助手:, answer) cm.add_and_check(assistant, answer)跑起来之后你会发现当对话累计到一定长度maybe_compress会自动触发一次摘要生成然后后续每轮调用的token数就稳定在预算区间内不再线性上涨。这个“成本收敛”的效果是context-mode最核心的价值所在。4. 实战中的坑与排查技巧实录4.1 摘要丢失关键实体运行一段时间后我遇到最普遍的问题是摘要模块把用户提过的关键实体给丢了。比如用户某天提到“我们公司用的数据库是PostgreSQL 14在华东区有三个实例”结果摘要压缩后只留下“用户公司使用PostgreSQL”后面模型对这个信息的引用一下就变得模糊了。排查后发现原因是压缩Prompt里没有强调“保留专有名词和数字参数”。加了一句“必须保留所有专有名词、版本号、数字、日期和精确名称”之后实体丢失率明显下降。后来我更进一步把摘要输出设计成结构化格式用JSON包裹{ key_facts: [用户公司使用PostgreSQL 14, 华东区三个实例], pending_tasks: [需要确认迁移时间窗口], user_preferences: [回复要求简洁] }结构化摘要比纯文本摘要好检索、好拼接也方便后续机器解析。4.2 检索命中但语义不连贯做检索增强模式时我遇到过一个奇怪的问题检索结果明明跟用户问题相关度很高但模型回答得前言不搭后语。后来发现原因是检索出来的片段缺少上下文语境——它是从某个长文档中间截取的开头没有引出话题结尾没有收束。解决思路有两个第一索引切片时保留“父文档标题”和“相邻段落”的上下文检索时把整组返回而不是只返回孤立的一小段。这个在LangChain里其实就是ParentDocumentRetriever的思路自己做也很简单索引时给每个切片记录parent_chunk_id召回子块时顺带把父块一并取出来。第二在prompt里明确告诉模型检索到的片段可能有部分冗余请结合整体信息作答。4.3 压缩后模型突然“失忆”最让人恼火的坑是压缩前模型还记得用户3轮前提出的需求压缩后它完全忘了一边说“根据历史摘要”一边在瞎猜。排查下来问题出在摘要质量上。摘要Prompt如果只写“压缩对话”模型会把重心放在情节概述上忽略那些“意图性很强但还未落实”的内容。比如用户说“我下周可能要去欧洲出差到时再聊合同细节”——这句话里的“未完成任务”属性很强但普通摘要很容易把它略过。所以我在压缩器中加了第二个强制要求“必须保留所有未完成事项、待确认问题和用户近期的明确意图”。加了这句话后失忆问题改善明显。4.4 踩坑小结速查表现象可能原因解决思路对话超过10轮后费用暴涨没有开启任何压缩模式切换hybrid模式并设置压缩水位线模型对早期信息记忆模糊滑动窗口过小增大窗口或升级为summary模式摘要后关键参数丢失压缩Prompt缺少强制保留项要求保留专有名词、数字、日期长对话后回复质量下降上下文过长导致注意力分散控制单次携带token在8k以内优先压缩检索关联度高但回答混乱片段上下文缺失索引时附带父文档上下文检索时整组返回压缩触发时请求超限阈值设得太高把触发阈值提前到预算的75%-80%5. 按场景选择三个真实业务案例的配置参考5.1 客服机器人混合模式是稳妥选择做客服机器人时用户情绪、诉求历史、售后单编号这些信息都很关键。我建议用混合模式摘要区保存用户的历史诉求和性格偏好最近窗口区保留当前会话中最近5轮对话的原始文本系统区固定客服角色规范。预算按 8k 上下文为例系统区 400 token摘要区 1800 token窗口区 3600 token其余做预留。我在这个配置下跑过大约200轮连续对话测试费用基本收敛也没有出现过用户历史诉求丢失导致的重开工单。原因很简单客服场景中用户常常来回补充信息摘要区能兜住早期背景而窗口区能保证最近需求不被压缩掉。两者各司其职配置就对味。5.2 知识库问答主走检索增强模式知识库问答是最典型的检索增强场景。每个查询进来先embedding化在向量库里召回top 20候选再重排取top 5拼入上下文。上下文预算分配上系统指令500 token、检索区1500 token、问题文本与最新一轮对话3000 token。这里我有个独门建议哪怕用了检索模式也要保留一个小的摘要区。因为用户往往会重复提问“我之前问过什么”如果没有摘要层级的历史记录检索难以覆盖这种意图模型就会胡编一个答案。加入摘要区后这种“元问题”的命中率大大提升。5.3 Agent多轮规划窗口摘要双通道Agent场景的特殊性在于它不仅要保存对话历史还要保存“执行轨迹”。工具调用的中间结果、状态变更记录、已经完成和未完成的任务清单这些最好单独维护不能混进用户对话历史里。我的做法是维护一个独立的plan_state结构每次工具执行完更新它然后由它生成简短的“执行状态摘要”放进上下文。用户对话历史仍然走混合模式。这样一来模型的“当前状态感知”和“历史对话记忆”分离非常清晰排查问题时也容易定位是状态更新错了还是历史压缩掉了关键约束。6. 一些可以继续深挖的扩展方向现在线上跑的生产系统在基础模块之上还可以做几个升级方向。第一个方向是自动调节预算比例。不要死守25%/45%这个比例而是根据实际对话长度动态调整。比如检测到当前会话短那摘要区可以缩小把资源让给窗口区如果会话很长反过来把窗口变窄给摘要腾空间。这个动态分配的逻辑可以用启发式函数实现不需要上机器学习那么重。第二个方向是分层摘要。单一摘要对大段历史信息密度不够可以隔一段长度就生成一个“区块摘要”多个区块摘要再汇总成一个“层摘要”。层级化之后检索时可以先命中顶层再逐层向下细化。这项工作做起来投入不小但对超长会话场景收益明显。第三个方向是用context-mode辅助评测。我后来发现context-mode的配置直接决定了评测集里“长对话记忆”指标的分数。所以如果你们团队做RAG或对话产品评测不妨把不同模式作为评测变量跑一遍——很多答案评测会直接告诉你的。具体还能怎么玩要看你的业务形态。但底层的思路是相通的一切选择要服从于信息密度和预算的平衡。最后说一点我这两年最深的体会吧——context-mode这个东西看起来是个技术实现细节本质上却是在跟“模型上下文有限”这个物理约束做博弈。没有任何一种模式是绝对免费的午餐全量模式胜在无损但输在成本摘要模式胜在密度但输在损耗检索模式胜在覆盖面但输在命中率。与其花大把时间找“万能模式”不如沉下心把预算分配、压缩触发、摘要质量这么几个细节打磨到位效果会稳定得多。后面我打算再基于这套代码扩展一个带API服务的完整示例到时候再来更新。如果你在上下文管理上也踩过什么冷门的坑欢迎随时找我聊聊。
阅读完成 · 觉得有帮助?
咨询建站