
我最早注意到 context-mode 这个词频繁出现在热搜和各路AI产品更新日志里是在一次被线上问题逼到加班的时候。我的AI客服机器人聊到第20轮就开始“失忆”——用户第3轮明确说要用顺丰发货第20轮模型却一本正经推荐普通快递甚至把用户刚确认过的收货地址都记串了。我一开始以为是提示词写得不行后来把每次请求的 payload 原样打出来看问题一下子就清楚了上下文窗口早被早期闲聊、重复的系统指令和一长串工具返回JSON占满了真正关键的信息排在后面模型根本“看”不到。这就是 context-mode 要解决的问题。它不是某个固定的开源项目名而是一类上下文管理模式的统称核心就三件事什么时候压缩、压缩掉什么、怎么重建。这篇文章我会把自己在真实项目里设计的 context-mode 服务完整拆开讲包括底层机制、可运行的代码、实测对比数据、调参经验以及哪些场景根本不该用它。适合正在做AI客服、Agent、个人助理这类对话产品的朋友参考。1. 先搞清楚context-mode 到底在解决什么1.1 大模型是“无状态”的记忆全靠你每次塞进去的tokens很多人第一次做对话产品时会下意识觉得模型“记得”以前的对话。其实不是。你调用一次接口就是一次全新的推理模型手里只有你这次传过去的那一串文本。所谓“多轮对话”不过是前端把历史消息一次次重新发给模型模型靠这些文本“假装”有记忆。这个机制跟人的短时记忆很像容量有限而且非常容易把注意力放在最靠后的内容上。心理学上有个类似现象叫“近因效应”在LLM里体现得更极端——几千条消息里排在队伍末尾、靠近新问题的那几条对最终回答的影响最大排在前面、尤其是被淹没在大量无关文本里的信息模型基本是“视而不见”的。所以当你面对“模型忘了用户说过的话”这个问题时第一反应不该是调整提示词而是审视你每次到底往上下文里塞了什么。塞100条历史并不等于它记住了100条多数时候只是白白占窗口、稀释注意力。1.2 窗口在变大但“上下文管理”的需求反而更迫切现在主流模型的上下文窗口从几年前的4K、8K卷到了128K、200K甚至更大。有人会觉得窗口这么大了直接全量丢进去不就行了实测下来不是这么回事。一是成本。把全部历史每次都发给模型token消耗是线性增加而大多数商用API是按token计费的一个高频客服场景50轮之后每次请求的输入token可能已经是几千甚至上万积少成多非常可观。二是质量。上下文越长模型对中段信息的注意力衰减越明显。我自己做过对比同样一段关键用户信息放在第5轮和第90轮模型对它的“记忆准确率”差一大截。长窗口是“能用”但不等于“用得好”。三是延迟。输入token越多首字返回越慢给用户的体感就是“卡”。这三个问题叠加起来就解释了为什么 context-mode 这种显式管理上下文的模式最近会变成热搜词——各家产品都在强调自己“更懂上下文”本质上是在比拼上下文管理策略。1.3 context-mode 不是某个项目而是一套通用模式去搜索引擎看 context-mode 的热搜词关联你会发现它被贴在各种地方浏览器扩展、Markdown编辑器、AI聊天工具、编程助手……每个产品里的具体含义都略微不同但核心思路一致把“隐式的上下文拼接”变成“显式的上下文治理”。这句话怎么理解普通做法是每次请求前机械地把所有历史拼起来不做任何取舍context-mode 的做法则是先对上下文做体检哪些是必须保留的高权重信息如系统指令、用户核心偏好哪些是可以压缩的过期细节如闲聊、太早的寒暄哪些可以直接丢弃如一次性的工具返回结果然后按规则重建出一个更精简、更聚焦的payload再发给模型。所以与其到处找“context-mode 的开源库”不如理解这层模式在自己的项目里落地一套。后面我会按这个思路展开一个可运行的实现。2. 底层机制四段式上下文结构和三种压缩策略2.1 先把你每次发出的上下文拆成四段我在排查上下文问题时第一步永远是把payload拆开看。一个典型的对话请求上下文基本可以分成四部分组成部分内容示例token占比建议可压缩性系统指令角色设定、行为约束、输出格式控制在总量10%-15%以内几乎不可压缩对话历史用户和AI的多轮消息视场景而定最容易膨胀高检索片段从知识库/文档中临时召回的内容按需注入用完即弃中工具结果调API、查数据库、函数返回的原始输出尽量压成结论非常高很多上下文爆炸的问题根源在于第四类。我见过一个实际案例天气API一次返回一两百行JSON订单查询一次返回几十个字段这类结果本来只对“当前这一轮”有用却被原样堆进历史永远占着窗口。所以我在设计 context-mode 时第一原则就是“区分一次性信息和持续性信息”一次性的工具结果、临时检索片段不进长期历史或者只保留一行结论持续性的用户偏好、已确认事实、未完成任务才值得花token保存。2.2 管理上下文的第一步把token数清楚还不会估算token就去压缩上下文就像不知道钱包里有多少钱就办年卡早晚出问题。这里给个简单的估算锚点用英文大约是“1 token ≈ 0.75个单词”中文大约是“1个汉字 ≈ 0.6至1.5个token”具体看分词器。实际开发里别靠猜直接用分词库。以OpenAI系模型为例tiktoken 是最常用的统计工具import tiktoken enc tiktoken.encoding_for_model(gpt-4o) print(len(enc.encode(你好我想查询上个月的订单记录。)))其他平台也都有等价的分词工具比如 Anthropic 系的 claude_token_counter、开源模型的 tokenizer 库。有了 token 计数下一步是定义两个关键数值窗口上限window_limit模型支持的上下文总长比如128K触发阈值trigger_threshold超过这个值就启动压缩建议在窗口上限的70%-85%。为什么不能等到100%才压缩因为每次请求还要给模型的回复留输出token你把输入塞满输出直接报错或截断。我习惯的做法是 max_context_tokens 模型上限的0.8也就是128K的窗口按100K左右来管理剩下28K给输出和临时注入内容留余量。每次请求都可以算一个“当前占用率”占用率 (系统指令 历史摘要 原始历史 本次新增输入) / max_context_tokens超过阈值就触发压缩这是 context-mode 的主循环。记住压缩是预防性的不是求救性的。2.3 三种压缩策略怎么选常见的压缩策略有三类我理解它们的优劣策略做法优点缺点适合场景滑动窗口只保留最近N条消息实现最简单、零成本早期关键信息会丢失短会话、寒暄类结构化摘要把旧历史用LLM浓缩成要点token省得多关键信息保留好有压缩成本、可能失真长会话、客服、Agent相关性召回按向量或关键词从历史中捞出与当前问题相关的内容精准、省token需要检索基础设施信息密集、问答类这三者不是互斥的。成熟一点的 context-mode 通常是滑动窗口打底保留最近10至20条完整消息保证语气连贯结构化摘要管更早的内容定期把旧段落浓缩成要点相关性召回作为补充用户问到某个很久以前讨论过的话题按需求去翻历史仓库。我在自己的实现里就同时用了前两者召回只在特定场景下接上向量库。3. 动手实现一个轻量级 context-mode 服务3.1 架构就三个模块别设计复杂了我第一次做这个功能时差点给它加一堆配置项、数据库和后台管理界面后来发现核心只需要三个模块存储模块把每轮消息记下来计算并记录token数保留原文。压缩模块在触发阈值时把最旧的一批消息交给摘要器生成结构化摘要替换掉原文。构建模块每次请求前把 system指令 历史摘要 最近N条消息 本次输入拼成最终payload。设计上有一条原则我非常坚持压缩前的原文必须先落到本地日志或数据库再用摘要替换。因为LLM摘要不可能100%保真出问题的时候你必须能在后端看到“压缩之前长什么样”。这个原则救过我很多次。3.2 核心代码一个勉强能上线的 ContextMode 类下面这段代码我用标准OpenAI兼容协议写其他平台把 base_url 和 model 换掉就能用。为了聚焦核心逻辑我删掉了一些边界处理但保留了最关键的部分。import os import time from dataclasses import dataclass from typing import List, Dict import requests import tiktoken dataclass class Message: role: str content: str tokens: int 0 ts: float 0.0 def llm_summarize(text: str, model: str gpt-4o-mini) - str: 调用LLM把一段对话历史压成要点。生产环境请加上重试和超时。 url https://api.openai.com/v1/chat/completions headers { Authorization: fBearer {os.getenv(OPENAI_API_KEY)}, Content-Type: application/json, } payload { model: model, messages: [ { role: system, content: ( 你是对话摘要引擎。把用户提供的对话压缩成要点 必须保留用户的明确偏好、已确认的事实、未完成的任务、涉及的具体数字。 禁止改写事实禁止添加原文没有的信息。控制在300个token以内。 ), }, {role: user, content: text}, ], temperature: 0.2, max_tokens: 300, } resp requests.post(url, headersheaders, jsonpayload, timeout30) resp.raise_for_status() return resp.json()[choices][0][message][content] class ContextMode: def __init__( self, model: str gpt-4o, max_context_tokens: int 8000, trigger_ratio: float 0.85, keep_recent: int 12, incremental_batch: int 4, ): self.model model self.max_context_tokens max_context_tokens self.trigger_tokens int(max_context_tokens * trigger_ratio) self.keep_recent keep_recent self.incremental_batch incremental_batch self.history: List[Message] [] self.summary: str self.summary_tokens: int 0 self._enc tiktoken.encoding_for_model(model) def _count(self, text: str) - int: return len(self._enc.encode(text)) def add_message(self, role: str, content: str) - None: msg Message(rolerole, contentcontent, tokensself._count(content), tstime.time()) self.history.append(msg) def _system_prompt(self, base_prompt: str) - str: if not self.summary: return base_prompt return f{base_prompt}\n\n[历史摘要]\n{self.summary} def build_payload(self, base_prompt: str, user_input: str) - List[Dict[str, str]]: # 先确保不超阈值反复压缩直到总长降下来 while True: base_tokens self._count(base_prompt) hist_tokens sum(m.tokens for m in self.history) new_tokens self._count(user_input) total base_tokens self.summary_tokens hist_tokens new_tokens if total self.trigger_tokens or len(self.history) self.keep_recent: break self._compact() # 增量压缩如果历史已经偏长顺手把最旧的一小批并入摘要 if len(self.history) self.keep_recent self.incremental_batch: self._incremental_summary() payload [ {role: system, content: self._system_prompt(base_prompt)}, ] for msg in self.history[-self.keep_recent:]: payload.append({role: msg.role, content: msg.content}) payload.append({role: user, content: user_input}) return payload def _compact(self): 把保留区之外的历史一次性压进摘要。 overflow self.history[:-self.keep_recent] if not overflow: return raw \n.join(f{m.role}: {m.content} for m in overflow) new_summary llm_summarize(raw, modelself.model) self.summary self._merge_summary(self.summary, new_summary) self.summary_tokens self._count(self.summary) # 压缩前务必落盘原文这里用日志占位实际项目写文件或数据库 print({event: compact, dropped: len(overflow), raw: raw}) self.history self.history[-self.keep_recent:] def _incremental_summary(self): 把最旧的一小批消息并入摘要防止一次性压缩幅度太大。 old self.history[:self.incremental_batch] raw \n.join(f{m.role}: {m.content} for m in old) new_summary llm_summarize(raw, modelself.model) self.summary self._merge_summary(self.summary, new_summary) self.summary_tokens self._count(self.summary) self.history self.history[self.incremental_batch:] staticmethod def _merge_summary(old: str, new: str) - str: if not old: return new return f{old}\n{new}简单说明用法cm ContextMode(modelgpt-4o, max_context_tokens8000) cm.add_message(user, 以后发货都用顺丰地址是杭州市西湖区XX路1号) cm.add_message(assistant, 好的已记录顺丰发货收货地址是杭州市西湖区XX路1号) # 中间又聊了30轮... # 第31轮用户提问时自动进入 context-mode 逻辑 payload cm.build_payload( base_prompt你是客服助理回答要简洁、口语化。, user_input帮我查一下上次说的那个订单到哪了, )这段代码的核心思路是“双轨制”最近的对话保留原始消息保证模型能看到完整的语气和细节更早的对话沉淀成摘要保证关键事实不丢又不让token失控。你可以根据模型能力调整 keep_recent模型越强保留量就可以越少因为它能从摘要里提取更多信息。3.3 真正接入生产环境前补上这几个细节跑通上面的代码只完成了30%接生产环境还有几件事得做。一是并发保护。我那版早期代码有一个真实事故两个用户请求同时触发 _compact()各自拿着同一份 history 压缩结果把对方的压缩结果覆盖了。修复方案很简单——给 ContextMode 实例加一把线程锁或把压缩逻辑扔进单线程的任务队列。二是适配不同API。现代模型API大多兼容OpenAI格式但 base_url、模型名、鉴权方式各不相同。封装一层 provider 配置就行PROVIDERS { openai: {base_url: https://api.openai.com/v1, model: gpt-4o}, azure: {base_url: https://your-resource.openai.azure.com/v1, model: your-deployment}, deepseek: {base_url: https://api.deepseek.com/v1, model: deepseek-chat}, }三是摘要故障降级。LLM摘要接口也可能超时或报错这时候不能把对话卡死。我的做法是摘要失败时直接把最旧的消息丢弃到 keep_recent 范围内并打一条警告日志保证请求能继续。摘要优化是不可或缺的但不是不可绕过的依赖。4. 实测数据与调参踩坑记录4.1 三组实测对比token曲线和质量分的取舍为了让调参有依据我拿一个200轮的客服机器人语料做了对比实验。语料内容包括用户下单、修改地址、询问物流、售后申请等常见场景。三种方案分别是全量直出每次都把全部历史拼进payload不做任何处理仅滑动窗口只保留最近15轮历史更早的丢弃context-mode本文方案最近12轮保留原文 更早内容结构化摘要。结果如下单轮输入token按实际请求统计质量为5人评分平均方案10轮累计输入token50轮累计输入token100轮累计输入token质量分5分制全量直出约1.8万约9.0万约18万5.0仅滑动窗口约0.9万约1.4万约1.5万3.2context-mode约0.9万约2.1万约4.0万4.6三个关键结论token成本上context-mode 相比全量直出在100轮时省了约78%相比滑动窗口只多了一倍出头但质量分却从3.2拉到4.6。质量分上全量直出虽然拿了5.0但实际上随着轮数增加评分方差也在变大因为模型被无关历史干扰偶尔会答非所问。延迟上50轮时全量直出的首字延迟大约4.8秒context-mode 稳定在1.5秒左右。这个体感差异在用户侧几乎是“能忍”和“不能忍”的区别。这个数据让我彻底确定了方案宁可花一点摘要成本也要保留早期关键信息而不是粗暴丢弃。4.2 踩过的坑一摘要漂移把事实改写了第一个坑出现在第60轮左右。用户问“我之前留的地址还帮我记着吗”模型回答出了旧地址。我去翻 payload发现历史摘要里写着“收货地址杭州市西湖区XX路1号”但原文明明是“滨江区XX路2号”。摘要器在压缩时把相近的两个地址搞混了。定位思路是三步先看摘要写的是什么再翻压缩前的落盘日志找原文最后确认摘要器确实改写了事实。修复方案有三层摘要提示词里必须写明“禁止改写数字、地址、姓名等事实信息”temperature降到0.2以下对关键实体地址、手机号、金额单独走规则抽取不在摘要里靠模型二次转述。4.3 踩过的坑二摘要膨胀反噬系统指令第二个坑是摘要越滚越长。增量摘要做了20轮之后历史摘要本身就有上千token每次请求都完整带进 system 段甚至把真正的系统指令挤得比例失衡。更尴尬的是摘要里包含大量“第X轮用户说……”这类过程描述价值极低。修复是给摘要设硬上限。我的做法是summary_tokens 超过600时再对摘要本身做一次压缩把旧的摘要和新的摘要合并提炼成更精简的版本。同时修改增量摘要的提示词要求“按主题归类不要按轮次罗列”。4.4 踩过的坑三工具日志污染历史第三个坑来自我自己设计的Agent循环。Agent调天气API、订单API后把原始JSON直接写进history几轮下来窗口被工具输出占了大半真正的对话反而少得可怜。这个坑的根源是我把“工具结果”和“对话消息”混在同一个数组里。修复很简单给消息增加类型字段把 tool_result 单列。工具结果默认不进长期历史只保留一行“查询订单成功共3条记录最新一条状态为运输中”。真到了需要展示原始结果给模型的轮次在用的时候单独拼进payload用完即弃。4.5 参数经验值表以下是我在多轮调整后觉得比较稳妥的参数起点直接抄作业可以少走弯路参数推荐值说明max_context_tokens模型上限的70%-85%给输出和临时内容留余量trigger_ratio0.80-0.85略保守宁可早压缩也不爆窗keep_recent10-20少于10太多细节丢失多于20压缩太频繁incremental_batch4-8每批压多少条旧消息summary_tokens上限500-800防止摘要本身退化成长文本摘要temperature0.1-0.3防止摘要器创造性改写每个项目语料不同最佳值一定会有偏差。我给的建议是先按这张表跑起来然后专门构造一个100轮以上的长会话语料对比正常用户问题在不同参数下的回答质量再微调。5. 什么时候别硬上context-mode边界与替代方案5.1 三类根本不适合的场景context-mode 不是万能药至少有三类场景我会明确劝退。第一类是单轮长文档处理。用户丢给你一本50页的说明书让你回答问题。这时候上下文管理毫无意义正确做法是直接把文档切片做RAG或者用支持大窗口的模型一次读入。滑动窗口、摘要压缩在“单输入超大”的场景里只会把重要内容压丢。第二类是极短会话。如果产品形态就是“一问一答”每次对话不超过三五轮模型自己的窗口足够装下全部历史那 context-mode 的压缩反而会带来不必要的摘要延迟和失真。别为了用模式而用模式。第三类是对历史一致性要求极高的场景。比如医疗记录、转账流水、法律条款协商这类信息哪怕错一个字都可能出问题。摘要压缩本质上是“有损压缩”在这种场景里应该保留完整原始记录宁可多花token也不能让摘要器替你转述关键事实。5.2 和RAG的配合谁管记忆谁管知识很多朋友问我有了context-mode是不是就不需要RAG了完全不是两者管的根本不是一回事。context-mode 管的是对话历史的动态压缩解决“模型记不住之前聊过什么”RAG管的是外部知识的按需检索解决“模型不知道业务知识和文档内容”。一个健康的知识型客服系统通常是这样分工的context-mode 负责把多轮对话整理成精简历史和用户画像RAG负责在用户问到具体业务时把相关文档片段捞进来两者在payload里各占一块互不干扰。举一个实际例子用户问“上次你说的那个退货政策还算数吗”历史部分由context-mode提供“之前讨论过退货政策”的摘要知识部分由RAG从最新文档里取回退货条款原文模型结合两者生成回答。缺了哪块都不完整。5.3 再往前走一步从摘要到用户画像最后说一个我觉得性价比很高的扩展方向。摘要压缩的过程中会反复看到用户的固定偏好、常用地址、购买习惯等结构化信息。与其让这些信息每次都藏在摘要里不如单独抽出来沉淀成一份用户画像profile比如{ preferred_logistics: 顺丰, address: 杭州市滨江区XX路2号, pending_tasks: [确认上个月订单发票], active_tone: 希望回答简洁 }让 context-mode 在压缩时同步更新这份画像每次请求把画像作为最高优先级放进系统指令。这样模型哪怕在100轮之后也能一上来就知道用户的基本盘比靠摘要去猜要可靠得多。我甚至觉得这个方向比单纯的上下文压缩更能提升长会话体验。最后分享一个我坚持了很久的习惯context-mode 的压缩动作一定要留审计日志压缩前的原文、压缩后的摘要、丢弃了哪些消息全部记录下来。我曾经靠一份几兆的压缩日志在十分钟内定位了一次“模型回答错地址”的线上事故如果没有那份日志排查会变成大海捞针。上下文管理不是一个能一劳永逸的功能它更像一个需要持续观察和调优的活体。不过一旦跑顺了长对话产品的体验提升是肉眼可见的——用户不再重复自己的偏好模型也不再假装失忆这就值回所有调试成本了。