如果你最近也在做大模型相关的应用开发,大概率会遇到一个很磨人的问题:对话轮数一多,模型就开始答非所问,速度肉眼可见变慢,账单数字也跟着飞涨。我最早把锅甩给模型能力不行,直到把每次请求的 payload 打出来看,才发现问题出在我们自己身上——我们把所有东西不分主次地往上下文里塞,硬生生把 Prompt 塞成了一个杂物间。
那段时间我反复调 prompt、换模型、调温度,效果都撑不过二十轮。后来我干脆停下来,把问题拆开看:模型本身没变,变的只是“每次请求时我们往窗口里放什么”。顺着这个思路,我整理出了一套可以落地实现的 context-mode(上下文模式)体系。这篇文章就聊聊我在这套体系上的完整设计、真实代码和踩坑过程,适合正在做 AI 对话产品、智能客服、RAG 检索问答的开发者,也适合那些想控制 token 成本、提升回答稳定性的团队参考。
1. context-mode 到底是什么:从一次翻车现场说起
1.1 一个每天都在发生的翻车现场
我有一个内部工具型对话系统,初期实现简单粗暴:每轮对话都把历史消息完整拼进 messages,系统 prompt 里再贴一份长长的产品文档。刚上线时效果很好,前二十轮几乎挑不出毛病。
到了第四十轮左右,问题开始集中爆发。用户问“我们刚才说的那个阈值是多少”,模型回答一个完全对不上的数字;我再往下翻日志,发现单次请求 payload 已经超过 3 万 token,接口延迟从 800ms 飙到 3 秒以上。
我用一个很粗糙但够用的公式复盘了一下:每轮用户消息约 120 字,助手回复约 300 字,加上 system prompt 里的 2000 字文档,第 N 轮请求的 token 总量大约是 system(2000) + 累计历史(420 × N)。到第 40 轮时,光历史就是 16800 token,再叠加 system 和当前问题,早就把模型的注意力窗口塞满了。模型不是变笨了,是它的“工作记忆”被垃圾信息淹没了。
1.2 context-mode 的准确定义
所谓 context-mode,我给的正式定义是:在调用大模型之前,对进入模型上下文窗口的内容进行结构化组织、折叠、裁剪和注入的一套策略集合。它不改变模型本身的参数,也不改变推理引擎,只改变“组装请求时到底放哪些信息、以什么顺序放、以什么形态放”。
听起来好像只是 prompt engineering 的另一种说法?不太一样。Prompt engineering 更多在琢磨“同一段话怎么写模型更能理解”,而 context-mode 解决的是“哪些信息值得进入上下文、哪些必须留下但可以压扁、哪些可以彻底丢弃”。前者是文案问题,后者是架构问题。
我建议把 context-mode 理解成传统后端里的“缓存策略”或者“内存管理”:不决定业务逻辑对不对,但决定系统在长生命周期下会不会把自己拖垮。凡是需要多轮交互、外部知识注入、长期记忆的 LLM 应用,最后都会撞上这堵墙,绕不开。
1.3 上下文是 LLM 应用的第一杠杆
我在项目里反复验证过一个规律:模型输出质量,极大程度取决于输入上下文的信息密度。同样一道数学题,上下文里全是无关日志,它就容易跑偏;上下文里精准给出公式和已知条件,它基本一次算对。
这也解释了为什么很多团队拼命调 prompt 却效果平平——问题往往不在最后一公里的表述,而在更早的“信息筛选”环节。把十几轮轮无关闲聊放进窗口,再好的 prompt 也拉不回来。context-mode 要做的事情,就是把这个筛选环节从“人工凭感觉”变成“规则化、可复现、可量化”的工程能力。
2. 四种基础模式:全量、滑动、摘要、检索增强
2.1 全量模式(Full Mode):简单直接的暴力方案
全量模式是我最先采用的方案,也是最容易理解的一种:不管多少轮历史,全部原样放进 messages,system 照常带上。
它的优势是零信息丢失。模型能看到完整的对话轨迹,所有上下文关系都在,适合短对话、单文件代码重构、单个文档提问这类窗口压力小的场景。我试过用它做一次性长文本润色,效果非常好,因为全文都在窗口里,指哪打哪。
它的致命问题也摆在那:token 成本线性增长,而且模型对中段信息的注意力会衰减。我做过实验,把一段 8000 token 的对话从第 5 轮截断和从第 30 轮拼接,同样是回答当前问题,前者明显更准确。窗口里塞满了旧信息,新问题的注意力就被稀释了。
所以我现在对全量模式只有一条使用原则:预估 token 不超过模型窗口的 50% 时,优先用它;一旦超过,立刻切到其他模式。
2.2 滑动窗口模式(Window Mode):控制增量,丢掉历史
滑动窗口模式的思路很像操作系统里的 LRU 缓存:只保留最近 N 轮消息,更早的直接丢弃。
我常用的配置是保留最近 10 轮。用户问题、最近几轮对话细节、当前上下文都在,模型不会丢最近的线索,同时请求体被严格限制在可控范围。这个模式对日志分析、客服会话、连续问答类场景特别友好。
代价也很直接:用户在第 3 轮说过的重要信息,到第 20 轮时被窗口挤出去了。模型到了后面会一脸茫然地问“您刚才说的是哪次配置?”。所以滑动窗口模式适合“单次任务型对话”,不适合“需要跨轮引用早期信息”的复杂任务。
2.3 摘要压缩模式(Summary Mode):用成本换记忆
摘要压缩模式是在滑动窗口基础上加了一层“记忆压缩”:把被移出窗口的早期消息,用一次额外的模型调用生成结构化摘要,再塞回 system prompt。
我最初对摘要模式有顾虑,因为它要多花一次模型调用的费用和时间。但实际跑下来,这个成本往往很值得。一次摘要调用大约消耗 500~800 token,换来的是把几千 token 的早期历史压成 200 token 的关键结论,整体 token 开销反而大幅下降,模型对长期任务的理解能力也明显提升。
摘要不是简单复述,而是有结构的。我要求在生成摘要时至少保留四类信息:用户的核心目标、已经确认的事实、待办事项、关键参数。这样后面组装上下文时,模型能快速识别“这个用户从头到尾都在解决某件事”,而不是把摘要当成一段背景故事读。
2.4 检索增强模式(RAG Mode):让相关内容自己浮上来
检索增强模式不依赖历史对话,而依赖外部知识库或文档库。用户提问进来,先走向量检索,把最相关的若干片段召回,再和问题一起组装进上下文。
我实践中常用的召回量是 4~6 段,每段控制在 300~500 token。再加一层重排会把精度拉高不少,但延迟代价也上来了。RAG 模式最适合知识库问答、大型代码仓库问答、政策文档咨询这类场景,它不追求“记住一切”,只追求“回答当前问题时把最需要的几块拼图放到位”。
这个模式的坑也我在后面章节里慢慢说,最典型的是召回的片段和当前问题表面相关、实际不相关,模型就会被带偏。
2.5 模式对比速查表
| 模式 | 核心策略 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| Full | 全量保留 | 信息无丢失 | token 线性增长 | 短对话、单文档提问 |
| Window | 只留最近 N 轮 | 请求体可控 | 早期信息丢失 | 多轮短任务、日志分析 |
| Summary | 压缩历史为摘要 | 折中记忆与成本 | 需要额外摘要调用 | 长会话、跨日跟踪 |
| RAG | 检索相关片段注入 | 知识面广 | 检索质量决定上限 | 知识库、代码库问答 |
3. 为什么不能只绑定一种模式:切换思路才是核心
3.1 单一模式的三个坑
有些人看完上面四种模式,会在项目里长期固定一种。我一开始也这样,后来在同一个项目里连续撞了三回墙。
固定 Full,对话一长就爆炸,每轮请求延迟不可控。固定 Window,用户聊到第 12 轮时问“我最早报的那个故障编号是多少”,模型一脸茫然。固定 RAG,用户问的是上下文里刚刚聊过的历史细节,检索器根本不知道这段对话发生过,因为对话历史没进索引。
单一模式只解决单一阶段的单类问题。真实产品里的用户行为是多变的:有人只问两三句就离开,有人连续聊一小时,有人边聊边查资料库。如果上下文组装策略只有一套静态逻辑,那系统注定只对一部分用户友好。
3.2 动态切换的触发条件
我最后实现的是一个按规则动态切换的选择器,判断维度有四个:
对话轮数。这个最容易量化。轮数少用全量,中间用滑动窗口,很多了走摘要。我项目的阈值是 6 轮以内全量,6~15 轮滑动窗口,超过 15 轮优先摘要。
Token 预算。每轮请求前先预估当前组装方案的 token 总量,超过窗口 50% 就升级到压缩策略。这是最稳妥的兜底逻辑,因为轮数不能完全代表 token 量,有人一句话能顶别人十句话。
意图类型。如果检测到用户要查知识库、问文档、查代码,直接切 RAG。意图识别可以很轻,一个基于关键词的规则或者一个小分类模型都行,不需要上重模型。
时延敏感度。对实时性要求高的入口,少用摘要模式,因为它要额外等一次摘要调用;对不敏感的场景,大胆用摘要。
3.3 切换的连续性设计
模式切换最担心的不是切不过来,而是切完之后模型接不上。用户刚说完一件事,你啪一下把前 20 轮压成摘要,模型回复说“我不太清楚之前的细节”——这就不及格了。
我在切换时做了一层“关键信息桥接”,不管从哪个模式切到摘要模式,系统都会额外保留三样东西:用户最初的目标、最近一次确认的结论、所有未完成事项。这三样被固定放进 system 字段。
这样切换后模型虽然看不到完整历史,但它至少清楚三件事:用户来干嘛、现在到哪了、下一步要做啥。对话的连续感主要靠这三个锚点撑起来,而不是靠逐字记忆。我后来把这个桥接结构也复用到窗口模式的保留策略里,让窗口丢掉的是“冗余过程”,而不是“关键节点”。
4. 实操:一个可运行的 context-mode 调度器
4.1 先定义统一的数据结构
所有模式最后都要生成 messages 数组给模型 API,所以我先定义一个统一的数据结构,让四种模式都基于它工作。
from enum import Enum from dataclasses import dataclass, field class ContextMode(Enum): FULL = "full" WINDOW = "window" SUMMARY = "summary" RAG = "rag" @dataclass class Message: role: str # "user" / "assistant" / "system" content: str meta: dict = field(default_factory=dict) @dataclass class Conversation: mode: ContextMode system_prompt: str messages: list budget_tokens: int = 12000 summary: str = "" user_goal: str = "" confirmed_facts: list = field(default_factory=list) pending_items: list = field(default_factory=list)Conversation 这个类里除了 messages,我特意放了 user_goal、confirmed_facts、pending_items 三个字段,这就是前面说的“关键信息桥接”。它们是摘要模式的核心原料,也是窗口模式裁剪时的保留清单,建议所有模式都维护这三个字段,切换时才不容易断片。
4.2 实现 Token 预估与模式选择
选择器是整个 context-mode 的大脑,它决定走哪条组装路径。我在项目里用的是 tiktoken 做 token 预估,比 len(text) // 4 靠谱得多。
import tiktoken _enc = tiktoken.get_encoding("cl100k_base") def count_tokens(text: str) -> int: return len(_enc.encode(text)) def estimate_messages_tokens(messages) -> int: return sum(count_tokens(m.content) for m in messages) def select_mode( conversation: Conversation, need_search: bool ) -> ContextMode: total = estimate_messages_tokens(conversation.messages) # 场景优先:需要查文档就切 RAG if need_search: return ContextMode.RAG # 预算优先:消息量太大直接压缩 if total > conversation.budget_tokens * 0.5: return ContextMode.SUMMARY # 轮数兜底:越多越倾向窗口和摘要 turns = len(conversation.messages) // 2 if turns <= 6: return ContextMode.FULL if turns <= 15: return ContextMode.WINDOW return ContextMode.SUMMARY需要注意,cl100k_base 对中文编码并不是一个字一个 token,中文在几百字时偏差不大,但长文本一定要用实际编码器。如果你的模型用的是其他 tokenizer,务必换成对应的编码器,否则预算算不准,切换就是不稳定的。
4.3 四种模式的组装逻辑
接下来是四种模式各自的组装实现。我先把代码贴出来,再逐段解释为什么这么写。
def build_full(conversation: Conversation): messages = [Message("system", conversation.system_prompt)] messages.extend(conversation.messages) return messages def build_window(conversation: Conversation, keep: int = 10): tail = conversation.messages[-keep:] bridge = _build_bridge(conversation) return [Message("system", conversation.system_prompt + bridge)] + tail def _build_bridge(conversation: Conversation) -> str: parts = [] if conversation.user_goal: parts.append(f"用户目标:{conversation.user_goal}") if conversation.confirmed_facts: facts = ";".join(conversation.confirmed_facts) parts.append(f"已确认信息:{facts}") if conversation.pending_items: todo = ";".join(conversation.pending_items) parts.append(f"待办事项:{todo}") return "\n" + "\n".join(parts) if parts else "" def summarize_history(messages) -> str: # 实际项目里这里调用一次模型,把早期 messages 压缩成结构化摘要 prompt = "请把以下对话压缩成结构化摘要,包含用户目标、已确认事实、待办事项、关键参数。\n" + "".join(m.content for m in messages) return call_llm(prompt) def build_summary(conversation: Conversation, keep: int = 4): old = conversation.messages[:-keep] recent = conversation.messages[-keep:] # 增量更新;避免每次全量压缩 if not conversation.summary or len(old) > 20: conversation.summary = summarize_history(old) else: conversation.summary = increment_summary( conversation.summary, old[-8:] ) bridge = _build_bridge(conversation) sys_content = conversation.system_prompt + "\n早期对话摘要:" + conversation.summary + bridge return [Message("system", sys_content)] + recent def build_rag(conversation: Conversation, query: str, hits) -> list: docs = [] for i, hit in enumerate(hits, 1): docs.append(f"[{i}] {hit.text}") context = "\n\n".join(docs) user_content = f"参考资料:\n{context}\n\n问题:{query}" return [Message("system", conversation.system_prompt), Message("user", user_content)]build_full 没什么好解释的,就是原样组装。
build_window 里我把桥接信息塞进 system,而不是塞进末尾,目的是让模型在处理每一条历史时都知道“用户的核心目标是什么”,这样即使窗口丢失部分细节,模型也能围绕目标推断。另一个细节是 keep 参数我没有写死,线上做 AB 测试时可以通过配置中心动态调整,非常方便。
build_summary 里我做了一个增量摘要的优化:第一次切到摘要时把全部旧历史压缩一遍;之后每次只要旧历史新增超过 8 条,就基于上一次摘要做增量更新,避免每轮都重新读一遍全量历史。这个优化直接把摘要调用的 token 成本降了大约一半。
build_rag 的组装相对标准,但有一个细节值得注意:引用编号。我给每个命中段落编号,在 system 里强调“优先引用 [编号] 对应的内容”,回答时可溯源,后续做答案校验也容易。
4.4 与模型调用层对接
调度器最终要变成一次真实请求。我的封装长这样:
def build_request( conversation: Conversation, query: str, need_search: bool = False, search_hits: list = None ) -> list: conversation.messages.append(Message("user", query)) mode = select_mode(conversation, need_search) conversation.mode = mode if mode == ContextMode.FULL: messages = build_full(conversation) elif mode == ContextMode.WINDOW: messages = build_window(conversation) elif mode == ContextMode.SUMMARY: messages = build_summary(conversation) elif mode == ContextMode.RAG: messages = build_rag(conversation, query, search_hits or []) return messages这个封装看似平平无奇,但它把一个很重要的原则落地了:一切模式切换在请求侧完成,不改动模型、不改动 prompt 措辞、不污染用户会话数据。任何时刻用户原始消息都保存在 conversation.messages 里,窗口中看到的只是经过组装后的视图。这样即使模式逻辑出 bug,原始数据还在,回放和修复成本都很低。
5. 踩坑实录:我在 context-mode 项目中遇到的四个问题
5.1 上下文污染:折叠之后遗留了过期信息
第一次上线摘要模式时,我发现一个很诡异的现象:用户已经明确说“那个方案我们不采用”,但模型还是反复引用方案里的参数。
查到最后发现,问题出在摘要生成。摘要严格记录了早期讨论的细节,但没有标记信息的时效状态。用户后来否定了方案,可否定这个动作发生在最近的原始对话里,没被及时更新进摘要。
我的解决方法是:每次维护桥接字段时,如果检测到用户对某一事实提出否定或修改,就把旧事实从未确认列表移除,同时把否定结论写进待办或已确认信息。这套逻辑现在写成了一个简单的 change-log 结构,摘要只从最新状态中取结论。
5.2 模式切换抖动:窗口尾巴把摘要带崩了
另一个翻车场景是滑动窗口切摘要的瞬间。用户正在讨论一个操作细节,窗口模式保留了最近 10 轮,一切到摘要模式,旧历史压成两行,窗口尾巴只剩 4 轮。模型下一轮回答明显变敷衍,因为它看到的“前因”只剩几句话。
问题根源是我把 keep 参数从 10 改成 4 太激进,导致最近讨论中的部分过程被挤掉了。我现在把滑动窗口到摘要的过渡阶段 keep 设成 8,同时桥接字段补充一条“当前正在讨论的细节”,模型就不会因为上下文突然缩水而断片。模式切换不是一刀切,最好有渐变过程。
5.3 Token 预估不准:不同语言差距很大
我早期用 len(content) // 4 估 token,上线后经常出现实际调用被截断的情况。后来换成 tiktoken 才发现,中文文本里一个汉字经常能到 0.6~1 个 token,而英文单词平均 1.3 个 token,我的粗暴公式在中文场景下严重低估。
这个问题的排查方式很简单,把线上请求的实际 usage 字段记录下来,和预估数做对比。我在日志里加了 usage 埋点后,发现平均偏差 20% 以上。换用对应模型的 tokenizer 之后,误差控制在 5% 以内。建议所有做上下文预算的团队都把这一步做好,否则后续所有模式切换阈值都是空中楼阁。
5.4 小步灰度与离线回归:改造后反而变差怎么办
context-mode 不是一个“改了就好”的功能,切换之后很有可能出现线上指标下降,比如用户满意度、任务完成率下降。第一次灰度切换摘要模式时,我就遇到这种情况。
后来我总结出一套离线回归方法:把线上真实会话日志完整保存下来,按时间顺序重放,每一步记录模式选择、token 消耗、模型回复。然后对比新旧方案在同一批会话上的表现。重点看两个指标:关键信息是否在生成结果中成功保留、单次请求成本是否真的下降。
有了这套回归链路,模式切换就能变成数据驱动的工作,而不是靠感觉调参数。我后面每次微调 keep、摘要触发阈值,都先离线跑一遍历史数据,再决定要不要上灰度。
最后分享一个实践心得
如果你现在正在做一个 LLM 应用,我的建议是先别急着把四种模式都实现。先上全量模式 + 滑动窗口模式,把请求 logs 和 usage 统计做好,等你真的遇到长上下文问题,再逐步加摘要和 RAG。context-mode 的价值不是“功能多炫”,而是“在合适的时机只放最重要的东西进窗口”。
另外,接口设计时一定要把 mode 选择做成可插拔的。我最初把选择逻辑硬编码在业务代码里,后面加 RAG 时被迫重构。现在整个 context-mode 调度器独立成模块,新增模式只需实现一个 build 函数,切换逻辑只改选择器,模型层完全不受影响。这套结构后来在多个项目里复用,每次都能省下大量联调时间。