写这篇文章是因为最近在做 AI 助手类应用时,被“上下文”这个词折腾得够呛。项目原本只是简单地把用户消息一股脑塞进模型请求里,结果对话一长,模型开始“失忆”,该记住的忘了,不该记住的乱答。后来把 context-mode 这个概念真正落到代码里,问题才算是根治。这篇文章把我的完整思路、代码实现和踩坑记录整理出来,希望能帮你少走弯路。
1. 先搞清楚:context-mode 到底在解决什么问题
1.1 上下文不只是“聊天记录”
很多开发者对上下文的理解还停留在“把历史消息拼起来发给大模型”。这个理解没有错,但不完整。上下文的核心是模型在当前时刻做出决策所需的全部信息,它包括对话历史,更包括系统约束、用户画像、外部工具返回的结果、当前任务目标,甚至包括某些已经被裁减掉但需要以摘要形式保留的“记忆”。
举个例子,假设你正在做一个客服机器人。用户问“我之前那个订单怎么还没发货”,模型需要知道的不只是用户刚发的这句话,还需要知道:订单号是多少、订单状态是什么、用户的历史投诉记录、当前客服的处理权限范围。这些信息不可能全部靠“聊过的内容”得到,很多是从数据库、订单系统、知识库实时拉取的。
context-mode 要解决的核心问题,就是如何用统一的方式去组织、裁剪、注入这些多源异构的信息。它不只是“聊天记录管理”,而是一套上下文生命周期管理方案:从信息采集、存储、加工、注入,到最后的清理和归档。
从这个角度看,context-mode 的最佳定位是上下文编排层,它位于应用逻辑和模型推理之间,专门负责“喂给模型什么”以及“用什么顺序、什么格式喂”这两个关键决策。
1.2 “模式”二字的含义:可切换、可组合、可降级
“mode”这个词很容易被人忽略,但它恰恰是这套方案的精髓。带模式,意味着不是一刀切地处理所有上下文,而是根据不同的会话阶段、业务场景、模型能力来动态切换策略。
常见的设计是把上下文处理分为三种模式:
- full mode:完整上下文模式,适合会话开场、需要精细化推理的场景,所有历史消息、结构化记忆全部注入。
- compact mode:压缩模式,当历史消息超过阈值时,对早期对话做摘要提取,只保留关键结论和未完成事项。
- minimal mode:最小上下文模式,只注入系统指令、当前用户输入和极少量必要记忆,适合用于轻量级任务,比如意图识别、关键信息抽取。
这种可切换的设计,带来的直接收益是成本可控。你可能已经注意到,大模型 API 的计费主要是 token 维度,一段长对话一旦翻到几万字,单次请求的费用就会肉眼可见地涨。压缩模式能把 token 消耗降低 50% 到 80%,而且质量不会明显下降。
另一个容易被忽略的好处是可降级。当模型请求超时或者预算受限时,应用可以临时从 full mode 降到 compact mode,保证服务不中断。这种优雅降级的特性,在生产环境中比想象中更重要。
2. 为什么需要 context-mode:三个致命痛点
2.1 token 预算与成本控制
先算一笔账。假设你做了一个基于 GPT-4o 的文档问答助手,平均每轮对话要注入 20 条历史消息,每条约 200 个 token,加上系统指令、用户当前输入、检索到的知识片段,单次请求轻轻松松超过 6000 token。如果用户每天产生 50 轮对话,一天就是 30 万 token。按当前市场价折算,这绝非一个小数目。
更麻烦的是,token 消耗会随着对话长度指数级恶化。如果你把 100 条历史消息全部塞进去,单次请求轻松突破两万 token,响应时间从一两秒涨到五六秒,而且模型在长上下文中的注意力分布会变得极其松散,关键信息被淹没在大量无关文本中。
context-mode 的价值在这里体现得非常直接。通过一个明确的压缩策略,把 100 条消息压成 500 token 的摘要,加上最近 5 条完整消息,总预算控制在 3000 token 以内,成本和响应速度都在可控范围。
2.2 上下文漂移:模型“忘记”了关键约束
上下文漂移是我在实际项目中遇到的最隐蔽的问题,也是最难排查的一个。它表现为:对话的前半段模型严格遵守了某些约束,比如“价格超过 300 元需要主管审批”,但聊到后面,模型完全忘记了这条规则,直接在回答中给出承诺。
原因在于模型对上下文的注意力不是均匀分布的。当上下文过长时,中后段的信息容易被稀释,尤其是本来就很靠前的系统约束,更容易被“挤”出有效注意力范围。
context-mode 应对这个问题的思路很有意思——它不只是压缩历史,还会强化关键约束的注入位置和频次。在我的实现里,系统级指令在每次请求时都放在最前面,并且在逻辑上重复出现两次:一次是纯文本形式,一次是 JSON 结构的形式。实测下来,这种双通道注入能显著减少约束遗忘的问题。
2.3 多场景切换下的状态管理
做过完整应用的人都知道,真实产品极少只有一种对话场景。一个助手可能要同时负责商品推荐、售后处理、订单查询、闲聊互动。每种场景需要的上下文结构天差地别。
没有上下文模式的管理,最常见的状态混乱就是:用户在问售后问题时,模型还记着之前的闲聊内容,把语气调成了轻松玩笑的状态;或者用户在多个任务之间来回切换,模型把上一个任务的中间状态带到了新任务中。
context-mode 给出了明确的边界:每个场景定义自己的上下文结构,切换场景时,旧场景的上下文被归档,新场景按自己的模式重新组装上下文。这就像给每个任务开了一个独立的“工作台”,彼此不串台。
3. 核心设计与实现:一个可落地的 context-mode 方案
3.1 分层上下文架构
在动手写代码前,我建议你先在脑子里建立分层上下文的框架。不要把所有信息一股脑塞进同一个列表,否则很难做针对性的裁剪。我自己的实现里,上下文被分成四层:
第一层是系统层(System Layer),包含角色设定、行为约束、输出格式要求、安全边界。这一层权重最高,不允许被任何机制压缩或裁剪。
第二层是工作层(Working Layer),包含当前任务目标、最近几轮对话、工具调用的中间结果。这一层是动态变化最剧烈的部分,也是上下文裁剪的主要对象。
第三层是记忆层(Memory Layer),包括长期记忆(用户偏好、历史结论)、短期记忆(当前会话的摘要、未完成事项)。记忆层通常以结构化文本或向量索引的形式存储,在需要时按相关性召回。
第四层是数据层(Data Layer),包含从外部系统实时拉取的数据,比如订单状态、库存信息、天气等。数据层的特点是时效性极强,每次请求都应重新拉取,不适合做缓存。
这四层在组装时是有优先级关系的,并不是简单拼接。我的做法是:系统层永远在最前面,接着是工作层中最近两轮完整对话,然后是记忆层中与当前问题高度相关的片段,最后是数据层中本次请求实时拉取的外部结果。
3.2 三种核心模式:full、compact、summary
我最终实现的 mode 切换逻辑,核心是围绕消息级与摘要级两个粒度做文章。
full mode 很好理解,就是把工作层的完整消息列表、记忆层的完整相关片段全部注入。它适合会话刚开始的阶段,或者正在处理关键业务操作的时候。
compact mode 是我日常用得最多的模式。它的策略是:最近 5 轮对话保留完整原文,更早的对话段调用摘要模型生成结论性摘要,摘要中保留动作、决定、待办这三类关键信息。比如用户询问某订单的退款进度,模型中段可能会出现这样一条摘要:用户询问订单 #1023 退款进度,客服已承诺 3-5 个工作日原路退回,当前状态:等待财务确认。这条摘要提供的信息足够模型做出后续判断,完全不需要原始对话里的每一句话。
summary mode 更进一步,只保留记忆层的结构化摘要,连最近几轮完整对话都不注入。它适用于非常简单明确的请求,比如“帮我查一下明天的天气”。这种请求本来就不需要多少对话背景,强行注入历史反而会给模型增加噪音。
3.3 关键代码:模式切换与上下文组装
我用的技术栈是 TypeScript + LangChain 生态,下面的代码是整个上下文模式最核心的组装逻辑,我用简化版展示。
// context-mode 核心类型定义 type ContextMode = 'full' | 'compact' | 'summary'; interface ContextStore { system: SystemMessage[]; working: ChatMessage[]; // 完整工作层消息 digest: ConversationDigest; // 压缩摘要 memory: MemoryItem[]; data: DataPayload; } interface AssembleOptions { mode: ContextMode; recentWindowSize: number; // 最近 N 轮保留完整原始消息 maxTokenBudget: number; // 总 token 预算,超限触发降级 query: string; // 当前用户输入 }组装函数是这个模块的心脏,它根据模式决定注入哪些层的信息,并且预估 token 消耗。
async function assembleContext( store: ContextStore, opts: AssembleOptions ): Promise<ContextMessage[]> { const messages: ContextMessage[] = []; // 系统层永远在最前面,任何模式下都不裁剪 messages.push(...store.system); if (opts.mode === 'full') { // 完整注入工作层全部消息 + 相关记忆 const memoryHits = await retrieveMemory(store.memory, opts.query, 5); messages.push(...store.working); messages.push(...memoryHits); } else if (opts.mode === 'compact') { // 只保留最近 N 轮完整消息,更早的用摘要替代 const recent = sliceRecent(store.working, opts.recentWindowSize); messages.push(...recent); messages.push({ role: 'system', content: `[对话历史摘要] ${store.digest.content}`, }); } else if (opts.mode === 'summary') { // 只注入摘要 + 少量相关记忆 messages.push({ role: 'system', content: `[对话摘要] ${store.digest.content}`, }); const memoryHits = await retrieveMemory(store.memory, opts.query, 2); messages.push(...memoryHits); } // 最后追加数据层 + 当前用户输入 messages.push(store.data.toSystemMessage()); messages.push({ role: 'user', content: opts.query, }); // 估算 token,如果超预算自动降级 const estimated = estimateTokens(messages); if (estimated > opts.maxTokenBudget && opts.mode !== 'summary') { return assembleContext(store, { ...opts, mode: opts.mode === 'full' ? 'compact' : 'summary', }); } return messages; }这段代码里有几个细节值得强调:
- 摘要并不是放在 history 的最后,而是放在最近消息之后,也就是说是“先最近消息,再摘要,再用户输入”的顺序。实测下来,这种顺序比“摘要放最前面”更好用,因为模型会优先关注紧挨着用户输入的最近内容。
- 降级递归调用是一个保险丝设计。即便你预估了 token 数,模型提供的 tokenizer 和实际情况仍有偏差,这个机制能从源头上杜绝“请求超限报错”这种低级故障。
- token 预估函数最好用模型官方 tokenizer 实现,不要用简单字符数除以 4 这种粗略估算,误差太大可能导致保险丝提前触发或者失效。
3.4 记忆持久化与检索增强
compact 和 summary 模式依赖一个质量可靠的摘要系统,而摘要系统依赖记忆的持久化。我在项目中用了一种很朴素的方案:JSON Lines 文件 + 向量检索。
具体做法是:每一轮对话结束后,把用户意图、关键实体、模型响应、未完成事项抽取出来,格式化成一个 JSON 对象,写入本地日志文件。同时,把这段文本交给 embedding 模型生成向量,存入本地向量数据库。当新请求进来时,用当前 query 的向量去检索 top-k 条最相关的记忆,拼接到上下文里。
这里有一条血泪教训:不要把所有的记忆都塞进向量库然后全量查询。如果记忆量很大,通过检索召回的片段可能与当前主题无关,反而污染上下文。我的做法是给记忆加上场景标签,在检索之前先按场景过滤,再执行相似度排序。这个简单的过滤步骤能让命中质量提升非常明显。
另外一个容易被忽略的点是记忆的时间衰减。半年以前的偏好如果和当前行为冲突,应该以当前行为为准。我在记忆检索的结果排序里,会把时间的衰减因子乘进去:
score = similarity * pow(0.95, days_since_last_active)这样最近活跃的记忆天然排在前面,而陈旧的记忆即使相似度不低,也会被挤到后面。实测下来,这个衰减因子对用户体验的提升是肉眼可见的——模型不再拿用户三个月前的旧偏好来回答今天的问题。
4. 实操记录:在真实项目里接入 context-mode
4.1 场景设定与参数选择
为了不让你觉得上面的内容全是概念推演,我把一个真实项目中的接入过程完整还原出来。
项目是一个企业内部的智能客服助手,处理员工关于 IT 设备报修、软件授权、报销流程等行政问题。最初版本是裸调用模型,用户反馈最集中的问题是“怎么聊着聊着就不记得前面说的了”,以及“有时候回答得特别啰嗦,明明一句话能说清楚的事”。
接入 context-mode 时,我做的第一件事是定义场景标签。这个项目有 5 个场景:设备报修、软件授权、报销咨询、进度查询、闲聊。每个场景在会话建立时有一个独立的 ContextStore 实例,互不干扰。
第二件事是确定参数。full mode 只在会话前 3 轮使用,超过 3 轮自动切换 compact。compact 模式的 recentWindowSize 设置为 5 轮,maxTokenBudget 设置为 4000。summary 模式只用于单轮查询类请求,比如查进度,系统识别到用户输入是查询意图时直接走 summary。
参数不是拍脑袋定的。我跑了 200 条真实历史的回归测试,把不同参数组合下的回答准确率和 token 消耗做了对比。最终选定的参数组合在准确率上只比全量注入低 1.8%,但 token 消耗下降了 63%,这个性价比完全可以接受。
4.2 上下文衰减系数与压缩阈值
在调整参数的环节里,最让我意外的发现是衰减系数对回答质量的影响。我原本把时间衰减因子设得很激进(0.85),结果发现模型频繁忽略用户在几天前表达过的明确偏好,比如“我之前说过不舒服的时候别推荐辣的”。把衰减因子调整到 0.95 之后,这个问题几乎消失。
由此得出一个经验:衰减系数的设置需要结合业务特性。如果用户的长期偏好很重要,不要用太激进的衰减;如果业务更新极快(比如促销活动规则),则应该用更小的衰减系数。没有通用的最佳值,关键是跑数据看结果,再反向调参。
压缩阈值的设置也有讲究。recentWindowSize 并不是越大越好,我试过把它调到 10 轮,结果 token 消耗明显上升,但回答准确率几乎没有变化。问题在于很多对话内容本身就无关紧要,比如“好的”“嗯”“谢谢,再见”之类的话,保留再多也不会提供有效信息。
更好的做法是在做压缩之前先过滤掉低价值消息。我在存入工作层时会给每一条消息打一个价值标签,标签的来源基于一个简单的规则引擎:如果消息包含关键实体、明确动作、业务关键词中的任意一项,标记为高价值;否则标记为低价值。压缩时,低价值消息直接被丢弃,不进入摘要也不进入最近窗口。这个过滤操作让 token 又降了 20% 左右。
4.3 配合函数调用的上下文注入
在很多真实的 AI 应用里,模型并不是只靠上下文里的文本就能完成任务的,它需要调用外部工具。context-mode 和工具调用的配合,是这次实战中花时间最多的一块。
最初我踩过一个很常见的坑:把工具返回结果的原始 JSON 直接拼接到上下文里,结果有两个问题,一是 JSON 很长,token 消耗大;二是模型经常被 JSON 里无关字段干扰,在回答里引用了一些跟用户问题完全无关的信息。
后来我在 context-mode 里加了一层工具结果再加工的逻辑。工具返回的 JSON 并不会被直接注入上下文,而是经过一个“提炼器”,把对当前用户问题最有用的关键字段提取出来,转成一句话描述。比如订单系统中返回的完整 JSON 可能长这样:
{ "order_id": "20250315001", "status": "shipped", "logistics_company": "SF", "tracking_number": "SF1234567890", "estimated_arrival": "2025-03-20", "warehouse_address": "..." }经过提炼器之后,注入上下文的只有一句话:订单 20250315001 已发货,顺丰单号 SF1234567890,预计 3 月 20 日送达。
工具结果注入的另一个关键是时间戳。有些工具的结果有很强的时效性,比如库存数量、价格、审批状态。我在数据层的每条数据后面都加了一条[数据更新于 2025-03-18 14:23]的标记,让模型在回答时能感知到信息的时效性,避免拿过期的数据做错误判断。
5. 常见问题与排查技巧实录
5.1 问题速查表
前前后后跑了两个多月,我把实际运维中遇到的高频问题整理成一张速查表,供你对照排查:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 对话前 5 轮表现好,后面准确率骤降 | 工作层消息过多,注意力被稀释 | 检查 token 预估日志,确认是否已触发 compact 降级 | 调低 compact 触发阈值,或加强摘要质量 |
| 模型频繁忽略系统约束 | 系统指令太靠后,或与用户消息间隔过长 | 查看组装后的完整上下文顺序 | 系统层重铸到开头,并考虑双通道注入 |
| 摘要越压越乱,回复逻辑断裂 | 摘要生成的 prompt 太简单,丢失了关键信息 | 抽查摘要内容,对比原始对话 | 调整摘要 prompt,要求提取动作、决定、待办三类信息 |
| 跨场景切换后,旧任务信息串入新任务 | ContextStore 未按场景隔离 | 检查会话变量绑定逻辑 | 每个场景独立 store 实例,切换时归档旧 store |
| 检索出的记忆与当前问题无关 | 未做场景过滤直接全量检索 | 打印检索命中记录 | 增加场景标签前置过滤,再加时间衰减因子 |
| 工具返回内容让模型产生幻觉 | 原始 JSON 直接注入 | 检查工具结果注入格式 | 增加提炼器,只注入关键字段的文本描述 |
| 同一轮对话 token 数不稳定 | 存在重复消息或未过滤低价值内容 | 统计消息条数和 token 分布 | 增加低价值消息过滤规则 |
5.2 几个独家避坑经验
第一,摘要不要只做一次,要滚动更新。我一开始的实现是每 20 轮对话做一次完整摘要,把前 20 轮压成一段总结。后来发现这样做有两个问题:一是对话超过 20 轮后,新产生的 20 轮还要重新摘要,此前生成的摘要无法复用;二是用户可能在第三轮讨论一个重要需求,到第 19 轮又提到这个需求,如果摘要太粗,这个关联关系就丢了。
我改成滚动摘要的实现:每次工作层消息超过阈值时,把最早的 5 轮原始消息和旧摘要拼接,生成新摘要,然后丢弃这 5 轮原始消息。这样摘要永远是最新状态,并且不会反复处理已经摘要过的内容。
滚动摘要确实采样了一段不短的开发时间,但效果很稳定——模型引用历史信息时,准确度比一次性摘要高很多。
第二,所有模式切换动作都要留日志。context-mode 在生产环境里是个黑盒,你很难判断某一轮对话它到底用了什么模式、为什么用了这个模式。如果不在代码里埋日志,出了问题基本只能靠猜。我的做法是每次组装完成后,把模式、token 预估值、消息层数、是否触发降级这四个关键字段打到日志里。这样复盘问题时,能快速定位是模式选择逻辑的问题,还是摘要质量问题,还是消息过滤的问题。
第三,context-mode 本身不要做太重。我很能理解想做一个完善框架的冲动,但记住它本质上是应用和模型之间的胶水层。如果你在上下文层里堆了太多业务逻辑,比如权限校验、数据清洗、格式转换,它就会变得难以维护,一改业务代码就要动上下文模块。正确的做法是:context-mode 只管上下文的选择、裁剪、格式化和注入,具体业务逻辑应该放在更上层的服务里。
6. 从 context-mode 到更深层的思考
代码层面能讲的差不多讲完了,最后聊两句“元认知”层面的体会。
我在接入 context-mode 的过程中反复确认过一个观点:大模型应用的性能瓶颈往往不在模型本身,而在喂给模型的信息质量。模型参数的差距可以通过换更大的模型弥补,但上下文里如果充满噪声、重复、过时、无关的信息,再强的模型也会被拖累。context-mode 做的事情,本质上是对信息的“提纯”——在模型推理之前,替它做一遍高质量的信息筛选。
关于信息筛选,我还有一个观察想分享。很多团队做上下文管理时,都会陷入一个误区:试图把“所有可能会用到”的信息都放进上下文里,以保证“模型什么都知道”。这种做法把 token 预算拉满,实际效果却不好,因为模型不是搜索引擎,不会在长文本中精确提取所有信息。context-mode 的设计哲学恰恰相反:敢于舍弃。明确判断哪些信息不需要出现在上下文里,比绞尽脑汁塞入更多信息更重要。
另一个值得思考的方向是:context-mode 的这些策略是否可以反向应用到模型训练或者微调阶段。比如,我们通过上下文摘要总结出的高价值信息,完全可以作为微调数据集的一部分,让模型在不需要显式注入大量历史的情况下,也能表现出对用户个性化偏好的理解。这个方向可以继续探索,它可能让 AI 应用的体验更进一步。
按照我现在的项目实践来看,context-mode 已经成了我搭建东西时的默认思维框架——不管是做客服机器人、文档问答还是 Copilot 类应用,我都会先问自己:这次的上下文该怎么分层、该怎么裁剪、该怎么归档。养成这种习惯之后,你可能会发现,以前那些“模型不聪明”的抱怨里,相当一部分其实是“上下文不给力”的锅。
最后分享一个目前在用的扩展玩法:把 context-mode 和用户行为序列打通。我在记忆层里记录的不只是对话内容,还包括用户在应用内的关键操作——点击了什么按钮、停留了多久、下载了什么文件。当用户再次发起对话时,这些行为数据作为数据层的一部分被注入上下文,模型的回答会明显更贴合用户当下的真实处境。这个方向还比较新,但我觉得它可能是继检索增强之后,AI 应用体验提升的下一个增长点。