基于context-mode的LLM上下文管理:分层、归档与召回实战

发布时间:2026/9/10 9:03:30
基于context-mode的LLM上下文管理:分层、归档与召回实战 我之前负责过一个文档问答机器人上线三个月后被投诉最多的问题就是“聊着聊着它就忘了”。用户早上进来问合同审核清单下午回来接着问模型已经完全不记得合同附件里写了什么甚至会把另一个项目的条款内容混进来。一开始我以为是模型选型选错了后来把每次请求的完整上下文导出来一看好家伙一次请求塞了 90 多万字符的对话历史和文档切片真正和当前问题相关的不到 2%。让模型在这么一大片噪声里找关键信息出错几乎是必然的。这就是我今天要聊的 context-mode。一句话解释它是给 LLM 应用设计的一套上下文管理模式核心是把“不管三七二十一全塞给模型”的做法改成“按模式分层、按需归档、按任务召回”的结构化方式让模型每一轮看到的都是经过筛选的高信噪比上下文。这套东西做完之后那个问答项目的多轮对话准确率从 72% 左右提到了 89%每轮请求的 token 消耗平均降了 41%。这篇文章会把设计思路、核心机制、具体实现步骤和踩过的坑完整复盘一遍。正在做 LLM 应用或者被“上下文越堆越乱、模型越聊越傻”这类问题折磨过的工程师可以直接照着落地。1. 先想清楚context-mode 到底在解决什么问题1.1 三个真实翻车现场我整理了一下当时线上最典型的几类问题它们基本覆盖了上下文失控的常见形态。第一种是遗忘型。用户在多轮对话中已经明确说过自己的公司名称、业务类型和委托事项但聊到第 8 轮以后模型像是失忆了一样反问“请问您所在的公司是”。原因很简单对话历史太长早期的关键信息被后续内容挤出了模型的“有效注意力窗口”模型不是看不懂是根本没聚焦到那里。第二种是混淆型。用户同时上传了多个项目的合同文档问“上个项目的违约金条款是什么”模型却把另一个项目的赔偿比例答了出来。这不是模型理解能力的问题而是我们把它推入了一个没有边界的上下文——所有文档切片都出现在上下文里模型没有办法可靠地判断“哪个才是上个项目”。从信息论的角度看这就是信噪比太低导致的信号遮蔽。第三种是幻觉型。上下文里只有文档的摘要摘要本身不够精确模型为了凑答案就开始自己编细节。比如问“项目验收标准”摘要里只有一句“验收标准详见正文”模型就顺口给出了一套虚拟的三方验收流程。这种情况最坑因为答案写得像模像样却完全不可信。这些翻车现场有一个共同点责任不在模型而在我们喂给模型的上下文结构。模型的能力上限很大程度上取决于输入上下文的质量。把一堆未经组织的原材料直接塞进去再强的模型也会被带偏。1.2 上下文管理的本质是“信噪比”问题很多人第一反应是既然上下文越长越容易乱那把窗口调大一点不就行了我一开始也这么想过后来实测发现这条路走不通。以常见开源模型的 128K 上下文窗口为例。窗口变大以后能塞进去的字符确实多了但模型对不同位置内容的注意力强度并不均匀。业界把这种现象叫做 lost in the middle——也就是模型对开头和结尾的内容记忆较深对中间段落的关注度明显下降。你想想当你把几十万字的合同、聊天记录、资料列表全拼在 prompt 里真正有信息量的东西大概率落在“中间位置”恰好是模型最容易忽略的区域。用信号系统的视角来看这就好比一个电台发射功率是固定的你把背景噪声调得越大有效信号的识别率就越低。token 预算就是你的发射功率问题信息密度就是信号杂七杂八的历史和无关联的内容就是噪声。context-mode 做的事情不是增加功率而是把噪声压下去让同样功率下信号更清晰。1.3 context-mode 的架构取舍分层、归档、召回我的思路拆成三个动作。第一是分层。把上下文拆成三个独立层级全局层、会话层、任务层。全局层放长期不变的内容比如角色设定、业务背景、用户偏好会话层放当前这段对话的近期历史任务层放跟当前问题直接相关的临时资料。三层各管各的互不污染。第二是归档。对话一旦超过某个长度早期的内容不能直接丢而是要做信息压缩提炼成结构化摘要存到外部存储里。需要的时候再按相关性召回而不是永远躺在上下文里占地方。第三是召回。每次请求进来时先做意图理解再根据意图从归档区、知识库、文档库里检索相关内容拼装进任务层。这相当于给模型配了一个“检索员”每一轮只把最该看的内容递给它。这套架构的好处是模型每一轮看到的上下文都短而密既能压低 token 成本又能提高回答准确率。坏处也明显系统复杂度上去了不再是一个 prompt 走天下得额外维护归档、检索、模式切换这些模块。但对于任何会话轮次多、知识库大的生产级应用这部分的工程投入是值得的。2. 核心细节解析三层上下文怎么设计与协作2.1 全局模式长期记忆和领域知识放这里全局模式是我设计的三个模式里最简单也最稳定的一层。它保存的是本次任务期间基本不变的内容主要包括三类信息角色设定、领域知识、用户画像。角色设定很好理解。比如问答机器人是“合同审核助手”这里的 system prompt 就应该写明它的职责边界、输出格式、注意事项。领域知识可以是合同条款的通用规则、行业术语表、审核要点清单。用户画像则记录用户偏好的回答风格、常用术语、历史偏好比如“用户习惯接收结构化清单式答案”“用户是一家建筑公司的法务”。我刚开始实现全局模式时犯过一个错误把太多东西都往这一层塞。领域知识写了一大堆导致每次请求的基础 system prompt 就有 3000 多 token。后来才意识到全局层越精简越好能放引用标识就放引用标识具体内容让任务层去检索。比如领域知识那块我只放“你有建筑行业合同审核经验”具体的合同法规要点做成单独的知识文档需要时通过检索工具召回。改完之后基础 prompt 压到了 800 token 左右效果反而更好了。2.2 会话模式滑动窗口来管理对话历史会话层负责管理当前对话的历史记录。这里我遇到过一个经典问题对话历史到底保留多少轮起初我直接保留最近 20 轮后来发现超过 10 轮后早期记录对当前问题的参考价值急剧下降反而会把模型对当前意图的判断干扰掉。最终我采用的是滑动窗口 重要性标记的结合方案。所有对话消息进入一个 ring buffer容量为 12 轮。每轮消息进来时系统会做一个简单打分这条消息是否包含用户明确指令、是否包含项目名称/金额/日期等关键实体、是否被后续消息引用过。按分数排序后只保留最高分的 10 轮其余触发归档流程。这个方案看起来朴素但实测效果相当好。以前 20 轮全量带入时模型经常被早期话题带偏用滑动窗口过滤之后回答准确率明显上升。关键原因是窗口外的噪声被挡掉了模型不必在十几条无关历史里猜重点。2.3 任务模式按当前意图动态加载资料任务层是变化最快的一层也是 context-mode 里最核心的动态部分。每次用户提问进来系统先做一次意图识别判断这个问题可能需要哪些信息然后去对应的数据源做检索。拿我的文档问答项目举例。用户问“B 项目第三条的违约金比例是多少”时意图识别模块会提取出几个关键实体项目名称 B、文档类型“合同”、条款编号“第三条”、实体属性“违约金比例”。检索模块拿着这些实体去向量库里召回最相关的几个片段再结合全局层里的用户偏好最终拼装出一个不超过 1500 token 的任务上下文。这里有个很关键的设计任务层的输出必须足够小。我给自己定的原则是单次任务上下文不超过 1500 token。因为任务层的目的是辅助“当前这一步决策”而不是提供全面的资料背景。裁得越狠模型的注意力越集中但也不能裁到关键信息丢失所以怎么分配这 1500 token 就非常讲究。2.4 token 预算怎么分我采用的分配矩阵全局、会话、任务三层不能无限制地抢 token否则系统就会劣化成原来的状态。我在项目里做了一张 token 预算分配表每层都规定了范围而且是动态调整的。层级默认预算最低预算说明全局层1200 token800 token角色、用户画像、领域知识引用标识会话层2400 token0 token最近 10 轮内的高分历史消息任务层1500 token600 token当前问题对应的检索结果与临时上下文总系统预算5100 token火花预算加上裕量后不超过 6000 token确保整体请求在可接受成本区间这个分配不是拍脑袋定的。我拿线上数据做过一轮统计95% 的真实请求只要保证每层的预算不低于上面表格里的最低阈值模型就能稳定地回答。而低于这个阈值之后回答质量的下降会非常陡峭。反过来超过这个预算多给的部分并不会带来明显增益纯属浪费成本。预算分配还有一个自适应策略。当任务层的检索结果特别丰富时系统会动态从会话层借一点 token 给任务层同时压缩会话层的历史条数。这个交互逻辑相当于一个“投资”机制——哪一层当前最有信息价值就把预算投到哪。3. 实操过程从零实现一个 context-mode 切换引擎3.1 定义模式与配置结构我在项目里用 Python 实现这套系统配置部分全部用 JSON/YAML 管理这样可以不修改代码就调整各层行为。from enum import Enum from dataclasses import dataclass, field from typing import Optional class ContextMode(Enum): GLOBAL global SESSION session TASK task dataclass class BudgetConfig: max_tokens: int min_tokens: int dataclass class ModeConfig: mode: ContextMode budget: BudgetConfig window_size: Optional[int] None retrieval_top_k: int 3 summary_enabled: bool True这里我故意把模式定义成一个枚举值方便后续切换逻辑里做条件分发。BudgetConfig 记录了每一层的上下限。窗口大小是会话层专用的参数表示保留多少轮消息。retrieval_top_k 是任务层用检索模块时向量库返回多少个候选片段。summary_enabled 控制是否对早期历史做摘要归档。配置示例长这样{ context_mode: { global: { budget: {max_tokens: 1200, min_tokens: 800}, system_prompt_path: ./prompts/global_system.txt }, session: { budget: {max_tokens: 2400, min_tokens: 0}, window_size: 10, summary_enabled: true }, task: { budget: {max_tokens: 1500, min_tokens: 600}, retrieval_top_k: 3 } } }实际项目里这些配置是从配置中心拉取的线上可以热更新不需要重启服务。开发环境直接读本地 JSON 文件省事。3.2 实现上下文构建器模式有了配置有了最核心的就是上下文构建器。它的工作是把三层数据组装成一个完整的 prompt 列表。class ContextBuilder: def __init__(self, config: dict, retriever): self.config config self.retriever retriever self.memory_store {} async def build(self, request, history, user_profile): global_block self._build_global(user_profile) session_block await self._build_session(history) task_block await self._build_task(request) messages [] if global_block: messages.append({role: system, content: global_block}) messages.extend(session_block) if task_block: messages.append({role: system, content: [任务上下文]\n task_block}) return messages def _build_global(self, user_profile): # 从模板加载全局 system prompt并注入用户画像 prompt self._load_prompt(global_system.txt) prompt prompt.replace({user_name}, user_profile.get(name, )) prompt prompt.replace({domain}, user_profile.get(domain, 通用)) return self._truncate(prompt, self.config[global][budget][max_tokens]) async def _build_session(self, history): # 用滑动窗口过滤出最近的高分消息 recent await self._score_and_slice(history, self.config[session][window_size]) messages [] for item in recent: messages.append({role: item[role], content: item[content]}) return messages async def _build_task(self, request): # 从向量库检索和当前问题最相关的片段 query request[query] candidates await self.retriever.retrieve(query, top_kself.config[task][retrieval_top_k]) task_block \n\n.join([f[文档 {i1}] {doc.text} for i, doc in enumerate(candidates)]) return self._truncate(task_block, self.config[task][budget][max_tokens])这个构建器的几个关键决策我解释一下。_score_and_slice是会话层最重要的函数。里面先对历史消息做重要性打分过滤掉低分消息再从窗口头部开始截取。注意我处理的是“保留高分消息 滑动窗口”而不是简单保留最近 N 轮。这样既能保证连续性又能砍掉那些无关紧要的闲聊。_build_task里用了异步检索避免向量查询阻塞主流程。检索结果统一包在一个[文档 N]的结构里这样模型能明确区分这是外部资料而不是对话里的一部分。_truncate函数是最后的保险。无论前面怎么算最终生成的层内容都不能超过预算上限。超过就直接按 token 截断。这里有讲究截断不能从中间切最好是让模型处理完整句子否则语义会被切断。我实现的时候是按句子切分后再拼接保证每一段都是完整的。3.3 接入 LLM 接口并处理模式切换构建器生成 messages 之后接 LLM 接口就很简单了以 OpenAI 兼容接口为例import openai async def chat(request, history, user_profile): builder ContextBuilder(config, retriever) messages await builder.build(request, history, user_profile) response await openai.ChatCompletion.acreate( modelgpt-4o-mini, # 换成你实际用的模型 messagesmessages, temperature0.3, max_tokens1024 ) return response.choices[0].message.content这里我不展开讲接模型的过程因为每个团队用的模型和平台都不一样。最要紧的是系统里的模式切换逻辑也就是系统怎么决定当前请求用哪个模式组合。我的做法是加一个意图识别前置模块。请求进来后先做一次轻量分类输出三个标签任务类型、时效性、知识需求。请求特征模式组合示例问题直接要求检索具体文档内容global task不加载历史“B 项目第三条的违约金是多少”问题依赖前几轮对话的上下文global session task“我之前问过的那个项目还需要补充哪些资料”纯闲聊或无明确任务意图global session不检索“你帮我总结一下刚才聊了什么”模式切换本身不复杂复杂的是判断哪条路合适。我一开始直接用规则引擎效果不稳定后来改成用一个小型意图分类模型准确率才上去。这个点我会在后面的问题排查里展开讲。3.4 归档与召回把“忘掉”变成主动的前面提到早期消息不能直接丢要归档。我在系统里实现了一个简单的归档-召回模块分两步。第一步是归档。当会话层滑动窗口淘汰某条消息时先判断这条消息是否包含关键信息用户指令、实体、数值。如果包含就把它交给摘要模块。摘要模块会把上一段对话的核心事实压缩成结构化记录存到数据库里。class Archiver: def __init__(self, llm): self.llm llm async def archive(self, old_messages, summary_db): key_facts await self.llm.extract_facts(\n.join(old_messages)) summary { conversation_id: old_messages[0][conversation_id], facts: key_facts, timestamp: old_messages[-1][timestamp] } await summary_db.insert(summary) return summary提取的事实就是几个要点用户提到了哪些项目名、关键金额、验收节点、双方责任等。这些事实后面会被检索模块当成索引字段。第二步是召回。当会话层出现需要回忆早期内容的信号时比如用户说“之前那个项目”系统就拿着实体去摘要库搜索把匹配的摘要作为任务层内容的一部分送进模型。这就把“忘掉”变成了“按需想起来”而不是把所有历史都留在上下文里。这一步做完系统的表现有了一个质的飞跃同样的问题模型不再依赖完整的早期对话来回答而是靠被归档的事实板来回答。准确性反而提高了因为摘要是结构化提炼过的比原始对话里散落的事实更清晰。4. 常见问题与排查技巧实录4.1 模式切换误判规则引擎不靠谱换意图分类模型我的第一版模式切换用的是规则关键词匹配比如检测到“文档”“条款”就进入 task 模式检测到“你刚才说”就进入 session 模式。结果上线第二天就翻车了——用户说你刚才说的那个条款帮我再解释一下既包含“条款”又包含“刚才”规则引擎直接冲突了最后进了 task 模式把上一轮完整对话历史给丢了。排查思路是看用户语义的“时空指向”。如果用户引用了过去的对话内容就必须带 session 历史如果用户只是在问当前问题需要的资料历史并不重要。这个判断用关键词很难覆盖因为同一个词在不同语境下含义完全不一样。我最终换成了一个轻量意图分类模型微调的时候准备了 5000 条线上标注数据。效果好了很多误判率从初版的 18% 降到了 4% 左右。如果你不想动用模型至少也要用句法特征 实体识别来辅助判断不要只用词面匹配。4.2 会话历史太长导致上下文超限优雅降级而不是直接截断线上跑了一段时间后我发现一个问题即使有滑动窗口session 层的内容偶尔还是会超过 2400 token 的上限。原因是有一些超长消息比如用户直接把一份 5000 字的合同粘贴进来。这时候如果按 truncate 硬切会把合同中间的关键条款切没导致任务层检索到的内容完全接不上。我最后用的解决方案是降级策略。当 session 层超限时先尝试把窗口从 10 轮压缩到 6 轮还不够就把低分消息直接归档只保留最高分的 4 条再不行自动把会话层内容压缩成摘要不再具体保留原始消息。这样保证了模型至少能看到一个完整的世界观不会被断在半截的合同文本误导。这个策略的代码实现不复杂麻烦的是要设计好“降级顺序”。我的经验是先缩广度轮数再缩深度单条消息。因为多轮语义的完整性往往比单条消息的细节更重要。4.3 向量召回噪音太大从结果里“反拣”上下文任务层的内容依赖向量检索但我踩过一个大坑检索回来的 top 3 片段经常有两段和问题无关。原因是我用的向量模型对长文档的分块处理不够好一个大段落里既包含了相关句子也包含了无关背景被同时召回后无关部分反而干扰了模型。解决的办法是加了召回后重排。先用向量检索召回 20 个候选再用一个轻量级重排模型cross-encoder对候选和查询做相关性打分最后取 top 3。这个处理让任务层的信噪比大幅提升回答准确率又往上走了 3 个百分点。代价是多了一次重排推理但用 lightweight 模型延迟只增加了 20ms 左右完全可以接受。4.4 成本与性能实测省下来的 token 非常可观最后放一组实测数据我觉得比什么理论都有说服力。我负责的项目在接入 context-mode 前后跑的是同一批线上请求样本量是 10 万次调用。指标接入前接入后变化平均请求 token 数18,20010,700-41%多轮对话准确率72%89%17%平均响应延迟1.6s1.2s-25%单次请求成本按 API 价格估算0.046 元0.027 元-41%token 下降的主要原因就是上下文被瘦身了不再背着一大堆历史。延迟下降一部分来自 token 变短后模型处理更快一部分来自检索异步化。准确率提升则完全归功于信噪比变高模型不需要在一堆杂质里挑答案。5. 最后再分享一个小技巧整个 context-mode 做下来我最大的体会是上下文管理的本质不是“记住更多”而是“懂得取舍”。模型的上下文窗口就算到 1M、2M如果往里面塞的都是重复信息、无关联历史、低价值摘要照样会被噪声淹没。聪明的做法是像人一样工作——手边永远只放最有用的几份资料其他全部归档需要时再精准取出。如果你想快速验证这套方案是否适合自己项目我建议先做一件事把线上真实的请求日志导出来统计每一轮 prompt 里到底有多少 token 是真正和最终答案相关的。这个比例如果低于 10%context-mode 大概率能帮你省一大笔成本还能让模型变聪明不少。一个小技巧在构建任务层上下文时不要只检索文档片段把你的检索查询也一起放进 prompt。比如你要问“违约金的计算基数”检索词是“违约金 基数”模型看到查询词后会更清楚这段资料是为了回答什么问题回答起来更有针对性。这个细节改完之后我项目的相关回答质量又上了一个台阶。