
做AI应用这一年多我最大的感受是很多人把Prompt工程聊得热火朝天却很少有人把“上下文”当作一等公民来设计。这里的context-mode其实是自己做的一套上下文管理模块的代号它把“该给模型看什么”这件事拆成一堆可切换的模式来应对不同场景。简单说它解决一个非常具体的问题同样的对话历史在不同的任务上下文里到底该全量带上、只带最近几轮、压缩成摘要还是按需检索。这个问题想不清楚后面所有的效果调优、成本控制都是空中楼阁。这篇内容适合正在做Chatbot、Agent或RAG应用的朋友读完你可以照着搭一套自己的上下文管理模块。1. context-mode是什么先搞懂它解决什么问题1.1 上下文窗口不是越大越好很多人有个误区觉得上下文窗口越大模型效果越好。其实完全不是这么回事。上下文窗口本质上是模型在一轮请求里能看到的输入上限超过这个上限的部分根本不会被处理而在这个上限之内也不是所有位置的信息都能被同等对待。先算一笔账。以目前主流的API定价来看每百万输入token的成本通常在几十元人民币量级上下文越长单次调用的费用就越高。一个正常的Agent任务对话历史很容易累积到几万token如果不做任何管理几轮对话下来单次请求的成本会从几分钱涨到几块钱这在生产环境里是不能接受的数字。更重要的是效果层面。业界很多评测比如LongBench这类长文本基准都发现当输入长度超过一定阈值后模型的准确率并不会继续上升反而可能下降。这背后有个被反复验证的现象叫“Lost in the Middle”——模型对长上下文的开头和结尾部分关注度最高对中间部分经常视而不见。这就好比让一个学生考前抱着一整本教材啃他看完前面忘了后面看到中间又忘了开头反而不如给他一叠整理好的重点笔记来得有效。所以做上下文管理的第一个核心认知就是上下文窗口不是给你“尽量塞满”的而是给你“精选安排”的。context-mode要做的第一件事就是把眼前这堆历史和资料按照当前任务的实际需要重新编排让模型把注意力花在最有价值的内容上。1.2 场景诉求不同模式就该不同我踩过最大的一个坑就是用同一种方式处理所有场景的上下文。后来才意识到不同任务对上下文的结构性需求完全不同。拿三个典型场景举例。第一多轮客服对话用户和机器人来回聊了二十轮但真正决定当前回答质量的往往只有最近三四轮的内容早期的“你好”“我想咨询一下”之类的寒暄完全没有保留价值。第二长文档问答用户丢进来一份一百页的PDF问了一个非常具体的问题这时候把整份文档塞给模型既费钱又容易让模型迷失方向更优的做法是先把相关段落找出来再回答。第三Agent执行任务模型需要调用多个工具、记住执行链路和中间结果这时候前面每一步的细节都可能有影响草率地截断历史会导致它忘记自己已经干了什么。场景上下文的核心诉求最容易翻车的方式多轮客服最近几轮的信息全量带上所有历史成本高且引入噪音长文档问答与问题相关的段落文档太长导致中间信息被忽略Agent工具调用完整执行链路与中间结果窗口截断导致忘记已完成步骤正因为诉求差异巨大单一策略肯定扛不住。context-mode的思路就是把这套决策做成一个明确的模式系统让不同的任务走不同的路。2. 四种上下文模式拆解原理、取舍与适用场景2.1 全量模式简单直接的标杆全量模式是最朴素的做法——当前会话里的所有消息原封不动全部拼进请求发给模型。这也是大多数人在最初接入API时无意识采用的方式。它的优势非常明显信息完整性最高模型能看到所有细节不存在“因为某条历史没带上而导致理解偏差”的问题。在会话很短、内容密度很高的时候全量模式就是最优解。比如用户在几个问题内连续补充了很多修改意见希望模型围绕这些意见做一轮综合修改这时候带全量历史最稳妥。但全量模式的代价也是最大的而且是线性增长的。对话轮次越多每次请求的成本和延迟越高。更麻烦的是一旦总长度超过模型的上下文窗口请求就会直接报错这在生产环境里等于故障。所以哪怕你选择全量模式也必须给它设置一个硬性上限。我的经验是按token数设置阈值比如超过8000或10000 tokens就强制走其他模式而不是等到报错才处理。实操中我还会在全量模式下做一个细节把系统提示词固定放在最前面因为它属于“永远需要被看见”的内容不能被后面的对话挤到中间去。别小看这个顺序问题在长上下文里系统提示词被淹没是常有的事。2.2 滑动窗口模式用最近N轮换稳定性滑动窗口模式的核心思想很简单只保留最近N轮对话更早的统统丢弃。这是控制成本和延迟最快见效的手段因为每轮请求的token数量几乎恒定模型的响应速度也会非常稳定。窗口大小怎么定两种方式按轮次按token数。我建议优先按token数因为模型的计费和上下文限制都是按token算的按轮次容易失控。一个比较通用的起点是4000到6000 tokens大约能覆盖最近几轮详细对话。如果对话普遍简短可以放宽到8000。滑动窗口最适合的场景是“轮次多、但局部的连续性更重要”的对话。比如智能客服用户在前面几轮可能一直在确认售后政策最后问了一句“那运费谁出”这时候模型只需要理解最近几轮关于运费的话题就够了完全不需要记住用户第一句话说了什么。但这个模式有个天然的短板窗口之外的信息会被无差别丢弃如果任务需要回顾早期内容模型就会“失忆”。我在实际使用中给它做了一层改良——窗口不是只保留尾部而是“系统提示词固定不动 最近N轮对话”也就是说窗口内永远包含系统指令窗口外的普通对话才被裁掉。这个细节非常关键后面在踩坑部分会细说。2.3 摘要压缩模式给历史减负给重点留座摘要压缩模式是我个人用得最多的模式。它的思路是当对话历史太长时先用一次模型调用把早期内容压缩成一段摘要然后用“摘要 最近若干轮完整对话”替代原始历史。举个例子一段二十轮的对话前十五轮可能有两三千个token把它们交给一个摘要模型输出一段三百个token的要点再把最近五轮的完整内容接到后面。这样一来模型既保留了早期的关键信息轮廓又不用承载所有历史细节。执行摘要压缩时有三个细节必须注意。第一摘要会调用一次额外的模型这会增加一次延迟和成本所以触发摘要的阈值要设置得合理不宜太频繁。我一般在会话超过7000 tokens时才触发第一次摘要。第二摘要本身也会占token如果会话非常长摘要还会不断变长这时候需要对摘要再做摘要也就是递归式压缩确保摘要的大小也受控。第三摘要提示词必须强制模型保留三类信息用户明确提出的约束条件、已经确认过的关键事实、尚未完成的任务。摘要压缩模式特别适合两种场景长时间多轮对话以及Agent执行复杂任务。Agent跑一个流程可能涉及十几次工具调用中间的每一步结果都可能成为后续决策的依据但全部保留又太长此时把中间过程压成结构化摘要既保住主干又丢掉冗余。2.4 检索增强模式让知识库随取随用检索增强模式就是RAG的思路——不把全部内容发给模型而是根据当前问题先从外部知识库里找出最相关的几个片段再把这些片段拼到上下文里。这个模式的基本流程是把长文档切成小块给每个块做向量化建立索引用户提问时把问题也向量化在索引里做相似度检索召回Top-K个片段然后把这些片段和问题一起交给模型回答。这里面最关键的三个参数切块大小、召回数量和相关性阈值。我常用的起点是chunk_size512个tokenchunk_overlap64个tokentop_k5。切块太大会让单块内容混杂多个主题降低检索精度切块太小又会切断句子之间的逻辑。overlap能让相邻块之间保留一些衔接信息避免关键内容恰好被拦腰截断。检索增强模式的优点非常突出知识库可以做得很大十个GB也没问题但每轮请求只花一小部分token成本极低。缺点同样明显——整个系统的效果上限取决于检索质量。如果检索结果本身就不相关模型再聪明也答不对。所以在这个模式下评估检索召回率比调Prompt更重要。另外拼装检索片段时我习惯在每个片段前标注来源比如“[材料1]”这样的锚点让模型在回答里可以引用这既提升可信度也方便排查问题。3. 照着搭一套context-mode管理器核心代码与路由策略3.1 模块边界与消息结构设计把概念落到代码之前先想清楚模块边界。一个完整的context-mode管理器至少要包含三块消息存储、模式选择、内容编译。消息存储负责维护整个会话的原始消息模式选择负责决定当前请求用哪种模式内容编译负责按照选定模式把原始消息变成实际发给模型的Prompt。消息结构上我建议不要直接用平台SDK里的消息对象而是先转成自己的内部结构方便附加元信息。下面这个结构很基础但很够用from dataclasses import dataclass, field from enum import Enum class ContextMode(Enum): FULL full SLIDING sliding SUMMARY summary RETRIEVAL retrieval dataclass class Msg: role: str # system / user / assistant / tool content: str ts: float 0.0 # 时间戳用于排序和窗口裁剪 meta: dict field(default_factorydict) # 来源文档ID、工具名等meta这个字段是很多人在一开始容易忽略的。它用来记录消息的来源比如这条消息是从哪份文档检索出来的、是哪一次工具调用的返回结果。上线之后排查问题90%的线索都靠这个字段。3.2 三个核心函数的实现细节第一个函数token估算。用它来判断到底该走哪种模式成本监控也靠它。生产环境里不必每次调用都精确计算用一个快速的估算函数即可def estimate_tokens(text: str) - int: # 简单估算中文约1.5字符/token英文约4字符/token # 更精确可以用tiktoken但线上高频调用时建议用估算 return int(len(text) / 1.5) if any(\u4e00 ch \u9fff for ch in text) else int(len(text) / 4)我这么说可能有点糙但实测在路由决策这个环节精度的要求其实没那么高误差20%以内完全不影响模式选择。真正要做精确计费的地方再用官方tokenizer也不迟。第二个函数滑动窗口裁剪。注意要保护系统提示词不被裁掉def sliding_window(messages: list[Msg], max_tokens: int) - list[Msg]: system_msgs [m for m in messages if m.role system] history_msgs [m for m in messages if m.role ! system] kept [] total sum(estimate_tokens(m.content) for m in system_msgs) for m in reversed(history_msgs): step estimate_tokens(m.content) if total step max_tokens: break kept.insert(0, m) total step return system_msgs kept这段代码的逻辑是从最新消息往前回溯直到token预算耗尽。很多初版实现会从头往后塞结果最新的消息反而被塞不下这是方向性错误。第三个函数摘要压缩。关键是触发时机和摘要模板def compress_history(messages: list[Msg], keep_recent: int, summarize_fn) - list[Msg]: if len(messages) keep_recent: return messages older messages[:-keep_recent] recent messages[-keep_recent:] summary summarize_fn( system请把以下对话历史压缩成结构化摘要必须保留1.用户明确提出的约束和禁令 2.已经确认的事实和决策3.尚未完成的任务和待办事项。 不要保留寒暄和无关细节。, messagesolder ) return [Msg(rolesystem, contentf[历史摘要] {summary}, meta{type: summary})] recent我特别强调摘要模板里的那三块内容这是被踩过坑之后总结出来的。如果模板只写“请压缩这段对话”模型往往会丢掉最关键的约束信息导致后续行为失控。另外还有检索增强的实现核心是把召回结果拼成上下文注意标注来源def retrieval_context(query: str, retriever, top_k: int 5) - str: hits retriever.search(query, top_ktop_k) blocks [] for i, hit in enumerate(hits, 1): blocks.append(f[材料{i}|来源:{hit.meta.get(doc_id, unknown)}]\n{hit.text}) return \n\n.join(blocks)3.3 自动路由策略怎么决定用哪种模式有了模式的具体实现剩下的核心问题就是什么时候用哪个模式。我一开始用的是手动配置每个接入方自己指定模式后来发现根本不现实——同一个会话不同阶段的需求是变动的需要自动路由。路由判断依据主要是三个信号当前会话的token总量、任务类型、是否有知识库可用。下面这个策略可以当做一个起点def route_mode(messages, task_typechat, has_kbFalse): total sum(estimate_tokens(m.content) for m in messages) if total 4000: return ContextMode.FULL if has_kb and task_type in (qa, doc_qa): return ContextMode.RETRIEVAL if task_type chat and total 12000: return ContextMode.SLIDING if total 7000: return ContextMode.SUMMARY return ContextMode.FULL路由顺序是有讲究的先把最短的会话交给全量模式因为成本可控且信息完整有知识库的问答场景优先走检索因为这是检索最擅长的领域对话场景超过一定长度优先滑动窗口保持稳定剩下的长会话统一走摘要压缩。还有一个重要的兜底逻辑检索模式不是百分之百可靠的如果召回结果为空或者召回的片段相关性得分都低于阈值就必须熔断回退到摘要模式或者全量模式绝不能拿一段空上下文硬着头皮让模型回答。线上跑的时候这类熔断事件要记录日志方便后续分析是不是知识库索引出了问题。4. 线上踩坑实录context-mode四个高频翻车现场4.1 摘要压缩吞掉了关键约束这个坑是在做Agent时踩的。用户在一个很长的会话里明确说过“不要调用短信接口”这个约束在早期消息里出现过一次。会话拉长之后摘要模型把这句话当成“寒暄和无关细节”给丢掉了。结果Agent在后面的执行阶段真的去调了短信接口造成了一次线上事故。解决办法有两个层面。第一是摘要模板必须强制要求保留“约束和禁令”区块这一步治标。第二是在消息结构里把约束类信息单独抽出来不参与摘要而是每次请求都固定拼装在系统提示词里这一步治本。我现在碰到这类敏感约束都是双保险同时上。摘要里保留一份系统提示词里再固化一份。4.2 滑动窗口把系统指令挤出局滑动窗口的翻车案例也很有代表性。初始实现里系统提示词和其他消息一样参与窗口裁剪结果窗口一滚动系统提示词就被挤出去了。模型失去了身份设定和输出规范回答风格立刻跑偏甚至开始编造不存在的功能。这个问题的解法在前面已经提过滑动窗口执行裁剪时要把system角色的消息单独拎出来永远保留在窗口最前面。所有非system消息才参与窗口计算。用代码表达就是sliding_window函数里先把system_msgs取出来再把剩余消息做裁剪。4.3 检索增强召回了大量噪音检索模式上线初期召回结果经常不尽人意。最典型的问题是向量检索召回的Top-10片段里真正有用的可能只有两三个其他都是“看起来相关实际不是”的干扰项。把这些噪音片段全部塞给模型反而会把正确答案掩盖掉。排查之后定位到两个主要原因一是chunk_size设成了256导致逻辑完整的段落被切开语义碎片化二是没有做重排单纯靠向量相似度排序而向量相似度对语义细节的辨别力有限。之后把chunk_size调整到512增加overlap同时引入了一个交叉编码器做重排只看Top-5的结果噪音问题大幅缓解。召回质量差的另一个排查方向是query改写——用户的问题往往很口语化直接拿去向量化效果不好先用一个小模型把query改写成更利于检索的形式命中率能提高不少。4.4 成本失控没有监控的上下文管理是裸奔最后一个坑不是功能问题而是成本问题。有段时间某个线上会话量暴涨月底账单出来发现模型调用费用翻了近三倍。翻日志才发现有个回调场景绕过了路由逻辑一直走全量模式会话又不短token消耗直线上升。这件事之后我给所有的模型调用入口都统一加上了token计量和成本预估并且做了两件事一是按会话维度记录每次请求的token用量超过阈值直接告警到钉钉二是给每个接入方设置月度预算预算用完了自动降级到成本最低的滑动窗口模式。成本监控这件事听起来不性感但做的不好是真的会烧钱。问题典型症状排查方向解决手段摘要吞约束Agent执行了被禁止的操作检查摘要输出是否包含约束区块摘要模板加约束区约束单独固话拼接系统指令被裁回答风格突变检查滑动窗口裁剪逻辑是否包含systemsystem消息固定不参与裁剪检索噪声过大答案被干扰项带偏检查chunk大小、是否重排chunk调大、加交叉编码器重排、query改写成本失控账单异常飙升检查是否有请求绕过路由统一入口计量、预算熔断5. 实测效果对比与选型建议5.1 四种模式横向对比把四种模式放在同一张表里对比看得更清楚模式信息完整性单次成本响应延迟最适场景主要风险全量最高随长度线性增长随长度上升短会话、需要精确回忆超窗口报错、中间信息丢失滑动窗口近端高、远端无稳定稳定客服、FAQ、轮次对话早期关键信息彻底丢失摘要压缩要点高、细节少中高多一次摘要调用偏高长对话、Agent任务摘要丢失关键细节检索增强取决于检索质量低中检索耗时知识库、长文档问答检索失败导致答非所问从实测数据看最让我意外的是摘要压缩在Agent长任务中的表现。原本担心摘要会丢细节导致Agent中途迷路但只要摘要模板强制保留“已完成步骤”和“未完成任务”多数情况下Agent都能顺利跑完流程而且token消耗比全量模式少了60%以上。这说明什么说明大多数对话历史里的信息冗余度是相当高的压缩的空间很大。5.2 新手起步的选型决策参考如果你刚准备在自己的项目里引入context-mode我的建议是按阶段来不要一步到位上完整路由。第一阶段只做全量模式但加上两个保险token估算和硬性上限超过上限就拒绝请求而不是报错。跑一两个星期把典型会话的长度分布统计出来。第二阶段引入滑动窗口和摘要压缩给对话类场景和Agent场景分别做切换同时把模式选择记录到日志里方便事后复盘。第三阶段再做检索增强而且优先接在“长文档问答”这个明确场景上等索引和重排都稳定了再考虑把它纳入自动路由。关于自动路由我特别提醒一句初始阈值不要拍脑袋先用离线数据统计会话长度分布。比如你发现80%的会话都在5000 tokens以内那全量模式的阈值就可以定在8000留出余量如果大部分会话一上来就超过一万那就得把滑动窗口的优先级提到最前面。最后分享一个排查小习惯所有模式切换的返回结果里我都会塞一个调试字段mode、total_tokens、cut_count、retrieval_sources。线上出了问题看一眼这个字段就能知道当前请求到底走了哪条路径是被摘要截断了还是检索结果不对。这个小习惯帮我省下了大量排查时间。之前有人问我“context-mode和直接用大窗口API有什么区别”我的回答是大窗口只是上限context-mode才是把预算花在刀刃上的方法。窗口再大也是有限资源关键是让模型每看一个字都不浪费。后续我打算把语义缓存也加进来重复问题直接挡在模型调用之前如果跑出效果再来分享一篇。