做AI应用开发这些年我踩过最深的坑就是上下文管理。很多人把 context-mode 当成一个简单的参数开关觉得把历史对话一股脑丢给模型就完事了。结果对话一长模型要么开始胡言乱语要么把最关键的约束条件忘得一干二净更扎心的是账单上的 token 消耗涨得比头发掉得还快。这篇文章我想把在几个实际项目里沉淀下来的一套上下文管理模式完整复盘一遍包括全量上下文、滑动窗口、锚定信息、摘要压缩这几种核心模式各自的适用场景、实现细节和选择逻辑也会把调试过程中踩过的坑、总结的排查思路一并整理出来。不管你是正在做 AI 客服、智能助手还是文档问答类应用这套思路应该都能帮你少走不少弯路。1. 先搞明白context-mode 到底在解决什么问题1.1 上下文窗口不是越大越好先把一个最容易被误解的概念说清楚模型支持的上下文窗口长度不等于你实际应该塞进去的内容长度。这就好比你请了个记忆力超群的助理。理论上他能记住一万页资料但如果每件事都让他把全部原始材料从头读一遍再做判断他反而会抓不住重点反应也慢而且每次调用都要付阅读的费用。大语言模型也是一样——把整段历史对话全部塞进 prompt表面看是保留了完整记忆实际上会带来三个连锁问题第一是注意力稀释。Transformer 架构里有个著名的现象叫lost in the middle模型对长文本中间部分的注意力明显弱于开头和结尾。这意味着塞得越多真正重要的历史信息反而越容易被淹没。第二是成本失控。上下文 token 是按输入计费的对话轮次一多每轮请求的 token 成本就线性上涨中长对话场景下这个开销非常可观。第三是响应变慢。输入越长首 token 延迟越高用户的体感就是越聊越卡。所以context-mode 本质上解决的就是一个矛盾既要让模型记得住又不能让它背太多。1.2 对话系统的记忆到底该怎么管要设计上下文管理模式得先拆解一下对话里到底有哪些信息需要保留。我自己习惯把上下文信息分成三层系统层约束角色的设定、回答的规则、禁止事项这些是无论如何都不能丢的。用户核心信息比如用户的名字、偏好、历史订单编号、之前明确表达过的诉求。这类信息跨轮次有效属于硬记忆。瞬时对话流当前这轮正在聊的具体内容、最近的几轮问答。这些信息决定了模型能否接得上话但时间一久就失去价值。很多失败的案例问题就出在这儿交给模型的上下文只有一层全部历史。系统层约束被淹没核心用户信息随着对话轮次增加被冲淡瞬时对话流反而占了一半以上的 token。context-mode 的核心思路就是分清这三层分别用不同的策略去处理而不是一把抓。1.3 设计目标省钱、稳定、不丢关键信息我在设计这套模式时给自己定了几条指标你也可以用它来衡量自己做得好不好目标具体表现关键信息零丢失用户在多轮前提到的重要信息切换模式后依然能准确召回成本可控长期对话场景下单轮 token 消耗不随轮次无限增长或者增长速率显著下降响应稳定不同长度的对话都能保持相对稳定的响应质量不出现越聊越差实现清晰上下文被谁截断、被谁压缩逻辑可追踪不搞玄学后面讲到的每一种单模式其实都是对这些指标的某个侧重。而生产环境里真正能同时满足这几点的往往是几种模式的组合。2. 四种核心模式的设计与选型2.1 全量上下文模式简单直接但别滥用全量模式就是字面意思把从第一条消息到当前这轮的全部历史一股脑拼进 prompt。这是最笨也最省事的做法适合两种场景。第一种是对话轮次极少、总 token 还远没碰到模型上限的情况。比如单轮问答工具、第一次交互的客服咨询总共没几轮对话全量塞进去毫无压力。第二种是需要对历史做精读的场景比如让模型分析一整段对话的情绪走向或内容质量这时候你需要的就是完整上下文。但全量模式在长对话里是个甜蜜陷阱。我见过一个团队把 20 轮以内的对话全量传入上线初期没问题后来用户粘性上来了单用户聊到 30 轮以上响应质量肉眼可见地下降。定位到最后就是典型的长文本注意力问题——模型把早期用户的原始诉求给忘了。更别说成本200 轮对话的 token 费用比 20 轮高出将近十倍这对长期跑在线服务是不可接受的。2.2 滑动窗口模式最常见的选择滑动窗口是目前实际项目里用得最多的单模式。思路很直白只保留最近 N 轮对话更早的内容直接丢弃。它的优点很明显实现简单、token 消耗稳定可控、实时响应速度有保障。比如把窗口设为最近 10 轮那么无论用户聊到 20 轮还是 200 轮每次传给模型的都只有最近 10 轮的内容加系统提示成本曲线是一条水平线。但代价同样明显窗口之外的历史信息会永久消失。用户在第 5 轮提到我要黑色的款结果聊到第 30 轮模型已经完全不记得了。所以滑动窗口适合即时性强、单轮交互相对独立的场景比如天气查询、商品咨询这类问一句答一句的应用。窗口大小怎么定我见过一个经验公式按业务平均单轮 token 数估算。假设平均每轮 200 token模型 8k 上下文系统提示占 1k那最多能留给窗口的空间约 6k-7k窗口就定为 30 轮左右再留点余量以防单轮超长。这个计算过程本身很简单但很多人会忘记留出峰值单轮的缓冲空间导致某些突然的长消息直接把窗口挤爆。2.3 锚定信息模式把不能忘的事焊死在系统提示里锚定模式是我个人最喜欢的发明也是 context-mode 里性价比最高的一个。它的核心思想是从上下文里抽出那些无论对话进行到哪里都必须保留的信息块单独固定住。最典型的锚定块是系统提示词和用户画像。比如一个购物助手第 3 轮的时候用户说了我只收顺丰快递这条信息如果没有被锚定等聊到 40 轮就会脱离滑动窗口模型就忘了。但如果把它抽出来放进固定的锚定区域它就永远存在于系统提示之后、窗口对话之前模型每一轮都能看到。实现上不需要什么高深技术本质就是做信息提取和拼接。每轮对话结束后用规则或一个小模型把值得长期保留的结构化信息抽出来比如 用户偏好 顺丰快递。下次拼 prompt 时先拼系统提示再拼锚定信息最后拼滑动窗口内容。优先级排序是这么来的系统提示约束的是模型的说话方式锚定信息约束的是必须记住的事实滑动窗口负责接上当前的话。三者缺一不可。2.4 摘要压缩模式用 token 换记忆的折中方案摘要压缩模式解决的是滑动窗口的痛点太旧的信息丢了能不能不丢能但别丢原文丢摘要。做法是当对话长度超过某个阈值时对更早期的对话调用模型生成一段摘要比如用户已确认购买黑色款手机壳要求发顺丰待支付。这串摘要会作为上下文里的一节固定内容和锚定信息类似但它是动态生成的——每个 N 轮更新一次。摘要有两个操作细节值得注意。一是摘要要面向未来而不是面向过去。意思是生成摘要时目的不是复述历史而是假设模型下轮提问我应该还记得什么。二是摘要需要分轮分层。我习惯每固定轮数做一次局部摘要跨很多轮后再做一次全局摘要。如果每次都是全局重摘既浪费 token又可能在多次摘要后把细节彻底磨没。这套模式真正适合的是客服工单、心理咨询、教育陪练这类旧信息长期有参考价值的场景。它比全量省钱得多比滑动窗口记忆能力强得多代价是要多维护一条摘要生成链路也会增加一些写队列的延迟。2.5 混合模式生产环境里真正的答案如果只选一种模式上线我会选混合模式。它没有太多玄学就是把上面几种模式按层级拼起来第一层放系统提示作为固定底板。 第二层放锚定信息每次动态刷新但始终存在。 第三层放历史摘要每隔一段距离压缩一次。 最后一层才是最近几轮的原始对话保证流畅接话。有一个实际例子可以说明它的威力一套 AI 售后助手系统提示里写清楚你是XX品牌售后客服不要编造政策条款锚定信息里存了用户的商品型号和购买日期摘要里记录了前 30 轮用户描述过的故障现象和已经尝试过的排查步骤最后窗口里保留最近 5 轮详细对话。这样一个 40 轮以上的对话实际传给模型的 token 可能只有全量模式的百分之三十而且模型对关键信息的召回准确率反而更高。3. 实操落地从零实现一个可用的 ContextManager3.1 定义数据结构与基础类先说清楚这一节给的实现是简化版本但骨架可以直接拿到真实项目里用。我用 Python面向对象的写法关键依赖是 pydantic 或 dataclass。先定义基础数据结构from dataclasses import dataclass, field from typing import Optional, List from enum import Enum from datetime import datetime class ContextMode(Enum): FULL full SLIDING sliding PINNED pinned SUMMARIZED summarized HYBRID hybrid dataclass class Message: role: str content: str timestamp: datetime field(default_factorydatetime.now) anchor: bool False dataclass class CompressedBlock: 压缩摘要块 summary: str start_time: datetime end_time: datetime covered_turns: int这里面的anchor字段很关键。锚定信息本质上也是一条消息只是它的anchor标记为True那么它在任何裁剪模式下都不会被移除。3.2 核心调度逻辑实现接下来是调度核心。我会实现一个类它暴露三个核心方法add_message、query、build_prompt。重点看build_prompt里面根据模式决定怎么组装。from collections import deque import tiktoken class ContextManager: def __init__( self, mode: ContextMode, max_tokens: int 8000, system_prompt: str , sliding_window: int 10, summary_threshold: int 20, encoder_name: str cl100k_base, ): self.mode mode self.max_tokens max_tokens self.system_prompt system_prompt self.sliding_window sliding_window self.summary_threshold summary_threshold self.history: deque deque() self.anchor_messages: List[Message] [] self.summarized_blocks: List[CompressedBlock] [] self.encoder tiktoken.get_encoding(encoder_name) self._unsummarized_buffer: List[Message] [] def add_message(self, role: str, content: str, anchor: bool False): msg Message(rolerole, contentcontent, anchoranchor) if anchor: self.anchor_messages.append(msg) else: self.history.append(msg) self._maybe_compact() def _count_tokens(self, text: str) - int: return len(self.encoder.encode(text)) def _maybe_compact(self): # 混合模式或摘要模式当非锚定历史超过阈值触发摘要压缩 total_tokens sum( self._count_tokens(m.content) for m in self.history ) if ( self.mode in (ContextMode.SUMMARIZED, ContextMode.HYBRID) and total_tokens self.summary_threshold ): self._summarize_old_history() def _summarize_old_history(self): # 实际项目里这里可以调用LLM生成摘要简化版用占位逻辑 old_messages list(self.history)[: len(self.history) // 2] if not old_messages: return combined \n.join(f{m.role}: {m.content} for m in old_messages) summary f[摘要] {combined[:100]}... # 真实项目换成LLM调用 block CompressedBlock( summarysummary, start_timeold_messages[0].timestamp, end_timeold_messages[-1].timestamp, covered_turnslen(old_messages), ) self.summarized_blocks.append(block) for m in old_messages: self.history.remove(m) def build_prompt(self, mode: Optional[ContextMode] None): mode mode or self.mode parts [] if self.system_prompt: parts.append({role: system, content: self.system_prompt}) if mode in (ContextMode.PINNED, ContextMode.HYBRID, ContextMode.SUMMARIZED): # 锚定信息固定在第一层之后 for m in self.anchor_messages: parts.append({role: m.role, content: m.content}) if mode ContextMode.FULL: for m in self.history: parts.append({role: m.role, content: m.content}) elif mode ContextMode.SLIDING: for m in list(self.history)[-self.sliding_window:]: parts.append({role: m.role, content: m.content}) elif mode in (ContextMode.SUMMARIZED, ContextMode.HYBRID): for block in self.summarized_blocks: parts.append({role: system, content: f此前对话摘要{block.summary}}) for m in list(self.history)[-self.sliding_window:]: parts.append({role: m.role, content: m.content}) return parts这个实现的核心逻辑是正常情况下走队列追加一旦超过摘要阈值就把较旧的半数消息压缩成摘要块锚定信息全程不受影响。最大的坑其实是deque的直接从头切片删除那会让索引错乱所以我用list(self.history)复制后再移除稳妥很多。3.3 与主流 LLM API 的对接细节build_prompt返回的是一个消息数组这个数组可以直接作为多数 OpenAI 兼容接口的messages参数。对接时有个细节要注意摘要块使用role: system而不是role: user。原因很简单摘要内容是辅助模型的背景信息不是用户新输入的话用 system 角色可以避免模型把摘要误认为当前用户正在说的话也能降低和真实用户消息混淆的概率。cm ContextManager( modeContextMode.HYBRID, max_tokens8000, system_prompt你是客服助手回答要简洁不要编造政策。, sliding_window5, summary_threshold30, ) cm.add_message(user, 我要买黑色的手机壳) cm.add_message(assistant, 好的黑色款有货请问您用什么型号) cm.add_message(user, iPhone 15 Pro记得发顺丰, anchorTrue) # 如果自定义字段被API拒绝则需要你的服务端剥离未知字段 messages cm.build_prompt() # 直接传给openai或兼容接口即可实际对接时还有一个很容易踩的坑很多模型的接口不允许 messages 里出现未知字段而 pydantic 序列化出来可能多带anchor、timestamp这些内部字段。解决办法就是build_prompt里手动构造 dictionary只保留 role 和 content 两个键。3.4 关键参数到底怎么定关于上下文模式的参数网上能搜到一堆建议但真正可靠的依据还是你的业务数据。我给一套自己常用的计算流程先估算平均单轮 token。拿真实对话数据统计客服场景一般 300-500 token 一轮开放闲聊可能 500-800。然后用公式反推假设模型上下文是 32k系统提示 500锚定信息 300保守留出 2k 缓冲应对单轮峰值。那么剩给历史窗口的空间约 29k。如果希望至少保留最近 20 轮原始对话那么每轮的剩余预算就是约 1450 token完全够用。如果你的对话平均每轮要 2000 token那就得把窗口压到 10 轮否则会超。摘要块的数量和 token 也要预留。我常用的策略是每 10 轮做一次摘要每段摘要控制在 200 token 内最多保留最近 5 段。这样即使对话长达数百轮历史部分的 token 也始终被压在 1k 左右成本完全可控。混合模式下优先级这个事也值得说宁可压缩最近对话的轮次也不能压缩锚定信息和最新的用户意图。因为最近对话可以减少轮次但用户当前的核心诉求丢了这一轮回答大概率就是废的。4. 常见问题与排查技巧实录4.1 高频问题速查表调试 context-mode 的时候很多问题是共通的。我整理了实际项目中遇到的高频问题做成速查表大家可以直接对照现象大概率原因排查思路模型忘了较早的关键信息滑动窗口把锚定信息滚出去了确认 anchor 标记是否生效检查 build_prompt 里锚定拼接的先后顺序token 成本不降反涨摘要逻辑频繁触发每次都重新压缩全量历史确认压缩只处理旧半数消息别把刚进去的新消息也压缩了对话流畅度变差窗口过小模型看不到足够多上下文调大滑动窗口用真实对话回放测试质量摘要内容严重失真摘要生成策略面向过去变成了流水账改成面向未来告诉模型只保留后续回答需要的决策依据API 报错未知字段pydantic/dataclass 序列化把内部字段带出去了build_prompt 返回值只保留 role 和 content响应延迟偶尔暴涨某轮触发了摘要生成阻塞在 LLM 调用上摘要改成异步生成或预生成、错峰执行4.2 上下文泄露与混淆问题这里必须单独提一个隐蔽问题多用户并发场景下的上下文串味。如果你只把 context-mode 放在全局变量里那肯定出事。两个用户同时对话他们的历史都往同一个 history 队列里塞模型一会儿觉得你是 A一会儿又变成 B这就是灾难性的上下文泄露。正确做法是让 ContextManager 实例按用户或按会话隔离。简单来说就是用用户 ID 做 key 存储单独的实例managers: dict[str, ContextManager] {} def get_manager(user_id: str) - ContextManager: if user_id not in managers: managers[user_id] ContextManager(modeContextMode.HYBRID) return managers[user_id]生产环境里还要给这个字典加锁或换成 Redis 存储避免并发读写问题。这是上线前必须检查的一项。4.3 性能与成本调优心得折腾了这么久的 context-mode我有几个不成文的经验可以分享第一摘要生成不要同步放到请求链路里。我自己第一次实现时就是在请求里调 LLM 做摘要结果最坏情况让用户多等了五六秒。后来改成异步任务对话结束后在后台更新摘要在线效果立刻顺滑很多。第二锚定信息要设置过期机制。不是所有锚定信息都该永久保留。比如用户的临时需求这周发货在下周就不重要了。所以在锚定信息上要带上时间戳定期批量清理。不清理的话锚定区会越长越多又变回全量模式了。第三模式切换要平滑过渡。不要在一轮对话中突然从滑动窗口切成全量模式模型的措辞习惯、引用旧信息的方式都会突变。比较稳妥的做法是渐进式切换先加摘要块再逐步扩大窗口让模型慢慢适应新结构。第四上下文覆盖率的自动化监控。我建议在测试集里固定一批关键信息问答对跑回归测试。每次改动上下文拼接逻辑后先用这些问答对验证召回率。这个测试集花半天时间构造但之后每次改代码都能帮你第一时间发现记住了旧的、忘了新的这类回归问题。最后再分享一个实际调试的小技巧把 build_prompt 的结果打印成人可读的文本日志每条消息前面标上序号、角色、是否锚定、是否是摘要。上线前拿几十轮真实对话跑一遍肉眼过一遍这个日志绝大多数上下文设计的问题都能在十分钟内暴露出来。这个办法比你看任何监控指标都来得直观我到现在还在用。
阅读完成 · 觉得有帮助?