1. 一句话讲透:context-mode到底在解决什么问题
先说个真实场景。我们团队内部有个知识库问答机器人,之前一直“单独问一句答得挺好,多聊几轮就开始胡说”。用户连着问几个问题之后,它要么把前面说过的结论忘得一干二净,要么把两个不同项目的规则混在一起讲。我最初以为是prompt写得不够好,反复调整system prompt、加了各种few-shot示例,效果都只是暂时改善。后来认真排查了一轮,才意识到问题根本不在prompt,而在“context-mode”——也就是上下文模式的设计上。
所谓context-mode,通俗地说,就是一套“给大模型准备‘临时记忆’的策略”。大模型本身不保留任何跨请求的记忆,你每次调用它,它看到的只是你这一次塞进上下文窗口的全部文本。那么问题就来了:如果这个文本里既有历史对话、又有知识库检索片段、还有系统指令,它们混在一起,模型怎么知道该优先听谁的?该记住哪些、该丢掉哪些?这就是上下文模式要回答的问题。
国内很多团队做LLM应用时,最常犯的错就是把所有东西一股脑拼进上下文。对话历史全塞、检索结果全塞、用户问题原样丢进去,最后模型被淹没在信息噪音里,输出质量自然不稳定。我这次重写项目,核心就是把“塞什么、塞多少、按什么顺序塞、什么时候该压缩、什么时候该检索”这套规则显式地设计出来,做成可配置、可观测的模块。
这篇文章就以这个知识库问答项目为主线索,讲讲我踩过的坑、最后落地的方案,以及各阶段可复用的实操细节。想自己搭大模型应用、做Agent或者做RAG检索问答的,可以重点看看第二部分和第三部分——那两部分基本把我从0到1的决策过程写全了,照着抄能少走很多弯路。
2. 主流的context-mode有哪几种,各自适合什么场景
2.1 滑动窗口模式——最简单但最稳的baseline
滑动窗口是最容易实现的上下文模式:只保留最近N条对话记录,更早的全部丢掉。设定一个窗口大小,比如最近6轮对话,每进来一轮新对话,就把最旧的那轮挤出窗口。相当于给大模型配了一个“只能记住最近几分钟事情”的短期记忆。
这个模式实现成本几乎为零,逻辑上就是在数组后面append、从前面pop,任何语言都能写。它的优势是稳定可控,上下文内容永远是大模型最近看到的内容,不会被摘要扭曲原意。缺点也很明显:只要问题涉及的话题在窗口之外,模型就完全“失忆”。比如用户在第2轮说过“我们用的是MySQL 8.0”,第9轮问“那我刚才说的数据库版本支持窗口函数吗”,如果窗口只保留最近6轮,第2轮的信息已经被挤出去了,模型只能凭空猜。
我个人的经验是:滑动窗口适合客服工单、短对话工具、表单填写助手这类单次交互为主、最多来回三五轮的场景。对于长对话、深度问答、Agent多步推理,它只能当baseline,不能当最终方案。如果你实在不知道用什么,先用滑动窗口把链路跑通,再往上叠加别的策略,这个落地顺序是对的。
2.2 摘要记忆模式——用空间换理解
既然滑动窗口会遗忘早期信息,那就把旧对话“浓缩”成摘要,继续留在上下文里。经典做法是维护一条独立的“滚动摘要”:每次对话超过一定轮数或者token达到阈值,就调用模型把之前的对话内容总结成一两段话,之后新的对话继续累积,直到下一次触发摘要更新,再把旧摘要和新对话合并后重新总结。
这样做的好处是,模型始终能看到全局脉络,哪怕是50轮前的核心结论,经过摘要链传递后还能保留。缺点是摘要会丢失细节,而且摘要本身有被模型“脑补”的风险。我在项目里专门测过:一段涉及精确数字和版本号的对话,摘要第3次滚动之后,数字经常被模型“修正”成更合理的值——比如把“MySQL 5.7升级到8.0”记成“MySQL升级到8.0”,版本信息就丢了。
所以我的建议是:摘要记忆适合长对话场景里“看重结论、不看重过程”的内容——用户偏好、决策理由、已确认事项。而精确配置、ID、版本号这类不可丢失的信息,应该单独抽出来放进结构化的“事实卡”里,不要只依赖摘要。摘要只保证模型“理解全局”,不保证模型“记住细节”,这句话是我踩了无数次坑后的核心体会。
2.3 检索增强模式——把外部知识变成可控上下文
RAG(检索增强生成)是另一种模式:每次请求先根据用户问题,从知识库、文档库中检索出最相关的几个片段,再把片段注入上下文。它的逻辑是“用的时候再找,不提前背下来”。这种模式适合知识库问答、法律/医疗/金融文档问答、企业内部规范查询等场景——因为文档总量远超上下文窗口,不可能全部塞进去,只能按需取用。
实现RAG的核心不只是“接一个向量数据库然后embedding”,更关键的是检索结果如何进入上下文。很多项目在这里踩坑:检索了Top-5片段,每个片段1500字,五个片段加上原文标题、来源信息,光检索内容就占了将近7500 token,剩下的对话历史和系统提示根本没空间。然后模型输出时强行把信息“挤”进剩余窗口,结果是上下文截断在奇怪的位置,输出质量急剧下降。
检索增强模式的关键参数有三个:Top-K(取几条)、片段长度(每条多少字)、相关性阈值(低于多少分就不取)。这三个参数必须结合你的上下文预算、文档粒度一起调,不能拍脑袋定死。我后面会专门讲一套预算分配方法,可以直接照着算。
2.4 混合模式——生产环境的最终选择
现实中的生产级应用,几乎不会只用单一模式。最稳的方案是混合模式:短期对话用滑动窗口,长期事实用摘要记忆,外部知识用检索增强,三者并行注入上下文,各自占据一个明确的token预算区间。这也是我这次项目最终落地的形态。
混合模式的核心不是“把所有技术都用上”,而是“由谁来决定走哪条路”。我的做法是加了一个前置的意图分类步骤:先判断当前用户问题属于“追问上一话题”“新开话题”还是“需要查文档”。如果是追问上一话题,重点扩大短期滑动窗口;如果是新开话题,优先检索增强;如果话题横跨多轮且涉及历史结论,则强调摘要记忆和事实卡。这个前置路由看起来多了一次模型调用,但换来的是每次请求的上下文构成都更“聚焦”,输出质量提升非常明显。
3. 实操:从零到一把context-mode落地到生产
3.1 第一步:盘点信息源,画上下文流程图
动手写代码之前,先花半天时间做两件事:盘点你的应用到底有哪些信息源,再画一张上下文构成图。以我的知识库问答项目为例,信息源有四类:
- 系统指令(角色设定、回答规则、语气要求)
- 短期对话历史(当前会话最近几轮)
- 长期记忆(跨会话的用户资料、历史结论摘要)
- 知识库检索结果(对应企业内部文档、规范片段)
信息源盘点完之后,把它们按“可变性”和“体积”两个维度分类。系统指令基本固定,体积小,永久保留;短期对话变化快,体积中等,限制轮数;长期记忆要维护更新,体积小但价值密度高,必须保证核心结论不丢;检索结果完全由当前问题决定,体积最大,必须做取舍。把这张图画出来之后,你会发现很多之前调prompt解决不了的问题,本质上是这四类内容没有明确的“分工边界”。
画图不需要任何工具,直接用白板或者文档画一个方框框图就行:用户输入在中间,左侧是各类上下文输入源,中间是“Context Assembler(上下文组装器)”,右侧是LLM调用,输出后回写记忆模块。这个流程图画清楚之后,后面所有代码都是它的翻译。
3.2 第二步:确定窗口预算,把token花在刀刃上
上下文窗口是有限资源,所以分配预算的第一步是算账。假设你用的是32K上下文窗口的模型,建议按下面这个基准比例分配:
- 系统指令:固定800~1200 token,不要超标
- 短期对话:8000~10000 token(约合最近8~12轮)
- 长期记忆:4000~6000 token(摘要+事实卡)
- 检索结果:6000~8000 token(Top-3到Top-5,每条800~1200字)
- 预留缓冲:2000~4000 token(留给模型生成本身和格式符号)
这个比例不是拍脑袋来的,背后有两个原则。第一,系统指令和长期记忆虽然是“背景信息”,但它们的价值密度最高,所以即使在检索内容不足时也不能压缩它们——宁可少给检索片段,也不要压缩角色设定和历史结论。第二,检索结果是最容易被“替换”的部分,因为它每次请求都会重新生成,所以预算不足时应优先削减这一块的Top-K,而不是缩短短期对话窗口。
实际操作中,我建议把预算配置写成一个中心化的配置项,不要散落在各个代码文件里。这样调参的时候只需要改一个地方,然后对比不同配置下的对话质量。我在项目里会额外加一个“预算使用量日志”,每次请求都记录实际token消耗,方便每周复盘哪些环节产生了浪费。
3.3 第三步:实现一个分级上下文管理器
窗口预算确定之后,代码实现的核心是一个“上下文管理器”,它负责接收原始信息源、执行截断/摘要/检索策略、最后组装成发送给模型的messages数组。下面是一份简化版的Python骨架代码,参考的是我项目里的实际结构(已做脱敏简化):
class ContextManager: def __init__(self, max_tokens: int = 32000): self.max_tokens = max_tokens self.budget = { "system": 1200, "short_memory": 10000, "long_memory": 5000, "retrieval": 8000, "reserve": 3000, "buffer": 2800, } def assemble(self, user_query, session_state, retriever): # 1. 系统指令固定加载 system_block = self.load_system_prompt() # 2. 短期对话:截取最近N轮,超出预算时按轮数裁剪 short_blocks = self.slice_recent_chat(session_state.chat_history, max_tokens=self.budget["short_memory"]) # 3. 长期记忆:读取摘要链 + 事实卡 long_blocks = self.load_memory_summary(session_state.memory) # 4. 检索增强:只取预算范围内的Top-K片段 retrieval_blocks = [] if retriever: hits = retriever.search(user_query, top_k=5) for hit in hits: block = hit["content"][:1200] if self.current_used_tokens() + estimate_tokens(block) > self.budget["retrieval"]: break retrieval_blocks.append(block) # 5. 组装messages,按系统 -> 长期 -> 短期 -> 检索的顺序拼装 messages = [] messages.append({"role": "system", "content": system_block}) messages.extend(long_blocks) messages.extend(short_blocks) if retrieval_blocks: messages.append({"role": "system", "content": "以下是相关文档片段,仅用于参考:\n" + "\n---\n".join(retrieval_blocks)}) messages.append({"role": "user", "content": user_query}) return messages这段代码本身没什么高深技巧,但有几个细节值得展开说说。
第一,消息顺序影响注意力分布。大模型普遍对开头和结尾的内容更敏感。开头放system指令,让模型记住自己的角色;中间放长期记忆和短期对话,作为推理背景;user query放最后,提醒模型当前要完成的任务。检索结果我放在“接近结尾”的位置,也就是user query之前,因为模型在读到用户问题旁边有参考资料时,更容易主动引用这些片段。
第二,检索片段的截断策略。我实测过很多次,与其硬塞5条完整的长片段导致总token超预算,不如前3条给足上下文(1200字),后2条只保留首句摘要(200字)。这样既保证了主要依据的完整性,又保留了额外的候选线索,模型如果发现前3条不足以回答,还能从后两条的关键句里找到方向。
第三,长期记忆不能只存摘要,必须加“事实卡”。摘要用于理解脉络,事实卡用于存储精确信息。比如用户在第3轮说过“数据库用的PostgreSQL 15”,这个信息放进事实卡,摘要里也提一句,双保险。更新事实卡的时机是用户明确给出新事实时,通过一个小模型做抽取,而不是每一次对话都触发。
3.4 第四步:上线前必须做的红队测试和回归测试
改完context-mode之后,最大的风险不是“跑不起来”,而是“看起来变聪明了,但某些场景反而变笨了”。因为增加摘要、检索这些机制后,模型出现了更多的“中间处理环节”,每一环都可能出错:检索召回不准、摘要丢了关键条件、路由判断错了方向。所以上线前一定要准备一个回归测试集,至少覆盖这些case:
- 单轮简单问答(验证基础能力没被破坏)
- 多轮追问(验证短期对话窗口有效)
- 跨话题跳跃后再回来(验证长期记忆是否恢复旧话题)
- 需要查询外部文档的复杂问题(验证检索增强的召回质量)
- 包含精确数字、版本号、截止日期的问题(验证事实卡机制是否保住关键信息)
- 用户主动纠正模型错误的情况(验证上下文更新机制)
这组测试不只测“模型回答对不对”,还要测回答的引用依据来自哪里。我实现里有个小技巧:在上下文的检索片段中添加不可见的标记,例如在每个检索片段末尾加上[ref:doc_id],测试时检查模型输出中是否出现了对应的ref标记。如果答案正确但ref标记缺失或者引用错误,说明上下文组装顺序或检索排序可能有问题,值得深挖。
4. 踩过的坑:context-mode常见问题与排查技巧
4.1 问题速查表
这一节把我在项目里实际遇到过的典型问题整理成速查表,按现象、可能原因、排查方向列出来,方便你直接对比定位:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 多轮对话后回答开始偏题 | 短期窗口被无关对话塞满,核心信息被挤出 | 检查窗口裁剪策略是否按轮数截断而非按内容重要性 |
| 用户问“我最早说的那个需求”时模型答不上来 | 早期信息只存在纯摘要中,摘要丢失了需求细节 | 检查摘要链更新逻辑,是否为每次都全量重摘要而不是增量合并 |
| 模型回答明明引用了文档,但内容是错的 | 检索Top-K中混入了低相关片段,干扰答案 | 调高相关性阈值,或先用rerank做一次精排 |
| 检索结果太长导致后续对话被截断 | 检索clip没有考虑整体预算 | 计算总token时刻校验超预算,而不是单独检查检索部分 |
| 上下文同一信息前后矛盾 | 短期对话和长期记忆同时存在旧值和新值 | 设计“事实更新”优先级:新对话 > 摘要 > 事实卡初版 |
| 系统指令偶尔被模型忽略 | 上下文太长,模型注意力分散到中间段落 | 压缩短期轮数,或将指令拆成“前置系统指令”和“结尾约束”两条 |
| 模型重复输出同一话术 | 摘要中冗余信息过多,模型陷入重复模式 | 给摘要增加“去重规则”,相同结论只记录最近一次状态 |
这个表里的每一条背后都是一个真实事故。比如“短期窗口被无关对话塞满”那一项,我们的机器人有次和用户聊了20轮“今天天气怎么样”这种寒暄,之后用户问正经问题时,窗口里全是天气话题,真正的业务信息早就被挤出去了。后来给寒暄类对话单独设置低优先级,优先保留带实体和事实性的对话轮次,情况才明显好转。
4.2 三个最值得注意的教训
教训一:检索结果会“喧宾夺主”。这是所有RAG项目最容易犯的错。检索片段在上下文里虽然标注了“以下内容仅用于参考”,但模型看到大段参考资料,总倾向于把它们当成“事实本身”,甚至会在检索片段和对话历史冲突时,直接采用检索内容。我们的解决方案是两个:一是控制检索片段总预算不超过上下文总预算的30%,不要让它占据过半篇幅;二是在prompt里明确写明优先级规则:“如果对话历史和文档片段冲突,优先相信对话历史中的最新陈述”。实测下来这个优先级规则极其有效。
教训二:摘要必须“可回溯”,不能只追求精炼。我最早设计的滚动摘要只保留“结论”,比如“用户确认使用MySQL作为主数据库”。后来发现这个结论没法回答“为什么不用Postgres”这种追问——用户当初明明说了理由,但摘要里没有。于是我把摘要结构改成“结论+理由+关键限定条件”三段式,每一段都尽量保留因果信息,而不是单纯压缩成一句话。这个改动让多轮追问的成功率提高了不少。
教训三:系统指令不是万能药,别把业务逻辑硬塞进prompt。最开始我们团队也希望用一条超级system prompt解决所有问题——规定模型必须怎么做、不能怎么做、先做什么后做什么,写了快2000字,结果模型执行起来经常前后矛盾。后来我把其中的“流程性逻辑”(比如先判断意图、再决定是否检索)从prompt里抽出来,改成代码层的路由逻辑,prompt只保留“角色说明+输出格式+优先级规则”。这是关键的一步:prompt负责定义“你是谁、怎么说话”,代码负责定义“每一步干什么”。两者各司其职,上下文模式才能真正稳定。
4.3 低成本验证技巧:日志与中间态观测
最后分享一个几乎零成本的排查技巧:给上下文管理器加“快照日志”。每次请求生成后,把最终的messages结构、各板块token消耗、检索命中的片段及分数全部记录到日志里。排查问题的时候,先看一眼快照,通常能定位到是哪个环节出了问题。
这个技巧我们在改版前完全没做,遇到问题只能靠猜——猜是prompt不好,还是检索不好。加了快照日志后,效率完全不一样。比如发现“模型忽略系统指令”时,日志显示system block被压缩到了只有400 token,原来是我们某次调整上下文排序时,系统指令被放到了中间位置,注意力分布被削弱了。这类问题如果没有日志做定位,可能又要白折腾好几天。
我建议你在自己的项目里也养成这个习惯:不要只记录调用大模型时的输入输出,更要记录输入是怎么组装出来的。这等于给上下文处理过程装了一个仪表盘,所有优化和排查都随之变得有据可依。
按照我这个思路把context-mode重新梳理一遍之后,知识库机器人的稳定性有了质的提升——至少多轮会话不再频繁“失忆”,检索内容也不会随便淹没对话背景。就我自己的体会而言,真正让一个LLM应用从“demo能跑”走向“生产可用”的关键,往往不是换一个更大的模型,而是把上下文这层看不见的基础设施打磨扎实。希望这篇文章里拆解的框架和踩坑记录,能帮你少走一段弯路。