
context-mode这个叫法我第一次见到是在一次内部技术评审里。当时同事的PPT上写着Context Mode v2.0下面是一堆看不太懂的流程图。后来我在AI应用开发里被上下文问题折磨过好几个通宵才慢慢意识到他说的不是某个参数而是一整套管理上下文的思路。今天把这块经验完整拆开讲。你会看到context-mode到底是什么、它由哪些模块组成、如何用代码落地以及我踩过的那些坑。这篇文章不是教科书概念而是一份把真实问答服务从每次现拼prompt改造成按模式管理上下文的完整记录。适合正在做智能客服、Agent编排和AI工具链的开发者也适合对上下文工程感兴趣的产品和技术负责人。现在上下文这个词已经被讲烂了但绝大多数项目还是停留在调大模型窗口、拼命拼长字符串的阶段。这篇想解决的问题很简单帮你建立一套可度量的上下文体系让它成为系统设计的一部分而不是只靠prompt技巧硬撑。1. 先理解 context-mode它解决的从来不是窗口大小1.1 真正逼我重写架构的是看不清上下文构成第一版的问答服务很简单Flask接收用户问题按照模板拼出一个几十行的prompt再调用模型接口返回回答。用户多问几轮以后我就把最近十轮消息也拼进去。一开始挺顺直到有用户连续问了20多个问题忽然模型开始丢信息。用户上一秒刚说完订单号A2001下一秒模型就说我没有看到您的订单号。我第一反应是模型能力不行第二反应是窗口不够大。于是把上下文窗口参数从32k调到128k结果接口直接报错提示token超限。直到我翻文档才搞明白最大输出token和上下文窗口是两个独立指标模型能处理的输入和输出之和不能超过窗口上限。也就是说你想让模型生成2000字那输入就得从窗口里再让出2000字的空间。更麻烦的是我发现就算窗口塞得下模型的注意力也是有限的。贴进去的80k历史文本里混着各种早上好谢谢稍等一下真正关键的订单号、报价、日期散落在中间模型根本抓不住重点。这件事给了我两个教训第一不能把上下文当成字符串随便拼要把它当成有结构的数据第二不能只看容量要看内容质量和优先级。这两点就是context-mode要解决的核心问题。1.2 context-mode 的三个层次我后来把上下文模式拆成三个层次来理解这个拆法在整个重构过程中帮了大忙。接口层定义一套统一的结构让用户输入、系统指令、工具返回结果、历史消息、知识库片段都能以同一种格式进入上下文系统。它解决的是数据怎么进来的问题。策略层提供一组命名好的模式比如short、long、deep每个模式代表一套完整的处理方案。它解决的是当前场景该用什么策略的问题。执行层实现从原始数据到最终模型输入之间的转换包括截断、摘要、检索、重排和格式渲染。它解决的是数据怎么变成优质prompt的问题。把这三层分开以后业务代码只需要关心调用哪个mode而不需要知道底层怎么拼字符串、怎么算token。这看起来只是代码组织的改变却让整个系统变得可测试、可观测、可复用。我特别想强调执行层。很多人以为把数据塞给模型就完了实际上模型输入端的差异会直接影响结果。同样是检索引擎返回的文档片段有的系统直接原封不动贴进去有的系统会先提取关键段落再重排。后者往往能用更少的token保留更多有效信息。context-mode的执行层就是把这些琐碎但关键的加工过程统一收口。1.3 为什么叫mode而不是一个参数如果只是一个参数那context-mode就没资格单独拿出来聊。真正的价值在于它把一组策略打包成一个可以被命名的配置。我常用一个例子解释你点外卖的时候不会每次告诉商家我要少油、不要香菜、米饭要加量你只会说按老样子来。老样子就是一个mode背后封装了一套规则调用方不需要了解细节。在我设计的上下文系统里一个mode至少包含四样东西上下文预算允许进入模型输入的上限通常用token数量表示。选择规则从候选上下文里选哪些、过滤哪些按什么优先级排序。压缩策略超限时是直接截断还是先摘要再合并。路由目标当前mode绑定哪个模型、哪个温度参数、哪个超时策略。把它们打包成mode之后系统里不同角色只需要传递一个字符串。新场景可以直接注册新的mode而不需要改业务代码。这个设计看起来很轻但解决了一个很现实的问题不会出现五个地方各写了一套prompt拼接逻辑最后改一处漏三处的情况。2. context-mode 的核心模块拆解2.1 上下文采集与归一化先让数据变成统一结构做context-mode的第一步不是写截断逻辑而是定义上下文的数据结构。我见过太多人跳过这一步直接在视图函数里写f你是客服以下是历史...结果一旦要接入新的数据来源整段逻辑就要推倒重来。我定义的核心结构叫ContextItem字段包括来源、内容、相关分、时间戳、优先级。不同来源的数据进来之后先转化成这种结构用户输入是source为user的内容知识库命中的段落是source为retrieval的内容工具返回的JSON是source为tool的内容系统指令是source为system的内容。优先级字段很关键。系统级指令永远是最高优先级其次是工具返回的当前结果再次是历史对话里命中实体的片段最后才是普通历史消息。这个排序会直接影响截断时候谁留下、谁走人。归一化规则同样重要。工具返回的JSON直接塞进prompt是最常见的浪费一份工单详情可能有几千个字符真正有用的只有状态、负责人和更新时间。我会在采集阶段提炼成一行工单[编号]状态已分配,负责人张三,最近更新10分钟前。这样既保留了关键信息又不会污染上下文。知识库片段也一样不要整个文档贴进去先把与当前问题最相关的段落截取出来再带上文档标题和出处。2.2 上下文预算分配怎么用数学方式决定取舍上下文管理的本质是预算管理。任何一个mode都必须有明确的token预算否则就会出现看似没有报错其实模型输入已经被静默截断的隐患。我经常用一个公式来算输入预算输入token上限 模型上下文窗口 - 最大输出token - 安全余量打个比方模型窗口是128k你的最大输出设置为2048安全余量设置为512那输入上限就是128k减掉这两个数约等于125442 token。如果有人想把输出上限调到16k那输入预算就得同步调低否则请求会直接失败。这个边界问题不解决线上就会随机出现Request too large的错误。预算定下来之后再来做分配。系统指令预留给5k当前问题预留给2k工具返回结果预留给10k剩下的交给历史和检索结果。这个预留不是一次性定死的要根据实际运行数据做滚动调整。我见过一个案例开发者在系统指令里塞了3万字的产品手册导致普通对话几乎没有可用的历史空间。我后来给自己加了一条硬约束系统指令超过2k token就必须拆出去放到检索库里按需加载。这听起来是常识但很多项目都在这个细节上栽过跟头。2.3 会话持久化与恢复上下文不能只活在一次请求里一次请求里的上下文好做真正麻烦的是跨请求恢复。用户上午问过的问题下午再回来系统要知道已经聊到哪了。我的做法是把会话状态拆成三层。第一层是固定记忆包括用户偏好、身份信息、业务约束每次请求都加载。第二层是近期消息保留最近几十轮的原始消息放在热存储里。第三层是长程摘要当历史消息超过预算时定时把它们压缩成结构化摘要。恢复的时候按顺序加载先固定记忆再读摘要最后补近期消息和检索证据。这里要特别提醒不要用SQLite或者MySQL去存全量原始消息然后每次请求都查出来拼。我第一版就这么干的用户一多数据库查询时间和token占用一起涨。后来改成热数据走缓存冷数据进对象存储摘要单独维护了一套索引请求延迟降了接近一半。存储方案没有绝对标准但原则是距离用户请求越近的数据越要放在低延迟的存储里。你不可能在每次请求时都去翻几个月的冷日志那不是好的上下文管理。3. 实操记录把一套问答服务改造成 context-mode3.1 改造前的问题和这次的目标原系统是给内部员工用的知识问答机器人接入产品文档和工单系统。核心逻辑很简单每来一个问题先检索文档取Top5段落再取最近10轮对话原文全部拼成一个大字符串扔给模型。最初的几个版本还行但老用户很快就发现问题了多轮之后模型经常忘记之前提到的工单号长问题拆成几步来问时第二步开始就跑偏系统prompt和检索结果混在一起模型分不清哪些是可执行指令、哪些是背景资料。这次重构的目标很明确设计short、long、deep三种模式系统能够按问题特征自动路由并且每种模式有独立的预算和策略。我要求自己不要为了炫技引入复杂框架先用手工规则和结构化代码把整个链路跑通确保每个环节都可观测。3.2 第一步把上下文定义成数据类我先把prompt字符串替换成一个数据类。用Python写的话大概是这样的结构dataclass class ContextItem: source: str content: str score: float timestamp: float priority: int 0 def render(self, max_chars: int 200) - str: if self.source tool: return f[工具结果] {self.content[:max_chars]} if self.source retrieval: return f[文档] {self.content[:max_chars]} if self.source user: return f用户: {self.content[:max_chars]} return self.content[:max_chars]同时定义一个mode配置dataclass class ContextMode: name: str budget_tokens: int max_output_tokens: int use_history: bool use_retrieval: bool use_summary: bool model: str为什么要这么做因为只有让上下文变成可以运算的数据结构后面才能支持优先级排序、token计算和模式切换。如果我还是用一个字符串变量那么所有策略都只能在字符串上做正则切片最终会退化成一堆if else。模式切换的底层逻辑其实就是对一个列表做过滤、排序和组合数据类让这个过程变得干净且可测试。3.3 第二步实现预算计算和滚动窗口token估算不能靠肉眼。我直接用模型对应的分词器来数token比如在OpenAI生态里可以用tiktoken其他模型也有各自的分词库。这里是一段常见的Python实现import tiktoken encoding tiktoken.encoding_for_model(gpt-4) def count_tokens(text: str) - int: return len(encoding.encode(text)) def fit_within_budget(items: list, budget: int, summary: str ) - list: total count_tokens(summary) picked [] for item in sorted(items, keylambda x: (x.priority, x.score), reverseTrue): item_text item.render() item_tokens count_tokens(item_text) if total item_tokens budget: continue picked.append(item) total item_tokens return picked这里的核心思路是先算已占用的token再逐条尝试加入。如果加进去会超预算就跳过。最后返回的集合保证了总数不会超过预算。优先级排序的作用是当空间不够时被牺牲的一定是低价值的普通历史消息而不是系统指令或检索命中的关键证据。滚动窗口的逻辑也要单独提一下。如果历史消息整体超过预算我不会一次性截断到最近N条而是先保留高优先级消息再尽量保留最近的消息。简单说就是把最近10轮这种僵硬的规则替换成在预算内尽量保留高价值内容的动态策略。这样用户就算问了20轮只要中间出现过关键实体的消息也不会因为超出最近10轮而被丢掉。长会话场景下再配合摘要压缩基本能覆盖大多数需求。3.4 第三步模式路由让每个请求找到合适的处理方式三种模式的定义如下模式适用场景预算策略short高频简单问答、意图确认8k token只加载系统指令、当前问题、Top3文档片段不加载历史long多轮客服对话、连续问题32k token加载近期消息、工具结果、检索证据超限时做滚动摘要deep复杂分析、代码审计、长文档问答64k token启用全文摘要、多路检索、多步推理允许更长输出路由函数用规则加启发式判断。我不会一上来就上机器学习模型规则简单、可解释、出问题容易修。示例def resolve_mode(query: str, history_len: int, user_level: str, query_len: int) - ContextMode: if history_len 2 and query_len 50: return MODES[short] if user_level expert or query_len 200 or 分析 in query: return MODES[deep] return MODES[long]这个路由就是一个优先级判断短问题优先走short省钱长问题或者专家用户走deep保证质量其余默认走long。再往下还能补一层自由文本分类模型但规则先行的好处是任何一次线上异常路由都能立刻解释清楚。实际跑了一段时间之后我发现约四成的请求落在short上token成本直接省了三到四成。这就是模式切换带来的立竿见影的收益。4. 常见问题与排查技巧实录4.1 上下文被截断后模型开始答非所问现象模型突然说相关资料未找到但日志里明明检索到了相关文档。我一开始怀疑是检索引擎出了问题排查了半天才发现是输出前把文档截断了。原因是fit_within_budget按优先级排序后把最后一个长文档直接丢掉了而它恰恰是当前问题最需要的那一篇。问题出在优先级只考虑了source的固定优先级没有把当前问题与文档的相关分纳入排序主键。修复方案是检索片段的排序分数先乘以一个相关性系数再参与全局排序。同时在截断记录里用日志标记本次丢弃了哪些内容。这个坑让我养成了一个习惯任何context-mode系统上线前都要写一条诊断接口能打印出最终prompt里到底放了什么东西。4.2 token估算和平台计费对不上本地用encoding_for_model(gpt-4)算的token和平台账单上的数额差了不少。原因通常有三个不同模型的分词器不一样本地估算和生产端用的根本不是同一个模型系统消息、函数定义和输出侧的token也会计费但估算的时候只算了输入侧的正文拼接时产生的隐式分隔符比如模板里加上的换行和标记符号也被继续算了token。我的处理方式是在每次请求日志里记录三个数字预估输入token、实际输入token、输出token。上线初期每天对一次账差得多的模块逐个排查。不要相信任何按字符数乘以0.75的估算公式要直接用线上模型的分词器。这个看起来很小的细节其实决定了预算监控的准确性。4.3 多用户并发时上下文串到了别人头上这是事故级别的问题。现象是用户A问完订单状态下一个问相似问题的用户B突然收到了用户A的订单信息。排查后发现当时的全局缓存把最近一次构造的prompt保存在一个共享变量里后到的请求直接复用了这个变量。因为模式写死的情况下我没有把session_id放进去。修复方案很简单所有上下文缓存的key必须由用户ID和会话ID组成读取缓存时校验归属。但更重要的教训是上下文是带身份的数据任何共享型中间件都要显式声明隔离级别。我后来在系统里加了一条测试用例专门模拟并发用户请求确保不会串线。测试覆盖不了所有事故但至少能把这类低级错误堵在发布之前。4.4 模式切换后效果反而变差deep模式本应该比long模式更好但实际切换后输出结果比原来还差。排查后发现是deep模式里启用了全文摘要摘要引擎把关键数字给泛化掉了比如把工单A2001的金额是13500元概括成工单金额超过万元。这种摘要损失对分析型任务是不可接受的。修复方向是deep模式不再默认使用自由文本摘要而是先抽取结构化关键信息比如实体、数字、状态再结合普通摘要一起进入上下文。同时模式切换要有回退机制如果deep模式的结果置信度低于阈值系统自动用long模式重跑一次并对比。不要怕多一次调用比起给出错误结果多花一次调用的成本完全值得。4.5 一个长期有效的调试习惯最后说一个我一直坚持的调试习惯写上下文快照日志。每次模型调用前把mode名称、上下文总token、各来源占比、被截断的条目列表、路由原因全部写成一行结构化日志。线上出问题时我只需要根据这条日志快速定位是路由误判、预算不足还是归一化丢失了关键信息。这套日志帮我在五分钟内解决过很多看起来像是模型变笨了的问题。事实上大部分模型表现变差都不是模型本身退化了而是你给它的上下文烂掉了。上下文烂掉的原因五花八门预算被低价值内容挤占、关键片段被截断、路由选错了模式、缓存串了身份。如果没有快照日志你只能对着模型输出猜原因。有了日志问题就变成了读日志、改逻辑、看效果的标准循环。这也是context-mode真正值得落地的原因它让上下文成为一个可以被观察、被度量的系统组件而不是藏在prompt字符串里的玄学。