大模型上下文管理实战:context-mode设计与落地
做过大模型应用的人早晚都会撞上同一个问题上下文到底该怎么塞、塞多少、什么时候清空。我去年在做一个文档问答机器人时被这个问题折磨得不轻后来自己整理了一套叫“context-mode”的处理思路说白了就是把上下文管理从“凭感觉写prompt”变成一套有规则、有优先级的系统方案。这篇文章不聊虚的直接把这套模式的设计思路、落地步骤和踩过的坑都拆出来希望能给正在做RAG、Agent或多轮对话应用的朋友一些参考。1. context-mode 到底是什么先从一个翻车现场说起1.1 一个非常典型的翻车场景我手上的项目是一个面向内部员工的资料问答机器人后台挂了十几份产品手册和操作文档。第一版实现很简单用户问问题我们把所有文档片段一股脑塞进prompt交给大模型生成答案。结果上线不到半天反馈群就炸了。问题集中在三块。第一回答变得极不稳定同一个问题上午问和下午问结果不一样有时候甚至会漏掉最关键的说明第二响应速度肉眼可见地变慢普通问题要等十几秒才出结果第三费用涨得让人肉疼我们当时的模型按token计费每次调用都把几万字塞进去一天下来账单直接翻了几倍。后来排查发现根子就出在“上下文”这三个字上。我们以为给模型的信息越多回答就越准确但实际上模型能接收的上下文窗口是有限度的而且超长上下文会稀释模型对关键信息的注意力。这就像让一个实习生同时读一百页资料再回答一个问题他能记住的反而是那些边边角角的内容核心信息反而被淹没了。1.2 上下文窗口不是免费午餐先理清一个基本概念。大模型在处理输入时会把你的prompt和历史对话全部编码成token模型能“同时看到”的token数量就是上下文窗口。市面上主流的模型窗口大小从8k到128k不等看起来很大但真正留给业务逻辑的空间没有想象中那么多。举个例子一个128k窗口的模型如果你的系统提示词写了2k历史对话占了50k检索回来的文档片段占了40k那么留给模型思考和生成本次回答的空间就只剩36k。更关键的是上下文越长模型在自注意力计算时的开销越大推理时间会显著增加而且长文本中早期信息容易被后续信息“冲淡”这就是业界常说的“迷失在中间”问题。所以把上下文塞得越满效果反而越差。context-mode的核心思路就是不再把上下文当成一个能无限塞东西的袋子而是把它看成一个需要精细化管理的资源池针对不同的问答场景切换不同的上下文组装策略。1.3 几个典型的上下文模式在落地过程中我把上下文管理拆分成了几种模式这里先给一个直观的对比模式名称适用场景核心策略典型token用量短对话模式闲聊、寒暄、意图确认只保留最近2轮对话和基础系统提示约1k以内文档精读模式针对某一份文档的深度追问只注入该文档的相关章节用户当前问题3k~10k跨文档检索模式问题涉及多份文档检索top-k片段摘要按得分排序5k~15k长会话压缩模式多轮对话超过N轮把已讨论内容做摘要保留摘要最近几轮2k~8k固定模板模式定时任务、批量处理预设上下文结构不累加历史固定值用这个表当基础后面所有的代码和策略都在围绕“怎么自动判断该用哪种模式、每种模式里放什么内容”展开。2. 核心设计思路与关键决策为什么不能无脑全塞2.1 无脑全塞的三本账在讲具体实现前我先把这个项目里最重要的一个决策过程讲透为什么不能无脑全塞。算三笔账就清楚了。成本账。假设你的应用每天调用一万次每次平均多塞5000个token按当前主流模型的定价仅上下文增量这一项一个月就要多花差不多几千块。这不是夸张我第一次接到账单的时候还以为后台被人刷了。省token不是抠门是真金白银。质量账。模型处理长上下文的能力在提升但远没有到可以随便用的程度。我做了一组对比实验同一批测试问题在完整注入20k token上下文和精炼注入5k token上下文两种情况下后者的正确率反而高出差不多15个百分点。原因不复杂信息越浓缩模型越容易抓住重点。性能账。上下文越长首token延迟越高。我们把平均上下文从18k降到6k以后接口P95延迟从11秒降到了4秒左右用户体验完全是两个级别。这三笔账算完“要不要管理上下文”就不是一个需要争论的问题了剩下的问题只是“怎么管理”。2.2 关键参数token预算分配与上下文边界context-mode设计了几个关键参数来约束上下文的组装过程。第一是token预算。每次请求我会先定一个总预算比如8k token。然后按比例分配给不同部分系统提示词占10%左右历史对话占30%检索内容占45%剩余15%留给模型生成。这个比例不是拍脑袋定的而是根据我们业务里“历史信息重要还是资料信息重要”的比重来调的。第二是上下文边界。历史对话不可能全留我设置了一个“轮次窗口”默认保留最近4轮完整对话更早的内容会根据重要性判断是否要进摘要。这里的边界条件包括时间阈值和轮次阈值比如超过30分钟不活跃的会话历史信息自动降级为摘要。第三是检索数量上限。单次检索返回的片段数量默认是5个每个片段控制在500词以内。这样即使多文档场景检索内容的总量也能稳定在2.5k词左右不会出现检索结果太多反而把窗口撑爆的情况。2.3 模式判断规则优先模型兜底context-mode落地时最棘手的问题是怎么判断“当前该用哪种模式”。我试过三种方案最终选择了规则为主、模型为辅的混合路线。第一种是纯规则匹配。用关键词和简单的意图识别来决定模式。比如命中“你刚才说”“之前提到”就进入长会话压缩模式命中“第几章”“第几节”就进入文档精读模式。优点是快、稳定、零额外成本缺点是覆盖不了没见过的说法灵活度不够。第二种是纯模型判断。每次请求前先调用一次模型分析用户问题并决定上下文策略。效果好很多但多一次模型调用就多一次延迟和费用高频场景下明显不划算。第三种就是现在用的混合方式先用规则做一次快速判断如果能明确命中就立即执行规则不确定时再走一次轻量的模型分类把问题归到对应的模式下。实测下来大约75%的请求走纯规则路线其余25%走模型兜底整体准确率能到九成以上代价可控。3. 实操落地一个可复现的 context-mode 实现方案3.1 整体流程与模块划分先看架构图这里我用文字描述整个流程用户输入 ↓ 第一步模式路由规则优先 模型兜底 ↓ 第二步上下文组装器按模式从不同记忆区取内容 ├─ 系统提示词区固定模板 动态指令 ├─ 短期记忆区最近N轮对话原文 ├─ 工作记忆区摘要、重要结论、待办信息 └─ 检索内容区向量检索/关键词检索引入的资料片段 ↓ 第三步token预算裁剪超长时按优先级丢内容 ↓ 第四步调用大模型生成回答 ↓ 第五步异步更新记忆新对话写入短期记忆区触发摘要压缩整个系统由五个模块组成模式路由、上下文组装器、token预算器、记忆管理器、检索模块。分模块的好处是后续想调整某一个环节不需要改动其他部分。比如把向量库从开源方案换成一个商业化产品只需要改检索模块内部实现其他模块完全不受影响。项目目录结构大概是这样的context-mode/ ├── config.py # 所有参数配置集中管理 ├── router.py # 模式路由规则 模型判断 ├── assembler.py # 上下文组装按模式取内容 ├── budget.py # token预算计算与裁剪 ├── memory.py # 短期记忆与摘要管理 ├── retriever.py # 检索模块封装 └── main.py # 入口串联各模块3.2 核心实现模式路由与上下文组装先说路由器的实现。我维护了一个规则列表每一条规则包含两个部分匹配条件和命中的模式。核心代码大致是这样的# router.py RULES [ { name: deep_dive_rule, pattern: r(第[一二三四五六七八九十百\d][章节条]|附录|手册第), mode: document_deep_dive, }, { name: follow_up_rule, pattern: r(你刚才|刚刚说|之前提到|上一个问题), mode: long_session_compression, }, ] def route(question, history, doc_hits): # 优先过规则 for rule in RULES: if re.search(rule[pattern], question): return rule[mode] # 规则没命中且历史轮次较多时走模型兜底 if len(history) 3: return llm_route(question, history) # 单轮问题默认走跨文档检索 return cross_document_retrieval模式路由定下来之后上下文组装器的工作就是根据模式从不同的记忆区里把内容捞出来拼装。这里最关键的一点是每个模式对应的prompt模板都单独维护不要试图写一个万能模板。比如文档精读模式的模板长这样你是一个产品文档问答助手。以下是用户正在查阅的文档内容 【文档节选】 {relevant_sections} 【用户问题】 {question} 请严格基于上述文档节选回答问题。如果文档中没有相关信息请明确说不知道。而长会话压缩模式的模板是另一种结构先给摘要再给最近对话最后给当前问题。模板分开写的好处是每个模式的核心逻辑都很直白出了问题也好定位。3.3 token预算计算与动态裁剪预算器是一个容易被人忽略但极其重要的模块。我的做法是预计算每个部分的token数然后按优先级从低到高依次裁剪。具体逻辑# budget.py def build_context(mode, system_prompt, history, docs, question, max_tokens8000): # 优先级系统提示 当前问题 检索片段 历史对话 历史摘要 parts {} parts[system] count_tokens(system_prompt) parts[question] count_tokens(question) parts[docs] count_tokens(docs) parts[history] count_tokens(history) parts[summary] count_tokens(summary) budget_left max_tokens - parts[system] - parts[question] # 先放检索片段最多占预算的50% docs_budget int(budget_left * 0.5) docs trim_to_token_limit(docs, docs_budget) budget_left - count_tokens(docs) # 再放历史对话最多占30% history_budget int(budget_left * 0.6) history trim_history(history, history_budget) budget_left - count_tokens(history) # 最后放摘要再超就逐句裁剪 summary_budget budget_left summary trim_to_token_limit(summary, summary_budget) return assemble_prompt(system_prompt, summary, history, docs, question)这套裁剪逻辑里有一个关键心得裁剪顺序比裁剪算法本身更重要。先把最不重要的内容裁掉而不是对所有内容一刀切地减半这样可以最大程度保住影响回答质量的部分。实际项目中这个“优先级排序”的思路直接决定context-mode好不好用。3.4 记忆管理摘要压缩与滑动窗口记忆管理器是保证长会话不跑偏的核心。我的策略是“三层记忆”结构原始对话只保留最近4轮超过的部分异步送去生成摘要摘要再和其他重要信息一起放进工作记忆区。实现上我用了两个队列来管理。一个是deque保存最近对话原文另一个是密钥值存储保存摘要和重要结论。页面有交互时会触发摘要更新而不是每次请求都重新摘要。# memory.py from collections import deque class ConversationMemory: def __init__(self, max_rounds4): self.recent deque(maxlenmax_rounds) self.summary self.important_facts [] def add(self, user_msg, assistant_msg): self.recent.append((user_msg, assistant_msg)) # 当最近对话超过窗口上限时触发异步压缩 if len(self.recent) self.recent.maxlen: self.compress() def compress(self): combined \n.join(f用户{u}\n助手{a} for u, a in self.recent) self.summary summarize(combined) self.recent.clear()这里要注意一个细节摘要不是只保留最终的总结而是要保留对话过程中的“关键决策信息”。比如用户中途说“方案B不要了就用方案A”这个信息比“某月某日用户讨论了方案A和B”重要得多必须抽出来单独存到important_facts里否则摘要压缩后很容易丢失这类关键转向。4. 常见问题与排查技巧实录4.1 踩过的坑上下文截断后关键信息丢失我最初用纯长度截断来处理超长上下文结果经常出现一种情况历史对话太长直接把检索到的资料片段截掉了模型完全看不到用户问题的来源自然只能胡编。后来改成优先级裁剪先砍历史摘要再砍早期对话最后才动检索片段。为了让这个机制更健壮我们还在每条内容上打了元数据标签比如“来源”“核心关键词”“重要性分数”裁剪时会优先裁掉重要性分数低的内容而不是机械地从头砍。这里有一个建议如果裁剪后仍然超长不要硬塞而是把用户引导为“精简提问”。可以在系统提示词里加一句“当前问题所涉及内容超出处理范围请重述或拆解问题”比强行塞进去让模型胡说要安全得多。4.2 坑点模式误判与上下文“串味”context-mode上线后我们被吐槽最多的一个问题是用户问“之前聊的那个问题你还没回答完”结果系统把它判定为跨文档检索模式从资料库里检索了一堆不相关的东西完全没接住用户要的“继续刚才的话题”。问题出在规则太粗糙。“之前”这个词既可以指“我们对话中的之前”也可以指“某文档里提到的之前”。后来我们在规则里增加了一个前置判断如果最近2轮对话里存在待办未完成的话题优先进入长会话压缩模式再结合对话状态判断是否切换。另外一个常见的“串味”是多个模式内容叠加后互相干扰。比如文档精读模式如果又把历史摘要塞进去模型可能会被摘要里的旧结论带偏。我的处理方式是物理隔离每个模式下的prompt结构是独立的不做跨模式拼接。宁可让模型少看到一些信息也不能让它看到相互冲突的信息。4.3 调试技巧用日志把每一次上下文拼接都记录下来这个建议看似朴素但我见过太多团队忽略它。context-mode一旦用起来最难排查的就是“为什么这轮回答变成了这样”因为prompt是动态拼的每次都不一样。如果日志里没有记录当时拼出来的完整上下文出了问题根本无从下手。我在系统里加了一个开关可以在测试环境打开debug模式把每次请求组装出来的上下文原样落到日志文件里。同时打上token数、各部分的来源标签、被裁剪掉的内容形成一个变化记录。实际调试时直接用diff对比前后两次请求的上下文构成很快就能定位是哪个环节出了问题。这个习惯帮我省了无数排查时间强烈建议做类似项目的朋友都从第一天就把日志体系建好。4.4 问题速查表现象可能原因处理办法回答质量突然下降上下文过长关键信息被稀释检查token预算压缩历史对话响应变慢上下文或检索内容过多调低历史轮次窗口限制检索片段数提到“之前”但接不住上话规则误判没进入长会话模式增加对话状态前置判断摘要里丢关键转向信息摘要算法没有提取决策类信息单独抽出important_facts单独保存一次性费用暴涨请求量增加或上下文超预算检查日志中各部分的token统计检索内容与历史摘要冲突上下文跨模式拼接改为按模式物理隔离不混装5. 这套方案还能怎么扩展context-mode目前能做到的是基于规则和轻量模型判断来切换上下文策略。再往下走我觉得有几个方向值得尝试。一个方向是让模式切换更智能化比如根据用户的历史行为预判下一轮问题是不是追问提前把相关上下文准备好而不是等用户问完再临时检索。另一个方向是引入更细粒度的记忆分级不只有摘要和原始对话还可以按照用户、项目、主题做分层记忆让不同维度的信息在合适的时机自动浮出来。还有一点让我比较意外的是这套思路其实不只能用在LLM应用里。任何需要“在有限空间里保留最有效信息”的系统都能借鉴这个优先级裁剪和模式切换的思想。做context-mode这段时间我最大的感悟是大模型本身的能力当然重要但真正决定一个应用好不好用的往往是这些看似不起眼的工程细节。上下文管理没有银弹就是不断测试、不断调整、记录每一次失误然后让系统在一个可控的范围内越来越稳。如果你也在做类似的应用我建议不要一上来就想做一个能处理所有情况的万能系统先把几种核心场景的模式跑通再慢慢加规则加判断这条路会顺畅很多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →