ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM应用上下文管理全解析:context-mode三种实现与工程实践

LLM应用上下文管理全解析:context-mode三种实现与工程实践 做LLM应用的人最近应该没少听context-mode这个词。不管是调试AI Agent的上下文丢失还是优化长文档问答的效果最后几乎都会落到同一个问题上你到底用哪种方式在处理上下文我最早是在改造一个内部知识库问答机器人时认真研究这块的踩了不少坑后来把context-mode的几种实现方式、适用场景和参数设计彻底梳理了一遍才算是真正把上下文这块吃透。这篇文章就把这些经验完整写出来适合正在做AI应用开发、涉及多轮对话或长文本处理的同学参考也适合想搞懂为什么我的AI总是记不住话的初学者。1. context-mode 到底是什么一个被反复提起又容易误解的概念1.1 先讲清楚这里的 context 指什么在LLM应用开发里context上下文指的是在一次模型调用中所有被送入模型输入端的文本信息。它不只是用户最近说的那句话而是包括系统提示词、历史对话、检索到的资料、当前任务状态等所有模型需要看到的内容。我举个简单的类比假设你找了一个实习生帮你处理工作他记性很差每次干活前都需要你把他需要知道的所有背景资料摆在他面前他才能开始。你每次给他多少资料、按什么顺序给、哪些给原文哪些给摘要这就是上下文管理的范畴。而context-mode就是这套给资料的完整策略——它决定了你在每一次调用模型时用什么逻辑来组装、裁剪、压缩、检索那些喂给模型的文本。实际工程里context-mode通常不是一个独立的功能模块而是一套贯穿整个应用的设计约定。它影响三个核心环节历史对话怎么存、相关材料怎么找、最终拼进模型的消息序列长什么样。很多团队一开始不重视这块直接在代码里把最近N条消息拼接后丢给模型短期能用一旦业务变复杂就会遇到越聊越笨答非所问费用飞涨这些典型问题。1.2 为什么需要一个模式来管上下文有人会问直接把所有内容都塞给模型不就行了吗模型不是支持很长的上下文窗口吗这个想法在demo阶段没错但到了生产环境就会撞上三堵墙。第一堵墙是成本。目前主流模型的定价基本都按token计费上下文越长每一次调用的成本就越高。一个每天调用上万次的API服务如果每次多塞2000个token的无效内容一个月下来就是一笔不小的开销。第二堵墙是质量。模型虽然有长上下文能力但能接受长输入不等于长输入效果就好。大多数模型对位于输入中部的信息关注度明显低于开头和结尾也就是所谓的lost in the middle现象。你塞进去的内容越多关键信息被稀释得越严重回答质量反而可能下降。第三堵墙是延迟。输入token数量直接影响首字响应时间上下文越长用户等待时间越长体验就越差。所以context-mode的本质是通过一套可配置、可复用的策略在信息完整性和成本/质量/延迟之间找到平衡点。它不是某个具体算法而是一个工程决策框架。2. 设计 context-mode 前必须想清楚的三个底层问题2.1 你的场景到底需要多长的上下文在动手写代码之前先回答一个问题你的应用最核心的交互形态是什么我把常见场景分成三类第一类是短对话型比如客服机器人、闲聊助手。这类场景里单轮请求所需的上下文通常只需要最近2-3轮对话加系统提示词总长度基本控制在2000 token以内。这种场景对context-mode的要求最简单做好滑动窗口基本就够了。第二类是长文档型比如知识库问答、论文阅读助手、合同审查。这类场景的难点在于用户可能会针对一份几十页的文档反复提问你不可能每次把全文都塞进去而是需要先定位到相关章节再把这些片段作为上下文。这种场景必须依赖检索增强单纯的滑动窗口完全不够用。第三类是任务编排型比如AI Agent、自动化工作流。这类场景的上下文不只是对话历史还包含工具调用记录、中间结果、任务状态等结构化信息。上下文管理的重点在于哪些中间结果需要保留哪些任务完成后就可以丢弃处理不好就会出现Agent忘记自己刚才干了什么的尴尬情况。我见过不少团队在项目初期不区分场景统一用最近N条消息这种策略结果做文档问答时效果惨不忍睹。所以第一步不是选工具而是明确你的场景属于哪一类、核心痛点是什么。2.2 信息等级怎么划分哪些不能丢哪些可以压缩决定上下文策略时最关键的思维转变是把上下文里的信息按重要性等级分层而不是一视同仁地全部保留。我习惯把信息分为三层第一层是核心指令包括系统提示词、用户当前这一轮的问题、任务目标。这部分必须原样保留不能做任何压缩或裁剪因为它们直接决定了模型理解任务的方向。第二层是支撑材料包括检索到的相关文档片段、上几轮对话中与当前问题相关的部分。这部分是有用的补充可以适当压缩比如对历史对话做摘要、对检索片段做重排后只保留最相关的部分。第三层是边缘信息比如很早之前的寒暄、已经完成的任务细节、与当前问题无关的历史记录。这部分应该果断丢弃或压缩成一两句话的状态记录。在实现context-mode时我建议先用一个配置文件或常量表把这个分层规则固化下来而不是在代码里散落着各种if-else。这样后续调整策略时只需要改配置不需要动核心逻辑。2.3 上下文窗口和成本的权衡关系很多教程会告诉你要根据模型的上下文窗口来设计但实际工程中模型支持的窗口上限和你的业务可用窗口往往是两回事。我自己的经验是设置一个软上限比直接顶到窗口上限更稳妥。比如模型支持128k上下文但你在业务层面把单次调用的上下文控制在8k-16k以内。原因有三点一是成本呈线性增长长期运行的边际成本差异非常大二是超过一定长度后模型对中间内容的注意力明显衰减再多的token也未必转化为回答质量三是很多业务场景的响应时间敏感过长的输入会直接拖垮用户体验。我在一个文档问答项目里做过对比实验同样一个回答任务使用8k上下文针对性问题检索后拼接和使用40k上下文把整份文档切片后大量塞入相比前者的回答准确率反而高出约15%单次成本降到了后者的五分之一。这个数据不绝对但足以说明多塞不等于效果好。3. 三种主流 context-mode 实现方案拆解3.1 滑动窗口模式最简单也最容易踩坑滑动窗口是大多数人接触的第一种context-mode。它的核心逻辑很简单维护一个消息列表每次调用时只取最近N条消息超出窗口的消息直接丢弃。from collections import deque class SlidingWindowContext: def __init__(self, max_messages10, max_tokens4000): self.messages deque(maxlenmax_messages) self.max_tokens max_tokens def add_message(self, role, content): self.messages.append({role: role, content: content}) def build_prompt(self, system_prompt): # 这里简化了token计算逻辑实际需要用tokenizer精确计算 selected [] total_tokens 0 for msg in reversed(self.messages): msg_tokens estimate_tokens(msg[content]) if total_tokens msg_tokens self.max_tokens: break selected.append(msg) total_tokens msg_tokens selected.reverse() return [{role: system, content: system_prompt}] selected这段代码看起来简单但实际用起来有几个坑。第一个坑是deque的maxlen只限制条数不限制token数。如果某一轮对话特别长比如用户粘贴了一段几千字的报错信息单条消息就会远超预算。所以一定要同时做token级别的预算控制。第二个坑是硬切断会破坏对话连续性。比如用户在第5轮问了一个问题第6轮针对回答追问第7轮换个话题滑动窗口可能在第8轮时把第5轮的原始问题挤掉了只留下回答内容模型就无法理解追问到底在追什么。针对这个问题我后来在窗口方案里加了一个关键消息保护机制检测到某条消息是用户提问且后续有连续的assistant回答与之对应就把这对问答整体保留而不是单独保留其中一条。实测下来对话连贯性提升明显。第三个坑是token估算。很多人用len(content)粗略折算在英文场景下误差还能接受但在中文场景下误差非常大。中文一个字在多数tokenizer里对应1-2个token直接按字符数估算会把预算搞错。建议直接用模型对应的tokenizer做精确计数这块后面章节详细说。3.2 摘要压缩模式用信息密度换长度当对话轮次比较多或者历史信息虽然不那么重要但后续可能被引用时滑动窗口直接丢弃就太浪费了。这时可以用摘要压缩策略。摘要压缩的核心思路是定期把较早的对话内容交给模型做一次浓缩生成一段状态摘要然后用这段摘要替代原始对话塞入上下文。这种模式在很多AI Agent框架里都有实现OpenAI的memory机制、Claude的auto-compact都基于类似思路。实际操作中我把压缩分为两级第一级是静态摘要发生在对话达到一定轮次阈值时触发。比如每次积累到20轮对话就对最早的那10轮生成一段200字以内的摘要替换掉原文。第二级是滚动摘要每次生成摘要时把上一轮的摘要和新积累的对话合并后再压缩形成一个持续更新的状态记录。def summarize_history(conversation: list, existing_summary: str ) - str: 对历史对话生成滚动摘要 if not existing_summary: target conversation instruction 请将以下对话浓缩为一个简明的状态摘要保留关键信息 else: target conversation instruction ( 这是此前对话的摘要\n f{existing_summary}\n\n 这是新增的对话内容\n f{conversation_to_text(conversation)}\n\n 请结合旧摘要和新内容生成一份更新的综合摘要 保留对后续对话可能有用的事实、用户偏好和未完成事项 ) response call_llm(instruction conversation_to_text(target)) return response[content]压缩模式有几个必须注意的地方。第一摘要阶段本身也在消耗token如果压缩频率太高压缩成本可能超过节省成本。我的经验是压缩性价比的临界点大约在原文长度的3倍以上——也就是一段对话原文有3000 token摘要压到1000 token以下时才值得做。第二摘要会丢失细节。如果用户中途问过具体的数字、地址、日期这些信息很可能在摘要中被模糊化。所以压缩模式比较适合态度型或状态型对话比如用户偏好、任务进度、已确认事项不适合精确型信息比如合同金额、配置参数、代码片段。精确信息应该走检索模式而不是压缩模式。第三压缩请求建议用更便宜的模型。摘要任务本身不需要太强的推理能力用大模型做纯属浪费。我在生产环境里用小型模型做压缩成本大约能再降一个数量级摘要质量并没有明显下降。3.3 检索增强模式让上下文从全部塞入变成按需取用检索增强RAG其实是另一种形态的context-mode——它的核心思想是我不再试图把所有信息都塞进上下文而是根据当前问题先从外部知识源里找回最相关的片段再把片段作为上下文喂给模型。这个思路对长文档场景几乎是唯一可行的方案。一个标准的检索增强context-mode包含四个环节第一个环节是索引构建。把文档切分成合适的块chunk为每个块生成向量嵌入存入向量数据库。切分的粒度很关键切得太粗定位不精准切得太细上下文碎片化丢失段落之间的语境。我常用的经验值是一块500-800个中文字符同时保留10%-15%的重叠避免关键信息被切在边界上。这个参数没有绝对标准建议根据你的文档类型实测调整。第二个环节是召回。把用户的当前问题做向量化去向量库里做相似度检索取回TopK个候选块。这里TopK的选择直接影响上下文组装K太大噪音多K太小可能漏掉重要信息。我一般先取Top10做重排再根据token预算保留Top3-5。第三个环节是重排。向量召回的结果只保证语义相关不保证信息互补常常会出现多个块讲的是同一件事的情况。用一个重排模型reranker对候选块重新打分再做去重能明显提高最终上下文的质量。第四个环节是组装。需要把检索出的片段和对话历史、系统提示按合理的顺序拼接。这里有个容易忽略的细节检索出的多个片段之间如果存在转折或递进关系最好在片段前加一个引导词比如以下是参考材料之一以下是相关政策原文帮助模型理解每个片段的定位。检索增强模式的复杂度明显高于前两种但它的优势在于可扩展性极强无论知识库有多大每次调用模型时塞入的上下文都限制在固定范围内成本可控、效果稳定。如果你做的是知识库问答类应用我强烈建议直接上这套方案而不是在把全文都塞进去上做优化。4. 手写一个轻量 context-mode 管理模块4.1 整体结构与数据模型理论讲完进入实操环节。我把自己在项目中沉淀的一个轻量context-mode管理模块完整拆解出来你可以直接参考甚至复制到项目里改改用。这个模块的设计目标有四个支持多种模式切换、token预算可控、接入成本低、容易扩展。整体分为三层第一层是数据模型层定义Message、Conversation、ContextPolicy这几个基础类。Message记录单条消息的role、content、timestamp、token数Conversation维护完整的消息序列ContextPolicy是配置中心保存当前使用的模式和相关参数。第二层是策略层实现三种模式对应的Builder类SlidingWindowBuilder、SummaryBuilder、RetrievalBuilder。每个Builder都实现一个统一的build(messages, policy)接口输入完整对话记录输出最终拼好的上下文消息列表。第三层是调度层根据场景需求选择合适的Builder负责任务分发。核心的数据模型用Python定义大致长这样dataclass class Message: role: str # system / user / assistant / tool content: str timestamp: float field(default_factorytime.time) token_count: int 0 def __post_init__(self): if self.token_count 0: self.token_count estimate_tokens(self.content) dataclass class ContextPolicy: mode: str # sliding / summary / retrieval max_tokens: int 8000 # 单次调用的上下文预算 max_messages: int 20 # 滑动窗口的消息条数上限 keep_last_rounds: int 3 # 必须完整保留的最近对话轮数 summary_trigger_rounds: int 10 # 压缩模式触发轮次 retrieval_top_k: int 5 # 检索模式取回片段数4.2 核心代码实现核心的Builder接口和滑动窗口实现class ContextBuilder(ABC): abstractmethod def build(self, conversation: Conversation, policy: ContextPolicy) - list[Message]: pass class SlidingWindowBuilder(ContextBuilder): def build(self, conversation: Conversation, policy: ContextPolicy) - list[Message]: messages conversation.messages # 第一步保护最近几轮完整保留 recent messages[-policy.keep_last_rounds * 2:] if len(messages) policy.keep_last_rounds * 2 else messages # 第二步从更早的历史里在token预算内尽可能多保留 early messages[:-len(recent)] if len(messages) len(recent) else [] budget policy.max_tokens - sum(m.token_count for m in recent) selected_early [] for msg in reversed(early): if budget 0: break if msg.token_count budget: selected_early.append(msg) budget - msg.token_count selected_early.reverse() return selected_early recentsummary模式的Builder核心逻辑是检查是否达到压缩触发阈值class SummaryBuilder(ContextBuilder): def __init__(self, summary_modelfast-model): self.summary_model summary_model def build(self, conversation: Conversation, policy: ContextPolicy) - list[Message]: messages conversation.messages # 如果对话轮次没有超过阈值直接走滑动窗口逻辑 if len(messages) policy.summary_trigger_rounds * 2: return SlidingWindowBuilder().build(conversation, policy) # 保留最近几轮完整对话更早的部分生成摘要 recent messages[-policy.keep_last_rounds * 2:] to_summarize messages[:-len(recent)] summary get_cached_summary(conversation.conversation_id, to_summarize) if not summary: summary summarize_history(to_summarize, existing_summaryget_prev_summary(conversation.conversation_id)) cache_summary(conversation.conversation_id, summary) summary_msg Message(rolesystem, contentf[历史对话摘要] {summary}) return [summary_msg] recentretrieval模式的Builder核心是组装检索结果class RetrievalBuilder(ContextBuilder): def __init__(self, vector_store, rerankerNone): self.vector_store vector_store self.reranker reranker def build(self, conversation: Conversation, policy: ContextPolicy) - list[Message]: # 取用户最近一次提问作为检索query query conversation.messages[-1].content # 召回候选块 candidates self.vector_store.search(query, top_kpolicy.retrieval_top_k * 2) # 重排如果有reranker if self.reranker: candidates self.reranker.rerank(query, candidates) # 按token预算截断 context_parts [] budget int(policy.max_tokens * 0.6) # 检索上下文占六成预算 for doc in candidates[:policy.retrieval_top_k]: if budget 0: break part f[参考材料{chr(ord(①) doc.order)}]\n{doc.text} if estimate_tokens(part) budget: context_parts.append(part) budget - estimate_tokens(part) # 检索上下文放在中间前后分别是系统提示和当前问题 context_block \n\n.join(context_parts) if context_parts else messages [ Message(rolesystem, content你是知识库问答助手请基于参考材料回答用户问题。若材料中无相关信息请如实说明。), Message(roleuser, contentf参考材料\n{context_block}\n\n用户问题{query}) ] return messages4.3 接入业务场景的完整流程把上面这些模块接入实际业务大致分五步。第一步在初始化阶段创建Conversation实例和ContextPolicy确定使用哪种模式。我一般把模式配置放在环境变量或配置中心里方便不同环境调整。比如开发环境用sliding模式降低调试成本生产环境用retrieval模式保证效果。第二步在每次用户输入到来时把用户消息追加到Conversation里。注意这里追加的应该是经过预处理的消息比如清洗后的文本、标注了意图的元信息。第三步调用对应Builder的build方法生成最终发给模型的消息列表。第四步把消息列表交给模型API得到回复后把assistant的回复也追加到Conversation里。第五步根据策略定期做维护滑动窗口模式什么都不用做summary模式在触发阈值时执行压缩并生成摘要缓存retrieval模式在后台增量更新向量索引。这套流程看起来不复杂但能解决绝大多数业务场景的上下文管理问题。更重要的是因为策略被抽象成了统一的Builder接口后续想换策略、调参数都只动配置和策略类业务代码不用改。5. 常见问题与排查技巧实录5.1 上下文越长回答质量反而下降这个问题几乎每个做长文本问答的团队都会遇到。你明明把相关材料都塞给模型了模型给出的答案却不如只给一小段精选材料时准确。我在排查这类问题时一般按三步走第一步检查关键信息是否落在输入的中部。把实际发给模型的完整prompt打印出来人工看一遍如果关键材料位于一大段文本的中间模型确实容易忽略。解决方法是把关键材料尽量放在系统性总结之后、用户问题之前的位置或者直接用请特别注意以下材料这样明确强调的提示词。第二步检查是否存在多段材料相互干扰。当多个参考片段包含相似但矛盾的表述时模型可能被带偏。解决方法是引入重排去重把信息冗余降到最低。第三步检查system prompt与检索内容之间的衔接。很多工程里system prompt写得很长很复杂把检索内容挤到了输入靠后的位置模型对后部内容虽然注意但token预算有限深层次信息就没有空间展开。这种情况需要对system prompt做精简。5.2 token 数统计不准确导致预算失控这个问题在中文场景下尤其明显。很多人为了省事用字符数除以一个估算系数来算token结果要么高估导致上下文没塞满就截断了要么低估导致直接把请求发到模型那边才发现超限。正确做法是用模型对应的tokenizer做精确统计。如果你的模型是OpenAI系列的用tiktoken库如果用开源模型用HuggingFace的tokenizer库。准确统计的代码成本并不高但能避免大量线上问题。import tiktoken _encoders {} def estimate_tokens(text: str, modelgpt-4o) - int: key model if key not in _encoders: _encoders[key] tiktoken.encoding_for_model(model) return len(_encoders[key].encode(text))这里有个细节不同模型的tokenizer差异很大如果切换了模型缓存一定要按模型做隔离否则用了不匹配的tokenizer统计出来的数字会有明显偏差。5.3 多轮对话中的记忆漂移用户连续问几轮之后模型突然忘掉了前面某轮确认过的信息这是summary模式最容易出的问题。原因基本都出在摘要阶段信息丢失摘要模型在压缩时把一些看起来无关紧要但后续实际被引用的细节滤掉了。我的排查方法是把每次生成的摘要和原始对话做对比看关键实体人名、数字、日期、产品名、需求点是否都保留。实测中有个很实用的小技巧在压缩指令里明确要求必须原样保留所有数字、日期、名称、金额等实体不要做任何改写或省略。加上这句话之后摘要里的关键信息丢失率显著下降。另外如果你发现某个信息对业务特别关键不要依赖摘要应该单独设置一个关键信息存储区在对话过程中就把这些信息抽取出来单独存放组装上下文时直接把这块内容拼进去。5.4 性能与延迟的平衡context-mode做得越复杂前置操作就越多。检索模式下向量检索加重排可能增加几百毫秒的耗时summary模式下压缩请求本身也是一次模型调用同样会产生延迟。我在项目里常用的优化手段有三个第一个是缓存。向量检索结果按问题向量hashTopK做缓存短时间内相同或相似的问题直接走缓存。摘要结果按对话ID缓存只有新对话才重新生成。第二个是并行化。当一次请求需要同时做历史摘要和知识检索时把这两个独立操作并行执行可以省掉一半的串行等待时间。第三个是异步预取。对于Agent类型的应用可以在用户还在输入过程中就提前做完上一轮上下文的预处理用户点击发送后直接进入模型调用环节体感延迟会明显降低。另外一个架构层面的建议如果业务对延迟极其敏感可以把context-mode的组装结果和模型调用做缓存对齐。相同的问题、相同的上下文直接命中缓存返回连模型都不用调。6. 一些让我少走弯路的经验小结最后分享几个我在多次项目里反复验证过的判断不一定适用所有场景但大概率能帮你避坑。第一能用滑动窗口解决的场景不要急着上检索增强。检索增强的工程复杂度不是开玩笑的涉及向量库运维、切分调参、重排模型部署都需要成本。如果你的应用是客服助手、闲聊机器人这类对话轮次不多、历史信息价值有限的场景滑动窗口加一个合理的摘要机制就完全够了。第二上下文的软预算要留有余量。我在设定max_tokens时通常会预留10%-15%的buffer因为实际构建prompt时系统提示词、角色设定这些额外内容也会占用token。如果预算卡得太死很容易出现prompt拼完发现超限又要临时截断的尴尬情况。第三把上下文组装逻辑做成可观测的。在开发环境里把每一次组装出的完整prompt落盘方便调试时逐条回溯。线上出问题时这可能是你定位为什么模型回答不对的唯一线索。很多团队只记录模型返回结果不记录输入的prompt出问题时就只能靠猜。第四context-mode不是一次设计定死的东西。模型在升级、业务在变化、数据分布也在变合理的做法是把模式、参数全部配置化每月抽时间review一次线上数据看看有没有更好的策略组合。我自己的经验是一个成熟的AI应用上下文策略的迭代频率不会低于业务代码的迭代频率。说实话context-mode这个topic没有多高深核心就是如何在有限的窗口里把最有价值的信息用最高效的方式组织起来。但正是这个看似基础的问题决定了你的AI应用在实际使用中到底好用还是难用。希望这篇文章能帮你把这个环节做扎实。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进