LLM上下文管理:context-mode四种模式实现与踩坑实践

📅 发布时间:2026/10/19 1:45:50
LLM上下文管理:context-mode四种模式实现与踩坑实践
context-mode这个词是我最近一个项目里给自己留的代号。起因很朴素我在给一个企业内部知识问答机器人做升级模型换了更大上下文窗口的版本但体验不但没变好反而翻车更频繁。对话到第 20 轮开始胡言乱语检索知识库后答非所问单轮成本还肉眼可见地在涨。后来我意识到问题不在模型而在我把上下文当成了一件可以无限堆叠的东西。于是我把上下文的管理方案单独拆出来做了一套可插拔的模块代号就叫 context-mode。这篇文章把从需求拆解、模式设计、代码实现到实测踩坑的完整链路写出来如果你正在做 LLM 应用、Agent 或者 RAG 服务这份经验应该能直接搬过去用。1. 先搞清楚问题上下文窗口不是越大越好而是够用且不浪费1.1 上下文窗口是硬约束不是软提示所有 LLM 服务都有上下文窗口限制。所谓窗口指的是模型一次生成时能同时看到的所有输入 token 与输出 token 之和。这就像一个临时桌面桌面越大你能摊开的东西越多但桌面的物理尺寸始终是固定的。很多人包括我最初有个误区只要选个窗口大的模型历史对话全塞进去就完事了。但实际写代码的时候会发现三个直接问题。第一是成本很多大模型服务按 token 计费输入 token 和输出 token 价格不一样输入越长每轮调用的费用线性上涨。第二是延迟模型处理输入的时间随 token 数增加尤其是使用长上下文模型时超出一定长度后首字响应时间会被拉长。第三是注意力稀释模型对长文本不同位置的信息敏感度并不均匀大海捞针式的测试早就证明过处在中间部分的关键信息经常被忽略。我踩的第一版坑就是无脑全量历史。结果某天用户在第 40 轮时问我刚才说的那个数字是多少模型答了一个错误的数字。原因不是模型笨而是完整历史里同时出现了五个数字模型不知道用户指哪个而正常情况下一个只看到最近三到五轮的模型反而能答对。这让我第一次意识到上下文管理的核心其实是信息筛选而不是信息堆叠。1.2 对话历史的信息熵在快速衰减多轮对话有一个很反直觉的特点越靠前的轮次信息密度越低。前几轮用户可能会交代背景、偏好、约束这部分价值高但聊到十几轮之后大量都是嗯对再举个例子这类衔接内容。把这些低信息含量内容原样塞进上下文等于用大量 token 稀释了真正有价值的信号。我给模拟项目X做过一个统计随机取 200 段真实用户对话把每轮的原文和该轮对后续回答真正有影响的信息做对比。结果大概有六成以上的历史轮次在后续回答里根本没被用到。也就是说全量历史模式里大部分 token 是在扮演氛围组——模型每次都要遍历它们却几乎不影响回答质量。所以上下文模式的第一个设计前提就是不要默认把所有历史都给模型看而是根据当前请求的性质决定给模型看哪几块内容。这个决策本身就是我所说的 context-mode 要解决的第一个问题。1.3 需求拆解一个固定策略永远伺候不好所有场景我从真实业务里梳理出四类常见诉求它们对上下文的需求是冲突的快速闲聊与简单操作用户问今天天气如何帮我建个日程这类请求只需要最近的对话状态历史越多反而越容易让模型分心。深度追问与多轮讨论用户围绕一个方案连续追问前面讨论的前提处处影响后面的结论这时候必须保留尽量全的前文。知识库查询用户提的问题涉及某个内部制度或文档需要从知识库检索相关片段并注入上下文这部分内容是额外的不能挤占对话历史的配额。关键信息核对用户想确认某次沟通中提到的金额、日期、责任人这时候需要精确的原始文本而不是经过摘要转述的内容。冲突点很明显既要让简单场景用最小的代价快速响应又要在关键场景保留原始细节。唯一的解法就是做多种模式让系统根据场景去切换——这就是 context-mode 的雏形。2. context-mode 的模式设计四种模式管好给模型看什么2.1 compact 模式克制到极致只留最近状态compact 模式是我最先实现的逻辑最简单系统提示词 最近 3 轮对话 当前用户输入。其他的历史一概不注入。它的优势非常明显token 消耗最小、响应最快而且模型注意力高度集中。但它的代价也很明确一旦用户在当前问题中引用到 5 轮之前的信息模型就没有任何线索。所以我当时给它配了一个触发条件只有当前问题不涉及历史中超过 3 轮之前的概念时才可用。实现上我用了一个很粗糙但有效的判断——用当前问题去和最近 3 轮的文本做重叠词检测如果重叠率过低并且对话轮数已经超过 5 轮就自动切到 summary 模式。这个启发式并不完美但在初期足够用。2.2 summary 模式滚动摘要兜住“遗忘”的下限summary 模式解决的是中等长度对话。做法是把早期对话压缩成一段结构化摘要包括时间线摘要、已确认的约定、未决问题、用户明确表达过的偏好。摘要由 LLM 生成生成之后作为一屏前情提要放在系统提示词之后原始历史不再进上下文。触发条件我定为两类一类是轮数超过 10 轮但不超过 30 轮另一类是最近对话的 token 数超过预算表的 40%。每 5 轮增量更新一次摘要不整段重算避免摘要本身变成大额开销。摘要格式我固定为四段式背景一句话、关键约定列表、未决问题列表、最近主题一句话。这样后续检索和切换时也容易复用。2.3 retrieval 模式知识即插即用但别喧宾夺主需要查知识库时用 retrieval 模式。实现套路非常常见把知识文档按语义切片后做向量化用户提问后计算相似度取 top-k 片段把片段拼接到上下文中。我在这块踩过两个比较大的坑后面第 4 节会细说这里只说设计原则。第一条原则是检索片段必须有身份信息。每条片段前面必须带来源文档标题和章节标题否则模型很容易把知识库内容和对话内容混在一起出现从对话里臆造制度的幻觉。第二条原则是给检索结果单独的 token 预算默认不超过总预算的 30%。一旦检索结果太多宁可减少召回数量也不能让它挤占掉对话历史的配额。毕竟知识库内容再丰富也只是参考资料真正的主线永远是用户当前的诉求。2.4 full 模式保留原始细节用于对账场景full 模式就是字面意思完整对话历史全部进上下文。适用场景其实比想象中窄——主要是需要前后对账的场景比如用户要求核对某次讨论中提到的具体报价、日期、人员分工这时候摘要模式会给不出准确原文compact 更是直接失忆。对 full 模式我做了两个限制避免它退回第一版的坑。一是护栏预算即便窗口有 128K我也只允许历史最多占 70%剩下 30% 留给模型输出和当前输入防止模型在长篇输出中途被截断。二是次数限制默认只有命中核对意图时才强制进入 full 模式普通对话即使轮数很长也优先走 summary。用户也可以手动指定但手动指定的模式会在会话结束后自动重置不会污染下一次对话。模式注入内容典型场景token 消耗信息保真度compact系统提示词 最近 3 轮闲聊、简单操作低低summary系统提示词 滚动摘要 最近 5 轮中等长度讨论中中retrieval系统提示词 检索片段 最近对话知识库问答中高中高限定领域full系统提示词 完整历史核对、长讨论收口高高3. 核心实现用 token 预算表驱动的上下文组装器3.1 预算表先于代码动手写第一个函数之前我先在项目文档里画了一张 token 预算表。这张表是整套 context-mode 的基石后面所有组装逻辑都围着它转。拿一个 32K 窗口的模型举例我的分配是这样系统提示词1200 token约 4%滚动摘要1600 token约 5%检索片段6400 token约 20%最近对话9600 token约 30%当前用户输入1600 token约 5%预留输出5600 token约 17%弹性缓冲4000 token约 13%合计刚好 32000。弹性缓冲是给模式切换留的比如从 compact 切到 summary 时摘要是新生成的需要额外空间。如果你用的是 8K 窗口建议把比例调成系统 8%、摘要 8%、检索 15%、对话 30%、输入 10%、输出 25%、缓冲 4%。小窗口更要保守。有个细节很容易被忽略模型输出也要占窗口。预留输出通常要留整窗的 15% 到 25%否则遇到需要长篇输出的请求模型会在生成到一半时撞到窗口上限实际表现为输出被截断或服务端报错。我第一版把输出预算压到 5%结果用户让模型列 20 条要点时每次都在第 12 条左右断掉这个教训印象很深。3.2 组装管线按优先级逐层拼接组装 Prompt 的顺序我固定为系统提示词 → 滚动摘要若有→ 检索片段若有→ 最近对话 → 当前用户输入。每一层注入前都先做 token 计数超过预算就削减该层的内容而不是削减后续层。比如检索片段超了预算我会降低 top-k 或丢弃排名靠后的片段但不会因此把系统提示词砍掉。下面是组装器的一个核心类我把关键方法写成了通用伪代码具体方法名以你对接的大模型服务 SDK 为准class ContextManager: def __init__(self, config: dict): self.budget config[token_budget] # 各层预算单位 token self.mode config.get(default_mode, compact) self.summary # 滚动摘要缓存 self.history [] # 原始对话历史 self.kb_cache {} # 已检索片段缓存 def count_tokens(self, text: str) - int: # 用模型服务商提供的 tokenizer 或统计接口 # 这里省略具体调用返回估算值即可 return len(text) // 2 # 仅示意勿用于生产 def select_mode(self, user_query: str) - str: # 意图启发式 轮数 token 水位 if self.is_verification_query(user_query): return full if self.is_kb_query(user_query): return retrieval if len(self.history) 30 or self.context_usage() 0.6: return summary return compact def build_prompt(self, user_query: str, kb_resultsNone) - list: mode self.select_mode(user_query) messages [] tokens_used 0 # 第一层系统提示词 sys_msg self.make_system_message(mode) messages.append(sys_msg) tokens_used self.count_tokens(sys_msg[content]) # 第二层摘要summary/full 有摘要则注入 if mode in (summary, retrieval, full) and self.summary: summary_msg {role: system, content: f前情提要\n{self.summary}} if tokens_used self.count_tokens(summary_msg[content]) self.budget.summary: messages.append(summary_msg) tokens_used self.count_tokens(summary_msg[content]) # 第三层检索片段 if mode retrieval and kb_results: kb_text self.pack_kb_results(kb_results) kb_msg {role: system, content: f参考资料\n{kb_text}} if tokens_used self.count_tokens(kb_msg[content]) self.budget.retrieval: messages.append(kb_msg) tokens_used self.count_tokens(kb_msg[content]) # 第四层最近对话 recent self.history[-self.recent_rounds:] for msg in recent: if tokens_used self.count_tokens(msg[content]) self.budget.dialogue: break messages.append(msg) tokens_used self.count_tokens(msg[content]) # 第五层当前输入 user_msg {role: user, content: user_query} messages.append(user_msg) return messages这段代码在设计上强调每层独立预算、顺序注入、超了砍当前层。实际生产环境里count_tokens必须换成精确计数否则预算表形同虚设。字符串长度除以 2 的估算法只能用在原型阶段。3.3 模式切换策略让规则引擎决定不让模型自由发挥第一种是意图驱动从请求里识别是否涉及知识库关键词、是否包含核对/确认/上次说的等核对意图词。第二种是轮数驱动对话轮数超过阈值就强制升级为 summary。第三种是水位驱动统计实际已占用的 token 数超过预算表的 60% 就允许做一次摘要压缩。三种信号取或关系谁先触发谁生效但同一轮内最多切换一次避免模式在两个模式间反复横跳。我试过让模型自己通过函数调用来决定上下文模式效果很不好。原因有二模型倾向于高估自己对历史的依赖动不动就选 full 模式而且函数调用本身也要消耗 token模式决策每轮多一次调用成本直接上涨。规则引擎虽然笨但它可解释、可灰度、可回滚这在工程上远比智能重要。3.4 配置化把模式和预算从代码里剥出去context-mode 做到第三个迭代版本后我把所有模式定义和预算都抽成了配置文件。每次调参不用改代码、不用发版改完配置热加载即可。下面是大致的配置结构{ modes: { compact: { recent_rounds: 3, summary: false, retrieval: false }, summary: { recent_rounds: 5, summary: true, retrieval: false }, retrieval: { recent_rounds: 5, summary: true, retrieval: true }, full: { recent_rounds: -1, summary: false, retrieval: false } }, token_budget: { system: 1200, summary: 1600, retrieval: 6400, dialogue: 9600, user_input: 1600, output: 5600, buffer: 4000 }, switch_rules: { kb_keywords: [制度, 政策, 文档, 流程], verification_keywords: [核对, 确认, 上次说的, 刚才那个], summary_threshold_rounds: 10, water_level_ratio: 0.6 } }这个配置结构有几个值得注意的点最近轮数用了-1表示不限摘要和检索的开关直接对应到组装器里的注入逻辑关键词列表只是第一版规则后面可以换成更准的意图分类模型。4. 实测与踩坑看起来对的方案跑起来全是细节4.1 一组实测数据混合模式性价比最高模拟项目X上线 context-mode 后我挑了三周的真实流量做对比分别跑过全量历史、纯 summary、纯 retrieval 和规则切换的混合模式每组流量 2000 轮左右。人工评估口径统一为回答是否准确且完整。表格里是我挑出的核心数据模式回答准确率平均首字延迟每轮平均输入 token相对全量的成本全量历史91%2.8s115001.00x纯 summary84%1.6s42000.38x纯 retrieval87%2.0s63000.55x混合 context-mode90%2.1s71000.62x最直观的结论是混合模式把成本压到六成左右准确率只掉了 1 个百分点纯 summary 的准确性下滑比想象中严重说明摘要丢信息的风险真实存在纯 retrieval 在涉及自由讨论的问题上容易答偏因为模型缺少完整上下文来理解用户当前到底在纠结什么。4.2 踩坑一摘要把关键数字“摘丢了”有段时间 summary 模式的准确率掉到 80% 以下追查后发现是摘要丢了数字。用户在第六轮说过预算控制在 80 万以内摘要压缩时模型把这句话概括成了预算需控制没有保留 80 万这个数值。等到第二十轮用户问那个 80 万的方案现在怎么看系统实际能看到的摘要里根本没有 80 万回答自然出错。解决思路有两层。第一层是提示词层面追加一个硬约束指令要求摘要必须原样保留所有数字、日期、人名和金额不允许用若干左右这类词替换。第二层是结构层面把摘要拆成叙述性摘要和关键事实表两块关键事实表用列表形式显式罗列数字型信息。每次生成摘要后我还加了一道校验逻辑用正则从原文和摘要里各抽一次数字数量相差超过阈值就重新生成或告警。这道低成本校验救回了大量准确性。4.3 踩坑二检索切片被硬切断语义接不上retrieval 模式上线初期用户普遍反馈回答里引用的内容看着像但细节对不上。定位后发现是切片方式的问题。我第一版用固定长度切知识文档每片 256 个字符结果很多完整句子被拦腰截断一段制度规定里有效期自……和起不再适用被拆到了相邻两片。向量检索召回时光召回前半段模型看到的信息是不完整的回答自然残缺。改成按语义块切片后问题基本消失。具体做法是先按 Markdown 标题拆成章节再把每个章节按段落细分段落超长时以句号、分号作为断点每片控制在 300-800 字。检索时除了返回文本内容还要返回标题路径比如员工手册 / 第三章 / 考勤细则 / 请假审批拼接到 Prompt 后模型能清楚知道这段话出自哪里回答时也会更克制。4.4 踩坑三模式频繁切换用户觉得模型“失忆”第三个坑是体验层面的。规则引擎有一版切换太激进用户在第 8 轮正常讨论时系统因为轮数超过阈值切到了 summary把前 5 轮的原始对话压缩了。用户紧接着问那你刚才说的第二点是什么模型回答得含糊用户原话是你是不是换人了。这类投诉连续出现后团队差点把 context-mode 整个回滚。复盘下来的结论是切换本身没有错错在切换前没有给用户心理预期。后来我加了两个动作。一是切换提示一旦模式从 compact/full 切到 summary先主动发一条系统消息内容是为避免超长对话记不住关键信息我已把前面的讨论整理成要点如下把摘要原文展示出来用户看到自己的信息被完整保留就不会觉得模型失忆。二是模式记忆同一会话内优先复用上一次的模式除非水位或核对意图明确触发切换否则不来回跳。这两个动作上线后相关投诉率直接降到了之前的十分之一以下。4.5 其他小坑增量摘要、缓存与并发摘要别整段重算每次从第一条开始重算历史会非常慢且贵。我改成每 5 轮对新增的 5 轮做一次小摘要再和已有摘要拼接。只在用户手动要求重新总结时才全量重算。embedding 结果要缓存知识库文档只要没变向量化结果就一直是有效的。我在内存里做了热点片段缓存命中率大约 40%能省掉不少向量服务的调用。并发下的水位竞争多个请求同时对同一个会话做摘要时要靠会话级锁避免重复计算。否则两个请求同时触发 summary历史会被压缩两次。5. 扩展把 context-mode 做成通用模块5.1 对外接口三件套做到这一步context-mode 已经不是一个写死在机器人里的函数而是一个可以独立复用的模块。我给它收敛成了三个对外接口# 1. 决策接口给定当前会话状态和用户输入返回推荐模式 decide_mode(history, user_query, metrics) - mode # 2. 组装接口按模式把各层内容拼成 messages供模型调用 build_prompt(mode, history, user_query, kbNone) - messages # 3. 压缩接口把历史压缩成摘要并对摘要质量做校验 compress(history) - summary三个接口彼此独立只要保证输入输出结构稳定内部实现可以随时替换。我见过有人把这三个接口直接做成一个服务用 HTTP 暴露给多个业务方共用效果也不错但前提是每个业务方要自带 token 预算和模式规则不能全局一套配置包打天下。5.2 与 Agent 工具调用的配合如果你的系统不是简单问答而是有工具调用的 Agent上下文管理会更难缠工具返回的结果也占上下文而且通常很长。比如一个查库存的工具可能返回 2000 token 的表格但用户真正关心的只是其中几行。我在 context-mode 里给工具结果单开了一个tool_result预算层默认不超过总预算的 25%并且超过 3 轮以上的旧工具结果在下一轮会被压缩成工具执行摘要只保留关键数值和结论。这个改法让 Agent 在连续调用多个工具后依然有足够的空间响应后续问题不会再出现模型把自己刚调出来的结果忘了的尴尬。5.3 后续演进自适应与评估闭环目前这版 context-mode 的规则还比较朴素我下一步想做的事情主要有三件。第一是自适应模式把用户当前问题和历史/知识库片段的 embedding 相似度作为检索模式的触发依据替代现在不太可靠的关键词列表。第二是多级记忆把工作记忆当前窗口内、短期记忆会话级摘要、长期记忆用户偏好库分层管理而不是只靠一个摘要字段。第三是评估闭环建一个离线评测集包含各模式下的标准问答每次规则调整后先跑评测集再灰度上线。其实很多上下文优化方案都挺好但缺的就是用数据证明它真的好这是我踩完这些坑以后最大的体会。写到这里可能你会觉得 context-mode 是一堆琐碎的工程细节。但我的体会恰恰相反上下文管理的本质不是省 token而是管理模型的注意力——决定在有限的窗口里让模型把注意力放在哪几件事上。省 token、降成本、降延迟这些都是把注意力管好之后自然会来的结果。如果你也在搞类似的东西我建议先用一天时间把日志里的 token 消耗、模式命中分布打出来看清楚现状再动手优化。最后再分享一个小技巧在把历史压成摘要之前先把摘要展示给用户看一眼这个动作能消解掉绝大多数由模式切换带来的不信任感——用户不怕你省内存怕的是你悄悄忘了他说过的话。