最近在复盘一个我自己维护的开源小项目,名字就叫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_budget=6000,那么不管会话多长,最终拼进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) | 适用场景 |
|---|---|---|---|---|
| concise | 3~5 | 10% | 2000~3000 | FAQ、闲聊、快速指令 |
| balanced | 8~10 | 50% | 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, mode="balanced", max_budget=6000, count_tokens=None): 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(mode="balanced", max_budget=6000) def chat(user_input): manager.add_user_message(user_input) messages = manager.build_prompt( system_prompt="你是一个严谨的AI助手。请基于历史摘要和用户最新消息回答。" ) response = openai.ChatCompletion.create( model="gpt-4-0613", messages=messages, temperature=0.3, max_tokens=1200, ) reply = response.choices[0].message.content manager.add_assistant_message(reply) return reply, messages我在实际demo里还封装了一个memory_usage()方法,每次调用后会打印:
mode=balanced, budget=6000, history_tokens=3920, remain=2080, summary_ratio=0.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翻出来看看。别管代码写得多糙,先跑通,再优化,上下文管理这件事就得在真实流量里练。今天的分享就到这里,希望能帮你在自己的项目里少踩几个坑。