
不知道你有没有过这种体验同一个提示词换一种上下文组织方式模型的表现就像换了一个人。有的回答精准得可怕有的却东拉西扯甚至一本正经地编造。这段时间我在调一个面向内部运营团队的知识问答助手前前后后改了十几版最后发现瓶颈不在模型本身而在一个容易被忽略的环节——context-mode也就是“上下文模式”。这个词看起来很简单但真正落地的时候你会发现它决定了你的请求怎么组织、历史记录怎么保留、检索结果怎么混入直接关系到大模型的回答质量、接口延迟和账单数字。这篇文章就围绕我实际踩过的那些坑把 context-mode 的选型思路、结构设计和实操细节一次讲清楚。适合正在做 Prompt 工程、RAG 系统、Agent 开发或者单纯被大模型回复搞得很头大的朋友。1. 先搞清楚 context-mode 到底在解决什么问题1.1 什么是上下文模式为什么它不是“上下文长度越大越好”很多人一上来就把 context-mode 理解为“把多少历史消息塞给模型”甚至觉得只要窗口够大问题就全解决了。这个理解太片面了。我见过最典型的一个案例某个团队购买了超大上下文的模型套餐结果把一整天的聊天记录、十几份PDF解析文本、各种系统日志全堆进请求里最后模型输出的质量反而糟糕。原因不难理解。大模型在做注意力计算的时候token 之间的相对位置和噪声比例都非常敏感。你把和当前问题毫无关系的陈旧信息塞进上下文中模型要么被误导要么在长距离依赖上“迷失”要么干脆把注意力分到无关信息上那回答质量不下降才怪。context-mode 本质上是在回答这么一个问题在每一次请求中哪些信息应该被放进上下文以什么顺序、什么结构、什么预算放进上下文。直观理解可以把 context-mode 想象成开会前你给老板准备的简报。你不可能把公司三年的所有邮件、报表、聊天记录全部打印出来堆在桌上。专业的助理会先确认本次会议要决策什么然后只挑相关的那几份报告、那几段聊天记录按逻辑顺序装订好再在关键位置贴上便签。context-mode 干的就是“简报装订”这回事。1.2 三类经典的 context-mode 形态做了这些项目之后我习惯把常见的 context-mode 实现分成三种形态第一种是直通模式Pass-through Mode。它适合单轮问答场景请求里只有系统提示词、用户问题和当前相关的数据没有历史对话。比如你要写一封邮件、翻译一段话、总结一篇新闻这类场景直通模式简洁高效它不承担记忆功能所有信息都靠这一次请求给全。优点是实现简单、成本低、响应快缺点是完全没有多轮对话能力。第二种是会话模式Conversation Mode。它把历史对话按时间线保存并在每次请求时追加到上下文中。这是绝大多数聊天机器人默认的做法。会话模式的难点在于历史记录无限增长之后怎么办。如果不做截断token 消耗呈线性上涨成本越来越高如果简单粗暴地只保留最后几条消息模型又会“记不住”用户很早之前说过的重要信息。第三种是检索增强模式Retrieval-Augmented Mode。它不依赖完整历史而是通过 Embedding、关键词检索等方式从知识库、对话日志中捞回与当前问题相关的片段再重组为上下文。这是 RAG 系统的标准形态也是目前大量商业化落地项目的首选。另外还有更进阶的分层模式Hierarchical Mode比如对长期记忆做摘要压缩、中期记忆做滚动窗口、短期记忆保留完整原文多个层级组合使用。这个我会在后面实操部分详细展开。2. 设计一个上下文系统先想清楚这几件事2.1 上下文窗口预算token 从哪来怎么分配接入了 context-mode 之后第一件事不是写代码而是算 token 预算。每个模型都有明确的最大上下文窗口比如常见的 8K、32K、128K 等。但你要明白最大上下文窗口不等于可用全部塞满。一方面输出部分要预留 token否则模型答到一半就被截断了另一方面系统提示词、工具定义、few-shot示例都会占用空间。我的习惯是把预算拆成四块系统指令区、数据区、历史对话区、输出预留区。以 8K 窗口的模型为例我会这样分配系统指令区 500 token 左右输出预留区 1000 token 左右剩下的空间再分给数据和历史。这个分配比例不是固定的比如代码生成任务输出通常很长可能要把输出预留区提高到 2000检索增强任务里数据区是主角历史对话反而可以压缩。具体到落地我建议你先对自己业务里的请求做一次日志统计。把近一周的真实请求抓出来统计每个请求的系统提示词长度、历史消息数量、检索片段长度和下游任务需要的最大输出长度再用 P90 或者 P95 分位数做预算如果你 90% 的请求里历史对话只需要 1500 token那就没必要预留 4000 token 给对话历史省下的空间可以给检索结果或越权扩大数据容量。2.2 上下文优先级系统提示词、历史对话、检索结果怎么排序这是个很关键、也经常被忽略的细节。同样的内容放在上下文的不同位置模型对它的“重视程度”完全不同。大模型的注意力机制会导致它对开头和结尾位置的文本更敏感中间位置的内容容易被“淹没”。这个现象在学术上被讨论过在工程实践中感受更明显。我在实践中确定的优先级策略是历史对话和检索数据是“可牺牲资源”系统提示词是“不可牺牲资源”。当上下文快满时先压缩历史对话再压缩检索片段最后才考虑精简系统提示词。而且系统提示词和当前的用户问题都必须保持在上下文的可见区域中不能被长历史对话挤到“视觉范围”之外。不同业务还有各自的排序规则。比如客服机器人用户最近一次反馈的聊天记录优先级高电商导购当前正在浏览的商品描述优先级高。我自己的经验是把“当前任务相关的确定性内容”放在上下文最前端把“需要模型参考的辅助信息”放在中段把“最新的用户问句”放在末尾这样模型能快速抓住当下重点又能获得足够的背景支撑。2.3 截断、摘要、淘汰三种策略怎么选历史对话拉满之后怎么处理常见三种手段。**截断Truncation**是最直接的方式只保留最近 N 轮对话。优点是实现快、性能好缺点是会丢失早期重要信息。适合那些对话轮次短、信息时效性强的场景比如咨询、问诊。**淘汰Eviction**是基于规则移除“不重要”的对话比如剔除掉寒暄消息、纯表情包消息、重复提问消息。这个策略对消息类型丰富、噪声多的场景很有效。我做过一个客服场景用户经常发“在吗”“你好”“好的谢谢”这些消息对理解当前意图毫无价值纯属浪费 token。通过正则和意图识别把这些消息打标构建上下文时直接过滤掉能省出 20%-30% 的 token。**摘要Summarization**是把早期历史滚成一段浓缩摘要保存事实性信息和关键结论丢弃细节。摘要效果最好但代价是有损压缩可能丢失细微信息而且每次生成摘要都需要额外的模型调用。适合对记忆要求高的场景比如教育陪练、个人助理、剧本创作等。这三者不是互斥的实际项目中经常组合使用。举个例子前 20 轮完整保留再往前 80 轮压缩成摘要更早的全淘汰。这样既有细节又有背景还能控制成本。3. 实操搭建一个可落地的 context-mode 方案3.1 定义一个上下文状态结构讲完概念和策略开始写实际代码。我会从一个最小可用的方案入手。所有代码都基于 PythonLLM 接口用兼容 OpenAI 风格的调用方式向量检索用最简单的 in-memory 实现目的是把 context-mode 的机制讲透你在生产环境替换成对应组件即可。先定义数据结构。每个会话维护一个 ContextState用来记录当前上下文的构成from dataclasses import dataclass, field from typing import List, Dict import time dataclass class Message: role: str # user / assistant / system / tool content: str timestamp: float field(default_factorytime.time) dataclass class ContextState: session_id: str system_prompt: str recent_messages: List[Message] field(default_factorylist) summary: str # 早期历史的压缩摘要 priority_map: Dict[str, float] field(default_factorydict) # 消息ID-优先级这里故意把状态设计成“摘要 近期完整消息”的组合结构。真正的滚动窗口只保留最近 N 条消息更早的对话做成摘要这就是前面说的组合策略。priority_map 用于记录每条消息的优先级权重构建请求时会依据它决定哪些消息可以被淘汰。接着是 ContextBuilder负责把 ContextState 组装成最终发给模型的 messages 列表class ContextBuilder: def __init__(self, max_tokens: int 8000, reserve_output: int 1000): # 最终可用给输入部分的最大token数 self.input_budget max_tokens - reserve_output def _count_tokens(self, text: str) - int: # 生产环境请用 tiktoken 等分词器这里做简化处理 return len(text) // 2 def build(self, state: ContextState, current_user_msg: Message): messages [{role: system, content: state.system_prompt}] used self._count_tokens(state.system_prompt) # 1. 把摘要作为历史背景放入如果空间允许 if state.summary: summary_block {role: system, content: f[对话历史摘要] {state.summary}} summary_cost self._count_tokens(summary_block[content]) if used summary_cost self.input_budget: messages.append(summary_block) used summary_cost # 2. 放入近期消息按时间顺序 for msg in state.recent_messages: if self._count_tokens(msg.content) used self.input_budget: break # 空间不足截断 messages.append({role: msg.role, content: msg.content}) used self._count_tokens(msg.content) # 3. 放入当前用户问题保证留足空间 messages.append({role: user, content: current_user_msg.content}) return messages这里有几个细节值得注意。摘要我放到 system 角色而不是 user 角色因为在很多接口实现中system 角色的消息在较长的上下文里不容易被后来的指令覆盖而且语义上它确实是系统级背景信息。近期消息按时间顺序追加直到预算不足为止这里实现的是尾部截断实际项目中还可以对旧消息做淘汰评分后再截断。最后一条当前用户消息必须保证放进去这意味着前面所有模块都要为当前问题“让路”。3.2 实现上下文构建流水线有了基础结构下面做完整一点的设计。我的构建流水线分为五个阶段收集、清洗、检索、组装、压缩。每个阶段都可以插拔替换。第一阶段收集从消息队列、数据库、缓存中拉取当前会话的数据包括用户消息、助手回复、外部工具返回结果等。第二阶段清洗按规则过滤噪声消息。比如长度小于 2 个字符的消息、连续重复的消息、无信息量的系统通知。我之前提到过“在吗”“谢谢”这类消息在社交场景是有礼貌价值的在模型上下文里纯粹是浪费所以直接过滤。第三阶段检索这是 RAG 的核心动作。把当前用户问题做 embedding去知识库检索 top-k 相关片段。需要注意检索的输入不应该只是当前这一条用户消息而是“最近的对话 当前问题”拼成的整体检索 query。很多初版 RAG 系统只拿最后一条问题去检索结果用户说“那第二个方案详细讲讲”系统根本不知道“第二个方案”指什么检索出来的内容自然不对。def build_retrieval_query(state: ContextState, current_msg: Message, max_query_len300): # 合并最近几条用户消息当前问题提升检索召回准度 recent_user_texts [ m.content for m in state.recent_messages[-4:] if m.role user ] combined \n.join(recent_user_texts [current_msg.content]) return combined[-max_query_len:]第四阶段组装把检索结果放入上下文。检索结果不是越多越好。我在实际调参中发现同一个问题上top-5 的结果如果不做去重和相关性过滤放进上下文后反而会引入大量重复信息。我的做法是对检索结果按 embedding 余弦相似度排序后做一次 Maximal Marginal RelevanceMMR重排让保留结果既相关又彼此不冗余。MMR 的简化实现import numpy as np def mmr_rerank(query_emb, doc_embs, doc_texts, top_k3, lambda_0.7): selected [] remaining list(range(len(doc_embs))) def _cosine(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) 1e-9)) while len(selected) top_k and remaining: scores [] for i in remaining: rel _cosine(query_emb, doc_embs[i]) div max(_cosine(doc_embs[i], doc_embs[j]) for j in selected) if selected else 0.0 scores.append(lambda_ * rel - (1 - lambda_) * div) best_idx remaining[int(np.argmax(scores))] selected.append(best_idx) remaining.remove(best_idx) return [doc_texts[i] for i in selected]参数 lambda_ 控制相关性和多样性的平衡。lambda_ 接近 1.0 时只用相关性排序接近 0 时只追求多样性。我在问答场景里一般设 0.6 到 0.75 之间这个区间能保证主要相关信息不被淹没同时避免检索结果全是同一个段落的翻版。第五阶段压缩在组装完成之后检查总 token 数是否超出预算一旦超出则按“先从中间裁掉冗余检索结果再裁旧历史最后整体生成摘要”的顺序处理。注意裁减顺序很讲究如果你先裁系统提示词那等于把最重要的指令砍掉后面模型行为会失控。3.3 动态调节 context-mode 的具体做法上面提到的都是静态规则但在实际业务中不同请求对上下文的敏感度差别很大。比如简单翻译“把这句话翻成英文”上下文模式根本不需要但“从前面对话里提取所有待办事项”没有历史就不可能完成。所以我在项目里加入了动态模式选择规则很简单先用分类器判断当前请求类型再决定采用哪一种 context-modeclass ContextModeRouter: def decide_mode(self, current_msg: Message, state: ContextState): text current_msg.content.lower() # 特征规则生产环境可以替换成分类模型 if any(k in text for k in [总结, 翻译, 写一封, 扩写, 缩写]): return pass-through # 单轮创作类不需要检索和长历史 if any(k in text for k in [刚才, 之前说的, 第几个, 然后呢, 你忘了]): return conversation # 高度依赖对话记忆优先保存历史 if any(k in text for k in [文档, 手册, 资料, 根据, 知识库, 政策]): return retrieval # 需要外部知识优先检索增强 if state.recent_messages and len(state.recent_messages) 8: return conversation # 默认长对话走会话模式 return pass-through这个路由器看起来简单但在大部分业务里基于关键词和少量正则已经能覆盖七八成场景。不要一上来就上复杂的意图分类模型先跑通规则再积累标注数据迭代。模式选好之后ContextBuilder 也可以对应调整参数。比如 retrieval 模式下检索结果的 top_k 可以提高到 5但历史对话只保留最近 4 轮conversation 模式下检索关闭历史保留最近 20 轮并把更早的压缩成摘要pass-through 模式下历史完全丢弃连摘要也不放省 token 省延迟。我在内部系统里还做了一层画像缓存按照会话 ID 缓存用户的偏好和业务标签每次请求时只把“该用户本次任务需要的那部分画像”插入上下文而不是一股脑把所有画像硬化到系统提示词里。这样既保留了用户体验上的个性化又把每轮请求的固定开销控制住了。4. 常见问题与排查技巧实录4.1 模型“失忆”甚至幻觉往往不是模型问题我调试阶段遇到最多的问题就是用户反馈“模型怎么忘了我说过的话”。查日志时十有八九是这个原因上下文构建时把历史记录中的关键消息裁掉了或者摘要压缩时把关键实体和数值弄丢了。排查技巧很简单在构建器的日志里输出每次请求实际使用的 messages 总览包含每个块的 token 数和来源标记。一旦用户反馈失忆先查这份日志看你到底给模型喂了什么。如果发现关键信息根本没进去那就是裁剪规则或摘要策略的问题跟模型能力无关。另一个容易被忽视的点是摘要的丢失。文本摘要模型在做压缩时往往倾向于保留“重要信息”但对业务系统来说用户手机号、截止日期、金额这类精确数字才是最重要的。自动摘要有可能把它们漏掉。我的做法是在摘要生成前先对原文做实体抽取把号码、日期、地址等结构化字段单独抽出放到摘要前面作为固定的关键信息块再让模型生成自然语言摘要。这样即使摘要正文有损关键字段也不会丢。4.2 检索增强后回答反而更差了有个非常反直觉的现象接入检索增强之后回答质量可能不升反降。我排查过几次最后定位到两个高概率原因。第一个是检索 query 构造太简单。直接用用户最后一条消息去检索忽略了前文对当前问题的限定召回结果偏离主题。这时候按照前面说的方法用“最近对话 当前问题”拼接检索 query召回质量会有明显提升。第二个是检索结果与用户问题相关性不足时还非要把结果填入上下文。更糟的是有些时候检索结果里包含了互相矛盾的内容模型要么不知所措要么强行从中编造一个答案。我的经验是加上一个相关性阈值判断当检索结果的最高相似度低于某个阈值比如 0.35视 embedding 模型而定就认为知识库里没有可靠答案干脆不塞检索结果让模型基于自身能力回答或明确说“没有找到相关资料”。这个“宁可不做不可乱做”的思路能显著降低幻觉率。4.3 成本暴涨的排查思路之前碰到过一次线上成本异常排查后发现是历史对话没有控制长度每个用户会话跑了三十几轮之后每轮请求都把之前的全部消息原样带上token 消耗越来越高等于每回答一条新消息都要为之前的二三十条消息付钱。这个问题的根源在于 context-mode 里没有设置“增量上下文”与“滚动窗口”的配合。修复方案是启用滚动窗口只保留最近 10 轮消息更早的消息做一次摘要缓存。同时把摘要缓存做成可复用模块如果后续对话长度在窗口内没有超出预算就不必每次重新生成摘要直到窗口快要溢出时再做增量摘要合并。这样既能避免反复压缩带来的额外模型调用又能把成本控制住。另外很多项目会忽略工具调用返回结果的 token 开销。在 Agent 场景里一次工具调用可能返回一大段 JSON如果这些内容原样塞进上下文一次请求可能就吃掉几千 token。我的做法是工具返回结果进入上下文前先做结构化裁剪比如只保留字段名和关键值或者用预定义模板做摘要。除非下游任务需要完整原始数据否则一律精简后入库。4.4 一个小技巧把 context-mode 做成可视化调试界面最后分享一个提高排查效率的技巧。纯靠日志和 print 调试 context-mode 太痛苦了。我后来给内部项目加了一个简单的调试页面展示每个请求的上下文视图左侧是消息时间线右侧是实际发给模型的 messages 列表并标注每个消息的来源是原始历史、摘要、检索结果还是系统指令。当业务方反馈回答不对时我直接把这个视图截图丢到群里一眼就能看出来哪里缺了、哪里多了、哪里顺序不对。这个界面不复杂几十行前端代码加一个 API 就能做但调试效率提升数倍。5. 结尾说实话context-mode 不是什么高深莫测的黑科技它就是认真对待“模型到底看到了什么”这件事。很多系统上线后表现不稳定回头排查发现不是模型不行而是上下文构建这套“前处理”做得太糙。我个人在实际操作中最大的体会是宁可把多轮对话历史控制得保守一些也要保证喂给模型的内容是精准、有序、贴合当前需求的。这一段跑通了后面无论是接知识库、接工具、接记忆系统都会顺很多。