做LLM应用开发的朋友,大概率都经历过这个场面:对话轮数一多,模型就开始“失忆”,刚才还聊得好好的,下一句突然答非所问;或者往prompt里塞的东西太多,接口直接报Token超限,账单也跟着往上飙。我之前在一个智能客服项目里被这个问题折磨了两周,日志翻了几百条,最后把代码里的context管理部分单独拎出来,重新设计成一套独立的“context-mode”上下文管理模块,才算彻底把这个问题治住了。这篇就把我在这套context-mode里踩过的坑、调过的参以及最终沉淀下来的可复用方案完整写出来,想自己动手优化上下文管理的朋友可以直接抄作业。
1. 为什么要做Context Mode:一次线上事故引发的反思
1.1 事故现场复盘
事情起因很简单。客服机器人上线后,用户问了一句“我要退货”,随后又聊了十几轮关于发票、物流、优惠券的内容。等到用户再问“那退货地址是哪里”的时候,模型完全懵了。它把退货信息当成了发票问题来回答,给出的地址是开票地址,不是退货地址。用户当场投诉,运营同事截图发到群里,配文是一个大大的问号。
我赶紧拉日志看实际发给模型的prompt,发现一个很典型的问题:上下文里面塞满了最近十几轮对话的原文,包括用户说的“谢谢”“好的”这类客套话,还有客服回复的一大堆优惠券使用规则。真正关键的“退货”意图、用户订单号、退货原因,早就被挤到了token窗口的边缘位置,模型其实还能看到这些内容,但注意力分配已经严重偏向最新的、篇幅更大的消息。窗口有限,信息无限,谁嗓门大谁占便宜。
1.2 本质问题拆解:上下文的核心矛盾
这次事故背后,其实是LLM应用开发里一个绕不开的矛盾:窗口容量永远小于真实信息量。模型的输入序列长度是固定的,而业务场景里的对话历史、系统指令、检索结果、工具返回结果,全都在这一条序列里抢位置。
你可以把上下文想象成一条传送带。新消息从一头放上去,旧消息就从另一头掉下去。问题是,掉下去的不一定是最该掉的。默认策略下,最先超载丢掉的是最老的消息,但最老的消息往往是用户最初的核心诉求。这就是传送带策略最大的坑:它按时间淘汰,而不是按重要性淘汰。
Context Mode要解决的核心问题就三个:
- 让关键信息始终留在窗口里。用户的真实意图、订单号、已确认的条件,这些信息不能被闲聊挤走。
- 压缩次要信息而不是丢光。旧对话不能直接删掉,它们可能包含后续需要的线索,压缩成摘要后体积小,但信息还在。
- 控制成本。同样一次对话,token用量下降,API费用跟着下降,响应时间也更短。
1.3 设计上下文模式的三个原则
踩完这次事故后,我给这套上下文管理定了几条原则,后来所有方案都是围绕这几条原则展开的:
第一,上下文必须分层。不能把所有历史消息混在一起当一锅粥处理,要区分系统指令、用户画像、会话摘要、最近对话,每一层处理方式不同。
第二,旧信息必须压缩。详细内容保留最近几轮,更早的内容通过摘要保存,摘要不是简单截断,而是提取关键要素。
第三,关键信息必须保活。用户的核心诉求、关键实体、未完成事项,这些信息在摘要和裁剪过程中要被单独识别并始终保留。
2. Context Mode整体设计思路:从被动拼接转向主动管理
2.1 传统拼接方式的三个缺陷
很多人早期做LLM应用,prompt就是“系统指令 + 全部历史消息 + 当前问题”这样一个大字符串。这种方式看起来简单,实际有三处硬伤:
一是长度不可控。用户聊到第30轮,所有历史原文都塞进去,窗口满了就报错,毫无优雅降级可言。我见过不少项目直接用try-except捕获Token超限异常,然后粗暴地把历史砍半,这种做法会让模型“断片”,因为它既丢了前后文关联,也没有任何补偿机制。
二是关键信息没有优先级。系统把所有消息一视同仁地按顺序排列,沙拉里混进一块牛排,它不会自动浮到表面。用户在第2轮说的“预算一万以内”,到第20轮被淹没在十几个“好的”“嗯嗯”里,预算信息等于丢了。
三是无法做成本控制。单个用户聊得越长,每次请求的token数就越大,成本呈线性增长,而且无法预测。
2.2 三层上下文架构:完整保留、摘要压缩、长期画像
我采用的方案是把上下文拆成三层,各自承担不同职责:
第一层:最近N轮完整消息。这一层是“工作记忆”,保留最近8到10轮原文。因为最近几轮通常和当前问题直接相关,模型需要看到完整的用词、语气、转折。这层不压缩,质量最高,但数量严格限制。
第二层:历史会话摘要。这一层是“中期记忆”,把N轮之前的所有内容压缩成一段要点摘要。摘要不是流水账,而是重点记录用户意图、已确认信息、未解决问题、关键实体。这一层既给模型提供了跨轮次的线索,又不占太多token。
第三层:用户长期画像。这一层是“长期记忆”,记录跨会话的稳定信息,比如用户偏好、身份属性、历史行为特征。这一层不属于单次会话动态生成的内容,而是从用户库中读取后注入。
这三层各自独立维护,组装prompt时按固定顺序拼接。模型知道哪部分是摘要、哪部分是实时对话,就不会把“历史摘要里的过期价格”和“当前对话刚确认的价格”搞混。实测下来,这种分层结构比所有消息平铺的效果好很多。
2.3 为什么用摘要而不是简单截断
有的朋友可能会问:直接把旧消息丢了是不是更省事?答案是可以省事,但会严重损失可用性。对话存在大量“隔空呼应”的场景,用户可能在20轮之后问“刚才说的那个方案,如果改成B套餐呢?”这里的“刚才”指的就是第3轮的内容。如果没有摘要,模型看到“那个方案”根本不知道指什么。
摘要方案里,模型能读到类似“用户在第3轮询问A套餐,价格为199元/月,用户倾向包年”的浓缩信息,就能理解“那个方案”指A套餐。代价是失去原文的某些细节,但这些细节大多数情况下并不影响回答质量。
这里我坚持了一个原则:压缩必须是智能提取,不能是机械截断。机械截断只是把长文本砍掉一半,会切碎语义。智能提取则是理解之后重新组织,保留关键信息、去重、合并同类项。这是Context Mode的核心投入点,也是它区别于普通“历史消息滑动窗口”的地方。
3. 实操落地:写一套可上线的ContextMode模块
3.1 数据结构与配置项定义
我先定义配置数据结构。配置集中管理是最重要的一步,后面调参直接改dataclass就行,代码逻辑不用动。
# context_mode/config.py from dataclasses import dataclass @dataclass class ContextConfig: model_window: int = 128_000 # 模型上下文窗口上限 max_prompt_tokens: int = 8_000 # 每个请求允许的最大prompt token数 reserve_tokens: int = 1_500 # 为模型回复预留的token数 recent_rounds: int = 8 # 完整保留的最近对话轮数 summary_max_tokens: int = 800 # 摘要目标长度 compact_trigger: float = 0.75 # 上下文使用率达到75%时触发压缩说明几个关键参数:
max_prompt_tokens是控制整个Context Mode的“总闸门”。它决定组装给模型的prompt最长可以到多少。如果你的模型窗口是128K,不代表你可以每轮都塞满128K。塞得越满,响应越慢,单次成本越高。在一个并发量高的客服系统里,把max_prompt_tokens从12K压到8K,整体响应时间能下降约30%,成本下降约35%,而回答质量几乎不受影响。
reserve_tokens是为模型回复预留的空间。模型输出占用的token是单独计费的,如果prompt加输出超过窗口上限会报错。所以prompt侧必须预留一部分给输出。经验值是总窗口的10%到15%,具体看你的业务输出长度。
compact_trigger决定什么时候触发压缩。设成0.75,表示当当前prompt用量达到max_prompt_tokens的75%时就提前压缩,而不是等到100%才处理。这样可以避免单次请求超限,给压缩过程留下缓冲。设得太低会导致频繁压缩,摘要更新太快,反而丢失细节;设得太高则容易在长对话中意外超限。0.75是个比较稳的起始值。
3.2 核心管理器实现
接下来是核心的ContextManager类。它负责三件事:维护消息列表、估算token用量、触发并执行压缩。
# context_mode/manager.py from typing import List, Dict, Callable from .config import ContextConfig class ContextManager: def __init__(self, config: ContextConfig, llm_fn: Callable): self.cfg = config self.llm_fn = llm_fn # 外部传入的模型调用函数 self.system_prompt = "" self.messages: List[Dict] = [] # 最近对话轮次的完整消息 self.summary: str = "" # 历史摘要 self.user_profile: Dict[str, str] = {} # 用户画像 def set_system_prompt(self, prompt: str): self.system_prompt = prompt def set_user_profile(self, profile: Dict[str, str]): self.user_profile = profile def add_message(self, role: str, content: str) -> None: self.messages.append({"role": role, "content": content}) self._try_compact() def _estimate_tokens(self, text: str) -> int: # 快速估算:中文字符按1.5 token计,其他按0.3 token/字符计 cjk_count = sum(1 for ch in text if '\u4e00' <= ch <= '\u9fff') other_count = len(text) - cjk_count return int(cjk_count * 1.5 + other_count * 0.3) + 4 def _current_usage(self) -> int: total = self._estimate_tokens(self.system_prompt) total += self._estimate_tokens(self.summary) total += sum(self._estimate_tokens(m["content"]) for m in self.messages) return total def _try_compact(self) -> None: usage = self._current_usage() if usage / self.cfg.max_prompt_tokens >= self.cfg.compact_trigger: self._compact() def _compact(self) -> None: # 找出需要摘要化的旧消息:保留最近 recent_rounds 轮原文 keep_count = self.cfg.recent_rounds * 2 # 每轮算user+assistant两条 if len(self.messages) <= keep_count: return old_messages = self.messages[:-keep_count] recent_messages = self.messages[-keep_count:] # 构造摘要请求 raw_text = "\n".join( f"{m['role']}: {m['content']}" for m in old_messages ) summarise_prompt = ( f"请把下面的对话压缩成不超过{self.cfg.summary_max_tokens}字的要点摘要。" "必须保留:用户核心意图、已确认的信息、未解决的问题、订单号/日期/金额等关键数字。" "不要保留客套话和重复内容。\n\n" + raw_text ) new_summary = self.llm_fn( [{"role": "user", "content": summarise_prompt}] ) # 合并旧摘要和新摘要,避免信息叠加 if self.summary: merge_prompt = ( "下面有两段摘要,一段是之前的历史摘要,一段是新生成的摘要。" "请合并它们,去掉重复内容,保留所有关键信息:\n\n" f"旧摘要:{self.summary}\n新摘要:{new_summary}" ) self.summary = self.llm_fn( [{"role": "user", "content": merge_prompt}] ) else: self.summary = new_summary self.messages = recent_messages def build_prompt(self) -> List[Dict]: messages = [{"role": "system", "content": self.system_prompt}] if self.user_profile: profile_text = "用户档案:" + ", ".join( f"{k}: {v}" for k, v in self.user_profile.items() ) messages.append({"role": "system", "content": profile_text}) if self.summary: messages.append({"role": "system", "content": f"[历史摘要] {self.summary}"}) messages.extend(self.messages) return messages这段代码有几个值得说的设计细节。
_estimate_tokens是快速估算函数,按字符类型分权重计算。中文字符信息密度比英文高,同样的语义词数更多,单独按字符乘系数更接近真实token数。实测下来,这种估算方式比“len(text) // 4”要准得多,误差通常在10%以内,而真调用tiktoken接口来算的话,每轮请求会额外增加几十毫秒延迟,高并发下不划算。我的建议是按估算值来控制,定期抽检校准即可。
_compact里把old_messages全部压缩成摘要,但recent_messages原样保留。这一步是有意识地区分“值得记住的过去”和“正在进行的现在”。摘要生成请求本身也是调用模型,这笔开销需要考虑:单次摘要调用可能消耗1000到2000 token,但相比每次请求都携带全部历史原文,平均下来还是省的。
3.3 接入业务流的参数计算
为了让你更直观地理解参数设置,我拿实际项目举例。
假设模型窗口是128K,API按token计费,客服机器人的单次回答平均输出600 token。我给max_prompt_tokens设置的是8000,reserve_tokens设置1500,那么prompt侧可用空间是8000。
预算分配如下:
| 内容 | token预算 | 说明 |
|---|---|---|
| 系统指令 | 800~1000 | 角色设定、回答规则、格式要求 |
| 用户画像 | 200~300 | 行业客户比较固定 |
| 历史摘要 | 600~800 | 旧对话的浓缩要点 |
| 最近8轮对话 | 2000~3000 | 每轮平均250~400 token |
| 检索结果/工具返回 | 1500~2000 | RAG场景临时注入 |
| 预留余量 | 500~1000 | 避免单次突发超限 |
合计用满8000。这套配置跑了几周,稳定性和响应速度都在可接受范围内。如果你的业务是文档问答,检索结果很大,就要把recent_rounds调小到4,或者把summary_max_tokens降到500,腾出空间给检索内容。
3.4 接入工作流
调用方式很简单:
manager = ContextManager(config, llm_fn) manager.set_system_prompt(客服系统提示词) manager.set_user_profile({"偏好": "价格敏感型", "所在区域": "华东"}) # 多轮对话循环 while True: user_input = input("用户: ") manager.add_message("user", user_input) prompt = manager.build_prompt() reply = llm_fn(prompt) manager.add_message("assistant", reply) print("助手:", reply)整个链路清晰:add_message负责写入并检查是否需要压缩,build_prompt负责组装最终发给模型的完整消息列表,业务代码不需要关心context内部逻辑,压缩和更新都在状态中自动完成。
4. 实战中的常见问题与排查技巧实录
4.1 摘要生成后,细节丢了找不回来
这是最常遇到的问题。某次测试里,用户在第5轮提供了退款账号,到第20轮时这段信息已经被压缩进摘要。模型回答时把账号记错了,因为摘要在压缩时把账号的某一位数字给漏了。
摘要本身就是有损压缩,所以不能完全依赖摘要承载关键信息。我的方案是给关键信息加一条“保活通道”:在系统提示词中明确要求模型在对话中把账号、订单号、金额这类实体信息原样复述一遍再开始回答其他内容;同时在摘要prompt里单独加一行“以下实体必须原样保留:账号、订单号、日期、金额”,生成摘要时强制保留。
即使这样,仍建议在业务层把关键实体单独存一份,比如数据库里存订单号字段,组装prompt时直接从库里读取覆盖到用户画像层。Context Mode不应该成为存放关键数据的唯一地方,它负责的是“让模型知道”,而数据库负责的是“让系统不丢”,两者各司其职。
4.2 Token估算不准确导致意外超限
_estimate_tokens毕竟是估算,如果业务中大量使用英文、代码块或者Markdown格式,估算偏差会增大。有一次我把一份产品文档全文塞进去,里面全是英文字母和代码,实际token数比估算值高了将近两倍,直接触发了API报错。
后来我在估算函数里增加了语言类型判断,并支持替换成tiktoken精确计算:
def _estimate_tokens(self, text: str) -> int: # 超长文本用精确计算,短文本用估算,兼顾性能与准确度 if len(text) > 5000: return len(self.encoder.encode(text)) cjk_count = sum(1 for ch in text if '\u4e00' <= ch <= '\u9fff') other_count = len(text) - cjk_count return int(cjk_count * 1.5 + other_count * 0.3) + 4对超长文本走精确编码,普通短文本走快速估算。大部分情况下,真正占用大头的是检索结果和长文档,这些走精确计算最稳;短对话消息用估算完全够用。加了这层逻辑后,线上再也没有出现过意外超限。
4.3 多轮压缩后摘要出现“滚雪球”失真
如果对话特别长,比如超过100轮,摘要会经历多次“旧摘要+新摘要”的合并。每一次合并都是一次有损压缩,关键信息可能被逐步淡出。最典型的例子是用户最开始说的“预算不要超过五千”,在前几轮摘要里还在,但经过三轮压缩合并后,被“用户倾向性价比高方案”替代,预算数字丢了。
这个问题很难完全避免,但可以大幅缓解。我的做法是给merge prompt里固定追加一句:“如果新旧摘要中同时包含数字或实体信息,必须全部保留,不得省略。”这句提示词针对模型在摘要合并时倾向于“总结概括”而不是“保留细节”的偏好,能起到明显的纠偏作用。
如果业务场景对精度要求极高,比如金融、医疗,那摘要就不该承担长期记忆职责,必须依赖外部存储做精确记录,Context Mode只保留最近一轮的操作上下文。这是架构选择问题,不是单靠调prompt能解决的。
4.4 快速排查工具:上下文占用可视化
定位上下文问题时,不能靠猜。我写了一个简单的状态导出函数:
def debug_usage(self): parts = { "system_prompt": self._estimate_tokens(self.system_prompt), "user_profile": self._estimate_tokens(str(self.user_profile)), "summary": self._estimate_tokens(self.summary), "recent_messages": sum(self._estimate_tokens(m["content"]) for m in self.messages), } total = sum(parts.values()) return { "parts": parts, "total": total, "limit": self.cfg.max_prompt_tokens, "usage_ratio": round(total / self.cfg.max_prompt_tokens, 3), "message_count": len(self.messages), "last_summary_length": len(self.summary), }排查时直接打印各层占用,一目了然。比如看到recent_messages占了6000多token,就知道最近对话轮次太长,该调小recent_rounds;如果summary占了1500,说明摘要prompt里指定的最大长度偏高,或者摘要内容没有正确截断。
这个函数建议留在生产代码里,结合日志平台定期采集,能提前发现上下文膨胀的趋势,而不是等出事故后再翻日志。
5. Context Mode的扩展玩法:RAG、流式输出与多智能体
5.1 与RAG结合时的上下文分配策略
在做知识库问答时,检索结果经常是上下文里的最大头。我见过有的场景把5篇文档全文都塞进prompt,上下文直接爆掉。Context Mode在这里的职责是控制检索结果的注入量:先保证系统指令和用户问题占固定预算,检索到的文档按相关性分数排序后,从上往下填充剩余空间,装不下的割舍掉,而不是全部硬塞。
实现方式是在_current_usage里加入检索预算的检查,或者单独封装一个RAGContextManager,在build_prompt时按剩余预算动态裁剪检索结果:
def _fit_retrieved_docs(self, docs: List[str], budget: int) -> List[str]: fitted = [] used = 0 for doc in docs: cost = self._estimate_tokens(doc) if used + cost > budget: break fitted.append(doc) used += cost return fitted这样核心对话上下文始终稳定,RAG只是临时占用剩余空间,不会挤掉系统指令和最近对话。
5.2 与流式输出结合时的状态同步
如果模型是流式输出,每次输出的token是一个一个蹦出来的,那么add_message("assistant", full_reply)这行代码必须在流式结束后才执行,不能在流式过程中边收边加。否则可能出现一种竞态:上下文管理器在流式输出中途触发压缩,把还没输出完整的assistant消息当成旧消息摘要化,导致后续输出断片。
我的经验是流式场景里,压缩触发时机尽量避开输出中,可以在入站用户消息时检查并压缩,或者输出完成后再触发检查。避免在生成过程中修改消息列表,是保证稳定性的一个原则。
5.3 多智能体场景里的上下文共享
多个智能体协作时,每个智能体既要有自己的局部上下文,又要感知全局上下文。直接共享全部历史会让每个agent的输入都膨胀。我的做法是用两级Context Mode:全局层级只放任务目标、共享约束和跨agent传递的关键结论摘要;局部层级放各agent自己的详细推理过程。
全局摘要的生成和合并逻辑,就是前面_compact方法的应用,只不过触发的时机不是token阈值,而是步骤边界。比如agent A完成一次工具调用后,把新增的结论追加到全局摘要,供agent B读取。这样每个agent的窗口都保持精简,协作信息又不丢失。
结尾
做context-mode这套模块,我最大的体会是:上下文管理不是“内存不足就删”,而是“空间有限但有策略地分配”。它考验的其实是项目对信息价值的判断——哪些内容值得完整保留,哪些压缩成要点,哪些根本不需要进窗口。这个判断没有标准答案,只能结合自己的业务场景反复调。
最后再分享一个小技巧:上线前拿三个月真实对话日志做离线回放,把每轮对话的实际token用量画成曲线,能一眼看出现有参数在高峰期的表现,这比上线后靠用户投诉来发现问题靠谱得多。参数不是一次性调好的,是慢慢磨出来的。希望这篇能帮你少走点弯路,省下的时间干点别的。