ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

轻量级LLM上下文管理:多轮对话中的Token压缩与注入实践

2026/10/8 11:51:59 拓冰建站 浏览量
轻量级LLM上下文管理:多轮对话中的Token压缩与注入实践 最近在复盘一个我自己维护的开源小项目名字就叫context-mode。说白了它就是一个管LLM上下文的小工具主要解决多轮对话里历史记录怎么存、怎么压缩、怎么塞回prompt的问题。如果你也在做AI客服、写作助手这类应用大概率会遇到上下文窗口不够用、token成本飙升、模型越聊越傻这些坑这个项目就是冲着这些问题去的。做LLM应用开发的人应该都清楚上下文context是决定模型输出质量的关键。同样是写一段代码把用户历史需求、报错信息、修改记录都完整塞进去模型给出的结果和只给最后一句提问完全不是一个水平。但上下文又不能无限塞窗口有上限token要花钱塞得太多模型反而会被无关信息干扰。context-mode做的就是这一层管理让你在有限的窗口里只保留对当前任务最有用的那部分历史信息。这篇文章我会从项目设计思路、核心实现、实际接入步骤和踩坑记录几个方面展开适合正在做智能客服、AI写作插件、聊天机器人或者任何需要管理多轮对话状态的人参考。内容不依赖特定框架用Python写的主逻辑接入OpenAI兼容接口你换成其他模型也差不多。1. 项目概述与设计思路1.1 为什么专门做一个上下文管理模块很多人刚开始写AI应用的时候思路很简单把聊天记录全部存下来每次调用接口时把所有记录拼到prompt里完事。在小规模demo里这确实能跑但东西一上线问题就来了。第一个问题是token消耗。假设用户一次性聊了50轮每轮平均200 token全部塞给模型就是10000 token。如果模型是GPT-4这类这个成本很快就不能看了。第二个问题是上下文窗口溢出。现在的模型窗口虽然越来越大但10000、20000 token的上下文一旦跨过边界接口直接报错用户当场卡死。第三个问题是“注意力稀释”。模型面对一大堆历史信息时真正重要的是最近几轮的关键信息和任务目标老早的寒暄、无关的中间步骤反而是噪音会干扰模型理解当前意图。context-mode的定位就是把这些琐碎问题收拢成一个统一的模块。它不关心你的业务逻辑只负责三件事把会话语料按模式分组、按预算裁剪、按策略注入。我设计的第一版只用了不到500行代码却能把一个原本30000 token的长会话压缩到平均4000~6000 token而用户语义理解准确率基本不降这就值回票价了。1.2 核心目标让上下文可控、可预算、可解释我给自己定的设计原则有三条。第一上下文必须可预算。每次请求发出的token数不能靠感觉要有明确的预算上限。比如设置max_budget6000那么不管会话多长最终拼进prompt的内容必须在6000 token以内。超出的部分要么截断、要么压缩不能突破红线。第二模式切换必须动态。不同的任务阶段对上下文的需求不一样。用户在闲聊模式只需要最近几轮用户在要求写代码或者改方案时需要项目级的信息用户在总结或续写长文时又需要全文级的历史脉络。context-mode把这种差异抽象成“模式”由调用方根据当前意图切换也可以规则自动判断。第三整个处理过程必须可解释。每次请求到底走了什么策略、保留了多少轮历史、压缩掉了哪些内容、最后注入的system prompt是什么都要能通过日志还原。否则出了问题你根本不知道模型为什么答非所问。这三点也是后来选择把它做成独立模块而不是塞在业务代码里的原因。只有独立出来才能写单元测试才能在不同项目间复用。1.3 技术选型为什么不用现成框架而是自己写一个模式管理器市面上其实已经有LangChain的Memory、Haystack的Memory等方案我也评估过但最后还是自己写了context-mode。原因是现成框架通常把“上下文管理”绑在对话链路上你只能按它定义的接口去存memory、取memory很难按业务语义自定义“模式”。我需要的是一个更底层、更轻量的东西。它不绑定任何Agent框架也不要求你重写业务流程。它只提供一个库函数级的API你丢给它一条新的用户消息它还给你一个组装好的prompt你告诉它当前处于什么模式它自动决定怎么压缩历史。这种设计非常适合那些已经跑得不错的业务系统做渐进式接入而不是推倒重来。实现技术栈也尽量简单Python 3.10纯标准库加两个轻量依赖tiktoken用于token计数openai用于接口调用。存储层用了内存字典加LRU淘汰生产环境可以无缝换成Redis。后面会讲到具体代码。2. 核心机制拆解三种模式与上下文调度2.1 三种上下文模式的语义设计context-mode把会话状态分成三种模式名称很直白concise、balanced、full。没有用特别玄学的词因为团队协作时大家好理解。concise模式适合高频短对话比如客服场景里的FAQ问答、闲聊、快速指令。它只保留最近3~5轮消息加上一条精简的system prompt。优点是省token、响应快缺点是模型看不到太久远的信息遇到需要回忆几轮前提的追问会失效。balanced模式适合一般任务比如文档修改、代码调试、方案讨论。它会在保留最近8~10轮的基础上把更早的内容压缩成“历史摘要”摘要里保留关键实体和结论丢掉寒暄和重复过程。这是默认模式因为大部分AI应用的真实需求是“记得住大概又别太贵”。full模式适合长文编辑、阶段性总结、跨轮次推理。这个模式下会尽可能保留所有历史但会做一次结构化裁剪把每条消息按角色、意图、关键字段抽出来去掉大量冗余表达用一个紧凑的JSON结构存下来。窗口内塞不下时再对中间时段做摘要处理。我画过一张对比表放在文档里核心参数就是下面这个。模式保留完整轮数摘要比例上限典型预算token适用场景concise3~510%2000~3000FAQ、闲聊、快速指令balanced8~1050%4000~6000文档修改、代码调试、方案讨论full全部结构裁剪视预算而定8000长文编辑、跨轮次推理、阶段总结2.2 token预算的管理究竟是按字算还是按token算这里必须强调一个坑很多人按字符数来估算长度然后发现死活算不准。因为同样的字符英文一个单词可能只占一两个token中文一个汉字往往占一到两个token表情符号、代码块、换行也会占用额外的token。context-mode从一开始就使用tiktoken按真实token数来统计不是估算是准确计数。拿GPT-4的tokenizer来说1000个英文字符大约对应250~300 token但1000个汉字大约对应700~1000 token。如果你的业务里中英文混杂还带代码按字符估算的误差可以到三倍。等到接口报错说超过max_tokens的时候就晚了。所以我在ModeManager里专门封装了一个count_tokens函数import tiktoken _enc tiktoken.get_encoding(cl100k_base) def count_tokens(text: str) - int: if not text: return 0 return len(_enc.encode(text))这里用的cl100k_base是OpenAI当前比较通用的编码器和GPT-4、GPT-3.5-turbo系列兼容。其他模型的话比如Claude或者国产模型它们有自己的tokenizer那就要替换成对应的计数方案。好在接入点很统一只要在init时传一个count_tokens函数进来就能适配任意模型。2.3 三档预算的分配方式有了token计数基准接下来就看怎么分配预算。我的做法是把一次请求的总预算分为三块system prompt预算、历史上下文预算、当前问题预算。比如总预算上限是6000 token我会留出约800给system prompt当前问题最多占1200那历史上下文就剩下4000。为什么这么分因为system prompt是模型的“行为准则”不能太短当前问题是用户正在关心的事情不能压缩能动的只有历史部分。分配后再按模式执行策略concise历史最近2轮完整消息 30字摘要balanced历史最近8轮完整消息 早期摘要不超过历史预算的一半full历史尽可能保留结构化记录超出部分按时间衰减做摘要。每个模式都有独立的“保底轮数”和“最大摘要占比”两个参数方便实际调优。2.4 上下文压缩的实现不只是截断而是结构化摘要最初我在concise模式下只是简单截断结果发现很多用户追问“你刚才不是说那件事吗”模型完全想不起来。后来改成“结构化摘要”之后效果好了很多。具体做法是每次压缩前先用一次小的LLM调用或者正则实体提取把早期历史转成一个固定格式的摘要内容包含用户核心目标、已完成的关键动作、待办事项、重要参数或人名地名。比如一段客服会话会把“用户报修型号为A200的打印机已完成网络配置等待固件更新”这种信息保留下来把“您好在的”“谢谢”这类寒暄全部丢弃。这里有个取舍问题摘要本身也要费token。如果每10轮就压缩一次摘要累积起来的token数也会膨胀。我的方案是设置一个摘要阈值只有历史超过一定长度才触发压缩而且摘要只保留两层一级摘要管最近3次压缩之间的信息二级摘要管更早的信息。超过二级摘要的信息就直接丢弃靠full模式才能恢复。这个设计的核心思路是让模型看到的信息永远是“精确的部分浓缩的概览”而不是一坨越来越长的原始流水账。3. 实操过程从零手写一个context-mode框架3.1 项目结构和依赖准备把项目clone下来或者自己建目录推荐结构是这样context-mode/ ├── context_mode/ │ ├── __init__.py │ ├── manager.py # 核心ModeManager类 │ ├── storage.py # 内存存储与LRU淘汰 │ ├── compress.py # 摘要压缩与结构化抽取 │ ├── policy.py # 三种模式的策略配置 │ └── utils.py # token计数、日志 ├── examples/ │ ├── chatbot_demo.py │ └── long_article_writer.py ├── tests/ │ └── test_manager.py └── README.md依赖方面我实际测试用的Python 3.10只需要tiktoken、openai两个第三方库。存储默认用内存dict生产环境建议换Redis因为多进程部署时各进程的内存态不共享。3.2 核心类ModeManager的骨架下面这是核心代码的简化版但足以跑通主流程。我为了可读性省略了类型标注和异常处理的细节生产版本比这个多几十行校验。class ModeManager: def __init__(self, modebalanced, max_budget6000, count_tokensNone): self.mode mode self.max_budget max_budget self.conversation [] self.summary [] self.count_tokens count_tokens or default_count_tokens self.policy get_policy(mode) self._lock threading.Lock() def add_user_message(self, content): self.conversation.append({role: user, content: content}) def add_assistant_message(self, content): self.conversation.append({role: assistant, content: content}) def switch_mode(self, mode): self.mode mode self.policy get_policy(mode) def build_prompt(self, system_prompt): with self._lock: context_messages self._prepare_context() prompt_messages [{role: system, content: system_prompt}] prompt_messages.extend(context_messages) return prompt_messagesbuild_prompt就是整个模块的出口。它不关心业务逻辑只负责返回一个可以直接发给模型API的messages数组。内部的核心逻辑在_prepare_context里。3.3 上下文裁剪策略滑动窗口摘要注入_prepare_context的实现是这个项目的灵魂。我把它拆成三步。第一步根据当前模式计算保留的完整消息轮数。concise保留2轮balanced保留8轮full则保留全部但优先做结构化裁剪。第二步把被淘汰的早期消息交给compress模块生成摘要。压缩返回的不是普通文本而是“摘要字典列表”每个元素表示一条关键信息{type: goal|action|parameter|conclusion, text: ...}。第三步把摘要和保留消息拼成一个紧凑的列表再检查总token数是否超过历史预算。如果超过就进一步减少完整消息轮数或对最后的保留消息做“精简复述”。这个过程我会做一个循环最多尝试三次三次后仍超过就直接丢弃中间部分只保留首尾。def _prepare_context(self): keep_rounds self.policy[keep_rounds] recent self.conversation[-keep_rounds * 2:] if keep_rounds ! -1 else self.conversation old self.conversation[:len(self.conversation) - len(recent)] summary_text self._build_summary_text(old) messages [] if summary_text: messages.append({role: system, content: f历史摘要{summary_text}}) messages.extend(recent) # token超标时逐步收缩 for _ in range(3): cost sum(self.count_tokens(m[content]) for m in messages) if cost self.history_budget: break recent recent[2:] messages messages[:1] recent return messages这里有个容易忽略的细节摘要如果放在system里模型通常会把它的优先级理解得更高这是我踩过坑之后刻意选择的。因为历史摘要代表了“确定性的结论和事实”比用户的下一句话更需要模型遵循。3.4 接入OpenAI兼容接口的完整示例框架写完之后接模型就很简单。假设你用的是OpenAI的ChatCompletion接口或者任何兼容的库只需要在请求前调用build_prompt。import openai manager ModeManager(modebalanced, max_budget6000) def chat(user_input): manager.add_user_message(user_input) messages manager.build_prompt( system_prompt你是一个严谨的AI助手。请基于历史摘要和用户最新消息回答。 ) response openai.ChatCompletion.create( modelgpt-4-0613, messagesmessages, temperature0.3, max_tokens1200, ) reply response.choices[0].message.content manager.add_assistant_message(reply) return reply, messages我在实际demo里还封装了一个memory_usage()方法每次调用后会打印modebalanced, budget6000, history_tokens3920, remain2080, summary_ratio0.35这样在调试阶段能直观看到每一次请求的上下文开销。3.5 场景一客服机器人接入第一个典型场景是客服机器人。这种场景的难点在于用户经常会在第5轮时回头说“刚才那个问题”。如果上下文不够模型根本不知道“刚才”指什么如果塞太多响应延迟和成本都上不去。我的做法是让客服机器人默认用balanced模式但在用户输入包含“订单号”“型号”“报修”等关键词时自动切到full模式完整记录整个沟通链路。等客服工单解决后再切回concise模式把长会话压缩成一条摘要存下来。实际跑下来的数据是接入前平均每次请求消耗12000 token接入后降到5500用户提问的首次解决率反而提升了约10%。主要原因不是token减少而是系统提示词和历史摘要让模型更聚焦在正事上不会被闲聊带偏。3.6 场景二长文写作助手接入第二个场景是长文写作助手比如帮用户续写小说或者生成方案文档。这种场景对上下文的要求极其挑剔既需要记住人物关系、前置情节又不希望模型被无关段落干扰。我在示例里用full模式配合结构化摘要来处理。每次迭代时Manager会把整部小说的“角色表”“时间线”“关键事件”作为常驻摘要把最近3章内容作为完整消息保留。这样模型续写时思路不会断也不会把角色名字写错。有一个参数值得分享摘要刷新频率。对长文写作我会在每新增5000 token后主动触发一次摘要重算而不是等到窗口快满才压缩。提前压缩的好处是模型不会在对话中段就丢失角色关系代价是每次重算也会消耗一些token。经过对比5000 token触发一次是性价比比较高的阈值。4. 常见问题与排查技巧实录4.1 问题一模式切换后模型仍然“失忆”我在内测阶段遇到过最诡异的问题明明已经切到full模式也把所有历史都塞进prompt了但用户提到十几轮前的信息时模型还是像没看到一样。排查了很久最后发现原因是full模式下单次请求太长触发了我代码里的一个“截断保护”当历史消息超过总预算时循环收缩逻辑把early段落全部裁掉了等于没切full。这类问题很隐蔽因为日志里不会直接报错。我后来在build_prompt的返回结构里加了一个debug字段把每次裁剪后保留的消息条数和丢弃的部分都记录下来。问题一旦出现直接翻日志看最后三次裁剪操作。排查建议是永远不要只看最终返回的messages要把“发生了什么”打在日志里。4.2 问题二token计数不一致导致预算失控我刚开始用tiktoken的cl100k_base来估算但在某个版本中遇到OpenAI接口实际计费token和本地统计对不上导致线上请求经常超限。原因是那个模型的tokenizer已经在接口侧做了升级而本地库版本还是旧的。解决方案很简单把tiktoken升级到最新版并在测试里做一个“计数校准”用例用固定文本对比本地计数和接口返回的prompt_tokens误差不能超过2%。接非OpenAI模型时这个校准步骤更必不可少因为你不能假设所有模型的tokenizer和OpenAI一致。4.3 问题三多进程部署后上下文丢失用内存dict存储conversation时如果部署了两台机器用户请求被负载均衡分到不同进程就会出现“模型完全不记得上一轮说过什么”。这是因为每个进程各存各的根本没有共享状态。解决办法有两个。简单方案是进程内加一个用户ID到Manager的映射并且用sticky session保证同一用户打到同一进程。正规方案是把storage层换掉继承一个Storage接口实现Redis版本把conversation和summary存到Redis的hash里key是session_id。我实际部署用的是第二种因为线上不能保证sticky session永远有效。Redis方案的关键是序列化格式我用的json直接看debug信息也方便。需要提醒的是Redis里不要存原样的长文本列表要定期做压缩合并否则存多了会占用大量内存。4.4 问题四压缩摘要的幻觉渗入对话压缩时用LLM做摘要摘要本身就可能出现“模型自己脑补”的内容。比如客服场景里摘要把用户没有说过的“已经付款”写进去后续问答就会沿着错误前提走。这个问题的根本原因是摘要模型和对话模型是同一个温度参数我初版把temperature设置成0.7摘要时也会发挥。修正办法是把摘要调用的temperature固定为0并且在摘要生成后加一道校验只保留原文中出现过的实体和动词凡是摘要里出现但原文没有的高频词一律丢弃。虽然实现起来要多写几行正则和比较但效果非常明显。4.5 常见问题速查表现象可能原因解决方法模型完全失忆历史被截断保护裁掉查看debug字段调整裁剪收缩逻辑token预算被突破本地tokenizer版本落后升级tokenizer并做接口计数校准多进程上下文丢失内存存储不共享换Redis存储并设置session维度key摘要内容与原文不符摘要模型幻觉温度设为0实体校验后再入prompt切换模式不生效旧的Manager引用未释放检查switch_mode是否触发策略重载长会话响应变慢过多消息参与摘要重算提前触发摘要刷新减少重算频率4.6 排查工具和日志设计工欲善其事必先利其器。context-mode在debug方面我做了三件小事效果立竿见影。第一件是给Manager加了一个_log_events方法每次进入和退出build_prompt都会记录一条结构化日志模式、预算、压缩策略、丢弃消息数、耗时。这样线上出了奇怪问题你可以先查日志定位是在哪一步出错的。第二件是写了一个“重放脚本”。给定一个真实会话的json文件脚本会按同样顺序调用Manager并在每一步打印messages数组的token分布。因为每次操作都能复现发现问题就很好办了。第三件是断言测试。test_manager.py里写了一批针对模式切换和token守恒的断言保证任何改动都不会让“上下文丢失率”悄悄上升。这个指标我一直盯着因为它比单次正确率更敏感。5. 一些经验心得和后续扩展这个项目从立项到第一版可用大概用了两周中间大部分时间不是在写功能而是在调整“摘要什么时候触发、保留几轮、怎么注入”这些细节。说实话上下文管理没有银弹最终都是各种取舍后的平衡。我的体会是不要试图做一个“聪明的全能上下文存储器”那只会越来越复杂。context-mode现在很克制的只做三件事按模式定预算、按预算压缩历史、按策略注入摘要。够用就好。如果后续要扩展有几个方向我觉得很有价值。第一是把模式从手写枚举改成“基于用户意图的概率预测”用一个小模型判断当前应该用concise还是full。第二是加入向量检索把早期历史按语义相似度召回而不是简单的时间衰减。这两个方向都能进一步提升长会话的表现但也会引入新的复杂度得按项目阶段来取舍。最后分享一个我在使用中最常开的“后门”调试时把system prompt里的历史摘要直接替换成“你不需要关注历史摘要只回答用户最后一条问题”然后对比效果。这个操作能在几分钟内帮你判断模型跑偏到底是历史摘要的问题还是系统提示词的问题。用多了你会慢慢摸清自己业务里什么该保留、什么该丢这比看任何文档都管用。如果你也在做类似的事情欢迎去把context-mode翻出来看看。别管代码写得多糙先跑通再优化上下文管理这件事就得在真实流量里练。今天的分享就到这里希望能帮你在自己的项目里少踩几个坑。