
去年下半年我接手一个 AI 客服机器人项目上线两周后用户开始集中反馈机器人越聊越轴前两天还能准确报出订单状态后来连用户刚问过的问题都要重复确认好几遍。翻日志才发现每次请求里塞进的历史消息已经累积超过 20 轮token 数逼近上下文上限模型开始选择困难。当时我用了一个下午重构会话层把所有上下文策略梳理成明确的 context-mode 配置问题当场解决单次请求成本还降了三分之一。今天想把这些经验完整写下来。这篇内容围绕 context-mode 展开适合正在做聊天机器人、智能助手、RAG 应用的开发者也适合所有被模型记不住前文折磨过的提示工程玩家。我会先讲清楚它到底在管什么再给三种主流策略的对比然后给一份可以直接抄走的 Python 实现最后把我踩过的坑和排查思路一并交代。1. 先讲清楚context-mode 管的是模型的记忆边界很多人第一次接触 context-mode都会误以为它只是把窗口调大一点。实际不是。context-mode 管的是你以什么策略、把哪些内容放进模型那一刻能看到的上下文窗口里它直接决定模型记得什么、忘掉什么、按什么顺序去理解当前这轮请求。1.1 上下文窗口不是字数上限而是注意力预算先厘清一个基础概念。大模型一次推理只能处理固定数量的 token这个数量叫上下文窗口。比如某些商用模型支持 128K 甚至 200K 的窗口看着很大但 token 不等于中文字数一句 20 字的话可能被拆成 30 多个 token。中文场景下1 个汉字大概对应 1 到 2 个 token标点和格式还会额外占。更关键的是窗口不是越大越好。业界有个著名的Lost in the Middle现象当上下文很长时模型对开头和结尾的内容把握得很好但对中间部分的记忆和引用能力明显下降。我用一个 32K 窗口做测试把订单规则放在 16K 位置附近模型经常忽略规则直接给出错误答案。所以 context-mode 的核心不是能塞多少而是**在有限的注意力预算里优先放什么、放弃什么**。1.2 两种视角下的 context-mode应用层参数与提示词结构在工程上context-mode 通常体现为两类配置。第一类是应用层的参数化策略。比如你调用某个聊天补全接口需要在 API 参数里决定 who 发起了本次请求、携带哪些历史消息、要不要附带检索结果、生成回答预留多少 token。这一层是可代码化的也是我能写出明确配置建议的部分。第二类是提示词层面的结构策略。系统提示词放在哪、用户消息怎么措辞、工具返回结果如何格式化都会影响模型对上下文的利用效率。我见过不少人只在参数里调上下文却忽略了消息数组里 system 提示词和工具结果的角色错位导致模型理解混乱。这两层得一起设计只调一层都容易出问题。1.3 从一张越聊越笨的曲线说起我习惯用一张准确率-会话轮次曲线来定义 context-mode 要解决的问题。横轴是会话轮数纵轴是模型回答关键信息的准确率。如果不做任何上下文管理大多数任务在 5 轮内表现良好10 轮后开始下滑20 轮后靠运气。原因很简单多头注意力对长上下文的建模能力有限早期的关键信息会被中间涌现的闲聊稀释。所以 context-mode 的本质就是牺牲一部分完整记忆保住大部分关键记忆。明白这个前提我们才能理性地讨论下面三种策略。2. 三种主流上下文投放策略截断、压缩、检索我在实际项目里把 context-mode 归纳成三大类全量截断、摘要压缩、检索召回。它们不是互斥关系成熟系统往往会组合使用但你得先理解每个策略单独使用时的行为和代价。2.1 全量窗口截断最省事也最容易暴雷策略逻辑是保留最近 N 轮对话原始内容超过 N 轮的直接丢弃或者只保留最近 M 个 token。实现简单效果稳定适合试跑原型和短会话场景。我最早就是这么干的。会话管理用一个队列超过 10 轮就把最老的一条消息弹出OpenAI 风格的结构大致长这样[ {role: system, content: 你是订单查询助手使用下方工单信息回答。}, {role: user, content: 帮我查一下订单 20241001 的状态}, {role: assistant, content: 该订单已发货当前位于杭州转运中心。}, {role: user, content: 预计什么时候能到} ]它的问题也很明显一旦用户在第 6 轮提起我前面说过的收货地址第 1 轮的信息已经被弹掉模型只能懵圈。而且这个策略完全不感知信息价值客户名字、订单号、时间节点这些关键实体正好是最容易被滑动窗口滑出去的内容。2.2 摘要压缩保住事实但会丢掉语感和细节策略逻辑是每隔若干轮把较早的对话发给模型做一次总结用一段摘要替代原始历史。比如前 10 轮对话压缩成 200 字的事件概要之后请求只携带这份摘要加最近几轮原文。这种模式的好处是能把关键事实留得更久坏处是摘要模型本身也会漏东西。我有一次做故障报修助手客服工单里的设备编号被摘要丢了用户问那我上次报修的那个设备呢助手答不上来体验非常割裂。更隐蔽的问题是摘要会抹平语气和过程信息。用户在早期可能已经表达过强烈不满摘要里只剩用户反馈设备故障后续回复就缺少安抚意识。所以摘要压缩不能无脑用必须搭配一个重要原则摘要里必须保留可检索的关键实体清单把订单号、设备 ID、人名、地址、时间戳单独列出来而不是混在自然语言里让模型提炼。2.3 检索召回用 RAG 的思路做历史记忆策略逻辑是不直接往上下文里堆全部历史而是把历史对话切成片段chunk离线做 embedding 向量化请求进来后根据当前用户问题的向量相似度召回最相关的 Top K 片段再注入上下文。这就是常说的检索增强生成RAG在会话记忆上的应用。这种方式是三种里成本最高、效果上限也最高的。它能解决20 轮之前的一句话突然被重新翻出来的问题代价是你得维护一套向量库、处理 chunk 切分和召回阈值调优。我在客服机器人上实测切换到检索召回后用户满意度提升明显但纯开发工作量比滑动窗口多了至少两个晚上——主要花在 embedding 模型选型和召回阈值调试上。2.4 三种模式怎么选一张表说清楚策略模式实现成本单请求 token 消耗记忆持久性信息失真风险适合场景全量截断低低但随轮次增长差老信息易丢低保留的都保真短问答、原型验证、客服 FAQ摘要压缩中中需额外调用摘要较好事实可保留高细节易被提炼丢失中长会话、需要控制成本检索召回高低且稳定最好可跨会话回溯中取决于召回质量长会话、复杂业务、跨轮追溯我的习惯是从左往右演进先用全量截断跑通等越聊越笨开始影响核心指标再上摘要压缩如果摘要压缩后依然有高频的翻旧账需求才考虑检索召回。这套路径可以避免一开始就背上向量库的运维负担。3. 落地代码用 Python 搭一个带 context-mode 的会话层理论说够直接上可运行的东西。这节我给出一份我在多个项目里复用过的上下文管理模块核心目标有三个控制 token 预算、支持不同 mode 策略、可观测。3.1 先定预算128K 窗口到底怎么切假设模型窗口是 128K token我强烈建议你永远不要把窗口用满。生成回答本身需要空间用户新输入也要空间而且窗口越接近极限模型性能下降越明显。我的安全比例是系统提示词固定预算 2K token不够就精简提示词不能让它随会话膨胀。会话历史动态预算约 70%即 90K 左右。这部分受 context-mode 策略控制。检索召回内容如果启用固定预算约 15%即 19K 左右超出就截断 Top K。用户当前输入 模型回答预留至少保留 15%也就是约 17K。预留不足会导致 API 直接报超限错误。每次组织请求前用 tokenizer 估算这几块的 token 数超过预算就走对应的缩减逻辑。这个思路比凭感觉删两轮历史科学得多。3.2 一个最小可用的上下文管理类下面这个类实现了三种 mode 的切换核心逻辑写在build_messages里。完整代码我精简成了可直接粘贴的长度。import tiktoken from typing import List, Dict class ContextManager: def __init__(self, system_prompt: str, mode: str sliding, max_history_tokens: int 90000, model: str gpt-4o): self.system_prompt system_prompt self.mode mode self.max_history_tokens max_history_tokens self.model model self.encoder tiktoken.encoding_for_model(model) self.history: List[Dict[str, str]] [] def _count_tokens(self, text: str) - int: return len(self.encoder.encode(text)) def _trim_history(self) - List[Dict[str, str]]: # 从最新的消息往前保留直到 token 预算用尽 kept: List[Dict[str, str]] [] used 0 for msg in reversed(self.history): cost self._count_tokens(msg[content]) if used cost self.max_history_tokens: break kept.append(msg) used cost return list(reversed(kept)) def add_message(self, role: str, content: str) - None: self.history.append({role: role, content: content}) def build_messages(self) - List[Dict[str, str]]: messages [{role: system, content: self.system_prompt}] if self.mode sliding: messages.extend(self._trim_history()) elif self.mode full: messages.extend(self.history) else: raise ValueError(funsupported mode: {self.mode}) return messages调用方式很简单cm ContextManager( system_prompt你是订单客服回答前先看历史不确定就向用户确认。, modesliding, max_history_tokens90000 ) cm.add_message(user, 帮我查订单 20241001) cm.add_message(assistant, 该订单已发货当前在杭州转运中心。) messages cm.build_messages() # 直接把 messages 传给模型接口即可这个类虽然朴素但已经把预算控制和策略可切两个核心点做进去了。3.3 摘要模式和检索模式怎么挂进同一个接口摘要模式不是单独换一个类而是在build_messages里加一个分支如果 history 总 token 超出预算就先调用一次摘要模型把最早的半数轮次压成一段summary再拼上最近几轮原始消息。def _summarize(self, old_messages: List[Dict[str, str]]) - str: # 省略具体调用效果是让模型输出一段结构化的历史摘要 # 建议固定要求模型输出 关键实体清单 和 事件经过摘要 两部分 resp call_summary_model(old_messages) return resp def build_messages_with_summary(self) - List[Dict[str, str]]: messages [{role: system, content: self.system_prompt}] if self._total_history_tokens() self.max_history_tokens: summary self._summarize(self.history[:-6]) messages.append({role: system, content: f历史总结{summary}}) messages.extend(self.history[-6:]) else: messages.extend(self.history) return messages这里有个容易被忽略的细节摘要内容不要用 system 角色还是不要用我的测试表明把摘要放在 system 里会让模型把摘要当成最高优先级指令而不是对话记忆。更稳的做法是放在一个独立的 system 消息里并且在摘要模板开头加上以下内容是历史会话的客观记录仅供参考。3.4 让每一步都可观测token 账本不能省工程上线后最怕黑盒。我在 ContextManager 里加了一个简单的账本结构记录每次请求的 system 占比、history 占比和生成占比。之后把这份日志打到监控系统你才能回答三个关键问题上下文有没有在膨胀截断策略有没有频繁触发哪些轮次的用户请求吃掉了大量 tokendef build_messages_with_debug(self): messages self.build_messages() total 0 for m in messages: tokens self._count_tokens(m[content]) total tokens print(f[context] {m[role]}: {tokens} tokens) print(f[context] total: {total}, history: {len(self.history)} msgs) return messages这一步在原型期看起来多余但等到线上出问题靠日志逆推上下文结构是最快的排查路径。4. 实测踩坑上下文污染的完整事故链路这段是全文我觉得最值钱的部分。下面三个坑我全部真实踩过而且每个的排查过程都可以复现。4.1 无限累积的 token 超限不是模型的问题是策略的问题有个周末线上突然报大量maximum context length exceeded错误。我第一时间以为是模型窗口不够点开日志发现每次请求都携带了完整的历史对话部分用户已经聊了 50 轮单次请求 token 直接突破窗口上限。根因就是 context-mode 缺失或者说得更直白代码里根本没有上下文管理。这种事故出厂时不会暴露因为测试对话都很短上线一周后长会话积累到临界点才开始爆。排查链路是这样的先去 API 网关看错误码分布确认全部集中在上下文超限再拉出报错会话的请求体发现 messages 数组长度已经超过 100最后用 tiktoken 统计单条消息体超过 130K token。定位到根因后我把滑动窗口模式推上线问题立刻消失。4.2 摘要把关键事实摘丢了返工最狠的一次后来我上了摘要模式效果稳定了一段时间然后出现一批诡异反馈用户说我明明之前报过警了你们怎么又问一遍。查对话记录发现用户在 12 轮前说过地址是西湖区文三路 138 号但摘要模型把它压缩成了用户提供了收货地址已确认具体内容没了。问题不在于摘要模型弱而在于摘要指令设计不合理。我当时只让模型概括对话没有强制要求保留可操作实体。修复方式是给摘要模板加清单约束第一段必须输出用户关键信息姓名、地址、订单号、设备编码等第二段才是事件经过。模型有明确的结构目标后实体丢失率明显下降。提示如果你在摘要里发现关键实体丢失不是加长摘要而是改造摘要模板。让模型先列清单再写叙述比任何技巧都有效。4.3 工具结果混进 user 角色模型分不清是谁说的话客服助手接了查订单 API 后我把工具返回内容直接拼到了 user 消息后面结构变成{role: user, content: 帮我查订单 20241001。返回结果订单已发货到达杭州。}模型很快学会一个坏习惯——把工具返回当成用户陈述。用户说要修改地址模型反而回根据查询结果您不能修改已发货订单语气变得极其生硬。排查后发现模型把工具结果的断言当成了用户自己的要求。解决方式是强制区分角色工具返回单独作为tool或function消息传入用户消息只保留用户原始输入。如果你的接口不支持工具角色至少要用分隔线把内容隔开并在 system 提示词里明确紧随用户消息后的花括号内容是系统查询结果不是用户发言。4.4 排查上下文的通用路径总结一下遇到模型看起来失忆或者越聊越怪别急着换模型或调 temperature。按这个顺序排查打印原始请求体看 messages 数组里到底装了哪些内容角色归属是否混乱。数 token用 tiktoken 或模型自带的 tokenizer确认各段占比是否在预算内。对历史做归因把当前这轮回答用到的关键信息点列出来回看哪些轮次提供了这些信息确认它们是否还在上下文里。切策略做 A/B同一批对话分别用全量截断和摘要模式跑一遍对比回答质量。这一步能帮你判断瓶颈在信息没放进去还是模型看到了但没理解。这套路径成功率很高因为大部分上下文问题都是结构问题不是模型能力问题。5. 按会话轮次自动切换模式动态 context 的成本账讲完固定策略的搭建和排错最后聊一个进阶玩法让 context-mode 跟着会话生命周期动态变化。5.1 轮次驱动的自动切换逻辑我目前在生产环境使用三段式切换会话 1 到 5 轮全量模式因为早期每句话都很关键信息密度高。会话 6 到 15 轮滑动窗口加摘要保留最近 6 轮原文更早的做成摘要。会话 15 轮以上切换检索召回把历史向量化按当前问题召回 Top 5 片段。这个切换不是靠硬编码轮次数而是由一个会话状态机管理。每次 add_message 后判断当前轮次决定下一次请求使用哪个 mode。生产环境的实测数据是单个会话从 20 轮往后平均每轮 token 消耗下降约 40%而用户对回答满意度没有下降。5.2 成本账窗口用满不等于效果最好很多团队升级到 128K 窗口后以为高枕无忧实际并没有。以某商用模型为例假设输入价格每百万 token 约 15 元128K 窗口单次请求如果塞满光输入成本就是 1.9 元左右而控制在 32K成本只要 0.5 元。把 1000 个并发会话放大一个月差距非常可观。更重要的是效果账。前面说过的 Lost in the Middle 问题让中间内容几乎白费所以塞满窗口不仅费钱还可能让模型被无效中间信息干扰。按预算切分、主动丢弃低价值信息在降低成本和提升质量上是同向的。5.3 我需要一直保留原始历史吗记忆分层的设计我的做法是把记忆分成三层短期记忆最近几轮原文、中期记忆摘要压缩后的结构文档、长期记忆向量库中的历史片段。这三层对应不同的检索优先级。每次组织请求时短期记忆全量保留中期记忆按预算截取长期记忆通过相关性召回。这套设计可以参考 LangChain 的 memory 模块但我不建议直接全套引入因为它的抽象层较重会话量大的时候问题定位成本偏高。自己用二三十行代码管理好这三个 list反而更可控。5.4 一个小技巧把当前任务提示词放到上下文的最后最后分享一个我在实测中反复验证的技巧不要在 system 提示词里堆所有规则而是把当前轮次最关键的指令放在用户消息后面的一个独立段落里。比如查订单场景最新的系统指令可以是你现在要给出订单状态结论如果信息不足直接说明缺失项。放在上下文末尾的内容模型关注度最高这个位置让关键指令的服从性提升了一个档次。我做了十五轮对比测试把核心指令从 system 挪到 user 消息末尾后关键信息的提取准确率提升了大概 3 到 5 个百分点。这个收益几乎零成本但前提是你先把 context-mode 的预算和策略管好——否则指令本身也可能被长上下文挤到中间去。context-mode 不是什么高深复杂的算法它是一整套关于模型记忆力边界的工程化取舍。从最基础的滑动窗口到摘要压缩再到检索召回每个模式都是在回答同一个问题在有限的注意力里什么信息值得被记住什么信息可以放心忘掉。希望这篇内容能帮你少走我趟过的那些弯路。