ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLM上下文管理实战:context-mode模块设计与三种模式解析

LLM上下文管理实战:context-mode模块设计与三种模式解析 1. 项目概述与需求拆解做LLM应用开发做到一定阶段基本都会撞上一个绕不开的墙上下文窗口。你发现模型明明支持128K甚至200K但业务一跑起来不是你自己的prompt模板太长把窗口挤没了就是多轮对话存了几轮就报错再不然是检索出来的资料一拼进去模型反而开始胡言乱语。这些问题表面上是文本太长本质上是上下文管理方式太粗暴。我去年在做一个内部知识库问答助手时前前后后被上下文问题折磨了两个多月最后逼着自己写了一个小模块——context-mode专门用来处理上下文怎么进模型这件事。这篇文章就把整个过程记录下来包括设计思路、核心代码、踩过的坑。context-mode是什么它不是一个重量级框架而是一套可插拔的上下文管理模式。简单说就是在把对话历史、检索文档、用户当前输入拼装成最终prompt之前先经过一个模式处理层由这个层决定哪些内容保留、哪些内容舍弃、哪些内容压缩后保留、哪些内容要临时去检索补充。核心目标是解决三个问题窗口溢出、相关性稀释、信息丢失。适合谁来读如果你正在做ChatBot、RAG应用、Agent或任何需要维护多轮对话状态的项目并且开始在上下文管理上感到吃力那这篇文章应该能帮上忙。我会从零开始讲代码都是可以直接抄走改的那种不需要你有很深的框架基础。2. 核心设计思路与方案选型2.1 从提示词工程到上下文模式的演进早期做LLM应用大家都习惯把上下文管理写死在业务代码里。比如一个简单的问答接口先拼一个system prompt再把用户问题拼进去最多加一句请基于以下资料回答。这种做法在demo阶段没问题但一旦进入真实业务场景就暴露了三个痛点第一上下文长度不可控。多轮对话时业务方希望记住用户10轮前的偏好但把完整历史全塞进去很快窗口就满了。第二信息权重不可控。知识库检索结果可能有三五段哪些是真正重要的模型并不知道往往会把次要信息当成重点。第三处理逻辑不可复用。今天你在项目A里写了截断逻辑明天项目B又要重写一遍而且项目A和B的处理策略可能还不一样。context-mode的出发点就是把这层逻辑从业务代码中抽出来做成一个独立的、可配置的模块。它不做具体业务只负责回答一个问题给定当前输入、历史消息、外部资料和模型窗口限制我应该把哪一段文本以什么形式放进prompt。2.2 三种核心模式的定义在设计这个模块时我参考了社区常见做法最终收敛了三种模式分别对应不同业务场景truncation模式按时间顺序保留最近的消息超出长度限制的旧消息直接丢弃。适用于对历史依赖不强、只需要最近几轮就能回答的问题。实现最简单速度最快。summary模式把超出窗口限制的历史消息交给大模型压缩成摘要再让模型基于摘要最近消息当前问题来回答。适用于需要长期记忆的场景比如医疗问诊、法律咨询、客服工单跟进。retrieval模式从完整历史或外部知识库中检索与当前问题最相关的片段代替全部历史输入。适用于知识密集型场景比如企业文档问答。这三种模式不是互斥关系实际项目中经常是summaryretrieval混合使用。context-mode的设计目标之一就是支持自由组合而不是逼你在一个固定方案里做选择。下一节我会讲清楚这个模块的接口是怎么设计的。3. context-mode的模块设计与核心参数3.1 为什么需要一个统一入口先看一个大多数项目的现状上下文处理逻辑散落在各个函数里。有的在API层拼历史有的在数据库层截断有的在前端就限制消息条数。这种状态下你没法准确知道模型到底看到了什么出了问题也不知道是该调数据库查询还是该改prompt。我给context-mode设计的核心是一个统一入口——ContextManager所有上下文处理都必须经过它。这样做的好处很明显调试时可以只盯这一个入口观察输入输出策略调整只改一处不用满项目找还可以加日志记录每次请求实际喂给模型的上下文全貌。具体接口如下class ContextMode(Enum): TRUNCATION truncation SUMMARY summary RETRIEVAL retrieval class ContextManager: def __init__(self, mode, tokenizer, max_tokens, **kwargs): self.mode mode self.tokenizer tokenizer self.max_tokens max_tokens self.kwargs kwargs def build_context(self, messages, query, **kwargs): # messages: 历史消息列表格式与OpenAI一致 # query: 当前用户输入 # 返回最终拼好的prompt字符串 pass这里的max_tokens是整个模块最关键的参数。它不是模型的最大上下文长度而是你为上下文部分划分的预算。比如模型窗口是8192 tokens你计划把输出保留给模型1024 tokens那么上下文预算就是8192-1024-当前输入tokens。这里需要说明一下实际操作中我建议额外留出10%-15%的冗余因为模型输出长度是动态的预算卡太死容易出问题。3.2 参数的计算逻辑很多人第一次写上下文限制时直接把模型最大长度-当前问题长度当作历史消息长度上限这样其实有隐患。模型生成输出也需要token空间而且有些模型的tokenizer对特殊字符的编码并不完全可控。我建议这样计算def calc_context_budget(model_max_tokens, max_new_tokens, query_tokens_len, reserve_ratio0.15): # 模型最大窗口 # 预留输出tokens # 当前query tokens reserve int(model_max_tokens * reserve_ratio) budget model_max_tokens - max_new_tokens - query_tokens_len - reserve return max(budget, 0)reserve_ratio就是冗余比例。当初我在一个文档分析项目里就是因为不设冗余模型话多时直接截断了输出返回了半截JSON把下游解析搞崩了。加了15%冗余之后类似情况基本绝迹。另外关于tokenizer选型这里必须用一个和模型配套的tokenizer。如果用transformers库直接加载模型对应的AutoTokenizer如果用OpenAI接口建议用tiktoken库。不要凭感觉估算字符数中英文混排时字符数到token数的转换非常不稳定。4. 实操实现三种模式的完整代码解析4.1 truncation模式的实现truncation模式是最容易上手的但里面的细节比想象中多。直接上代码def build_truncation_context(self, messages, query): # 先将当前query作为最后一条消息 full_messages messages [{role: user, content: query}] # 计算单条消息的token数并倒序累积 reversed_budget [] token_count 0 for msg in reversed(full_messages): msg_tokens len(self.tokenizer.encode(msg[content])) token_count msg_tokens if token_count self.max_tokens: break reversed_budget.append(msg) # 恢复顺序 selected list(reversed(reversed_budget)) return selected这里的核心是倒序累积。为什么要从最后一条往前算因为对模型来说对话越往后的内容和当前问题的相关性通常越高。这个逻辑里有一个容易被忽略的点系统提示词system prompt要单独处理。我建议把system prompt拆出来不参与截断预算的计算始终保留。除非你的system prompt超过整体窗口的一半否则不会影响大局。还有一个小细节如果单条消息本身就超过剩余预算怎么办比如用户一次粘贴了一篇2000字的文章但预算只剩500 tokens。我采用的做法是直接截断这条消息的内容并在前面加上前置内容已省略标记。这样模型至少能看到用户问题的后半部分。4.2 summary模式实现summary模式稍微复杂一点。思路是当历史消息超出预算时先把较早的消息合并成一段调用一次大模型生成一段摘要然后把摘要最近消息当前问题作为最终上下文。我最初担心这样会多一次模型调用、增加延迟实测下来如果只在历史消息超限时才触发摘要平均延迟增加在可控范围内。实现代码def build_summary_context(self, messages, query, summarize_fn): # 先计算所有历史消息query的token数 history_text format_messages(messages) if len(self.tokenizer.encode(history_text)) len(self.tokenizer.encode(query)) self.max_tokens: return messages [{role: user, content: query}] # 超过预算触发摘要 # 分割点将最近N条消息保留其余进入摘要 recent messages[-6:] # 保留最近6条 pin_mode messages[:-6] summary summarize_fn(format_messages(pin_mode)) summary_msg {role: system, content: f以下是更早的对话摘要{summary}} return [summary_msg] recent [{role: user, content: query}]summarize_fn可以是任意可调用对象内部可以调用GPT、Claude、本地模型等。这里我踩过一个坑摘要任务本身会占用一次模型调用如果摘要模型和问答模型是同一个就会同时占用调用频率限制。后来我在团队内部改造时把摘要调用放在异步队列里避免阻塞主链路。但如果你只是个人项目同步调用问题也不大。再一个关键点是保留最近6条这个数字。6条是我根据业务经验定的但实际项目中应该根据max_tokens动态调整。比如预算大保留12条预算小保留3条。动态化思路是首先确定最近消息段的总token预期占预算的40%剩下的60%给摘要。按我的经验40%这个比例能让模型既接收到近期的具体事实又从摘要里获取足够背景。你可以按自己的业务微调。4.3 retrieval模式接入retrieval模式最大的存在意义是对话历史往往很长但回答当前问题可能只需要其中某几段。与其把全部历史塞进去不如先把所有历史切块、向量化、存入内存或向量数据库然后根据当前问题向量去检索最相关片段。结合LangChain或LlamaIndex代码量不大def build_retrieval_context(self, messages, query, vectorstore): # 将历史消息按一定窗口切块 history_text format_messages(messages) chunks split_text(history_text, chunk_size256, overlap32) # 这里用预先建好的向量库或者临时构建 # 如果是首次使用先add_texts if vectorstore._collection.count() 0: vectorstore.add_texts(chunks) docs vectorstore.similarity_search(query, k3) retrieved_text \n.join([doc.page_content for doc in docs]) # 拼装时当前query必须完整保留 return [ {role: system, content: f以下是检索到的历史片段{retrieved_text}}, {role: user, content: query} ]这段代码背后有一个非常核心的设计问题向量检索的粒度。chunk_size256是我常用的初始值但这个值取决于你业务的语义粒度。如果知识库是规章制度一条规定通常一两百字256比较合适如果是长篇小说可能需要512甚至1024。过小的chunk会导致语义断裂过大的chunk会导致检索噪声多。我在日志分析项目里对比过chunk从128调到256后回答准确率提升近10个百分点。所以这个参数值得多花时间调。还要注意对话历史的向量化不能每次都全量重建。我建议维护一个持久化的向量库每次新增消息时只增量更新。同时定期清理过期的历史记录避免向量库无限膨胀。5. 实战中的问题与排查经验5.1 上下文截断的三大隐藏问题第一个问题是截断位置不当。很多人写截断时直接从开头切掉一部分。这会导致模型丢掉关键身份设定。我建议把system prompt、当前问题、最近消息都设为不可截断区。第二个问题是消息角色错乱。截断后可能会出现历史里残留一条assistant消息但前面对应user消息被切掉了的情况。模型看到一条孤零零的assistant回复会产生迷惑。解决办法在截断逻辑里增加一个校验保证保留的历史消息始终以user开头。伪代码如下def sanitize_messages(selected): while selected and selected[0][role] ! user: selected.pop(0) return selected第三个问题是多字节语言被截断成乱码。如果tokenizer不是严格按多字节编码切分直接按字符截断可能产生半个汉字。用tokenizer.encode再decode回来就能避免这个问题不要用简单的len(text)来判断。5.2 摘要模式的递归压缩陷阱很多人让大模型生成摘要后直接把摘要当作历史然后在下一轮再次超限时又把摘要新消息一起压缩成新摘要。这个想法没问题但容易掉进摘要的摘要陷阱。举例第一轮历史有1000 tokens压缩成200 tokens摘要。第二轮又来了500 tokens新消息总长度是200摘要500新消息query还是超过预算于是模型把200摘要新消息压缩成新的300 tokens摘要。问题是摘要会丢失细节摘要的摘要会丢失更多细节几轮之后模型对原始事实的记忆就会严重失真。我采用的改进方案是分层摘要关键事实保留第一层对每轮历史生成一条语义标签数组而不是直接合并成唯一摘要。第二层每次压缩前先从原始历史中抽取出问题关键词、用户明确要求等结构化信息单独存放。第三层最终上下文拼装时取最近原始消息 历史摘要 关键事实列表而不是摘要摘要摘要。这个方案在客服工单场景中效果很好客户投诉的具体诉求不会被层层压缩丢掉。5.3 检索模式的评分与排序策略使用LangChain的similarity_search默认返回TopK但我在实际项目里发现相似度分数低的片段不一定没用。比如用户问退款流程检索到一段退款申请条件相似度0.72另一段是UPS物流损坏处理相似度0.65。向量模型可能认为前者更相似但在业务逻辑里用户关心的退款流程可能藏在售后处理流程的第五小节而向量检索只匹配了字面没匹配上下文。我的经验是不要只看相似度分数还要配合使用时间加权。对话历史中越近的消息对当前意图影响越大所以最终排序公式可以设计成score similarity_score * alpha recency_score * (1 - alpha)其中alpha建议在0.5到0.7之间。如果项目场景强依赖旧知识alpha调高如果是闲聊或即时问答alpha调低。实际调参时可以准备一组带标注问题跑一次离线评测对比不同alpha下的人为判断准确率。这个环节比较费时间但值得做做得越细后面上线越稳。6. context-mode的扩展方向写到这里context-mode已经从一个简单的上下文管理工具变成了一个可以灵活组合的框架。我在项目里最新加了一个Auto模式它会根据当前query的token量、历史消息数量、检索片段数量自动在truncation、summary、retrieval之间切换。这个逻辑也不复杂本质就是一个基于规则的决策器。例如当前query很短历史消息又多又杂那优先走retrieval历史消息里有用户明确指定的记住我说过的话则强制走summarying如果整个上下文加起来都不超过预算那就不做任何处理直接原样塞给模型。另外我还把context-mode做成了一个小包装器可以集成到任意调用OpenAI兼容接口的代码里。只要你在调用chat.completions.create之前把messages经过context_manager.build_context处理一下就能立刻改善长对话稳定性。如果你有兴趣可以按这个思路自己扩展把不同的模式策略写成插件甚至用配置文件来描述路由规则。我个人体会最深的一点就是上下文管理不是简单截断而是要在保留信息量和控制噪音之间找平衡。最初我总想一股脑全塞进去生怕模型漏了什么上下文结果模型输出越来越差后来学会做减法反而效果提升明显。context-mode里面最值得你反复调试的就是每个模式里的阈值数字——保留多少条消息、摘要长度控制在多少、检索TopK取多少。建议你先跑起来再用真实业务数据去调这些参数而不是照抄任何默认值。
返回列表