ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LLM上下文管理实战:四种Context模式与工程实现

LLM上下文管理实战:四种Context模式与工程实现 做 AI 应用开发这两年我踩得最多的坑几乎全和“上下文”有关。模型本身没有记忆每次 API 调用都是一次全新的对话但真实业务却要求它记得住用户上一句说了什么、前三天投诉过什么、这个订单关联哪些历史记录。于是 context-mode 这个词频繁出现在各种技术讨论里——直译是上下文管理模式说人话就是每一轮对话你到底要把哪些历史信息、哪些外部资料、哪些系统指令拼成一个 prompt 喂给模型。这篇文章不聊论文里的理论框架就聊我在真实项目里怎么设计、实现和调优 context-mode。适合正在做智能客服、AI 助手、文档问答、代码辅助这类应用的开发者也适合刚入门 LLM 应用、想搞懂上下文窗口到底怎么管理的新手。核心会拆四种模式、一套可复用的 Python 实现以及我踩过的几个真坑照着能搬进自己的项目里。1. Context-Mode 到底是什么为什么绕不开它1.1 大模型的“金鱼记忆”与业务的长对话需求先承认一个基本面主流大模型在推理时是完全没有“记忆”的。它只对当前这次请求里你给它的文本做处理不会自己想起几分钟前你问过它什么。所谓“多轮对话”本质上是应用层自己维护一份历史记录每次请求都把历史拼进 prompt 里重新发给模型。这个动作就是 context-mode 的起点。我记得第一次做智能客服原型时天真地以为把 messages 数组一股脑全传进去就行。前 20 轮没问题到了 40 轮单次请求的 token 数轻松过万响应延迟翻倍费用肉眼可见地涨。更麻烦的是模型开始被早期无关对话干扰答非所问。那一刻我意识到context 管理不是一个“要不要做”的问题而是一个“怎么做、做到什么粒度”的问题。业务侧对“记忆”的要求也分很多层。有的场景只需要记住当前会话的最后几句有的场景要跨越会话记住用户的长期偏好还有的场景需要结合历史工单、知识库、订单系统等多方信息。单一策略根本覆盖不了所有需求所以才需要区分不同的 context mode。1.2 上下文窗口不是越大越好很多人觉得既然模型支持 128K、200K 的上下文窗口那还管理什么全塞进去不就完了。这个想法我早期也有过直到被成本曲线教育了一顿。费用多数大模型按输入 token 计费历史越长每一轮都在为重复的旧内容付费。延迟模型需要处理更长的输入首 token 延迟明显上升用户感知就是“变卡了”。精度模型对超长上下文中部的信息利用率会下降像人一样“看了后面忘了前面”。当无关内容稀释了关键信息回答质量反而更差。所以 context-mode 本质上是一个工程判断题在有限的 token 预算里选出对当前这一轮最有价值的信息。窗口大只是能力上限能不能用好是另一回事。1.3 Context-Mode 的本质一个价值排序问题如果把每一步 LLM 调用拆开看prompt 里无非是几类内容系统指令、对话历史、检索资料、工具返回结果。Context-Mode 要做的就是给这些内容排优先级决定谁进、谁不进、各占多少预算。这个“排序”背后有三个变量相关性和当前问题关系越大的信息越值得放进去。时效性近期的对话通常比几小时前的对话更重要但有时候早期的一句话反而决定了整段对话的走向。不可压缩性有些信息必须原文保留比如用户留下的电话号码、订单号、精确的金额数字有些信息可以压缩成摘要比如“用户之前问过物流规则”。理解了这个框架再去看各种 context-mode 方案思路就会清晰很多——每种方案其实都是这三个变量不同权重的组合。2. 四种主流 Context 模式选型思路一次讲清2.1 无状态模式不是原始是克制无状态模式就是每次请求只带当前输入加上必要的 system prompt不带任何历史对话。它听起来最“原始”但在很多场景里是最优解。比如做意图分类、敏感词识别、单据信息抽取这类单次任务历史对话不仅没用还会干扰模型判断。我做过一个工单自动打标系统最初把用户之前的所有聊天记录都传进去模型反而开始从旧对话里“脑补”出一些不存在的标签。改成无状态模式后准确率直接提升了好几个百分点。适合无状态模式的典型场景一次性信息抽取姓名、地址、金额分类、打标、质检等判别型任务没有连续交互需求的工具型调用选它的关键判断标准只有一条这一轮的结果是否依赖前面几轮的输入。不依赖就大胆用无状态。有一点要提醒无状态不等于没有 system prompt。恰恰相反无状态模式下系统指令要写得非常完整因为模型没有任何历史可参考所有业务规则、输出格式、边界条件都要在 system prompt 里一次交代清楚。2.2 滑窗模式最稳妥的默认选择滑窗模式Sliding Window是实践中最常用的方案只保留最近 N 轮对话超出部分直接丢弃。它像一个固定长度的队列新的进来旧的出去。为什么我把它列为默认选择因为它简单、可控、不容易出大错。你不需要额外的摘要模型不需要向量检索只需要维护一个有限大小的消息队列每次请求前把队列内容拼进 prompt 就行。实现上要注意两个细节按 token 而不是按轮数计算窗口。用户一条消息可能几百 token也可能就几个字。按轮数切窗口会导致预算忽高忽低。按 token 数累加超过阈值就向前裁剪更稳。保留一个 overlap 区。裁剪时不要把刚好在边界上的消息拦腰截断最好是保留一个 10%-20% 的重叠区让模型对“上下文从哪里开始”有个自然的过渡。滑窗模式的缺点也很明显它记不住早期的重要信息。如果用户在对话开始时报过他的会员等级滑窗滑掉了后面再问权益时模型就不知道了。这个问题的解法通常是叠加摘要或检索模式而不是只用滑窗。2.3 摘要压缩模式用代价换记忆深度摘要模式解决的是“既要控制 token又要记住早期信息”的矛盾。思路是把超出窗口的早期对话定期交给模型压缩成一段摘要之后用摘要替代原始对话参与后续生成。我实际用过的流程是这样对话超过一定轮数或 token 阈值后触发一次摘要操作。摘要操作把“已有摘要 新增的 N 轮对话”一起发给模型让它输出一份更新后的摘要。更新后的摘要替换旧摘要原始对话从消息队列里移除。这个模式的关键难点在于“摘要的摘要”问题。如果你每次都基于上一版摘要继续压缩信息会逐层衰减。用户一开始留下的手机号、订单号这类硬信息经过两次抽象后很可能就丢了。我的经验是两类信息分开处理。硬信息实体、数字、关键事实用独立的字段或“重要信息列表”保存不走摘要流程软信息用户情绪、偏好、诉求变化才交给摘要模型去概括。这样既省 token又保证关键信息不丢。2.4 检索增强模式让相关片段自己冒出来检索增强模式通常和 RAG 一起出现适用于上下文来源非常广泛的场景知识库、历史工单、产品文档、多轮长对话历史。它的核心是把大段文本切块、向量化、建索引然后在每次问答时召回最相关的 top-k 片段拼进 prompt。它的价值在于不需要把整段历史都喂给模型只需要让最相关的内容“自己浮现”。比如用户问“退款能到哪张卡”系统会从历史记录里检索出当时绑定银行卡的对话片段拼进去模型就能准确回答。但检索模式有几个容易翻车的点切块粒度。块太长召回结果包含太多无关内容块太短又可能切断完整语义。我一般先用 200-400 个 token 左右试再根据业务调整。召回数量。top_k 设置太大会把不相关内容也带进来设置太小又可能漏掉关键信息。常见起点是 3-5 个片段。与当前问题的相关性计算。纯向量相似度不够用时我会加一层关键词过滤或重排序把“语义相似但实际无关”的结果压下去。检索模式一般不单独用而是和滑窗或摘要叠加。滑窗保证近期对话完整检索保证早期关键信息能被找回摘要兜底承载全局脉络。2.5 四种模式对比与选型速查模式记忆深度实现成本信息精度典型适用场景无状态无极低高无干扰分类、抽取、打标滑窗最近 N 轮低中多轮客服、闲聊摘要全局但会衰减中中低长对话、会议纪要检索全局且精准命中高中高知识库问答、工单场景选型上没有绝对最优只有组合。我建议从最简单的滑窗起步跑通后再根据实际的信息丢失问题叠加摘要或检索。一上来就上全套复杂架构很可能是在为想象中的问题买单。3. 实操从零实现一个 ContextManager 组件3.1 先定需求一个多轮客服助手的上下文设计前两章聊了原理和选型这一章直接上代码。假设我手里有一个售后客服助手的场景它有这么几个需求用户会连续多轮描述问题中间穿插问一些无关问题最后要回到之前的某个诉求。早期用户提供过订单号、收货地址这类关键信息必须始终记住。知识库里有一套售后规则需要结合规则回答问题。每次请求要控制 token 预算不能无限膨胀。基于这些需求我最终采用“滑窗 关键信息持久化 可选检索”的混合方案近期对话用滑动窗口保留关键实体单独存超出窗口的早期细节靠摘要兜底。下面就是这个方案的核心实现。3.2 核心数据结构和代码实现先把消息结构定义清楚。这里我直接用字典列表每个消息包含 role、content 字段方便后续管理。from typing import List, Dict, Optional import tiktoken class ContextManager: 一个轻量的 context-mode 管理器。 支持三种模式 - sliding: 滑动窗口只保留最近的 token 范围内的消息 - summary: 滑动窗口 早期消息滚动摘要 - hybrid: 滑动窗口 摘要 关键信息持久化 def __init__( self, mode: str sliding, max_context_tokens: int 4096, overlap_tokens: int 512, summary_trigger_tokens: int 2048, model: str gpt-4o-mini, ): self.mode mode self.max_context_tokens max_context_tokens self.overlap_tokens overlap_tokens self.summary_trigger_tokens summary_trigger_tokens self.model model self.messages: List[Dict[str, str]] [] self.summary: Optional[str] None self.key_facts: Dict[str, str] {} self._encoder tiktoken.encoding_for_model(model) def _count_tokens(self, text: str) - int: return len(self._encoder.encode(text)) def add_message(self, role: str, content: str) - None: self.messages.append({role: role, content: content}) self._maybe_roll_summary() def extract_key_facts(self, llm_call) - None: 用一次 LLM 调用从新增消息里抽取硬信息存进 key_facts。 # 这里省略具体 prompt核心是让模型输出 JSON 格式的实体列表 pass这里有一点要先说明tiktoken 是按模型编码器来数 token 的不同模型的词表不一样token 数会有差异。实际项目里如果你用的是千问、文心、Llama 这类模型要换成它们对应的 tokenizer否则预算会算不准。这个坑我在 4.3 节再细说。接下来实现滑动窗口的裁剪逻辑。核心思想是从最新消息开始往前累加 token 数超过预算就在头部留一个 overlap 区。def _apply_sliding_window(self) - List[Dict[str, str]]: total 0 window: List[Dict[str, str]] [] # 从尾部往前遍历保留最近的消息 for msg in reversed(self.messages): cost self._count_tokens(msg[content]) if total cost self.max_context_tokens - self.overlap_tokens: break total cost window.append(msg) window.reverse() # 如果窗口为空说明单条消息就超预算必须截断 if not window and self.messages: msg self.messages[-1] truncated self._truncate_text(msg[content], self.max_context_tokens) window [{role: msg[role], content: truncated}] return window_truncate_text 的实现不复杂核心是二分法找到能塞进预算的前缀。这里不展开细节但有一个原则要记住截断永远发生在“最不可能影响当前回答”的位置。对旧消息来说保留开头部分通常比保留结尾更有价值因为开头往往包含了背景信息。然后是摘要触发逻辑。当消息总量超过阈值就调用一次摘要模型把“旧摘要 已滑出窗口的消息”压缩成新摘要。def _maybe_roll_summary(self) - None: if self.mode not in (summary, hybrid): return total_tokens sum(self._count_tokens(m[content]) for m in self.messages) if total_tokens self.summary_trigger_tokens: return # 选出需要被压缩的旧消息从头部开始累计到触发阈值 compressed: List[Dict[str, str]] [] cost 0 for msg in self.messages[:-1]: # 最后一条留给实时窗口 if cost self._count_tokens(msg[content]) self.summary_trigger_tokens: break cost self._count_tokens(msg[content]) compressed.append(msg) if compressed: new_summary self._summarize(compressed) self.summary new_summary self.messages self.messages[len(compressed):] def _summarize(self, messages: List[Dict[str, str]]) - str: # 这里是摘要模型的调用通常需要把旧摘要和原始消息一起送进去 # 返回一段自然语言摘要 pass有一个细节容易忽略摘要触发不能太频繁。如果每次消息到达都触发一次摘要模型调用次数会非常可怕。我一般会加一个冷却条件比如距离上次摘要至少过了 5 轮或累计新增了 2000 token才再次触发。上面代码里通过 summary_trigger_tokens 来控制实际项目中还要判断 self.summary 是否存在、上次压缩了哪些消息避免重复压缩同一段内容。最后是 build_prompt把 system prompt、摘要、关键信息、滑动窗口拼成一个 LLM 请求能用的消息列表。def build_messages(self, system_prompt: str) - List[Dict[str, str]]: final: List[Dict[str, str]] [] final.append({role: system, content: system_prompt}) if self.mode in (summary, hybrid): if self.key_facts: facts_text \n.join( f{k}: {v} for k, v in self.key_facts.items() ) final.append({ role: system, content: f[持久化关键信息]\n{facts_text}, }) if self.summary: final.append({ role: system, content: f[历史对话摘要]\n{self.summary}, }) window self._apply_sliding_window() final.extend(window) return final这里把 key_facts 和 summary 都放在 system 角色里而不是 user/assistant 对话里。这么做是有讲究的模型在训练时对 system 指令的遵循度通常更高而且把“事实信息”和“对话流”分开可以降低模型把摘要内容误认为当前对话内容的风险。实际测试下来这种分层结构比混在一起更稳。3.3 参数怎么填max_tokens、overlap、threshold 的选型依据代码写完了但参数乱填一样白搭。我把几个关键参数的经验值和使用逻辑列出来。max_context_tokens这是给“对话历史”的总预算不是整个 prompt 的预算。要预留出 system prompt、检索结果如果有、用户当前问题、模型输出的空间。比如模型上下文是 8K我通常把历史窗口设成 4K剩下 4K 留给系统指令、实时输入和输出余量。如果设得太满单次请求很容易顶到上限报错。overlap_tokens一般取 max_context_tokens 的 10%-20%。不要小看这个重叠区它承接了上一轮窗口结尾和这一轮窗口开头之间的关系让模型知道哪些内容是被裁剪后保留下来的延续。我试过完全不留重叠区模型偶尔会把被截断的那句话当成完整语义来理解出过几次答非所问的事故。summary_trigger_tokens这个值取决于摘要模型的成本和你对信息丢失的容忍度。触发太早摘要频繁生成费用高触发太晚早期消息在滑窗里被挤掉摘要没来得及生成信息就丢了。我一般设为 max_context_tokens 的一半左右给摘要生成留出足够时间窗口。top_k检索模式起始设 3观察回答质量再调整。如果模型明显缺信息往上加到 5-8如果回答变得冗长或出现无关内容往下减到 2。参数没有万能解但有个方法论每次只调一个变量用固定的测试集对比输出质量。我维护了一组 30 条左右的典型问答每次调参都跑一遍比凭感觉调高效太多。3.4 完整调用链路从历史记录到模型回复把上面的组件串起来一次完整的对话流程大概是这样的def chat_with_context(cm: ContextManager, user_input: str, api_call) - str: # 1. 把用户新消息加入上下文管理器 cm.add_message(user, user_input) # 2. 构建最终的 prompt 消息列表 system_prompt 你是售后客服助手回答要简洁涉及退款规则时引用知识库。 messages cm.build_messages(system_prompt) # 3. 调用 LLM response api_call(messages) # 4. 把 assistant 回复也加入上下文管理器 cm.add_message(assistant, response) return response这段代码看起来简单但有几个顺序问题值得琢磨。为什么在调用前先 add user 消息因为 build_messages 里的滑窗逻辑是基于完整消息列表计算的如果先 build 再 add最新一条用户消息就不在窗口里了。这个顺序问题我见过很多新手踩坑。另外api_call 这个抽象函数在实际项目中可能是 OpenAI SDK、可能是国产模型 HTTP 接口、也可能是企业内部的网关。无论哪种把 context 管理单独做成一个组件和具体的模型调用解耦是这套设计的核心价值。以后换模型、调参数、加新模式都不需要动业务代码。4. 常见问题与排查技巧实录4.1 上下文污染模型被无关信息带偏上下文污染是最隐蔽的问题。表现是模型回答本身没语法错误但答非所问或者被历史里某句话带偏了方向。排查时你检查 prompt 也没发现明显问题但就是感觉“发飘”。我遇到过最典型的一个案例用户在对话早期抱怨过“你们客服态度差”后面几轮聊的是退款流程。模型在回答退款问题时突然冒出一句“我理解您对客服态度不满”把用户吓了一跳。原因就是滑窗里保留了那句情绪化的话模型把它当成了当前主题。解决办法有两个方向。一是在入窗前做意图过滤情绪宣泄类消息不进上下文队列二是在 prompt 里明确区分“历史记录”和“当前问题”比如加一句“以下历史记录仅为背景参考请优先回答用户最新问题”。我两种都试过双管齐下效果最好单靠 prompt 不够稳定。这个问题的排查思路是把历史消息一条条去掉做消融测试直到找出影响回答的那条消息。定位后再决定是过滤规则处理还是提示词约束成本完全不同。4.2 摘要越滚越失真信息损耗怎么控制摘要模式用久了你会发现一个规律摘要越滚越“平”。第一次摘要还能保留具体事件到第三轮摘要就只剩下“用户咨询售后问题”这种没营养的话。核心原因是摘要模型在多次递归压缩时倾向于保留最泛化的信息丢掉具体但重要的细节。我的解法在前面提过就是硬信息和软信息分离。直接说具体做法在摘要触发的同时用一次额外的抽取调用把订单号、金额、日期、手机号、地址等实体抽出来存进 key_facts。摘要只负责概括“发生了什么、用户情绪如何、诉求是什么”这类软信息。生成 prompt 时key_facts 始终原文保留摘要只是辅助背景。这个分离方案我在两个项目里验证过效果比纯摘要好很多。核心还是那句上下文管理的本质是价值排序有些信息不可压缩就不要强行压缩。4.3 Token 预算失控计费与截断的隐藏坑Token 预算失控有好几种形态我挨个说。第一种是计数不准。用错了 tokenizer 模型或者拿字符数当 token 数算导致实际发送的 token 远超预算。不同模型家族词表差异很大中英文混合内容的 token 密度也不一样。最稳的做法是用你实际调用的模型对应的 tokenizer没有官方 tokenizer 就用近似模型估算并留出 15% 的缓冲。第二种是 system prompt 膨胀。系统指令里塞了大量不常改写的规则、示例、few-shot每一轮都在重复计费。解决办法是把不随对话变化的静态指令拆出来做缓存动态部分单独拼。有些平台支持 system 层缓存优化能大幅降低重复计费值得研究一下。第三种是输出 token 挤占预算。很多模型的 max_tokens 参数如果设得和上下文上限一样大输入侧一旦涨起来输出空间就会被压缩出现生成一半断掉的情况。我在接 OpenAI 兼容接口时习惯把输出上限留出总窗口的 1/4比如 8K 窗口里输出最多 2K输入上限就是 6K。这样模型不会因为“输出空间不够”而截断。4.4 检索命中率低chunk 切分与召回优化的实战经验如果你的 context-mode 里带了检索命中率会直接决定回答质量上限。这里分享几个我在 RAG 项目里总结出来的实战经验。切分要按语义边界不要按固定字符数硬切。我一开始用 500 字固定切块结果好好的一个流程说明被切成两半检索时语义残缺召回全是噪音。后来改成按标题、段落、列表等语义块来切效果好得多。一个内容块只表达一个主题。这是切块的黄金标准。如果一块里面既有退款规则又有物流规则用户问物流时这块的向量会被退款内容稀释相关性分数自然低。检索后一定要重排。向量相似度只保证“语义接近”不保证“逻辑可用”。我加过一层 lightweight reranking用一个小的分类模型或者干脆用 LLM 判断候选片段与问题的相关性把 top 10 重新排序取 top 3命中率提升非常明显。注意 embedding 的更新频率。如果知识库内容经常更新旧文档和新文档之间的表述可能不一致。定期对全库做一次重新向量化比增量更新更稳就是费点算力。4.5 问题速查表症状可能原因快速解法回答被历史带偏上下文污染过滤情绪消息prompt 区分历史与当前问题早期信息丢失滑窗溢出且未摘要叠加摘要或检索模式关键信息独立持久化请求报 token 超限计数不准/预算分配不当用正确 tokenizer预留输出余量生成中断输出 token 被挤占单独设置输出上限输入预算控制在 3/4 以内检索召回全是噪音切块太粗/语义块被切断按语义边界切块加重排序摘要越压越空递归摘要信息衰减硬信息抽实体软信息才走摘要这张表我经常贴在项目 Wiki 里团队其他人遇到类似问题能直接对照排查省了不少沟通成本。5. 场景扩展Context-Mode 在不同业务里的落地姿势5.1 智能客服分层记忆设计智能客服是 context-mode 最典型的应用场景。我把它拆成三层记忆来处理会话级记忆当前会话内的对话历史用滑窗保存最近 N 轮。用户级记忆跨会话的长期偏好比如用户的会员等级、常驻地、历史投诉记录独立建表存储每次会话开始时注入 system prompt。业务级记忆订单状态、售后进度这类动态业务数据通过接口实时查询而不是从历史对话里猜。这三层分别对应不同的模式互不干扰。会话内用滑窗跨会话用检索业务数据走查询接口。如果你只用一个模式包打天下客服场景一定会出问题。5.2 代码助手文件级上下文代码助手对上下文的需求和对话类应用很不一样。用户问“这个函数为什么报错”模型需要看到函数所在文件的完整代码而不只是最近几句对话。所以代码助手的 context-mode 往往是“当前文件全量 相关文件片段”的组合。我的经验是核心文件用全量注入前提是文件别太大关联文件用依赖分析定位。比如用户提到“login 模块”就顺着 import 关系把相关文件的关键函数片段检索出来。这里的检索不同于知识库问答更像代码级 RAG索引的粒度是函数和类不是段落。另外代码助手对 token 换算非常敏感几千行的仓库如果全部塞进上下文不是能不能的问题是成本的问题。所以代码类 context-mode 通常要和仓库索引配合按需加载。5.3 长文档分析多级压缩与检索结合长文档场景比如 100 页 PDF、整本书、海量工单是 context-mode 的极限考验。全量注入不可能纯摘要又会丢细节。我的做法是多级策略第一级文档切块入向量库做细粒度检索。第二级按章节生成章节摘要建立目录级索引。第三级模型回答问题时先定位到章节再在章节内做细粒度召回最后综合生成回答。这个过程很像查资料先看目录确定章节再看章节摘要确定小节最后精读具体段落。把所有工作都压在一级检索上很容易出现“检索到一堆相关但不直接”的尴尬。多级压缩还有一个额外好处章节摘要在用户提问前就可以离线生成不占用在线推理时间。我处理过一批百万字级别的政策文档离线阶段把每章摘要和向量索引都建好上线后单次问答的延迟和普通知识库问答几乎没有差别。做 context-mode 这几年我最大的体会是它没有标准答案只有取舍。你会遇到很多看似诱人的“最佳实践”但真正适配你业务的往往是在一次次消融测试和线上数据反馈中磨出来的。我自己现在做任何一个新项目都先从一个最简单的滑窗开始跑通后再根据真实的信息丢失问题一步步叠加摘要、检索、持久化。这样每一步都清楚自己在解决什么问题而不是为了“架构完整”而上复杂方案。最后再分享一个小技巧无论你选哪种模式都要把每一轮实际发给模型的 token 数、模式决策日志、以及模型的回答质量打分记录下来。这些日志在调优时价值巨大很多看似玄学的模型行为最终都能在日志里找到线索。上下文管理是一门经验学科日志就是你的实验记录本。
返回列表