
做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 # 每轮算userassistant两条 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 接入业务流的参数计算为了让你更直观地理解参数设置我拿实际项目举例。假设模型窗口是128KAPI按token计费客服机器人的单次回答平均输出600 token。我给max_prompt_tokens设置的是8000reserve_tokens设置1500那么prompt侧可用空间是8000。预算分配如下内容token预算说明系统指令800~1000角色设定、回答规则、格式要求用户画像200~300行业客户比较固定历史摘要600~800旧对话的浓缩要点最近8轮对话2000~3000每轮平均250~400 token检索结果/工具返回1500~2000RAG场景临时注入预留余量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用量画成曲线能一眼看出现有参数在高峰期的表现这比上线后靠用户投诉来发现问题靠谱得多。参数不是一次性调好的是慢慢磨出来的。希望这篇能帮你少走点弯路省下的时间干点别的。