1. context-mode 到底是什么,解决什么问题
做了一段时间大模型应用开发的朋友,应该都遇到过同一个尴尬场景:模型明明上下文窗口是 32K、128K,看起来很大,可真正跑起来,聊到第 20 轮就开始“失忆”,要么答非所问,要么干脆报错 Invalid token count。问题不在模型能力,而是我们根本没有认真管理“喂给模型的内容”。
context-mode 这个概念,说的就是“以什么方式、按什么策略组织并更新上下文”。网上有人叫它上下文模式,有人叫上下文管理策略,核心就一件事:在大模型对话、Agent 调度、RAG 问答等场景里,把有限的上下文预算花在刀刃上,让系统在长对话、长文档、多轮工具调用中保持稳定、可追溯、低成本的运行状态。它不是某个特定框架里的开关,而是一整套设计方法论,具体落到代码层面,就是一组上下文组装规则、截断策略、压缩策略和回复态持久化策略。
这篇文章我想围绕 context-mode 展开,讲清楚三件事:第一,为什么在 2025 年这个时间点,上下文管理成了刚需;第二,不同业务场景下,context-mode 应该怎么设计、怎么选型;第三,我会直接给出一套可运行的 context-mode 管理器实现,带完整的策略代码和参数计算过程,方便你根据自己的项目直接改造。适合正在做对话机器人、Agent 工作流、文档问答、AI 编程助手这类应用的朋友,尤其适合那些已经被上下文越堆越长、效果越来越差折磨过的人。
先说一个我自己的观察:很多人以为把历史消息全部塞给模型,就是“长记忆”,这其实是个典型的误区。模型的注意力在超长上下文上会明显衰减,英文社区管这个叫 lost in the middle,中间段信息被忽略的情况特别严重。也就是说,哪怕模型没有报错,你的上下文里堆了一大堆旧消息,实际能起作用的可能只有开头和结尾那几段。context-mode 的出发点,就是承认上下文是一个需要精打细算的资源,而不是一个可以无限扩展的垃圾桶。
2. 核心设计思路:为什么不能无脑堆上下文
2.1 上下文预算的根本限制
先说一个基础概念:上下文预算。假设你用的是 32K 窗口的模型,每次请求实际能用的 token 并不是 32000,因为你的 system prompt 要占空间,工具定义要占空间,当前用户问题要占空间,为了容错还得留出回复生成的输出空间。真正留给“历史对话”的,往往只有六到七成。
我做过一次很粗糙的统计,一个客服问答场景,system prompt 写了个 800 token 的角色设定,接了一个订单查询工具和一个售后规则查询工具,工具定义加起来 1200 token。现在用户问你“我上周买的那个东西物流卡住了,帮我查一下”,当前轮次问题大概 50 token,预留回复 token 1024。哪怕你的上下文窗口是 32K,固定消耗也接近 3000 token,能用来装历史对话的其实只有 29000 左右。如果每轮问答平均消耗 800 token 的历史增量,那 32K 窗口大概 36 轮左右就会撞顶。
这和其他资源管理很像:你租了个 100 平米的办公室,但空调机房占了一间,会议室占了一间,仓库占了一间,真正给工位用的就剩 60 平米了。context-mode 就是帮你计算“每间房该分多少面积”并且动态调整的方案。
2.2 三种主流上下文模式的底层逻辑
我这些年用下来,感觉 context-mode 的实质就是三种策略之间的权衡,大多数实现都逃不开这个框架。
第一种是“滑动窗口截断模式”。只保留最近 N 轮对话,更早的直接删掉。这是最简单、最稳定的方案,任何模型都能跑得很好,因为你永远只在窗口内操作。缺点是早期信息会被粗暴遗忘,适合业务场景本身不太依赖“很久之前约定过什么”的闲聊型对话。
第二种是“摘要压缩模式”。历史对话太长时,用一次额外的 LLM 调用把早期对话总结成摘要,然后把摘要和最近 N 轮具体对话一起放进上下文。这个方案能保留更多长期信息,代价是每次压缩都要多花一次模型调用费,而且摘要过程本身有信息损失。比如用户三天前说“我比较喜欢深色系风格”,摘要如果只写了“用户对 UI 有偏好”,模型后续就不知道怎么接话。
第三种是“结构化重塑模式”。把对话历史按“事实”、“用户偏好”、“进行中任务”、“最近行为”几个维度拆开,用结构化字段保存,组装上下文时按优先级排序。这个方案效果上限最高,但实现成本也最大,需要你做实体提取、意图分类、状态维护,适合 Agent 类应用,因为工具调用本身就需要维护“当前任务状态”。
选哪种模式,本质上取决于三件事:你的业务对早期信息的敏感度有多高、你的单次请求成本红线是多少、你团队能接受的开发复杂度是多少。没有银弹,只能说在某个具体阶段,某个模式更合适。
2.3 固定窗口与动态策略的组合误区
还有一种常见的组合误区,就是认为“窗口大一点,加个截断就稳了”。我见过不少团队直接把窗口调到 128K,然后继续用最早的“只保留最近 30 轮”策略,结果发现两个问题:一是单次请求的输入 token 大幅上涨,成本直接翻了好几倍;二是模型处理大段无关历史时,延迟明显升高,尤其在并发量上去以后,用户体验会变得很糟糕。
正确思路应该是让“窗口大小”和“上下文模式”解耦。窗口大小是硬件资源的边界,但业务层面的信息保留策略必须独立设计。比如你的窗口是 128K,不代表你每次都要填充到 120K,而是应该根据业务需要,把总用量控制在一个合理水位上,同时保留扩展余量。context-mode 管理器的核心职责,就是根据当前状态自动计算“应该放什么、不该放什么、放多少”,而不是让开发者在每轮对话里手写拼接逻辑。
3. 实操环节:设计并实现一个可运行的 context-mode 管理器
3.1 整体架构与模块职责
先说我的实现思路。这是一个 Python 写的 context-mode 管理器,核心就四个模块:预算计算器、历史管理仓库、模式策略器、上下文组装器。它们各自职责很清晰,方便你后面拆分改造。
- 预算计算器:根据模型窗口上限,动态计算 system 占位、工具占位、当前问题占位,反推出历史区可用余量。
- 历史管理仓库:负责持久化对话轮次,支持按时间倒序取数据,支持删除与压缩操作。
- 模式策略器:根据当前上下文占用和业务配置,选择使用 trim、summarize 还是 restructure 策略。
- 上下文组装器:把各块内容合并成最终的 messages 数组,并输出内部状态快照做日志。
我这么拆的原因是,实际业务里上面的每个环节都可能单独调整。比如某天你换了模型,窗口从 32K 变成 128K,你只需要改预算计算器的配置,其他模块完全不用动。如果某天你想把摘要模式从全局摘要改成滚动摘要,只需要动模式策略器,上下游完全感知不到。
3.2 关键代码实现一:消息结构与预算计算
先定义一个简单的消息数据类,不引入外部重型依赖,保持可读性。
from dataclasses import dataclass from datetime import datetime from typing import Dict, List, Optional, Any @dataclass class Message: role: str # "system" | "user" | "assistant" | "tool" content: str timestamp: datetime msg_id: str metadata: Optional[Dict[str, Any]] = None def estimate_tokens(self) -> int: # 粗略估算:中文场景每字约 1.5 token,英文每 4 字符约 1 token # 实际生产建议使用 tiktoken 做精确计算 char_count = len(self.content) return int(char_count * 1.5) + 8 # +8 是角色和格式开销这里我故意用估算而不是精确 tokenize,因为在真实调度场景里,每来一条消息就做一次精确 tokenize 消耗太大,而且预算本来就要留安全余量,估算误差在 10% 以内完全可接受。生产环境真要精确算,再接 tiktoken 也不迟。
接着是预算计算器。它的任务是“给定窗口上限,算出历史区还能放多少 token”。
class BudgetCalculator: def __init__(self, hard_limit: int, safety_ratio: float = 0.15): self.hard_limit = hard_limit self.safety_ratio = safety_ratio def compute_available_for_history( self, system_messages: List[Message], user_messages: List[Message], tool_defs: List[Message], output_reserve: int = 1024, ) -> int: fixed_cost = 0 for msg in system_messages: fixed_cost += msg.estimate_tokens() for msg in tool_defs: fixed_cost += msg.estimate_tokens() for msg in user_messages: # 当前问题必须完整保留 fixed_cost += msg.estimate_tokens() safety_reserve = int(self.hard_limit * self.safety_ratio) available = self.hard_limit - fixed_cost - output_reserve - safety_reserve return max(available, 0)关于 safety_ratio 我有话说。很多人不做这个预留,结果回复一旦变长就直接撞窗口报错。我建议至少预留 15%,之前线上服务跑到 10% 就偶尔崩,抬到 15% 之后彻底稳定。这个是拿报警电话换来的教训。output_reserve 默认 1024 是给回复留的空间,如果你的应用经常生成大段 Markdown 表格或代码,建议调到 2048 以上。
3.3 关键代码实现二:历史仓库与三种模式策略
历史仓库我这边用内存 + Redis 两级结构演示,生产环境可以直接挂 Redis 或者 MongoDB,核心方法就几个:添加消息、按窗口取历史、删除指定范围。
class HistoryStore: def __init__(self, max_items: int = 500): self._items: List[Message] = [] self._max_items = max_items def add(self, msg: Message) -> None: self._items.append(msg) if len(self._items) > self._max_items: self._items = self._items[-self._max_items:] def recent(self, n: int) -> List[Message]: return self._items[-n:] def all(self) -> List[Message]: return self._items def replace_head_with_summary( self, summary: str, keep_recent: int, ts: datetime, ) -> None: recent_msgs = self._items[-keep_recent:] summary_msg = Message( role="system", content=f"[历史摘要] {summary}", timestamp=ts, msg_id="summary", ) self._items = [summary_msg] + recent_msgs然后写三种模式策略器。这一步是整个 context-mode 的核心,我先把模式枚举和策略分发写出来。
from enum import Enum class ContextMode(str, Enum): TRIM = "trim" SUMMARIZE = "summarize" RESTRUCTURE = "restructure" class ContextPolicy: def __init__( self, mode: ContextMode, max_history_tokens: int, keep_recent_turns: int = 10, llm_summarizer=None, ): self.mode = mode self.max_history_tokens = max_history_tokens self.keep_recent_turns = keep_recent_turns self.llm_summarizer = llm_summarizer # 摘要模式需要传入 def apply( self, history: List[Message], current_question: str, ) -> List[Message]: if self.mode == ContextMode.TRIM: return self._apply_trim(history) if self.mode == ContextMode.SUMMARIZE: return self._apply_summarize(history) if self.mode == ContextMode.RESTRUCTURE: return self._apply_restructure(history) raise ValueError(f"unknown mode: {self.mode}") def _apply_trim(self, history: List[Message]) -> List[Message]: # 只保留最新 keep_recent_turns,直接用轮次而不是 token 做单位 return history[-self.keep_recent_turns:] def _apply_summarize(self, history: List[Message]) -> List[Message]: # 需要调用 LLM 生成摘要,摘要 + 最近 N 轮具体对话 if self.llm_summarizer is None: raise RuntimeError("summarize mode requires llm_summarizer") total_tokens = sum(m.estimate_tokens() for m in history) if total_tokens <= self.max_history_tokens: return history keep_tokens = 0 keep_list = [] for msg in reversed(history): msg_tokens = msg.estimate_tokens() if keep_tokens + msg_tokens >= self.max_history_tokens * 0.4: break keep_list.append(msg) keep_tokens += msg_tokens keep_list.reverse() older_msgs = history[: -len(keep_list)] if keep_list else history summary = self.llm_summarizer(" ".join(m.content for m in older_msgs)) summary_msg = Message( role="system", content=f"[滚动摘要] {summary}", timestamp=older_msgs[0].timestamp if older_msgs else datetime.now(), msg_id="rolling_summary", ) return [summary_msg] + keep_list def _apply_restructure(self, history: List[Message]) -> List[Message]: # 结构化重塑模式:区分用户事实、偏好、任务状态、最近行为 facts = [] preferences = [] active_tasks = [] recent = [] for msg in history: meta = msg.metadata or {} category = meta.get("category", "recent") if category == "fact": facts.append(msg.content) elif category == "preference": preferences.append(msg.content) elif category == "task": active_tasks.append(msg.content) else: recent.append(msg) structured_blocks = [] if facts: structured_blocks.append(Message( role="system", content="[用户事实] " + ";".join(facts[-20:]), timestamp=history[0].timestamp, msg_id="facts", )) if preferences: structured_blocks.append(Message( role="system", content="[用户偏好] " + ";".join(preferences[-20:]), timestamp=history[0].timestamp, msg_id="preferences", )) if active_tasks: structured_blocks.append(Message( role="system", content="[进行中任务] " + ";".join(active_tasks[-5:]), timestamp=history[0].timestamp, msg_id="tasks", )) return structured_blocks + recent[-self.keep_recent_turns:]关于 summarize 模式,我要特别说明那个max_history_tokens * 0.4的判断条件。我把它当作“最近具体对话的保留预算”,也就是哪怕老消息再重要,最近这批不能超过历史预算的四成,否则摘要就没意义了,新具体对话的比例太低,模型会过度依赖摘要里的二手信息,反而更迷糊。这个 0.4 是我试过 0.3、0.5 之后觉得最实用的比例。
3.4 关键代码实现三:上下文组装器与状态快照
组装器的任务是把 system 模板、工具定义、历史策略执行结果、当前问题按固定顺序拼成 messages 数组,同时输出调试信息。
class ContextAssembler: def __init__(self, policy: ContextPolicy, budget_calc: BudgetCalculator): self.policy = policy self.budget_calc = budget_calc def assemble( self, history_store: HistoryStore, system_prompt: str, tool_defs: List[Message], current_question: str, ) -> Dict[str, Any]: all_history = history_store.all() available = self.budget_calc.compute_available_for_history( system_messages=[Message( role="system", content=system_prompt, timestamp=datetime.now(), msg_id="system", )], user_messages=[Message( role="user", content=current_question, timestamp=datetime.now(), msg_id="current", )], tool_defs=tool_defs, ) self.policy.max_history_tokens = available processed_history = self.policy.apply(all_history, current_question) messages = [Message( role="system", content=system_prompt, timestamp=datetime.now(), msg_id="system", )] messages += tool_defs messages += processed_history messages.append(Message( role="user", content=current_question, timestamp=datetime.now(), msg_id="current", )) total_used = sum(m.estimate_tokens() for m in messages) return { "messages": messages, "stats": { "total_used_tokens": total_used, "available_tokens": available, "history_message_count": len(processed_history), "selected_mode": self.policy.mode.value, }, }组装顺序为什么是 sys + tools + history + current?原因有两点。第一,system prompt 和工具定义必须在最前面,因为很多模型对指令位置的敏感度很高,放后面容易被历史对话干扰注意力。第二,当前用户问题必须放在所有历史之后,这样让模型的注意力自然落在最近的任务目标上。这个顺序在实测中比“把历史放最后”的准确率稳定高出不少。
实际接入业务时,我强烈建议把返回的 stats 打到日志或者监控面板里。total_used_tokens 和 available_tokens 两个数字能直接反映出你的上下文水位,方便你判断当前策略是不是太保守或者太激进。这是整个系统里性价比最高的观测手段。
3.5 参数计算实例:一个完整的调用流程
下面演示一个具体调用流程,模拟一个电商售后问答场景,窗口上限设 32K。这个例子包含了从策略选择到最终消息组装的全过程。
# 初始化 store = HistoryStore(max_items=200) policy = ContextPolicy( mode=ContextMode.SUMMARIZE, max_history_tokens=20000, keep_recent_turns=12, llm_summarizer=lambda text: f"用户之前咨询了售后流程,并明确表示偏好文字客服。原始内容:{text[:100]}", ) budget = BudgetCalculator(hard_limit=32000, safety_ratio=0.15) assembler = ContextAssembler(policy=policy, budget_calc=budget) # 模拟历史:40 条消息 for i in range(40): store.add(Message( role="user" if i % 2 == 0 else "assistant", content=f"第{i+1}轮对话内容,包含订单信息和售后咨询进度", timestamp=datetime.now(), msg_id=f"msg_{i}", )) # 当前问题 question = "我的订单还没到,帮我查一下物流状态" result = assembler.assemble( history_store=store, system_prompt="你是一个电商平台智能客服,负责处理订单咨询和售后问题。回答要简洁、准确、有礼貌。", tool_defs=[ Message( role="system", content="工具1: query_logistics(order_id) 查询物流状态;工具2: query_after_sale(order_id) 查询售后记录", timestamp=datetime.now(), msg_id="tool_defs", ) ], current_question=question, ) print("selected_mode:", result["stats"]["selected_mode"]) print("history_message_count:", result["stats"]["history_message_count"]) print("total_used_tokens:", result["stats"]["total_used_tokens"]) print("available_tokens:", result["stats"]["available_tokens"])这个例子里,预算计算器先算出 system、tool、current 问题约占 2600 token,再留出 15% 安全余量 4800 token 和 1024 的输出留白,于是历史区可用大概是 23500 token。但 policy 里 max_history_tokens 我手动设成了 20000,这其实是故意留的第二层保险:宁可少用,也不要把历史区顶满。因为一旦顶满,下一次进来新消息就必须立刻压缩,系统的压力和抖动都会变大。
运行结果中,SUMMARIZE 策略会发现 40 条历史的总 token 远超过 20000,于是保留最近约 30% 的具体对话,其余被 LLM 压缩成滚动摘要。这样 messages 里既有摘要作为长期记忆,又有近期具体对话保证模型能理解细节,总 token 控制在合理水位内。
4. 常见问题与排查技巧实录
4.1 上下文被截断后“失忆”怎么办
最典型的症状是:用户在第 15 轮提到“我之前已经说过了,我要退的是那件蓝色的”,而模型根本不知道蓝色那件事。如果是 TRIM 模式,大概率是截断太狠,keep_recent_turns设得太小。我见过有人为了省 token 设成 5,结果关键信息全丢了。
排查思路很简单:先把最近两轮的实际上下文 dump 出来,看看有没有包含用户提到的关键信息。如果没有,调大keep_recent_turns;如果有但还是答错,那就不是截断的问题,而是注意力分配问题,这时候要去检查是不是 system prompt 或者历史中间夹杂了太多无关段落,挤压了关键信息的相对位置。
一个实用技巧:如果业务对“用户曾经说过的诉求”特别敏感,建议直接从 TRIM 切到 RESTRUCTURE,并且把“用户诉求”单独存成一个 fact 字段。这样哪怕最近的 10 轮对话里完全没提到蓝色那件事,组装上下文时也会在 facts 区块里带上“用户要退蓝色商品”这条记录,模型永远看得到。
4.2 SUMMARIZE 模式信息损失怎么控制
摘要压缩不可避免会丢信息,但丢多丢少差别很大。我之前犯过一个严重错误:直接把 30 轮历史一次性丢给模型生成一段 300 字的摘要,结果模型把中间有转折的对话完全搞反了。用户先说“我要退”,后来被客服劝住了改成“再等等”,摘要里却只写了“用户要退货”,后面一整个会话都被带偏了。
后来我改进成“分块摘要 + 关键事实抽取”两层结构。第一层,每 10 轮生成一小段摘要;第二层,从每个分块里抽取出用户明确表达的诉求和状态变化,单独存成 fact 列表。这样即使摘要本身不够细腻,fact 层面的硬信息也不会丢。实现上就是多调用一次 LLM,但准确率提升非常明显。
另外,摘要触发时机不要等到历史快满了才做。我建议在历史占用达到预算的 70% 时就提前执行一遍摘要,然后把旧的原文删掉。这样做的好处是,压缩过程是在系统还算“宽松”的状态下完成的,即使摘要生成长一点、慢一点,也不会影响当前请求的时效性。如果每次都等 95% 才去压缩,一旦 LLM 摘要调用超时,你都没地方腾挪。
4.3 成本估算与实际调参经验
模式选型对成本的影响非常大。我在一个日请求量 10 万次的客服系统上对比过:TRIM 模式每月输入 token 大约 4000 万,SUMMARIZE 模式因为每次压缩都要额外调用一次 LLM,总 token 会涨到 6000 万到 7000 万。换算成钱,按比较便宜的价格算,一个月差出一顿饭钱,但换来的是多轮场景明显更好的记忆表现。
如果你的业务量很大、预算有限,我建议采用“混合模式”而不是全局统一。具体做法是:前 6 轮用 TRIM,7 到 20 轮用 SUMMARIZE,超过 20 轮再切 RESTRUCTURE。因为刚开始还没多少历史,摘要没意义;中期信息量大了,摘要能保证记忆;后期涉及长期任务状态维护,结构化更好用。策略器只要加一个计数器就能实现这种切换。
还有个容易被忽略的调参项:output_reserve。如果你的应用经常生成大段表格或者代码,这个值必须调大。我踩过一次坑,把回复预算设成 512,结果模型生成到一半就撞窗口,整个响应报错,用户看到的是聊到一半突然没下文,特别伤体验。后来统一改成 2048,再没出过这个问题。
4.4 关于 context-mode 的误用边界
最后说两句不该用 context-mode 的地方。如果你的业务场景是“每次请求都是独立、完全无状态的”,比如单轮翻译、单轮分类任务,那上下文管理完全没有意义,直接空历史请求就行。别为了用框架而用框架。
另外,如果模型窗口极大、成本也很低,而且你的对话长度长期在 1 到 5 轮之内,那也完全不需要引入这套复杂度。context-mode 适合的是“对话轮次多、历史依赖强、成本敏感”三者至少满足其一的场景。画蛇添足只会增加延迟和排查难度。
我自己在把 mode 从 TRIM 切到 SUMMARIZE 再切到 RESTRUCTURE 的过程中,最大的感受是:没有哪个模式是默认最优的,只有适不适合当前业务。先跑监控数据,再看模型行为,最后根据真实情况调整参数。这个顺序永远不要反过来。
一点小经验放在最后:无论你选哪种模式,请一定把“策略选择的结果”记录到日志链路里。遇到线上问题,你能一眼看出那轮对话当时用的是哪种模式、历史保留了多少条、上下文水位是多少。如果没有这些日志,排查上下文问题基本等于盲人摸象,你会花掉比调模式本身多十倍的时间。