1. 为什么需要 context-mode:从一次线上事故说起
我在做 AI 对话类应用时,第一次认真对待“上下文”这个词,是因为一次线上事故。当时一个客服机器人跑了一个多月,用户聊得越深,机器人回答越飘,到最后连用户的名字都记错了——后来查日志才发现,对话历史已经累计了几万字,模型能看到的上下文被旧内容塞满,真正有用的新信息反而挤不进去。那一刻我意识到,长对话里的 context 管理不是“要不要做”的问题,而是“怎么做好”的问题。
这个项目叫 context-mode,它本质上是给大语言模型应用做一套“上下文管理模式”的工程化方案。试想一下:一个对话框只能装下有限的内容,用户聊了五十轮,不可能把全部历史都丢给模型,那旧的信息怎么处理?新的信息怎么保留?哪些该丢、哪些该留、以什么形式留?这些东西如果靠写死逻辑硬扛,场景一变就崩。context-mode 要解决的就是这组问题——把上下文处理从“拍脑袋的字符串拼接”升级成“可配置、可观测、可切换的管理系统”。
我写这篇总结的背景是,我自己在几个不同项目里反复踩过同一个坑:一开始觉得上下文不就截断一下嘛,到头来做完发现要处理的是记忆、成本、响应速度、信息密度之间的平衡,牵一发动全身。这篇文章会从设计思路、核心实现、参数调优到排障过程完整拆一遍,适合正在做大模型应用、被长对话折磨过的开发者,也适合刚入门想理解上下文工程怎么落地的朋友。
2. 项目整体设计与思路拆解
2.1 上下文问题的本质是“资源竞争”
先说一个最直观的比喻。大语言模型的上下文窗口就像一张固定大小的桌面,你的工具和材料只能摆这么多,但一个会话里产生的信息是源源不断的,桌面必然越堆越满。你当然可以把旧材料一股脑扫进垃圾桶(截断),但那样做很可能把重要的约定、用户偏好、关键数据一起扔掉;你也可以把所有材料都摊开(不截断),但那样做很快就把桌面撑爆,模型处理速度也会急剧下降。
所以设计 context-mode 的第一步,不是急着写代码,而是明确一个问题:上下文资源要分配给谁?这个决策场景其实很多样。客服场景下,用户的历史工单信息往往比当轮寒暄更重要;写作助手场景下,用户风格偏好的优先级非常高;代码助手场景下,最近的修改痕迹和报错信息权重最大。不同场景的“资源倾向”完全不同。因此我在设计时没有走“一种策略打天下”的路线,而是定义了一套上下文模式的接口,每种模式对应一种资源分配策略,由调用方自行选择。
这个设计有一个直接的好处:上下文处理逻辑可以和业务逻辑解耦。业务方只需要声明“我用哪种模式”,而不需要关心底层是怎么压缩、怎么重组、怎么裁剪的。对于工程团队来说,模式的迭代升级可以独立进行,不会动不动就改动上层业务代码。
2.2 四种核心模式的定位与取舍
在 context-mode 项目里,我最初规划了四种模式,分别是滑动窗口模式、自动摘要模式、关键信息保留模式和分层记忆模式。这四种模式不是拍脑袋随便定的,它们对应了四种很常见的资源分配策略。
滑动窗口模式最简单直接:只保留最近 N 轮对话,更早的统统丢弃。它的优点是成本低、响应快、实现非常容易,适合闲聊类或步骤型任务——用户问一步做一步,前面的交互确实不关键。缺点是模型对“很久之前说过的事”完全没有记忆,遇到需要回溯的对话就会失忆。
自动摘要模式则是把旧对话实时“压干”,提取成一段摘要放进新一轮请求里。这条策略对长故事型对话非常有效,用户讲了二十分钟的诉求,你压成两百字的要点,模型既不失忆,又不会背太重的包袱。代价是摘要本身有损耗,压缩过程也可能因为模型理解偏差造成信息失真。
关键信息保留模式的做法,是设定一个信息提取器,从历史对话中抓出“不可丢失”的字段——比如用户的名字、偏好、已确认的方案、重要承诺,然后把它们单独存储,随每轮请求注入。它不追求全局理解,只保证核心事实不丢,计算代价低,非常稳定。
分层记忆模式是最重的:近几轮用全文、稍远用摘要、再远只保留结构化信息,形成多层次的记忆结构。它最接近人类交流的真实记忆方式,效果也最好,但实现复杂度和 token 开销都比较高。
这四种模式我都在真实项目中跑过,一句话总结:没有绝对优劣,匹配场景才是关键。下面这个表格是我在实际选型时的参考标准:
| 模式 | 适合场景 | 记忆强度 | 成本/复杂度 | 典型局限 |
|---|---|---|---|---|
| 滑动窗口 | 问答、工具调用、轻量交互 | 低 | 极低 | 长对话容易失忆 |
| 自动摘要 | 访谈、写作、故事型交互 | 中高 | 中 | 摘要质量依赖模型能力 |
| 关键信息保留 | 客服、预约、电商 | 高(局部) | 低 | 不保留全局语义 |
| 分层记忆 | 复杂项目助手、深度陪伴 | 高 | 高 | 实现复杂,需调参 |
2.3 为什么不能只做“暴力截断”
很多人第一次做上下文管理时会问:直接把 messages 数组从头部裁剪掉不就行了吗?我刚开始也这么干过,实际跑下来问题很明显——用户在两小时前提过“我是学生,价格敏感”,这些信息早就被推进了窗口之外,等客服机器人推荐贵价方案时,用户体验会非常糟糕。
暴力截断的问题在于它不区分信息的价值密度。对话历史里通常混着三种信息:寒暄和流程话术、场景状态、长期事实。前一种丢了无所谓,后两种丢了就影响核心体验。截断是“一刀切”,它没有能力做这种区分,本质上是对记忆资源的粗暴浪费。
context-mode 的核心理念就是代替这种钝操作:通过模式化处理,让信息在进入模型之前,先完成一次“记忆资源的整理”。该丢掉寒暄,该保留事实,该压缩长段叙述,每一步都有策略可依。这也是为什么这套设计要在工程上可配置——你需要根据产品定位去定义什么信息是“资产”,什么信息是“噪音”。
3. 核心实现:数据结构与调度逻辑
3.1 上下文对象的三层结构
动手写代码之前,最关键的是先把上下文的数据结构理清楚。我见过很多项目直接用一个大数组存对话,然后在函数里到处做前缀截断和后缀拼接,维护起来让人头皮发麻。在 context-mode 里,我把上下文抽象成三层结构:原始消息层、组织层、注入层。
原始消息层就是对话记录的数组,每一条包含 role、content、timestamp、tokens 等基础字段。组织层则是模式管理器的核心,负责把原始消息根据当前模式转换成“可发送给模型的内容”。注入层是组织层的输出,通常是一个字符串或结构化消息数组,再附加一些系统指令和外部知识。这三层各司其职,上层代码永远只跟组织层打交道,原始数据不会被随意篡改。
我举一个具体例子。在自动摘要模式下,组织层做的事情是:遍历原始消息,找到标记为”已处理“的旧消息,调用摘要接口生成内容,然后替换原始消息为一条 role=summary 的合成消息。整个过程对业务代码透明,你不需要关心摘要什么时候触发、合并进哪个位置。
3.2 Token 计算:一切调度决策的基础
上下文模式的背后有一台“度量衡”,就是 token 计数。所有模式决策,比如是否触发摘要、窗口保留多少轮、摘要需要压缩到什么程度,都取决于当前请求的总 token 预算。token 的计算口径不统一,后面所有决策都会失真。
我强烈建议不要自己手写简单的字符除以 4 这种估算方式,不同模型的分词器差异很大。我当时直接用对应模型的 tokenizer 进行计算,并把它封装成一个独立的模块,做一层缓存。因为单轮对话里同一段文本可能被反复计数,缓存能把这部分开销压到可以忽略不计。
token 计算还牵涉一个很多人注意不到的点:模型对 messages 数组中不同角色的内容有不同计费方式。当前大多数模型的 token 统计是把 system、user、assistant 内容都算进去的。所以设计上下文压缩时,不仅要看总 token 数量,还要看各部分结构能不能被安全替换或摘除。
3.3 模式管理器的核心调度逻辑
模式管理器的调度我采用了“策略模式+观察者模式”的混合架构。策略模式负责让四种模式可插拔替换,观察者模式负责在上下文状态变化时通知各个组件联动。
调度逻辑的核心是一个函数:decide。它接收当前的请求上下文和 token 预算,输出一个处理方案。这个函数内部会依次检查几个问题:当前总 token 是否超过窗口红线?如果超过,当前模式是什么?该模式下的压缩动作是否已经被执行过?如果执行过,是否需要降级到更强的压缩手段?这套链路的执行顺序是固定的,但每一环的策略可由模式自定义。
来看一个简化的调度伪代码:
def decide(mode, messages, budget): current_tokens = sum(msg.tokens for msg in messages) if current_tokens <= budget.low_threshold: return {"action": "pass_through", "reason": "below_threshold"} if mode == "sliding_window": return plan_sliding_window(messages, budget) if mode == "auto_summary": if summary_was_done(messages): return plan_key_info_preserve(messages, budget) return plan_auto_summary(messages, budget) if mode == "key_info": return plan_key_info_preserve(messages, budget) if mode == "hierarchical": return plan_hierarchical(messages, budget) raise UnknownModeError(mode)这段代码看起来简单,但它定义了整个系统的骨架。后面每种策略只需实现自己的 plan 函数,返回的结构统一为 action + payload,上层拿到之后直接执行即可。这种设计让后续加新模式变成了纯粹的“加分支”,而不是重构。
3.4 滑动窗口模式:不是简单丢掉头部
滑动窗口听起来容易,真实现起来有几个让人抓狂的细节。
第一个是窗口大小的度量单位。用“保留最近 N 轮”作为单位非常不精确,因为有的轮次一条消息就几百 token,有的轮次只有几个字。我最后改成按 token 上限保留:窗口 = 满足总 token 预算的最新连续消息序列。这样窗口的实际轮数会动态变化,但预算始终稳定。
第二个是“丢消息不丢状态”。很多长对话里,用户在第十轮确认过一个时间,第十五轮又提到同一个时间,这时直接丢掉第十轮是安全的,因为信息已经在后面复现了。但如果用户在第十轮给了身份证号,后面全程再也没提过,那就绝对不能丢。滑动窗口模式本身无法识别这部分内容,所以我给它配了一条辅助规则:关键信息提取器会先从即将被丢掉的窗口段里,抓出高价值字段转移到长期存储里。这样滑动窗口模式就不再是“失忆模式”,而成了一个带保险开关的轻量模式。
3.5 自动摘要模式:递归压缩的工程细节
摘要模式最核心的痛点是:不是所有对话都适合一次摘要。一次长访谈可能有一万 token 的历史,让模型一次读完全部再做摘要,模型在超长输入下的摘要质量会下降,速度也慢。实际工程里的做法是递归摘要——先把最早的一段压缩成摘要A,继续读下一段,把“摘要A + 新段落”再压成摘要B,以此类推。
这个“摘要链”听起来聪明,但实现时要注意一个关键陷阱:摘要的长度上限不能固定不变。因为摘要经过每一轮融合后内容会越来越稠密,如果上限写死,新信息会被不断挤掉,最后摘要就慢慢退化成一句空话。我采用的做法是给摘要链设一个“摘要预算比例”——压缩后的摘要 token 数不超过原始段落 token 数的五分之一,同时不超过当前模型摘要 prompt 的可用窗口。这样递归压缩时,摘要能够保持一定信息饱和度。
递归触发时机,我用了双阈值:绝对阈值和相对增长阈值。绝对阈值是总 token 超过某个数值就触发;相对增长阈值是连续三轮增量超过一定比例就触发。双阈值可以避免极短对话的误触发,又能对快速膨胀的对话做出及时响应。
4. 实操过程与关键参数调优
4.1 一套可复用的搭建过程
如果你想把 context-mode 这套思路落地到自己的项目里,我建议按下面步骤操作,每一步都经过实际项目检验。
第一步,确定上下文窗口预算。你需要知道自己使用模型的上下文窗口上限。不要贪多,我建议把实际发送给模型的总 token 控制在窗口上限的 60% 到 70%。比如窗口是 128K,业务发送量就控制在 80K 到 90K 左右,留出模型生成回答的余量,也给系统 prompt 和外部检索内容留出缓冲区。
第二步,确定业务红线字段。和产品经理或需求方一起列出“无论如何不能丢”的信息。这一步经常被跳过,但恰恰是最重要的。客服场景的红线是订单号、用户编号、投诉进度;写作场景的红线是主题、风格、禁止事项。这些字段会变成关键信息保留模式里的抽取模板。
第三步,分场景配模式。面向 C 端闲聊的产品,一二轮交互低频长对话,用滑动窗口模式就够;面向企业知识库问答的,用关键信息保留;面向深度访谈、写作辅助的,用自动摘要或分层记忆。模式参数统一放配置中心,允许运营按渠道调整。
第四步,上线可观测。只有当你能够观察到每条消息处理前后的 token 变化、被压缩了哪些内容、模式触发了多少次摘要,你才能迭代优化。我给 context-mode 加了一个 DEBUG 输出层:每个请求返回处理前后的 messages 对比和动作日志。这在实际排障时帮了大忙。
4.2 预算分配的实战建议
token 预算分配是我调参调得最多的部分。我总结出一个经验公式,适用于大多数业务场景:系统指令约占 8%,外部知识检索内容约占 15%,历史对话约占 37%,当前轮次和模型回答预留约 40%。这个比例不是金科玉律,但当你没有头绪时,它是一个很好的起点。
实际操作中,我遇到过一种情况:外部知识内容非常长,比如企业知识库里检索出了三篇长文,直接把历史对话挤到了边缘。这种情况下有两个处理思路:一个是对检索内容做相关段落抽取,而不是整篇塞入;另一个是给历史对话启用摘要模式,让历史从全文变成一个紧凑摘要,把空间让给外部知识。两个思路可以同时用,效果会更明显。
另外要留意的是,不同模型的指令遵循能力不同。某些模型对 system 指令严重超长时会表现得迟钝,这时候要把系统指令里特别长的示例内容转移到独立的 few-shot 消息里,用结构化的方式分段给模型。
4.3 场景实测:三个项目的模式选择
这里分享三个真实项目的模式选择,你可以直接抄作业。
第一个项目是电商客服机器人,用户多轮询问订单状态、退换货流程。我选的是“关键信息保留 + 滑动窗口”的组合。提取器固定抽取订单号、用户诉求类型、最新物流状态,其余寒暄一律不进留存区。实测效果:单轮响应延迟从 2.8 秒降到 1.9 秒,用户满意度反而上升,因为模型不会再被大量无关历史带偏。
第二个项目是长篇小说写作助手,用户跟 AI 连续对话修改文稿,一轮对话动辄几千字。我选自动摘要模式,每 4000 token 触发一次递归摘要。初版摘要上限设为 800 token,实测发现用户早期的风格设定会在摘要链中慢慢淡化,后来我把摘要提示词里明确加入了“风格偏好、主人公关系、关键情节承诺”三个必留项,问题就解决了。
第三个项目是代码辅助工具,开发者经常贴一段代码让模型改,然后贴下一段。这种会话上下文跳跃很快,用滑动窗口反而好,但窗口粒度按 token 而不是轮数,只留最近 6000 token。实测发现非常稳,因为代码任务中“当前贴的代码”是绝对核心,历史贴过的代码被覆盖后,价值已经不大。
4.4 成本与性能的平衡观测
上下文管理模式直接决定了 API 调用成本。我在项目中做了一个简单的成本观测面板:每个请求记录输入 token 数、模式、压缩动作,再乘以模型单价,汇总成日成本曲线。这个面板让一个被忽略的事实浮出水面——启用自动摘要模式后,虽然每次调用的 token 变少了,但摘要本身也是一次额外的模型调用,短对话太多时会反而增加总成本。
我最后得出的结论是:上下文压缩不是越激进越好。对于平均会话长度低于 10 轮的应用,滑动窗口模式总成本最低;对于平均会话长度超过 20 轮的应用,自动摘要模式的综合成本优势就开始凸显。这个分界点在不同模型单价下有差异,但经验值可以拿来参考。
性能方面也有一个容易被忽略的瓶颈:如果摘要或提取动作是串行调用的,每当触发压缩,用户请求的响应时间会多出一次甚至两次模型调用。我是用“异步预压缩”解决的:上一轮对话返回后,后台立即启动对当前对话的压缩预处理,而不是等下一轮用户提问才压缩。这样用户几乎感知不到压缩耗时。
5. 常见问题与排查技巧实录
5.1 摘要把关键信息压没了
这是摘要模式最常遇到的坑。表现是用户上一轮亲口说的一个要求,下一轮模型就忘光了,日志查出来发现那条要求被摘要得面目全非。
排查思路有两个方向。先看摘要 prompt 是否明确指定了必留字段。很多初版摘要就是一句“把以下对话总结一下”,模型自由发挥的空间太大,必然丢失业务关键项。我后来把摘要 prompt 改成“必须包含以下字段:用户身份信息、明确指令、拒绝过的方案、已确认的时间地点”,效果立竿见影。
第二个方向是检查摘要链的层级数。如果超过三层,底层的细节被反复压榨后几乎不存在了。这种情况下需要调整触发阈值,让摘要触发得更早,而不是等到对话积累到巨量才一口气压缩。宁可多触发几次小摘要,也不要一次压缩一个巨无霸会话。
5.2 模式切换时状态“串味”
我在一个项目里给同一个会话配了模式自动切换:短对话走滑动窗口,变长了自动切摘要。结果出现了一个诡异问题——切完模式后,模型突然把已经“忘了”的老信息记起来了,但记的是错的。
排查发现是模式切换时,摘要模式把旧消息压成 summary 消息,同时老消息被标记为已处理并移除。但此时关键信息保留器里存着的旧数据没有同步更新,导致模型既看到 summary 又看到旧结构数据,两套来源对同一件事说法不一致,模型自然混乱。
解决方式是在模式切换之前做一次状态一致性校验:清空旧模式的中间缓存,重新从原始消息层生成目标模式的完整状态。不要试图做增量迁移——模式与模式之间的状态结构差异太大,增量迁移的坑会比收益多得多。
5.3 Token 计算不一致造成预算失真
有些时候你看到总 token 已经压到预算以内,但发出去请求还是爆了窗口。原因是 models 那边的 token 计算可能和本地运行时计算不一致。尤其是使用 OpenAI 这类 API 时,对方在 messages 数组里附加的一些 invisible tokens 是不在官方文档里的。
我做了两个措施:第一,用固定样本集做本地 tokenizer 与线上 API 返回 usage 字段的对账,记录下来差异比例;第二,在预算上再做一层保险系数,我选择 0.85,也就是本地算出来总 token 不超过窗口上限的 85% 才允许发送。这个保险系数几乎消灭了线上因 token 超限导致的 400 错误。
5.4 上下文被“看不见的 system 指令”污染
最后说一个非常隐蔽的问题。某些框架或中间件会自动往 system 里塞内容,比如安全审核指令、工具调用说明,这些内容在代码里看不到,但会稳定占据 token 预算。我在项目里遇到过 system 指令一夜间从 500 token 涨到 4500 token 的情况,原因是上游框架升级默认注入了一整份工具说明文档。
解决方案是接入一个 system 指令审计任务,定时把每条系统指令的 token 占用和内容来源打印出来。这样你可以发现那些不在你代码里的额外指令,并从源头清理。这个技巧在我后来每一个大模型项目里都用上了,非常值得养成习惯。
我在实际维护 context-mode 项目时最深的一个体会是:上下文管理不是一个一次性开发任务,而是一个需要持续调参、持续观测的系统工程。所谓模式,说到底是你对“模型应该记住什么、忘掉什么”的表达方式;而模式切换的核心依据,永远来自你对业务场景的真实理解,而不是某个算法能够自动替代的。最后再分享一个小技巧:给你的模式管理器加一个“手动覆盖”入口,当运营或用户明确反馈记忆出错时,能即时切换模式定位问题,这个后门在关键时刻比任何自动策略都管用。