做大模型应用开发,最头疼的一环永远是上下文管理。窗口就那么大,便宜的模型 8K、贵一点的 128K,看着不小,可几轮工具调用、一次 RAG 检索、一段长文档塞进去,照样给你撑爆。我前后做过两个对话项目,都栽在同一个坑里:系统提示词被历史冲淡,多轮回答越聊越蠢,token 费用还蹭蹭往上涨。后来我把上下文管理单独抽出来做了一层统一方案,内部代号就叫context-mode。它不是某个开源库里的现成包,而是我自己整理的一套上下文管理模式,把不同对话场景拆成不同模式,每个模式自己定义系统提示、保留策略、摘要压缩规则,上层应用只负责切换,剩下的事全部交给 context-mode 处理。如果你也在被“对话越久越傻”“token 烧太快”“同一套历史在不同场景下用着都别扭”这些问题折磨,这篇内容值得你从头看完。
1. context-mode 是什么:为什么上下文需要“模式化”
1.1 我踩过的两个典型坑
第一个项目是客服知识库问答。一开始很天真,把用户每次提问、每次回答都原封不动塞进 messages 数组,上下文越长,回答质量反而越差。后来定位发现,用户问“退款流程”,模型却在一堆历史里翻出三小时前聊过的“发错货”案例,回答被带偏,而且每次请求都要把全部历史发给模型,成本直接翻倍。
第二个项目是带工具调用的 Agent。更惨,工具返回结果动辄几千字,连续调用三轮,上下文窗口就爆了,模型开始无中生有补参数,甚至把上一次的工具结果当成用户输入重复解析。我意识到问题不在模型,而在上下文策略:不同场景对历史的要求完全不同。客服问答需要保留完整对话脉络,工具链跑批只需要保留当前任务链和相关中间结果,长文本总结根本不需要历史,只需要文档本身。把这些塞进同一个上下文管道里,用同一套策略去裁剪,就是根因。
1.2 context-mode 要解决的三个核心问题
context-mode 不是某个具体算法,而是一套组织上下文的规则,核心解决三个问题:
第一,场景隔离。不同任务对历史的需求粒度不同,不能一个方案打天下。我要求所有模式都有独立的系统提示词、独立的窗口预算、独立的保留轮数,模式之间互不污染。
第二,预算可视化。每个模式都要明确回答一个问题:这个场景下,上下文窗口里最多能放多少内容?我按输出 token 预留比例、系统提示词固定开销、历史保留预算三层拆解,让每一笔 token 花在哪都清楚。
第三,退化兜底。无论设计多完善,总会出现上下文超限或者信息冲突,模式内部必须有一道最后防线,通常是摘要压缩和强制裁剪,保证请求永远不会因为超长而失败。
注意:context-mode 这个名字很容易让人以为它只是 prompt 管理,但它管的是 prompt、历史、工具结果、摘要、预算这几部分的组合策略,本质是一个轻量级的上下文运行时。
2. 整体设计思路:五个模式与两条管线
2.1 模式划分:chat / summary / agent / retrieval / code
我按业务场景把上下文策略抽象成了五个基础模式,项目里几乎不用再加新的:
- chat:普通多轮闲聊。保留最近 N 轮完整对话,超出部分按时间衰减压缩成摘要,系统提示词保持轻量。
- summary:长文本总结。只保留当前待总结文档和用户指令,历史摘要单独存,不塞进主窗口。
- agent:工具调用。系统提示词带工具定义和调用规范,保留最近几次工具输入输出结果,中间推理过程只保留结论。
- retrieval:RAG 问答。系统提示词里放进“仅依据检索片段回答”的约束和相关片段,对话历史只保留最后几轮。
- code:代码生成与解释。系统提示词强调格式和语言版本,历史保留最近修改上下文,同时允许注入“当前文件路径/运行环境”这类结构化信息。
这个划分不是拍脑袋,而是来自真实场景的 token 消耗观察。工具调用场景里,工具返回结果往往占了总 token 的 60% 以上;RAG 场景里,检索片段通常是第二大头。如果这两类和闲聊混用同一个保留策略,要么闲聊历史把检索片段挤掉,要么工具结果塞满导致指令被截断。分模式之后,每个场景都能独立调节。
2.2 上下文保留策略:滑动窗口与摘要压缩
每类模式内部用了两条管线:滑动窗口和摘要压缩。
滑动窗口很简单:只保留最近 N 个完整回合,N 由模式配置决定。chat 模式 N 通常取 10 到 20,retrieval 模式 N 取 6 到 8,agent 模式 N 不看回合数,看工具调用次数,一般保留最近 3 次调用。注意“完整回合”的定义,包括用户消息、助手消息、以及两者之间的工具调用链,缺一条都会让模型推理断链。
摘要压缩是兜底:当历史内容超过窗口预算时,把更早的内容用模型生成一段摘要,替换掉原文。摘要不是简单复述,我要求摘要里保留两类信息:用户明确表达过的需求和已经达成的结论,这两类信息丢失了上下文就真的断了。至于寒暄、澄清、重复追问,压缩时允许丢弃。实现上,摘要生成是独立的一次模型调用,用 summary 模式的精简提示词来做,不占用户侧预算。
2.3 状态流转:模式怎么切换
模式切换不是简单的变量赋值,它涉及历史数据的归属问题。我从一开始就加了两个原则:
切换先快照。进入新模式前,把当前模式的完整历史和摘要存档到独立的 session 上下文里,切回来时能原样恢复。这个设计救了我一次:Agent 执行中途切去 RAG 问答,用户问完检索问题又切回 Agent,结果工具链状态还在,没断。
跨模式只传结论。模式切换时,新模式收到的不是旧模式的全部历史,而是旧模式最后生成的摘要或者关键结论。比如从 chat 切到 summary,summary 模式只接收“用户要求总结这份文档”这个意图,不接收此前几十轮闲聊。
3. 核心实现细节:代码层面的关键设计
3.1 数据模型:模式定义与注册
我用 Python dataclass 定义模式配置,每个模式实例就是一个普通对象,方便测试、序列化和热更新。核心字段如下:
from dataclasses import dataclass, field from typing import List, Optional, Callable @dataclass class ModeConfig: name: str system_prompt: str max_rounds: int # 最多保留的完整回合数 history_budget: int # 历史部分 token 预算 reserve_output_tokens: int = 1024 # 为模型输出预留的 token 数 enable_summary: bool = True # 超限时是否压缩 keep_tool_trace: bool = False # 是否保留工具调用中间结果 compress_rounds: int = 4 # 每多少轮触发一次摘要重写 class ContextMode: def __init__(self, default_mode: str = "chat"): self._modes: dict[str, ModeConfig] = {} self._active = default_mode self._history: List[dict] = [] self._summary_cache: dict[str, str] = {} self._session_snapshot: dict = {} def register(self, cfg: ModeConfig) -> None: self._modes[cfg.name] = cfgregister 方法就是模式注册入口。我把所有内置模式放在 config 里,业务侧可以通过继承 ModeConfig 追加自定义字段,比如 retrieval 模式还有 top_k 和片段长度。
这里有一个容易被忽略的点:history_budget 必须小于模型窗口的可用 token 数减去系统提示词长度再减去输出预留。比如模型窗口 8192,系统提示词 800,输出预留 1024,那 history_budget 最多只能给 6368。我曾经在一个 8K 窗口上把 history_budget 设成 7500,看起来没问题,但真跑起来模型输出一长就报超限。后来我在 manager 里加了预算校验,初始化时就检查每个模式的配置是否超限,超了直接抛错,把问题暴露在启动阶段而不是线上。
3.2 上下文管理器:保留策略的执行
管理器负责把 history_budget、max_rounds、enable_summary 这些配置变成实际生效的 messages 列表。核心逻辑在 build_messages 方法里:
def build_messages(self, current_input: str): cfg = self._modes[self._active] if not cfg: raise ValueError(f"unknown mode: {self._active}") sys_msg = {"role": "system", "content": cfg.system_prompt} if cfg.name == "retrieval": sys_msg["content"] += f"\n检索片段:\n{self._retrieval_context}" # 历史裁剪:先按轮数滑动窗口 recent_history = self._history[-cfg.max_rounds:] # 超预算时触发摘要 if cfg.enable_summary and self._estimate_tokens(recent_history) > cfg.history_budget: older = self._history[:-cfg.max_rounds] summary_text = self._compress(older, cfg.compress_rounds) self._summary_cache[self._active] = summary_text recent_history = self._history[-cfg.max_rounds:] messages = [sys_msg] if self._summary_cache.get(self._active): messages.append({"role": "system", "content": f"对话历史摘要:{self._summary_cache[self._active]}"}) messages.extend(recent_history) messages.append({"role": "user", "content": current_input}) return messages_estimate_tokens 是 token 估算函数,没有条件调推理模型来做,用 tiktoken 按模型类型切分。如果你不想引入额外依赖,可以按字符数粗估:中文约 1.5 token/字,英文约 3.5~4.5 token/词。粗估只影响是否触发摘要,误差在 20% 以内都能接受,但方向错了会导致频繁压缩或者超限踩线,所以我建议至少按模型族做一次校准。
_compress 方法调一次独立的模型请求,把 older 列表生成一段小摘要,并要求模型保留所有硬性结论:
def _compress(self, messages: List[dict], max_rounds: int) -> str: # 按 max_rounds 分批压缩,避免一次性输入过长 chunks = [messages[i:i + max_rounds] for i in range(0, len(messages), max_rounds)] summaries = [] for chunk in chunks: text = json.dumps(chunk, ensure_ascii=False) resp = self._llm.complete( system="将以下对话压缩为摘要,只保留用户明确需求和已达成的结论,丢弃寒暄和重复内容。", user=text ) summaries.append(resp) return "\n".join(summaries)分批压缩是我踩过坑之后加上的。原先一次性把所有历史塞进去让模型总结,长对话能到十几万 token,接口直接超时。分批之后虽然多几次模型调用,但每段输入短,输出稳定,摘要质量也更高。
3.3 模式路由:上层的唯一入口
上层业务不直接操作 history,只调 switch 或者 route。switch 是显式切换,route 是根据用户输入自动判断模式。我同时保留了两种,因为真实场景两者都常用:机器人网关适合 route,内部流程编排适合 switch。
def route(self, request_text: str) -> str: text = request_text.strip() # 规则优先,命中直接切换 rule_hits = { "code": ["写代码", "python", "javascript", "实现一个函数"], "summary": ["总结", "太长", "浓缩", "要点"], "agent": ["调用工具", "执行任务", "查询", "call"], "retrieval": ["查资料", "知识库", "文档", "rag"] } for mode, keywords in rule_hits.items(): if any(k in text.lower() for k in keywords): return self.switch(mode) # 未命中走分类模型,但不等响应完成,超时兜底到 chat try: mode = self._classify(text) return self.switch(mode) except TimeoutError: return self.switch("chat")route 里最重要的一条经验是:自动分类必须超时兜底。我试过用分类模型做路由,但推理服务偶发超时,一次超时直接把用户请求拖死。后来改成规则优先、模型兜底,兜底超时立刻切 chat 模式,用户请求最多慢几百毫秒,不会整个挂掉。
4. 实操演示:给一个问答机器人接入 context-mode
4.1 初始化 context-mode
接入项目的第一步是初始化管理器并注册内置模式。我用一个 yaml 文件保存模式配置,启动时加载,改配置不用动代码:
modes: chat: max_rounds: 12 history_budget: 4000 reserve_output_tokens: 1024 enable_summary: true retrieval: max_rounds: 6 history_budget: 6000 enable_summary: false retrieval_context_limit: 3000 agent: max_rounds: 8 history_budget: 5000 keep_tool_trace: true加载代码:
import yaml with open("context_mode.yaml") as f: raw_cfg = yaml.safe_load(f) cm = ContextMode(default_mode="chat") for name, cfg in raw_cfg["modes"].items(): cm.register(ModeConfig(name=name, **cfg))注意:yaml 配置里 max_rounds 和 history_budget 都可能被业务侧动态覆盖,我建议提供 generate_config() 这类方法,在启动时把所有配置打印出来,方便上线前做预算审计。
4.2 定义自有模式
内置模式不够用时,业务侧可以注册自己的模式。比如一个文档审阅机器人定义一个 review 模式,系统提示词要包含“只输出问题列表,不修改原文”这类硬约束:
cm.register(ModeConfig( name="review", system_prompt="你是一名文档审阅助手。只输出问题列表,不修改原文。问题项必须包含位置、风险等级、修改建议。", max_rounds=4, history_budget=3000, enable_summary=True, keep_tool_trace=False )) cm.switch("review")自定义模式的好处是你不必改动库本身的逻辑。我需要特别说一句:新模式的 system_prompt 一定要在加载时做 token 预算检查,否则它自己就把窗口吃完了,后续历史没地方放。
4.3 接入对话循环
对话循环接起来很简单,我把它写成一个最小可运行的伪代码:
def chat_loop(api_key: str): cm = ContextMode(default_mode="chat") # 注册模式... while True: user_input = input("you: ") if user_input == "exit": break cm.route(user_input) # 自动切换模式 messages = cm.build_messages(user_input) reply = call_llm(messages, api_key) print(f"bot: {reply}") cm.push("user", user_input) cm.push("assistant", reply)push 方法会追加一条消息到 history,并触发内部轮数检测,超轮就自动淘汰最老的回合。轮数检测的触发时机放在 push 之后而不是 build_messages 之前,因为如果用户只输入还没回答就切走了,这条输入不应该占用窗口预算。
4.4 效果对比
我拿同一个客服知识库场景做过对比。接入前,8K 窗口跑 30 轮对话后开始出现引用错误、回复跑题;接入 context-mode 后,跑 60 轮仍能保持稳定,且单次请求 token 消耗从平均 7000 降到 3800 左右。原因是摘要替掉了大量陈旧历史,系统提示词又能按模式独立控制,不会被历史稀释。
成本方面也有明显变化。原来无脑保留全部历史,每次请求像搬家,token 消耗和窗口长度成正比;现在只保留摘要加最近窗口,减少的 token 大约在三成到五成之间。不过要提醒一句:摘要压缩本身要消耗独立的模型调用,对小体量应用可能得不偿失,建议只在 max_rounds 设定的窗口超过 8 轮后再开启摘要。
5. 常见问题与排查实录
5.1 模式切换后历史错乱
现象:从 chat 切到 retrieval 再切回 chat,用户发现机器人忘了之前聊的内容。原因大概率是切换时没做快照,旧模式的历史被新模式的覆盖,或者切回去时没恢复摘要。排查时先检查 session_snapshot 是否真的写入,再看切回 chat 时是否把旧 session 的 history 重新挂上。我建议为 session 加一个唯一 id,切换时把当前模式的完整状态序列化到 id 下,切回时只恢复 id 对应的状态,别用成员变量直接存。
5.2 摘要压缩丢关键信息
现象:摘要生成后,用户追问之前提到过的一个具体号码,模型答不出来。原因大都是 _compress 的输入提示词只写了“保留需求和结论”,没有声明“保留数字、时间、地址、名单等实体”。改进方法很直接,在压缩指令中加一行“必须完整保留对话中出现的数字、时间、地点、人物名、编号”。实测丢关键信息的情况少很多。另外要避免对那段压缩输出过度压缩,多级摘要嵌套叠加会导致信息衰减,我最多允许两层。
5.3 工具调用模式下 system 被截断
现象:agent 模式里工具定义太长,加上最新用户输入,上下文窗口直接超限。原因:我没有把工具定义单独计入 system_prompt 的长度预算。修复方案是在 ModeConfig 增加 tool_trace_budget,和 history_budget 分开算。工具定义部分我用一个独立的工具注册表,在 build_messages 里动态拼装,超长时按调用优先级丢弃不常用工具。不要所有工具一股脑塞进去,大多数场景下模型只需要三五个工具,其他都是噪音。
5.4 并发请求上下文互相污染
现象:多用户同时接入同一个机器人,A 用户的历史串到了 B 用户身上。原因:context-mode 实例是单例,内部 history 共享。修改方案是把 ContextMode 改成工厂模式,每个用户一个实例,或者至少以用户 id 做 key 存独立 history。我最终采用的是工厂模式,在网关层按 session id 创建实例,实例里只保留该会话的 history、summary_cache、snapshot,所有状态跟业务 session 生命周期绑定。这一块别偷懒,共享全局状态在并发下迟早出事。
5.5 预算配置的实测推荐值
最后给几组我在不同场景下实测比较稳的初始配置,不方便直接抄的就当参考基线:
| 模式 | 建议 max_rounds | 建议 history_budget | 建议摘要开关 |
|---|---|---|---|
| chat | 10~20 | 4000~6000 | 开 |
| retrieval | 6~8 | 5000~7000 | 关(片段优先) |
| agent | 8~10 | 4000~6000 | 开(保留工具轨迹) |
| summary | 2~3 | 3000~5000 | 关(只处理输入文档) |
| code | 8~12 | 5000~8000 | 开 |
这些数值不是公式算出来的,而是我根据常用模型窗口、输出预留、系统提示词占比反复试出来的。真正上生产时,建议每个模式单独跑一轮压测,把历史上 90 分位长度接进去,确保 build_messages 出来的预算不超过 85% 窗口上限,剩下 15% 留给模型发挥。
我个人在实际调试中的体会是:context-mode 的收益不是杀了某个惊艳的算法,而是把事情拆清楚。模式拆得越明白,上下文管理的边界就越清楚,模型能力自然就释放出来了。如果你也想在自己项目里引入这套思路,不要一上来就把五个模式全铺开,先从 chat 和 retrieval 两块入手,跑通快照、摘要、预算校验这三件套,再逐步加复杂模式。这个方向后续还有不少可以琢磨的地方,比如跟缓存层联动、按用户偏好动态调窗口,不过那是另一个话题了,先把基础扎稳。