☰
context-mode实战:三种上下文策略解决大模型越聊越笨问题
2026/10/7 13:20:42 网站建设 项目流程

去年下半年我接手一个 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(f"unsupported mode: {self.mode}") return messages

调用方式很简单:

cm = ContextManager( system_prompt="你是订单客服,回答前先看历史,不确定就向用户确认。", mode="sliding", max_history_tokens=90000 ) 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 占比和生成占比。之后把这份日志打到监控系统,你才能回答三个关键问题:上下文有没有在膨胀?截断策略有没有频繁触发?哪些轮次的用户请求吃掉了大量 token?

def 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。按这个顺序排查:

  1. 打印原始请求体:看 messages 数组里到底装了哪些内容,角色归属是否混乱。
  2. 数 token:用 tiktoken 或模型自带的 tokenizer,确认各段占比是否在预算内。
  3. 对历史做归因:把当前这轮回答用到的关键信息点列出来,回看哪些轮次提供了这些信息,确认它们是否还在上下文里。
  4. 切策略做 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 不是什么高深复杂的算法,它是一整套关于"模型记忆力边界"的工程化取舍。从最基础的滑动窗口,到摘要压缩,再到检索召回,每个模式都是在回答同一个问题:在有限的注意力里,什么信息值得被记住,什么信息可以放心忘掉。希望这篇内容能帮你少走我趟过的那些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询