最近好几个做 AI 应用的朋友都在聊 context-mode聊着聊着就发现大家踩的坑高度一致要么对话稍微长一点就报 context length exceeded要么为了让模型记住前文花式堆 prompt结果钱没少花效果反而越搞越差。其实 context-mode 不是某个具体产品的专利它是一套“怎么管理和使用上下文”的策略组合直接决定了你的聊天机器人、Agent 或者文档问答工具是聪明还是智障。这篇文章我会从设计思路、工程实现到线上问题排查把 context-mode 的一次完整落地过程摊开讲清楚适合正在折腾大模型应用的产品经理、后端研发和独立开发者参考。1. 先搞清楚context-mode 到底是什么1.1 一个容易被忽略的设计维度很多人第一次接触 context-mode是在某个模型 API 的参数列表里看到一个叫 context_mode 的字段然后一脸懵。实际上它不是一个标准协议各家实现也不同但底层想解决的问题是同一个大语言模型的输入上下文是有限的而真实对话是无限的我们必须主动决定“哪些内容放进本次请求”。你可以把模型理解成一个只能记住最近 N 句话的人。超过这个范围前面的内容就彻底忘了。context-mode 就是在做“选择性失忆”和“记忆重点”这件事。换句话说它不是模型内部的东西而是模型外面那一层“怎么拼 prompt”的策略。这个维度的设计经常被忽略因为很多人一开始只关注 prompt 写得对不对。但真到了产品里用户不会只聊三句话。一个客服机器人每天可能要处理几十轮对话一个编程助手要跟着仓库上下文跑很久如果没有一套可靠的上下文管理机制模型的表现会随着对话长度迅速劣化。context-mode 解决的就是这种“长对话下的失控感”。1.2 从两次翻车现场说起先说我自己最早的一次翻车。当时做了一个基于开源模型的法律问答 demo直接把用户所有历史消息全拼进 prompt结果对话到第 6 轮就开始报错等到第 8 轮干脆请求都发不出去。后来我把历史全砍掉只保留当前用户问题倒是能跑通了但模型开始反复问用户“您刚才说的具体情况是什么”——它把上一轮已经给过的信息全忘了用户体验极其糟糕。第二次翻车是用摘要方案。我写了个脚本每轮对话结束后把前文用模型总结成一句话存起来然后拼到下一轮 prompt 里。结果聊天对象从“一个具体的客服”变成了“一个总是在复述概要的复读机”因为原始细节被压缩没了用户问“我刚说的合同编号是多少”摘要里根本没有这个具体值。这两次经历让我彻底明白context-mode 不是简单地把 token 省下来就完事了它是一个关于“信息取舍”的产品决策。你保留什么、丢掉什么、什么时候压缩、什么时候全量保留这些都要结合业务场景来定。2. 为什么上下文管理这么难三个绕不开的约束2.1 窗口上限是硬天花板不管用哪个模型上下文窗口都是一个物理上限。以常见的商用模型为例有的支持 32K token有的支持 128K token听起来很大但注意 token 不是字数。一个中文字符大约消耗 1 到 2 个 token一段 2000 字的历史消息可能就要 3000 多 token。你再想想系统提示词、用户当前问题、工具返回结果、中间推理过程这些都挤在同一个窗口里。我之前做过一个粗略测算一个只有 10 轮历史的客服对话如果每轮平均 300 字加上角色标签和格式符号光历史就要吃掉 6000 到 8000 token。如果业务上还要把产品文档、知识库片段塞进去128K 的窗口也会很快捉襟见肘。所以 context-mode 的第一要务就是在窗口之内做好空间分配。这就像收拾一个行李箱空间就那么大你不能什么都往里塞必须根据行程长度、物品优先级来取舍。2.2 记忆的“新鲜度”和“关键度”打架上下文管理的难点不只是空间还有信息的时效性和重要性之间的冲突。一般来说最近的对话内容对当前问题影响最大但有些信息虽然是很早之前提到的却依然非常关键比如用户的姓名、订单号、偏好设定、合同金额。如果只用简单的“最近 N 轮”策略这些关键信息可能在第 10 轮就被挤出去了。如果全部保留空间又撑不住。context-mode 本质上是让系统在“新鲜度”和“关键度”之间找一个动态平衡点。我见过一个比较聪明的做法系统内部维护了两类记忆。一类是短期工作记忆也就是最近几轮的完整对话另一类是长期事实库把用户明确提供过的关键实体和结论抽出来单独存储。每次请求时把两者都拼进问上下文里。这样就既保留了真实细节又不用无限堆历史。2.3 成本随 token 线性上涨体验却可能边际递减很多技术方案从功能上看没有问题但一算成本就扛不住。大模型 API 的计费基本都按 token 来输入和输出分开计价而输入部分通常每次请求都会重复计算。也就是说上下文越长你每次调用的费用就越高而且这种涨幅是线性的。更扎心的是钱花多了效果不一定好。上下文里冗余内容越多模型越容易被无关信息干扰回答反而变得含糊。我在实际测试中发现把 20 轮完整历史全塞进去和只保留最近 5 轮加一个关键信息摘要后者在回答准确率上往往不输有时候甚至更好因为模型没有被大量旧消息带偏。所以 context-mode 的另一个价值是成本控制。你每省下来的那几千 token在日请求量很大的产品里可能就是一笔相当可观的费用。这也是我后来愿意花时间把上下文管理做精细化的最直接动力。3. 常见的 context-mode 设计模式与选型逻辑3.1 滑动窗口模式简单但够用滑动窗口是最容易想到的思路维护一个固定大小的消息队列新消息进来最旧的消息出去。实现起来非常直接只要记录每条消息的 token 数总长度超过阈值就从头开始删。这种模式最大的好处是可控和稳定。它不会漏掉最近发生的上下文因为理论上新的内容总是优先保留。它特别适合那些“单轮依赖强、跨轮依赖弱”的场景比如通用闲聊、单次问答、临时的任务咨询。缺点也明显早期的关键信息会被无差别清掉。用户如果第 1 轮说了自己的身份第 15 轮再问一个跟身份相关的问题模型已经完全不记得了。所以使用滑动窗口时通常要在系统提示词里写明“如果用户早前提供过身份或关键参数请先向用户确认”至少不要瞎编。3.2 摘要压缩模式用空间换记忆摘要压缩模式的核心思想是把历史对话丢给模型做“信息浓缩”生成一段结构化的摘要每次请求时用摘要代替完整历史。这种模式能把几万 token 压到几百 token非常省成本。但摘要压缩有一个很常见的坑摘要无法完整保留具体数值、专有名词和细节。模型在总结时天然倾向于保留“大众信息”丢掉“稀缺信息”。合同编号、日期、手机号、产品型号这类内容虽然对用户来说至关重要但在摘要里很容易被忽略。我做过一个改进是在生成摘要时通过提示词要求模型按固定格式输出比如请总结对话保留以下内容 - 用户明确提到的身份信息 - 所有数字、编号、日期 - 最终结论和待办事项 - 当前未解决的问题 其他内容尽量精简。这样摘要的可用性会明显提升。不过仍有概率丢细节所以摘要模式更适合对单点准确度要求不高、但对话延续性要求高的场景比如情感陪伴、知识辅导类产品。3.3 关键信息持久化模式像数据库一样记忆第三种模式是把上下文里值得长期记住的信息抽出来存到外部存储里比如数据库或 Redis。每次组装上下文时先从库里捞出相关条目再配合最近几轮完整对话一起发送。这套方案灵活性最高相当于把模型的外部记忆产品化了。用户的偏好、历史订单、阅读记录都可以被结构化存储并在合适的时机重新注入。它特别适合客服、医疗预诊、金融咨询这类需要跨会话记忆的业务场景。不过它的复杂度也最高。你需要定义“什么信息值得存”需要设计抽取逻辑还要考虑信息的更新和失效策略。比如用户上周说喜欢喝美式这周说最近在戒咖啡那旧的偏好就要被覆盖而不是两件事同时存在让模型困惑。我一般会把这种模式定为 context-mode 的“高配版”产品发展到一定阶段再引入。刚开始就用它很容易把自己淹没在数据清洗和同步的泥潭里。3.4 混合模式多数产品的现实答案在实践中单一模式很难扛住复杂的真实场景。我现在更倾向于混合模式最近 5 轮对话完整保留更早的历史用摘要兜底同时从长期记忆库里查询与当前用户问题相关的实体信息。举个例子一个 AI 提效助手可以这样配置层级内容来源系统指令角色定位、回答格式、安全约束固定配置动态记忆用户长期偏好与关键档案外部数据库短期对话最近 5 轮完整原始消息会话缓存压缩历史第 6 轮之前的轻量摘要定时生成业务数据查询到的订单、文档片段工具调用返回这种分层结构的好处是每一层各司其职既保证短期对话的自然度又让长期信息不丢失还能显眼地控制 token 总量。代价是代码复杂度提升需要维护多个数据源的一致性。混合模式是我目前最推荐的生产级选择也是后面章节要实现的方案。如果你的产品已经出现了上下文管理问题不用急着上多复杂的架构可以先从“滑动窗口 单层摘要”开始跑通后再逐步加入外部记忆。否则一上来就搞混合模式排查问题的成本会很高。4. 手写一个轻量 context-mode 管理器4.1 数据结构与接口设计纸上谈兵差不多了直接看代码。我用 Python 写了一个最小可用的 context 管理模块支持滑动窗口和摘要兜底两种策略。核心数据结构就是消息列表每条消息包含角色、内容和估算的 token 数。from dataclasses import dataclass, field from typing import List, Optional, Callable dataclass class Message: role: str # system / user / assistant / tool content: str tokens: int 0 def __post_init__(self): if self.tokens 0: self.tokens estimate_tokens(self.content)模块的对外接口很简单只需要三个方法class ContextManager: def __init__( self, max_tokens: int 8000, keep_recent: int 5, enable_summary: bool True, summarizer: Optional[Callable[[List[Message]], str]] None, ): self.max_tokens max_tokens self.keep_recent keep_recent self.enable_summary enable_summary self.summarizer summarizer self.history: List[Message] [] self.summary: Optional[str] None def add_message(self, role: str, content: str) - None: self.history.append(Message(rolerole, contentcontent)) def build_context(self, current_user_message: str) - List[Message]: # 核心方法把历史 当前消息组装成最终请求体 pass def clear(self) - None: self.history.clear() self.summary None这里的estimate_tokens是一个估算函数不必非常精确够用就行。我通常使用一个简单的字符与 token 换算比例中文按 1 个字符约等于 1.3 个 token 估算。def estimate_tokens(text: str) - int: # 中文场景的粗略估算实际项目中建议用 tokenizer 或 tiktoken zh_chars sum(1 for ch in text if \u4e00 ch \u9fff) other_chars len(text) - zh_chars return int(zh_chars * 1.3 other_chars * 0.3) 24.2 核心逻辑窗口裁剪 摘要兜底接下来是核心方法build_context。它的逻辑分三步走。第一步先把最近若干轮完整历史拎出来。def _get_recent_messages(self) - List[Message]: return list(self.history[-self.keep_recent * 2:])注意我这里是按角色数量来取因为一条完整的“用户问 助理答”算一轮对应两条消息所以乘以 2。第二步把更早的历史压缩成摘要。这里我假设外部传入了一个summarizer函数它接收一段历史消息列表返回字符串摘要。实际项目里这个函数就在内部调一次大模型。def _build_summary(self) - Optional[str]: if not self.summarizer: return self.summary old_messages self.history[:-self.keep_recent * 2] if not old_messages: return self.summary self.summary self.summarizer(old_messages) return self.summary第三步组装最终上下文。先算一下当前请求里各部分占用的 token如果超了 max_tokens就继续往前裁剪更早的历史只保留摘要。def build_context(self, current_user_message: str) - List[Message]: system_msg Message(rolesystem, content你是一个靠谱的助手。) user_msg Message(roleuser, contentcurrent_user_message) recent self._get_recent_messages() summary_text None if self.enable_summary: summary_text self._build_summary() if summary_text: # 把摘要伪装成一条 system 消息放在最前面 recent recent # 不含摘要后面单独插入 else: summary_text None base_tokens system_msg.tokens user_msg.tokens sum(m.tokens for m in recent) if summary_text: summary_tokens estimate_tokens(summary_text) # 如果基础就已经爆了返回空列表让上层报错而不是硬截断 if base_tokens self.max_tokens: raise ValueError(context too long even without summary) # 从 recent 尾部开始删直到放得下摘要 while recent and base_tokens summary_tokens self.max_tokens: dropped recent.pop(0) base_tokens - dropped.tokens result: List[Message] [system_msg] if summary_text: result.append(Message(rolesystem, contentf【历史摘要】{summary_text})) result.extend(recent) result.append(user_msg) return result4.3 把历史消息变成上下文在上面的代码里recent里的 history 消息顺序是从旧到新。组装结果时我把它放在摘要之后、当前用户问题之前。这个顺序是有讲究的模型对输入中靠后内容的注意力往往更强所以最重要的当前问题要放在最后摘要和历史只作为背景信息。我在项目里实际使用时还会对 recent 里的 assistant 消息做一些清理。比如把一些工具调用产生的内部 debug 信息、超长思考过程过滤掉只保留最终面向用户的回答这样能减少不少 token。另外一个容易被忽略的细节是system 消息尽量不要在每轮都重复塞一大段。我的做法是把固定角色说明放在系统层单独维护而把动态摘要和临时指令拼接成一条独立的 system 消息。这样既保持了灵活又避免所有配置混在一起难以排查。最终调用大模型时直接把这个列表作为 messages 参数传过去就行。像这样context_manager.add_message(user, 我想查一下昨天的订单) context_manager.add_message(assistant, 您在 2025-04-01 有一笔订单编号为 D10086……) messages context_manager.build_context(那这个订单发货了没有) client.chat.completions.create( modelyour-model, messages[{role: m.role, content: m.content} for m in messages], )这样每次请求时模型看到的就是一个结构清晰、重点明确的 prompt而不是一长串混乱的聊天记录。4.4 可控性给用户一个模式切换开关前面讲的是通用实现但真实产品里用户对上下文的诉求是不同的。一个写代码的开发者希望保留完整的前后文方便模型理解项目结构一个只想快速问百科知识的用户则完全不需要历史。所以我会在产品的对话设置页放一个“记忆模式”开关对应三个档位简洁模式只保留当前输入和系统提示词适合一次性问答。平衡模式使用“最近 N 轮 摘要”的混合策略也就是上面的默认实现。长谈模式扩大 keep_recent 和 max_tokens 配置尽量保留完整上下文代价是更高的成本和延迟。这个开关表面上是个产品功能实际上就是在调整 context-mode 的参数。我把这层参数透出到后端接口里比如请求体里加一个context_mode字段def build_context(self, mode: str, current_user_message: str) - List[Message]: if mode concise: self.keep_recent 0 self.enable_summary False elif mode balanced: self.keep_recent 5 self.enable_summary True elif mode long: self.keep_recent 20 self.enable_summary True return self.build_context(current_user_message)看似只是改两个参数用户体验差别会很大。我实测过同一个模型、同一个问题用简洁模式回答经常比较“冷淡”用平衡模式则能承接之前聊到的细节体感上像换了个更聪明的机器人。这也说明上下文管理不只是工程问题更是产品体验的一部分。5. 落地过程中的常见问题与排查经验5.1 token 算不准导致截断错乱我在早期用字符数估算 token结果吃了不少亏。同样是 100 个字符中文可能消耗 150 token英文可能只有 30 token混合内容更是没法估准。如果估算值偏低系统会在不知不觉中把上下文塞爆如果偏高又会过早裁掉有用历史让模型“失忆”。后来我改用模型对应的 tokenizer 来计算。如果用的是 OpenAI 兼容接口直接用 tiktoken 最方便pip install tiktokenimport tiktoken def estimate_tokens(text: str) - int: enc tiktoken.encoding_for_model(gpt-4o) return len(enc.encode(text))这个方案用起来很稳。如果你用的是国产模型或者开源模型通常官方也会提供对应的 tokenizer 包。总之不要靠拍脑袋估算生产环境里的 token 数宁可调用慢一点也要保证预算是准的。5.2 摘要污染越压越傻摘要方案最典型的翻车现场是“二次摘要污染”。第一次摘要生成了 500 字第二次把上一次的摘要和新的对话合并后再摘要又压成 300 字。几轮下来信息被反复压缩每次都会损失一部分细节结果模型记住的内容可能已经完全偏离用户原意。我踩过这个坑后的解决办法是摘要只从原始消息生成绝不在旧摘要基础上再摘要。也就是说每一次摘要都拿“完整旧历史 当前新对话”作为输入虽然会多花点 token但质量有保证。如果为了省那点输入成本搞递归摘要最后模型失去关键信息用户流失的代价远大于省下的费用。5.3 多轮任务里的“丢身份”问题还有一个高频问题是模型忘记了自己当前的角色设定。比如系统提示词里写了“你是某店铺的售后客服”但对话进行到 20 轮之后模型突然开始用第三人称回答甚至宣称自己是一个通用 AI。这个问题往往不是模型本身忘了而是随着历史消息增多其他 user 和 assistant 消息把 system 指令的相对权重稀释了。我的处理办法是不要把系统提示词放在最前面就完事而应在每次构建上下文时把它放在 messages 列表的最后一位也就是紧跟用户当前问题之前。实测这样对角色保持很有帮助因为模型对末尾内容的注意力更强。5.4 参数调优的实测参考关于 keep_recent 和 max_tokens 怎么定我整理了一版实测参考数据。场景是客服问答机器人平均每轮对话 300 到 500 字系统提示词 600 token用户问题平均 200 token。场景max_tokenskeep_recent摘要开关效果评价简单百科问答40002 轮关成本低够用普通客服80005 轮开平衡效果与成本长文档分析120008 轮开需要更宽窗口编程助手1600010 轮开需配合文件级上下文这个表只是一个起点实际要根据你的平均对话长度再微调。我建议在测试环境记录每次请求的 messages 总 token 数输出日志然后观察有多少请求被裁剪。如果裁剪率超过 30%说明 keep_recent 定得太高或者摘要没有充分发挥作用。排查时也可以临时把 summary 的内容打印出来直接人工看一眼摘要里有没有保留用户的关键信息。这种方法比只看 token 数字直观得多。# 打印每次请求的摘要 curl -X POST https://your-api.internal/debug/context \ -H Content-Type: application/json \ -d {session_id: test_001}返回结构里带一个 summary 字段我习惯把它当成测试报告来看。如果发现摘要里连续几轮出现同一句话那就说明摘要逻辑可能出现了重复累积的问题需要检查是不是把旧的摘要又拼进去了。6. 关于 context-mode 的进一步扩展现在这套 context 管理器只是一个会话内的状态管理但 context-mode 还可以继续扩展做成跨会话的长期记忆。做法是在外部数据库里给每个用户建立一个 profile 表存储用户的显式偏好和关键事实。每次新会话开始时先查一下 profile把相关内容塞进 system 提示词。我在实际项目里就是这么做的。用户第一次说“以后叫我小王”我就把它写入 profile后续所有会话都会带上这个信息。用户说“不喜欢太长的回答”我也记录下来系统提示词里就会追加“回答尽量简洁”。这样即使用户每次开新会话模型也能保持一定的连续性体验上非常接近那种“懂我”的助手。另一个可以扩展的方向是把 context-mode 和工具调用结合。比如一个订餐助手用户在上一轮已经选好了菜品这一轮要下单上下文管理器可以自动把“已选菜品”抽成结构化的 JSON而不是把整段对话都丢给模型。这样更精准也更容易和业务系统对接。需要注意的是这些扩展都要建立在基础上下文管理稳定的前提下。如果每次请求的 messages 列表你都无法准确预测和复现再加外部记忆和工具调用只会让问题雪上加霜。我个人的经验是context-mode 没有银弹它更像是一套需要根据业务随时调整的工程策略。最核心的原则很简单让模型每次读到的信息是“够用且不冗余”的。做到这一点大模型应用的正确率、稳定性和成本表现都会有立竿见影的提升。
阅读完成 · 觉得有帮助?