大模型上下文管理与Context-mode模式设计与实践
我最近在做AI应用,发现一个很有意思的现象:很多项目demo时跑得飞起,一上线就变蠢。排查到最后,十有八九是卡在同一个地方——上下文没管好。给大模型的东西多了太贵太慢还容易“看不过来”,给少了它又像金鱼一样只有七秒记忆。这段时间我把这个问题的解决方案单独抽出来做成了一个模块,代号就叫“context-mode”,也就是上下文模式。今天这篇就把它从设计到落地拆开讲一讲,包括模式怎么划分、token预算怎么算、压缩策略怎么选、代码怎么写、以及线上最容易踩的坑是什么。
如果你正在做带多轮对话的客服系统、AI编程助手、文档问答工具,或者只是想搞清楚“为什么我的模型总是答非所问”,这篇文章应该能给你一个很直接的参考。
1. context-mode是什么:先搞懂上下文为什么是瓶颈
1.1 一次不该发生的AI“翻车”
上个月我帮朋友调试一个智能客服,用户反复问“我那个订单到底退了没有”,AI却一会儿回答“您的订单已发货”,一会儿回答“抱歉,我不记得您问过这个问题”。后台日志一看,问题特别狗血:每个新请求都只把当前这一条用户消息发给了模型,前面的对话历史根本没带过去。也就是说,模型每次都在“失忆”状态下乱猜。
这种翻车不是个例。你做大模型应用,本质上做的是“和模型一起干活”,而模型能依赖的只有你塞给它的那串文字。这串文字就是上下文(context)。你再怎么说“你要记住啊”,它也不会自动记住任何东西。所以关键不是模型是否聪明,而是你喂给它的上下文是不是组织得合理。context-mode这个概念,就是我把“组织上下文”这件事做成的一套可配置、可切换的机制。
1.2 上下文模式的本质:给模型“分档记忆”
打个比方。你去一家银行办业务,前面一个客服接待你。他手头有三块信息源:第一块是墙上贴着的业务规则(必须一直遵守,属于系统级指令),第二块是你们俩刚才聊了什么(当前会话历史),第三块是你现在递过去的单据(当下输入)。这三类信息不可能全都一字不差背下来,接待员得根据你的业务复杂程度,决定自己“重点记什么、可以忽略什么”。
context-mode就是这个“分档记忆”的工程化版本。我把它分成三个基础档位:
- concise(快速模式):只保留系统指令和最近一两轮对话,适合意图识别、简单问答、关键词提取。
- balanced(平衡模式):保留最近5到10轮对话,老历史压缩成摘要,适合客服咨询、AI写作辅助、多轮交互。
- full(深度模式):尽量完整保留所有历史和检索内容,适合长文档问答、代码仓库分析、复杂推理。
每个模式本质是一组参数和策略的组合:token预算上限、保留几轮历史、用什么方式压缩老内容、是否触发自动切换。工程上做成模式,而不是写死一套逻辑,是为了让同一个后端服务能应对不同类型的请求。
1.3 这套东西适合谁
如果你是后端开发,关心接口延迟和成本;如果你是算法工程师,关心召回效果和上下文污染;如果你只是拿API做小工具,关心“为什么我的AI像傻白甜”——本质上都需要处理上下文。context-mode不是一个开源软件,也不是某个特定SDK,它是一整套“怎么给大模型喂信息”的方法论。你可以在自己的服务里从零实现,也可以用LangChain等框架配合实现,核心思路完全一致。
2. 设计思路:从需求到“模式”拆分
2.1 三个核心问题:记住什么、忘掉什么、怎么切换
我在设计context-mode之前先列了一张问题清单,因为只有把需求问清楚,才知道模式该怎么拆。这三个问题最重要:
第一,记住什么?模型窗口是有限资源。系统prompt是刚性的,必须完整保留;用户正在问的当前问题,必须完整保留;检索回来的相关资料,如果和当前问题相关,也要保留;真正能被压缩、被删减的是中间的“旧历史”。
第二,忘掉什么?要主动忘记的是那些又长又没用的客套话、已经完成的子任务、以及旧版本的信息。比如用户“哈哈”“好的”“然后呢”这种消息,压缩时可以直接丢掉。这不是浪费,而是为了避免上下文噪音干扰模型注意力。
第三,怎么切换?很多服务固定用一个策略,要么永远只留最近几轮,要么不管三七二十一全塞进去。这样都不理想。理想情况是:如果检测到用户说“刚才那段代码你再仔细看看”,就应该自动把模式从balanced切到full;如果只是问“你好”,就保持在concise。切换可以手动触发,也可以做成自动路由。
2.2 模式划分的依据和参数模板
我最初直接拍脑袋定义了五种模式,后来发现维护成本太高,收敛成三种。每一种模式的核心参数如下表所示:
| 模式 | token预算(以8k窗口为例) | 保留近期轮数 | 历史压缩策略 | 典型场景 |
|---|---|---|---|---|
| concise | 1k - 2k | 1 - 2轮 | 不压缩,直接丢掉 | 意图识别、快捷问答、命令行交互 |
| balanced | 4k - 6k | 5 - 10轮 | 旧历史做摘要 + 抽取关键信息 | 客服多轮、写作辅助、日常助手 |
| full | 7k+ | 尽量全留 | 只截断超越窗口的末尾部分 | 长文档问答、代码审查、长上下文分析 |
注意,token预算不是越大越好。上下文越长,模型处理每个token的注意力就越稀疏,有时候塞了1万5千字的资料,反而会把最关键的指令淹没。我倾向于默认用balanced,只有当任务确实需要“看完整个项目”才切到full。
2.3 为什么用“模式”而不是“全套塞进去”
有人可能会说,现在模型上下文窗口不是已经到128k甚至更大吗?全塞进去不就行了?真不行。第一是钱的问题,全量发送的token费和算力都高得多;第二是延迟问题,请求体越大,首token返回越慢,我实测长上下文在低并发下也会多出两三倍耗时;第三是效果问题,模型对长文本的注意力容易下降,尤其是中间部分,被称为“迷失在中间层”。一个线上服务如果每个请求都全量塞,成本会直接失控。
把上下文拆成模式,本质上是做“服务治理”。我们要在效果、成本、延迟之间取平衡。就像数据库查询不可能总用全表扫描一样,给大模型的请求也不能总是“拉满”。
3. 核心细节解析:我是怎么把一个“context-mode”做出来的
3.1 消息结构规范化:角色标签是地基
无论用哪种模式,发给模型的messages都必须结构清晰。不要只存拼接好的字符串,否则压缩、截断、统计token都会非常痛苦。我用的消息结构类似这样:
from dataclasses import dataclass from typing import Optional, List @dataclass class ContextMessage: role: str # system / user / assistant / tool content: str timestamp: float # 用于按时间排序 priority: int # 越大越优先保留,默认0 token_count: int # 缓存估算的token数 summary: Optional[str] = None # 如果是压缩摘要条目,存摘要这里有两个关键点:
- role不能乱写。工具调用结果要标成tool,而不是塞进user。很多模型API对角色名有要求,乱标轻则报错,重则导致模型无法区分别人说的话和自己说的话。
- priority这个字段是我最常用的。系统指令优先级是100,工具执行结果是90,当前用户消息是80,历史对话默认是0。压缩时先删priority为0的旧内容,再考虑低优先级。
为什么要加timestamp?因为多线程环境下,消息到达顺序和后端处理顺序可能不一致。用时间戳排序能避免串话。
3.2 token预算计算:数字不能靠拍脑袋
做context-mode第一件事就是限制总量。不同模型窗口不一样,你不可能真等请求发出去了才知道超没超。我的做法是先设置一个“可用token预算”,公式如下:
可用窗口 = 模型最大窗口 - 输出预留token - 安全buffer比如使用gpt-4o-mini(128k窗口),我设置输出预留8192,安全buffer留2048,那么可用窗口就是128k - 8k - 2k = 118k。这118k就是所有输入内容的总预算。
然后按优先级分配:
system内容固定,占约2000 当前用户输入和检索片段,占约6000 历史轮次预算 = 118k - 2000 - 6000 = 110k如果切换成balanced模式,可以把“历史轮次预算”压到30k,剩下的空间留给更多检索片段。实际开发中,我会定义一个配置类:
class ModeConfig: def __init__(self, name, total_budget, history_budget, max_turns, reserve_output): self.name = name self.total_budget = total_budget self.history_budget = history_budget # 历史最多占这么token self.max_turns = max_turns # 最多保留多少轮 self.reserve_output = reserve_output # 留给模型输出的token MODE_CONFIGS = { "concise": ModeConfig("concise", 4000, 1000, 2, 1024), "balanced": ModeConfig("balanced", 8000, 4500, 10, 2048), "full": ModeConfig("full", 12000, 9000, 100, 4096), }注意:实际模型窗口是固定的,这里的total_budget是为了“人工限制请求体”而设置,防止把整个窗口填满导致输出空间不够。我在生产环境偶尔会收到“maximum context length exceeded”报错,绝大多数就是没做这个预留。
3.3 压缩策略:滑动窗口 + 摘要 + 关键信息抽取
当历史轮次超过max_turns,或者总token超过history_budget时,就进入压缩环节。我实现了三个层次的策略,按激进程度递增:
第一层:滑动窗口裁剪。只保留最近N轮,之前的全部丢弃。这个策略最快,但有明显缺点:用户如果聊到第6轮才说“按照我第一次说的需求改”,模型早就忘了第一次说的什么。所以不能纯滑动。
第二层:旧历史摘要。把超出窗口的部分交给模型(或者用规则)生成一段摘要。比如用户和AI在前20轮确定了“做一个带会员积分的电商小程序”,后续30轮都在改颜色、调接口,那摘要就写上“需求背景:带会员积分的电商小程序;当前进展:已完成页面Demo;未决事项:支付接口和积分规则”。这样模型可以快速恢复“记忆”。
第三层:关键信息抽取。直接从历史里抽取结构化的“必保留项”。我通常会存三种:用户明确提到的ID/数字/日期;用户提出的约束(“不要用蓝色”“必须本地部署”);未完成的任务(“下次继续改登录页”)。这些项会被放到高优先级的“pinned”消息里,即使摘要丢了它们也不能丢。
压缩时按顺序执行:先滑动窗口,再关键信息抽取,最后才摘要。不要一开始就全部丢给模型做摘要,成本高且摘要也会“忘事”。我见过有人每轮对话都做一次摘要,结果每次摘要都要消耗几千token,很快预算又爆了。正确的做法是只在超出阈值时触发一次级的重写。
3.4 动态切换:容易忽略但非常有效的入口
我做了个很简单的自动切换规则:当用户消息里出现“重新回顾一下之前说的”“看看这个需求的第一版描述”“把整个项目分析一遍”这类长上下文意图词时,直接把模式临时切换成full,并把之前被压缩的原始消息从“历史存储”里捞出来全量放回。这就要求你不仅要把压缩后的summary存下来,还得把原始消息存在Redis或数据库里,不能真的删掉。
另外,我还在system prompt里写了一句“当前处于balanced模式,如果问题比较复杂,需要完整历史,请直接调用contextModeSwitch()”。这个函数不是App里真实存在的,但模型看到后会主动提醒我们切换模式。实测下来,它比单纯的代码判断词法要灵敏得多,因为模型能理解语义。你也可以在API请求里把模式名字作为环境字段放进去,帮助模型“进入状态”。
4. 实操过程:核心代码实现与效果验证
4.1 环境准备
先说明一下,这段实现不依赖LangChain,直接走OpenAI SDK,因为底层逻辑最好自己掌控。只需安装:
pip install openai tiktokentiktoken用于token计数。如果你用的是其他模型,可能需要改用huggingface的tokenizer。注意:不同模型的tokenizer不通用,不要拿一个模型的估算值去卡另一个模型的预算。
4.2 定义一个ContextManager类
核心代码不长,但每一步都有讲究。先写一个简洁版本:
import tiktoken import time from typing import List class ContextManager: def __init__(self, model="gpt-4o-mini", mode="balanced", reserve_output=2048): self.model = model self.mode = mode self.encoder = tiktoken.encoding_for_model(model) self.messages: List[ContextMessage] = [] self.reserve_output = reserve_output def _count_tokens(self, text: str) -> int: return len(self.encoder.encode(text)) def add_message(self, role: str, content: str, priority: int = 0): msg = ContextMessage( role=role, content=content, timestamp=time.time(), priority=priority, token_count=self._count_tokens(content), ) self.messages.append(msg) def set_mode(self, mode: str): self.mode = mode def build_request_messages(self) -> List[dict]: cfg = MODE_CONFIGS[self.mode] # 按时间排序 self.messages.sort(key=lambda x: x.timestamp) # 1. 系统消息和实时优先消息永远保留 system_msgs = [m for m in self.messages if m.role == "system"] pinned_msgs = [m for m in self.messages if m.priority >= 80] history_msgs = [m for m in self.messages if m.priority < 80 and m.role != "system"] history_msgs = history_msgs[-cfg.max_turns:] # 滑动窗口 history_msgs = self._apply_budget(cfg, history_msgs) final_msgs = system_msgs + pinned_msgs + history_msgs # 最外层保证不超 final_msgs = self._trim_to_fit(cfg, final_msgs) return [{"role": m.role, "content": m.content} for m in final_msgs] def _apply_budget(self, cfg, history_msgs): total = sum(m.token_count for m in history_msgs) if total <= cfg.history_budget: return history_msgs selected = [] used = 0 # 从最近开始往回选,保证后面的对话优先保留 for m in reversed(history_msgs): if used + m.token_count > cfg.history_budget: continue selected.append(m) used += m.token_count if len(selected) >= cfg.max_turns: break selected.reverse() return selected def _trim_to_fit(self, cfg, msgs): total = sum(m.token_count for m in msgs) max_input = cfg.total_budget - cfg.reserve_output if total <= max_input: return msgs # 从最不重要的中间部分开始丢,但保留system和pinned dropable = [i for i, m in enumerate(msgs) if m.role not in ("system", "user") or m.priority < 80] # 这里简化处理:直接截断历史的低优先级部分 result = [] used = 0 for m in msgs: if used + m.token_count > max_input and m.priority < 80: continue result.append(m) used += m.token_count return result这段代码只是一个核心骨架。生产环境还需要加锁、日志、异步队列。尤其要注意self.messages.sort在多用户并发下会互相干扰,所以每个session最好一个独立实例。
4.3 对话过程的完整调用示例
下面是一个模拟客服对话的调用过程:
cm = ContextManager(mode="balanced") # 系统指令 cm.add_message("system", "你是某电商平台的客服助手。当用户询问订单状态时,你需要先确认订单号,再查询物流。", priority=100) # 用户问了问题 cm.add_message("user", "我的订单到了吗?", priority=90) cm.add_message("assistant", "请提供您的订单号。", priority=0) cm.add_message("user", "订单号是20241011A,帮我看看。", priority=90) cm.add_message("assistant", "我查一下,请稍等。", priority=0) payload = cm.build_request_messages() print(payload)输出会看到system在最前,pinned(高优先级)的用户消息紧跟着,低优先级的assistant内容最后。这样模型首先看到的是“当前用户的问题和关键信息”,而不是被一堆“请稍等”“好的”淹没。
如果你把模式切到concise,同样这段对话,低优先级的assistant“我查一下,请稍等”很可能直接被过滤掉。这在敏感场景反而是好事,因为不必要的客套话不会干扰模型判断。
4.4 实测数据:效果和成本的变化
我在一个内部客服Demo上做了对比测试。原来无脑塞全部历史,请求平均token数是21730,首token延迟约1.8秒,单次成本约0.03美元。切到balanced模式后,请求token降到6580,首token延迟降到0.7秒,成本降了近70%。回答质量方面,用同一批测试问题,专家人工打分从78分提升到83分。为什么效果反而更好?因为去掉了一堆无关的闲聊历史,模型注意力更集中了。
full模式我也测过,请求token会回到17000以上,但只有在处理“用户要求回溯整个项目变化”的请求时才会切过去,平时不启用。这个模式不能成为默认,否则前面省的都白搭。
4.5 扩展到RAG和工具调用
如果你做了RAG,检索出来的文档片段怎么塞进context-mode?我的原则是:检索片段按优先级85塞进去,介于系统指令和当前用户消息之间。这样做是因为系统指令决定了模型的整体行为,当前用户消息是它必须回应的目标,检索资料是回答目标所需的“参考资料”。这个顺序相当重要——如果你把参考资料放在最前面,模型可能一上来就复述参考资料,反而忽略了用户刚问的问题。
工具调用结果也是一样。比如模型调用了查天气工具,返回的结果要标记为tool角色,优先级90。工具结果特别长时,可以在存入时先用规则抽取关键字段再塞给模型,不一定要存原始JSON。
5. 常见问题与排查技巧实录
5.1 上下文被截断,核心信息仍然丢失
有段时间我的服务总是“忘记”用户提供的订单号。明明手动查库,消息里有啊。最后发现是我写的_apply_budget有Bug:它从最近消息开始往前选,但如果用户一开始就提到订单号,而后面又聊了二十轮,订单号早被滑出去了。解决方式就是关键信息抽取:在用户消息入场时,用正则把订单号、邮箱、地址等实体抽出来,单独存成pinned消息。这样即使历史被裁剪,关键实体依然会在每一轮请求里出现。
排查技巧:在build_request_messages里打一行日志,输出每条消息的优先级、token数、是否被丢弃。写完当场就能看出是哪一步把信息丢了。
5.2 摘要压缩之后,模型开始“胡编乱造”
我在一个文档问答项目里,用户问“第一版本有哪些功能”,模型回答时多写了一个“支持多人协作”,而第一版根本没有这个功能。查下来是摘要压缩时,模型把“后续计划”当成了“已有功能”写进了摘要。之后我给摘要环节加了一条硬性约束,在摘要prompt里写“你只能整理已明确完成或确认过的事实;任何未确认的计划都必须单独放到pending区域,不得混入summary”。另外,把原始历史存到外部数据库,并保留每一轮压缩后的summary和原消息的对应关系。这样即使摘要错了,还能溯源。
5.3 多会话上下文串号
这是最容易出的线上事故。我刚开始用了一个全局List存历史,两个用户同时提问时,互相能看到对方的消息。这个问题的根源不是context-mode本身,而是会话隔离没做好。正确做法是给每个session一个独立的ContextManager实例,存在类似session_id到CM的字典里,并设置过期时间。我一般用Redis存序列化后的消息列表,key带session前缀,TTL设为2小时。上线前一定要做并发测试:同时开5个会话,各自发送随机数字,然后检查A会话里有没有B的随机数。这种低级问题一旦发生,用户信任直接崩塌。
5.4 调试上下文最实用的三件套
说实话,调试context-mode比调试普通逻辑要难,因为你看不到模型内部的“注意力热力图”。但有三样工具能大幅提升排查效率:
- 请求前日志:把mode、总token、各段token、丢弃列表打出来。我一般只打summary,不打印完整内容,避免日志爆炸。
- Message导出重放:提供一个debug接口,把最终发给模型的完整messages按顺序dump到文件。如果你在用户报告问题后能立刻拿到这份文件,80%的上下文问题都能定位。
- 版本标记:每个ContextManager实例启动时带一个版本号,比如
v1.0.3-balanced,写入日志。防止线上跑的代码和本地不一致。
另外还有一个独门技巧:开启“自言自语测试”。在系统指令里加一句“在回答前,先用一句话概括你收到的上下文主要内容”。模型会输出一个速览,比如“当前用户问退款,系统指令要求确认订单号,历史中有订单号A123”。如果这句话和你预期不符,立刻就知道上下文拼错了。
5.5 别忽略输出预留
最后想特别提醒一下token输出空间问题。我见过一个朋友的项目,模型窗口设的是8k,他把上下文塞到7900,结果模型回一个完整答案需要1500 token,直接报错“maximum context length exceeded”。所以不管你用什么模式,都必须保留足够的输出token。实际中我至少留2048,复杂推理留4096甚至更多。否则context-mode做得再花哨,模型都没空间发挥,一切白搭。
6. 写在后面:我对context-mode的实操体会
这套东西我在自己项目里用了几个月,最大的感受是:不要追求“完美上下文”,先跑起来,再根据日志一点一点调。从我实测的结果看,balanced模式是性价比最平衡的起点,绝大多数对话场景都够用。等遇到长文档深度分析,再切到full也不迟。
想再分享一个小技巧:在系统指令里加上“当前模式名”,比你想的更有效。比如你写“你现在处于balanced模式,请优先关注用户最近提出的需求,并对较远的对话内容做简化处理”,模型真的会按照这个模式去分配注意力。模式既是工程上的参数,也应该变成模型行为的一部分。这个细节帮我解决了大量“模型过度关注老历史”的问题。
后续我想把context-mode做成一个更通用的服务,支持从Redis自动恢复会话、向量化历史摘要、多租户隔离。现在已经能把一次性请求延迟降到百毫秒级,后面如果补齐持久化,其实整个会话系统就可以完全托管了。希望这篇能帮你少走一些弯路。