news 2026/9/10 9:03:30

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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_k=self.config["task"]["retrieval_top_k"]) task_block = "\n\n".join([f"[文档 {i+1}] {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( model="gpt-4o-mini", # 换成你实际用的模型 messages=messages, temperature=0.3, max_tokens=1024 ) 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。比如你要问“违约金的计算基数”,检索词是“违约金 基数”,模型看到查询词后,会更清楚这段资料是为了回答什么问题,回答起来更有针对性。这个细节改完之后,我项目的相关回答质量又上了一个台阶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 9:03:04

在线批量tcping检测怎么测?从客观判断方法

用 www.kkce.com(KKCE 快快测)​ 做在线批量 TCPing 检测,从“客观判断”的角度来说,核心逻辑是:不靠逐个 Telnet 的“连得上/连不上”下结论,而是用同一批节点、同一组参数、同一时间窗并发拨测多个 IP端口…

作者头像 李华
网站建设 2026/9/10 9:02:16

Java继承与多态详解:从零基础到牛客刷题通关

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 9:01:38

在VMware Workstation中安装RHEL 8虚拟机:从配置到排错全指南

1. 为什么是RHEL 8,为什么偏偏用VMware Workstation来装先说个常见的场景。很多朋友第一次装Linux,往往图省事选了Ubuntu,图形界面漂亮、驱动齐全、遇到问题百度一下全是答案。但当你开始准备红帽认证,或者公司内部的开发、测试、…

作者头像 李华
网站建设 2026/9/10 9:00:23

SSM+JSP图书管理系统毕业设计:从框架集成到事务实现

简介:面向Java学习者和毕业设计学生,基于SSM框架、JSP技术与MySQL数据库实现的图书管理系统资料包,同步配套毕业论文、开题报告与任务书,覆盖毕业设计的主要环节。压缩包共870个文件,整体大小约9.3MB,包含J…

作者头像 李华