
调试基于大模型的应用时最让人头疼的问题之一就是明明在对话里强调了关键信息模型却答非所问或者说来说去只盯着最近几条消息。这个问题的根源不在模型本身而在于我们怎么管理“上下文”——也就是每次请求真正发给模型的那些内容。context-mode或者叫上下文模式就是针对这个问题设计的一组策略根据不同的任务场景动态调整上下文构建方式在有限的上下文窗口里塞进最有用、最相关的信息而不是一股脑全丢进去。这篇文章把我最近在项目里落地 context-mode 的完整过程拆开讲为什么需要它、三种模式怎么定义、token 预算怎么算、混合检索怎么接、切换逻辑怎么定、以及我实打实踩过的坑。内容偏工程实践适合正在做 AI 应用开发、提示词工程、或者想把 RAG/长对话效果调好的朋友参考。文里的参数和代码都是实际项目里调过、能直接改来用的不是理论推导。1. 为什么要给上下文做“模式”1.1 上下文窗口是硬约束不是越大越好先说一个反直觉的现象很多大模型的上下文窗口已经拉到 128K、200K但把长文档全部塞进 prompt 里模型的表现并不会更好。原因在于注意力机制对长文本的处理是非均匀的——越往中间的内容越容易被“淹没”开头的系统指令和结尾的最新消息往往对生成结果影响最大。翻译成人话就是你给模型读一百页资料它可能只记住了前两页和最后两页。我习惯把这个问题类比成收拾行李。箱子能装多少东西是固定的你要做的不是把所有衣服都塞进去而是根据去的地方、待几天、什么天气决定带什么。context-mode 就是这个“决定带什么”的过程。很多人在实际项目里遇到的“模型回答很空”“答案不准”“丢失用户之前提过的约束”十有八九不是因为模型不行而是上下文整理得太随意了。而且上下文越长成本越高、延迟越高。多传一个 token 就多一分钱多一轮网络传输就多几十毫秒。工程上永远要在“效果、成本、延迟”三者之间做权衡。context-mode 的价值就是把这个权衡过程从纯手工试错变成一套可解释、可复用、能持续调优的机制。1.2 不同任务对上下文的需求完全不同实际业务里用户问的问题五花八门但按信息组织方式可以归成三类场景每一类对上下文的要求都是矛盾的任务场景典型问题主要信息需求单一策略的失败表现快速问答“发票怎么开”“退款多久到账”最近几轮对话 少量FAQ命中塞入大量文档回答冗长跑题知识库检索“报销流程里金额超过五千要谁审批”从几十份文档中准确定位片段只看历史对话模型无从回答复杂分析“对比这两个月的销售数据找出下滑原因”全局摘要 多文档片段 多轮关键信息信息零散结论缺乏依据这就是 context-mode 存在的理由。单一上下文构建策略解决不了所有问题因为你面对的是三种完全不同的信息需求。要么为了准确牺牲灵敏要么为了灵敏牺牲深度。而模式化的设计就是给每种场景配一套独立的“打包方案”同时保留统一的接口——调用方不需要关心内部细节只需要指定当前场景。2. context-mode 的整体设计思路2.1 核心维度宽度与深度我在设计项目里的 context-mode 时始终盯着两个维度看宽度和深度。宽度指的是一次请求里放入了多少个信息源——检索片段个数、对话历史轮数。宽度大覆盖的信息面广但每个片段分配到的 token 就少细节容易被压缩。深度指的是每个信息源保留了多少细节——是完整原文还是压缩摘要还是只留关键结论。深度大单条信息的还原度高但能放进去的信息源就少。这两个维度是此消彼长的。深度模式下token 预算主要投给检索内容历史对话就得做摘要压缩精准模式下对话历史完整保留检索就只取 Top3。所有模式本质上都是在宽度和深度之间找一个平衡点。2.2 三种模式的定位与参数我在项目里最终跑通了三种模式分别叫 compact、balanced、deep。虽然名字朴素但足够表达含义团队协作时沟通成本也低。三种模式的初始参数如下参数项compactbalanceddeep适用场景简单问答、客服对话日常办公、知识库检索深度分析、报告解读推荐窗口预算4000 token8000 token16000 token检索片段数Top 3Top 5Top 10单片段最大长度200 token500 token800 token历史对话保留最近 4 轮最近 10 轮最近 15 轮 摘要压缩全局摘要不生成可选生成强制生成输出预留 token5008001500这些数字不是拍脑袋定的而是根据实测的 token 分布调出来的。我当时的做法是先跑了一批真实用户问题统计不同模式下 system prompt、检索片段、历史对话、用户问题各自占了多少 token。然后根据“最终回答质量”和“上下文总长度”的关系曲线把预算调到一个相对舒服的区间。比如 compact 模式大多数场景的上下文总量在 3.5K 左右就能覆盖再往上加检索片段回答质量并没有明显提升反而延迟和成本上去了。2.3 自动切换怎么判断该用哪个模式模式切换有两种方式用户手动指定或者系统自动判断。手动指定好办输入框加个前缀语法就行比如输入 /deep 就强制进入深度模式。真正的难点在自动切换——系统怎么知道当前这个问题适合哪种打包方式我实现的自动切换逻辑主要看四个信号问题长度问题越长内部逻辑关系越复杂需要的上下文支撑就越多。我设了一个经验阈值问题超过 80 个字符至少升到 balanced。检索命中情况针对用户问题做混合检索后如果命中的片段分散在两个以上的文档且得分都比较高说明这是一个跨文档的分析型问题直接给 deep。对话轮次连续对话超过 10 轮意味着早期历史里很可能有重要的约束或背景信息此时用 compact 显然不合适至少 balanced。句式信号问题里出现“对比”“分析”“总结原因”“影响是什么”这类词直接判定为复杂任务使用 deep。需要注意自动切换的触发条件不能太激进。我踩过的一个坑是同一用户连续问三四个简单问题突然问了一个长问题系统立即从 compact 跳到 deep检索策略变了回答风格突变用户会觉得很突兀。后来我改成“会话内模式延续”也就是本次模式取“当前判定”和“最近三次模式”中的最高档位。这样既保证了复杂问题能升档又不会因为一次偶发输入导致频繁抖动。def resolve_mode(current_mode, detected_mode, history_modes): # 防止模式抖动取最近三次和当前检测结果中的最高档 all_modes history_modes [current_mode, detected_mode] return max(all_modes, keymode_rank)排序定义简单一点compact1balanced2deep3。这个函数在会话开始阶段调用一次之后整个会话沿用统一档位。3. 核心实现token 预算、检索与上下文组装3.1 先把 token 账算清楚做 context-mode 的第一步是能精确统计 token 数。很多人喜欢用len(text)估算长度这在英文上勉强可用遇到中文和代码就完全不准了。中文字符的平均 token 占用因模型而异有些模型一个字占两个 token有些则占一个多。我项目里统一用的 tiktoken 库按当前模型编码精确计算。import tiktoken enc tiktoken.encoding_for_model(gpt-4o) def count_tokens(text: str) - int: return len(enc.encode(text))预算分配的思路是“先扣固定的再分剩余的”。系统指令、任务指令、改写后的问题这三部分基本是固定的先算清楚。剩余预算按比例分给检索内容和对话历史。比如 balanced 模式下8000 token 的预算先扣掉系统指令 600、任务指令 400、输出预留 800、用户问题 200剩下约 6000 token按 50% 和 50% 分给检索内容和对话历史也就是各 3000。我给三档模式都设了比例参考预算项compactbalanceddeep系统指令500600700任务指令300400500检索内容占比50%50%55%历史对话占比30%35%25%当前问题固定固定固定输出预留5008001500这里有个细节值得强调输出预留一定不能省。很多人把窗口预算算得很满结果模型回答到一半被截断。宁可少放一个检索片段也要保证模型有足够的生成空间。我见过太多线上事故最后查出来是“输出 token 上限设低导致的回答中断”这属于典型的省了小钱、亏了大钱。3.2 混合检索向量召回加关键词召回检索环节是 context-mode 的弹药库。单纯用向量检索有个常见问题对专业术语、缩写、产品名的精确匹配能力弱。比如用户问“EPC 项目的付款节点”向量检索可能匹配到一些语义相近的“工程项目管理”内容但漏掉文档里真正的“EPC”章节。我的做法是混合检索向量召回负责语义泛化BM25/关键词召回负责术语精确匹配然后用加权融合把两类结果合并。融合时我用了最简单的加权排序具体权重根据线上反馈调。def hybrid_search(question, top_k, alpha0.5): vec_results vector_search(question, top_k * 2) keyword_results keyword_search(question, top_k * 2) merged {} for doc_id, score in vec_results: merged[doc_id] merged.get(doc_id, 0) alpha * score for doc_id, score in keyword_results: merged[doc_id] merged.get(doc_id, 0) (1 - alpha) * score ranked sorted(merged.items(), keylambda x: x[1], reverseTrue) return ranked[:top_k]alpha 的值我推荐从 0.5 起步然后根据实际场景调。如果发现检索结果里频繁出现“语义沾边但不相关”的内容就调高 alpha加重向量权重如果发现术语匹配不上就调低 alpha加重关键词权重。检索片段还有一个容易忽略的操作相关性阈值过滤。低于阈值的片段不要进上下文否则就是纯噪音。我用的是 cosine 相似度阈值 0.35低于这个值直接丢弃。这个阈值不是统一的需要根据向量模型的表现调整——有的模型普遍打高分有的模型分数偏低可以先跑一批标注数据找出分界线。3.3 组装上下文按相关性分配 token 配额拿到检索结果后下一个关键操作是按相关性分配 token 配额。很多方案是平均分配——每个片段给一样的长度。但这其实不合理Top1 的相关度可能远高于 Top5凭什么给它一样的展示空间我用的策略是按排序位置做衰减分配。比如 total_retrieval_budget 3000给 Top1 分配 35%Top2 分配 25%Top3 分配 15%Top4 分配 10%Top5 及之后的每个片段分配 10%。这样既能保证高相关片段展开细节又不会让低相关片段占太多位置。片段本身太长的需要截断。截断策略也要讲究不能直接从中间切那样会切断语义模型读起来一头雾水。我是按句子边界截断优先保留包含关键词的句子及上下文而不是机械地取前 N 个字符。最后组装 messages代码大概是这样的def build_context(question, mode, history, retrieval_hits): cfg MODE_CONFIGS[mode] messages [{role: system, content: SYSTEM_PROMPTS[mode]}] # 全局摘要deep 模式必带 if mode deep and global_summary: messages.append({role: system, content: f会话全局摘要{global_summary}}) # 检索片段标注来源 for rank, (doc_id, score, text) in enumerate(retrieval_hits, 1): truncated truncate_by_sentence(text, token_limit_for_rank(rank, cfg)) messages.append({role: user, content: f[参考片段 {rank}/{len(retrieval_hits)}来源{doc_id}]\n{truncated}}) # 对话历史 for msg in compress_history(history, cfg): messages.append(msg) # 当前问题 messages.append({role: user, content: question}) return messages这里我把检索片段放在前部、历史对话放中间、当前问题放最后是有意为之的。前面提到模型对开头的指令和结尾的问题注意力更强检索片段放在系统指令后面相当于“接着指令看资料”当前问题放最末尾模型会带着这个问题去读上面的片段注意力更集中。3.4 对话历史的三种处理方式对话历史的处理方式直接决定了模式的深度档位。我项目里用了三种方案从简单到复杂递增第一种是简单截断只保留最近 N 轮。优点是实现简单、不消耗额外 token缺点是容易丢掉早期的重要上下文。适合简短对话场景也就是 compact 模式的主力方案。第二种是摘要压缩。对于超过 N 轮的早期历史我用一次额外的 LLM 调用把它们压缩成一段两三百字的摘要。注意不要一次性把所有旧历史都丢给“摘要器”然后只保留结果建议分段落压缩每 5 轮一批最后再把几段摘要合并。这样做的好处是避免早期信息过度丢失坏处是增加了一次额外调用有延迟和成本。我在 balanced 和 deep 模式下都使用了摘要压缩只是触发轮数不同。第三种是关键信息快照。这是我从实践中总结出来的也是我认为最有价值的技巧。用户对话里经常会出现一些硬性约束比如“这个方案不要用 Python 2”“预算不能超过一万”“客户是制造业不是互联网”。如果这些信息藏在比较早的历史轮次里摘要压缩时可能被当作琐碎细节丢掉导致模型后续回答风格跑偏。解法是把这类约束在对话初期就识别出来单独存成一个列表每次组装上下文时无条件注入不占对话历史预算。实现上我做了一个简单的规则检查命中“必须”“不要”“不能”“限制”“注意”等词就把这句话抽出来存进快照里。识别准确率做不到 100%但已经能覆盖大部分关键场景。3.5 实测同一个问题在三档模式下的表现理论说了不少直接看一组实测数据更直观。测试文档是一份研发团队的周报合集用户问题“根据最近两周的周报前端组的主要阻塞点是什么跟上一迭代相比有变化吗”compact 模式的输出比较“泛”。模型能说出来“前端组存在阻塞”但具体阻塞内容说得模糊原因是检索只给了 Top3 片段丢失了对比所需的上一迭代信息。balanced 模式明显好转模型能列出“布局重构进度落后”“接口联调阻塞”等三四个具体阻塞点但对比部分只有一句话带过。deep 模式的效果最好模型不仅列了阻塞点还结合历史摘要指出“阻塞点从 UI 适配问题转向接口联调问题”给出了完整的演变过程。指标compactbalanceddeep上下文总 token约 3200约 7600约 14500回答完整度60%85%95%延迟约 1.2s约 2.1s约 3.5s适用场景客服、快捷问答日常办公助理深度分析这个结果也验证了之前的判断deep 不是“更好的模式”而是“特定场景的模式”。如果用户只是问“退款几天到账”你用 deep 给它塞十几篇文档反而会因为检索噪音导致回答变差。所以模式不是越高越好匹配场景才是最重要的。4. 实操中的坑与排查技巧4.1 检索 TopK 里的“噪声片段”怎么压向量检索有一个非常经典的毛病TopK 结果里经常混入一两个“看似相关、实则无关”的片段。比如用户问“怎么申请休假”检索结果前三名里出现了“休假制度与薪酬计算关系”这类扩展内容模型就容易被带偏回答里开始讲薪酬忘了用户只想知道请假流程。我的处理办法有三层第一层是相关性过滤相似度低于阈值的片段直接不进上下文第二层是 MMR最大边际相关性去重让进入上下文的片段之间差异尽量大避免三个片段都在说同一件事浪费预算第三层是在指令模板里强调“严格依据给定片段回答如果片段中没有相关信息请直接说明不知道”给模型一个不硬编的出口。三层叠加之后噪声的影响基本可控。4.2 摘要压缩导致“硬约束丢失”前面提到了关键信息快照这里再展开说说它是怎么救了我的。早期版本里我把所有旧历史都丢给摘要器只保留摘要结果。结果用户明明说过“这份报告的结论先不要告诉客户”摘要压缩后这句被丢了模型在一次对话里直接说出了内部结论。幸好是测试环境没造成实际事故。从那以后我把摘要和约束彻底分开了。摘要只负责压缩叙述性内容约束性内容走独立的快照通道。提取规则很简单但很实用命中“不要”“请勿”“必须”“只限于”“不能透露”“禁止”等词这句话就要抽出来放到快照列表里并且每次组装上下文时无条件附加到系统指令区域。这个方案运行到现在没有再发生过约束丢失的情况。4.3 自动切换导致回答风格漂移这个坑我在前面提过——自动切换太灵敏会导致同一会话内回答风格不稳定。用户可能完全没意识到自己的问题变长了只感觉“这个 AI 怎么突然说话风格不一样了”。我最终的方案是“会话内取最大档位”def get_session_mode(user_input, session_state): detected detect_mode(user_input) modes [session_state.get(mode, balanced), detected] session_state[mode] max(modes, keymode_rank) return session_state[mode]注意session_state 里的 mode 一旦升档就不再降档直到新会话开始。这样虽然牺牲了一点点“弹性”但换来的是稳定一致的用户体验。另外如果产品形态允许最好在界面上展示当前模式。我们后来在输入框下面加了一个小标签写着“当前深度模式”用户看到标签后就不会觉得回答风格突变是“模型抽风”了。4.4 输出截断、检索为空、模式冲突还有三个常见问题我整理成一个速查表方便复查症状可能原因排查思路解决方案回答总被截断输出预留 token 不足检查生成日志里是否有 finish_reason 为 length提高输出预留压缩检索片段长度模型说“没有信息”但文档里有检索 TopK 太少或阈值太高打印检索片段分数看是否有过高分片段被丢弃降阈值或提高 TopK检查 query 改写两种模式效果都一般系统指令模板没分离对比不同模式下 system prompt 是否一致为每档模式单独维护 prompt 模板不共用关于 prompt 模板分离这一点我再多说一句。项目早期我是三档模式共用一套 system prompt结果发现 compact 模式下指令太长、挤占有效上下文deep 模式下指令太弱、压不住检索噪音。后来我给每档模式单独写了一套 promptcompact 强调“简洁作答”balanced 强调“依据材料”deep 强调“全面归纳并标注来源”。改完之后三档模式的表现都上了一个台阶。5. 调试经验与后续想扩展的方向写到这里正文快收尾了我想再分享几个调试时觉得特别有用的习惯。第一个习惯是可视化上下文面板。我开发时会把最终发给模型的 messages 完整打印到调试界面里每一段都标注 token 数和来源是检索片段、是历史摘要还是系统指令。看起来是个笨办法但排查问题效率极高。有一次用户反馈“模型答非所问”我打开面板一看发现一个低相关检索片段占了 40% 的预算问题出在检索排序权重没调好——这种问题不看实际 prompt 是很难定位的。第二个习惯是先定预算再选模式不要反过来。有人会问“这种场景该用 balanced 还是 deep”正确的思路是先算清楚“这个任务最多能接受多少 token”再反推检索数量和对话历史的保留策略最后落到某个模式上。预算决定模式不是模式决定预算。第三个习惯是不同模式独立维护 prompt。这一点在“常见问题”表格里提过但值得再说一遍。模式是上下文构建策略的外壳prompt 是策略的灵魂二者必须匹配。后续我想做的扩展有两个方向。一个是给 context-mode 加上“上下文质量打分”在每次请求结束后评估上下文的冗余度、相关度、信息覆盖率用这个分数去反推检索权重和片段截断策略是否需要调整。另一个是建立一套评测集把历史上出现过的高质量问答对收集起来每次改动检索逻辑或模式参数先跑一遍评测集回归避免“修好一个问题、带崩一片场景”的尴尬。context-mode 这套东西没有太深的技术门槛核心就是把“上下文管理”从经验式的随手调参变成一套有参数、有逻辑、可观测的工程机制。如果你也在做类似的应用建议从最小的两档模式compact 和 deep跑起来先解决“简单场景太啰嗦、复杂场景答不全”这两个最直观的问题再逐步加细节。做工程最怕一步到位上下文管理尤其如此——参数这个东西永远是调出来的不是设计出来的。