☰
LLM上下文管理实战:用context-mode解决对话失忆与Token成本失控
2026/10/15 6:36:15 网站建设 项目流程

做LLM应用开发的朋友,应该都遇到过这种情况:对话一长,模型就开始“失忆”,答非所问;日志里频繁出现token超限报错;运营一看账单,上下文费用占了七成。我最近在梳理一个叫context-mode的上下文管理模式,就是专门对付这些问题的。它不是一个花哨的概念,而是一套可以落地的上下文组织与调度方案,核心思路就一句话:把上下文当成一种可切换、可分层、可回收的资源,而不是一股脑塞给模型的“黑盒”。这篇文章适合正在做智能客服、聊天机器人、Agent或多轮对话应用的开发者,尤其是已经被上下文问题折磨过的人,读完可以直接拿去改自己的代码。

1. 为什么需要context-mode:上下文管理的本质痛点

1.1 当对话变长,问题从哪儿来

先做一个最简单的实验。你向某个大模型连续提问,每次提问都带着全部历史消息。前5轮一切正常,到第20轮时,单次请求的输入已经膨胀到几万字。这时会发生三件事:一是费用肉眼可见地涨,因为很多模型按输入token计费;二是响应变慢,因为模型需要处理更多前缀;三是模型反而更容易“答非所问”,因为关键信息淹没在大量无关内容中,注意力被稀释了。

这个现象很反直觉:按理说历史信息越多,模型应该越懂你,但实际效果是信息越多、噪声越多、准确率越差。原因在于,大模型处理长文本时,中间的细节会被压缩,真正起决定作用的往往是开头、结尾以及最近几轮的内容。也就是说,你花了大价钱把整本聊天记录塞进去,最后模型只是草草扫了一遍,核心结论却可能夹在中间被忽略了。

用一个生活类比来解释:全量堆积上下文,相当于你把一整年的聊天记录打印出来递给同事,说“你看看我在想什么”。同事翻到第三页可能就忘了第一页的内容,你俩沟通效率反而更低。真实场景里,我们需要的是一份“会议纪要”、几个“关键数字”、以及“最近三句原话”,而不是一整年的原始流水。

1.2 现有方案的三个典型困境

既然全量塞入不行,很多团队转入了“手工优化”,但实际做下来会发现,每一种常见方案都有明显的后遗症。

第一个方案是简单裁剪:只保留最近N轮对话。这个方案最容易实现,但致命问题是“一刀切”。用户在20轮之前提到过一个重要偏好,比如“请用面向小学生的语言回答”,一旦被裁掉,模型后面就开始用专业术语输出,用户立刻觉得产品变笨了。你需要在裁剪逻辑里维护一长串“不可丢关键词”,但维护起来非常痛苦,而且换个场景规则就失效。

第二个方案是全局摘要:把历史消息统一压缩成一段摘要,每轮对话都重新生成一次。这个方案解决了裁剪误删的问题,但成本极高。每来一条新消息,你都要让模型重新总结一次全部历史,一次总结就要消耗几千token,如果用户聊100轮,总结的成本甚至可能比原始请求还高。而且事后总结的摘要往往丢失具体细节,比如用户说过“我住在江南区”,摘要可能只留下“用户提到居住地”,具体地点被抽象掉了。

第三个方案是不管不顾,只在报错时兜底:当请求超过窗口上限,就随机删掉几条消息再重试。这个方案成本最低,但行为完全不可控,偶尔能跑通,偶尔删掉了关键的系统指令,模型直接开始胡说八道。

这三个困境有一个共同根因:上下文管理被当成一个“事后补救动作”,而不是“事前设计”。你希望用同一个固定策略,去应对不同长度、不同任务、不同重要性的对话,效果自然不稳定。

1.3 context-mode解决什么问题

我们把视角换一下:不追求“一个通用策略处理所有情况”,而是预先定义几种上下文使用方式,让系统在不同阶段自动切换不同的方式。这就是context-mode的核心思想。

具体来说,context-mode做的事情是:定义若干种“模式”(mode),每种模式对应一套完整的上下文处理策略——包括保留什么、压缩什么、存放在哪、何时回收。系统根据对话长度、任务类型、时间敏感度等因素,动态选择合适的模式执行。这样做的直接收益是:

  • 短对话时用轻量模式,省掉不必要的摘要开销。
  • 长对话时切到分层模式,热数据留在窗口、冷数据沉淀到存储。
  • 关键任务时用精确模式,宁可多花token也要保住核心实体。
  • 收尾总结时用摘要模式,把整段对话压缩成可回溯的结构化记录。

这篇文章后面会详细展开:context-mode如何分层、各种模式如何选择、以及我实际落地这套模式时的代码结构和踩坑记录。

2. context-mode的整体设计:把上下文当成“可切换的模式”

2.1 核心抽象:上下文的分层结构

要支持模式切换,第一步是把“上下文”这个模糊概念拆成不同层级。我在实际项目里通常把上下文分成四层:短时上下文、工作上下文、长期上下文、临时上下文。

短时上下文指当前这轮对话的实时信息,比如用户刚刚输入的问题、模型刚刚给出的回复。它的特点是时效性强、数据量小,但每一轮都会变化。

工作上下文指当前任务需要的关键材料,比如用户正在编辑的代码文件、正在讨论的合同条款、已经确认的需求列表。它的特点是任务相关度高,一旦任务切换,这层内容需要被重新装载或替换。

长期上下文指用户的持久偏好、历史结论、身份信息等。它跨会话保留,通常存放在外部存储中,而不是每次塞进模型输入。

临时上下文指一次性信息,比如“这条日志是排查时用的”“这个中间结果只需要这次计算用到”,用完即可丢弃。

层级存储位置生命周期典型内容
短时上下文内存数秒到数分钟当前轮次的用户输入、模型输出
工作上下文内存或缓存任务期间代码片段、合同条款、需求列表
长期上下文数据库或文件跨会话用户偏好、身份信息、历史总结
临时上下文内存单次请求检索中间结果、调试信息

这四层各自独立,只有组合起来才构成一次请求的完整提示词。模式切换的本质,就是调整这四层中每一层的“保存策略”和“注入比例”。

2.2 模式类型与选择逻辑

在分层结构之上,我定义了四种常用模式:严格模式、自动模式、滑窗模式、摘要模式。它们之间不是互相替代的关系,而是覆盖了不同对话阶段和不同任务强度。

严格模式(strict)的特点是尽力保留高价值内容,减少摘要操作。它适合代码生成、配置修改、精确检索这类任务,因为这类任务对上下文中的原始细节要求极高,一个变量名写错都会导致结果错误。严格模式下,工作上下文被完整保留,短时上下文尽量全量保留,只有当窗口逼近上限时才触发裁剪。

自动模式(auto)是默认模式,适合大多数普通对话。它不做激进操作,只根据当前上下文长度自动判断:如果总量很小,就等效于全量保留;如果总量接近阈值,就自动回收临时上下文、压缩短时上下文中的旧轮次。自动模式追求的是“最省心的折中”。

滑窗模式(sliding)适合超长对话,比如用户连续聊了几百轮。它保留最近N轮原始消息,更早的内容统一压缩成摘要,并放入长期上下文的“历史档案”中。滑窗模式能控制单次请求体积,但代价是需要维护持续更新的滚动摘要。

摘要模式(summary)适合任务收尾或整体理解,比如你要让模型总结一段长对话的结论、提取行动项。这种模式不再追求保留全部细节,而是把整段历史折叠成结构化文本,模型只需要基于精炼信息输出。

选择逻辑上,我通常会写一个“模式判定器”,输入当前上下文的token数、任务类型标签、最近一次模式,输出下一个模式。判定规则大致是:

  • 上下文小于2000 token时,一律用自动模式,不触发任何压缩。
  • 上下文超过4000 token且任务类型为“对话闲聊”时,切到滑窗模式。
  • 上下文超过4000 token且任务类型为“精确操作”时,保持严格模式,但触发临时上下文清理。
  • 当用户显式要求“总结”或检测到对话尾声时,切换摘要模式生成结构化结果。

2.3 为什么“模式切换”比“统一塞入”更合理

这里有人会问:分模式和分策略,听起来只是把几个方案放到了一起,本质有什么区别?区别在于“切换”这个动作是被显式管理的。

统一塞入的思路是:把所有历史都交给模型,让它自己决定关注什么。问题在于模型能力有限、窗口有限,它无法在超长上下文中有效检索所有信息。模式化管理的思路是:由我们的系统在外部替模型做信息筛选和优先级判断,再把筛选结果喂给模型。这相当于把“上下文处理”从模型的推理过程中剥离出来,变成应用层可控的逻辑。

还有一个工程上的理由:模式切换是可测试、可监控的。传统的全量塞入,效果好不好完全依赖试错,你很难说清楚是哪个历史片段导致结果变化。而模式切换把每次切换的原因、切换前后的token变化都记录在日志里,你可以针对单个模式做A/B测试,出了问题也能快速定位是哪一层、哪个策略出了问题。

这个设计思路和操作系统的运行模式有异曲同工之妙。用户态和内核态本质就是两种不同模式:用户态做常规操作,限制权限;内核态做特权操作,风险高但能力强。系统根据任务需要在这两种模式间切换,而不是把所有操作混在一起。context-mode把“普通对话”和“精确任务”分开处理,逻辑也是类似。

3. 从零落地一个context-mode核心实现

3.1 基础设施:定义模式枚举和状态载体

这一节直接进入代码层面。我以Python为例,因为大多数做LLM应用的原型阶段都用Python,但思路可以平移到任何语言。

第一步是定义模式枚举和上下文的“条目”结构。不要直接用一个列表装所有消息,那样没法区分优先级和生命周期。我建议的最小结构是这样:

from enum import Enum from dataclasses import dataclass, field from typing import Any, Optional import time class ContextMode(Enum): AUTO = "auto" STRICT = "strict" SLIDING = "sliding" SUMMARY = "summary" class ContextLevel(Enum): SHORT_TERM = "short_term" WORKING = "working" LONG_TERM = "long_term" EPHEMERAL = "ephemeral" @dataclass class ContextEntry: level: ContextLevel content: Any created_at: float = field(default_factory=time.time) priority: int = 3 key: Optional[str] = None

其中key字段非常关键,它表示这条上下文的“标识”。比如“user_name”“current_code_file”“last_question”,用key可以在压缩时快速检索和替换,而不是只能遍历内容。

第二步是实现一个基础的存储容器。每一条ContextEntry进入容器时,按level和priority归位,这样后续按模式调度时,可以只对某个层级做操作。

class ContextStore: def __init__(self): self._entries = [] def add(self, entry: ContextEntry): self._entries.append(entry) def by_level(self, level: ContextLevel): return [e for e in self._entries if e.level == level] def remove_ephemeral(self): count = len(self.by_level(ContextLevel.EPHEMERAL)) self._entries = [e for e in self._entries if e.level != ContextLevel.EPHEMERAL] return count

这层结构看起来简单,但它是后续所有模式策略的地基。如果你在一开始就把所有消息混在同一个数组里,后面做裁剪、摘要、持久化都会非常痛苦。

3.2 核心调度:滑窗、压缩与持久化

接下来是核心的ContextManager。它对外提供三个主要能力:add()添加新上下文、decide_mode()判定当前模式、build_prompt()生成最终送给模型的提示词。

class ContextManager: def __init__(self, max_window_tokens=4000, summary_threshold=0.7): self.store = ContextStore() self.mode = ContextMode.AUTO self.summary_text = "" self.max_window_tokens = max_window_tokens self.summary_threshold = summary_threshold def add(self, level: ContextLevel, content: Any, key: Optional[str] = None, priority: int = 3): self.store.add(ContextEntry(level=level, content=content, key=key, priority=priority)) def decide_mode(self, current_prompt_tokens: int, task_type: str = "chat"): if task_type == "summary": self.mode = ContextMode.SUMMARY elif current_prompt_tokens > self.max_window_tokens and task_type == "chat": self.mode = ContextMode.SLIDING elif current_prompt_tokens > self.max_window_tokens and task_type == "precise": self.mode = ContextMode.STRICT else: self.mode = ContextMode.AUTO return self.mode

decide_mode是模式切换的入口。我的习惯是,所有模式切换都要在日志里记录原因,后面排查会救命。比如:

logger.info("mode switch to %s, reason=%s, tokens=%d", self.mode, reason, current_prompt_tokens)

滑窗压缩的逻辑放在build_prompt之前调用。每次准备生成新提示词时,先估算当前总量,如果超出阈值,就对低层级条目执行回收或摘要。

def _evict_if_needed(self): for entry in self.store.by_level(ContextLevel.EPHEMERAL): self.store.remove_ephemeral() short_entries = self.store.by_level(ContextLevel.SHORT_TERM) if len(short_entries) > 20: # 将旧轮次合并成摘要 old_entries = short_entries[:-6] text = " ".join([str(e.content) for e in old_entries]) self.summary_text = self._call_summarizer(text) self.store._entries = [e for e in self.store._entries if e not in old_entries]

token估算这里需要说明一下。如果你用的是上游SDK,尽量用上游的tokenizer来做精确估算,因为它和计费口径一致。自己按“4字符=1token”估只会造成偏差,导致阈值失效。我的经验是:把tokenizer封装成一个独立的estimate_tokens(text)函数,所有阈值判定都走这个函数,不要手动数。

持久化方面,最省事的方案是把长期上下文直接写入SQLite或文件。每次会话结束后,把long_term层级的内容序列化保存,下次会话启动时按用户ID加载。这只是个IO操作,不展开写,但要注意:持久化一定要按“对话会话ID”隔离,否则多用户会互相串数据。

3.3 接入实际调用的完整流程

把上面的代码串起来,一个完整的调用流程是这样:

cm = ContextManager(max_window_tokens=4000) # 1. 系统初始化时,加载长期上下文 for item in load_long_term_context(user_id): cm.add(ContextLevel.LONG_TERM, item["content"], key=item["key"], priority=5) # 2. 用户每发来一条新消息,先添加输入 cm.add(ContextLevel.SHORT_TERM, user_message, key="user_input", priority=4) # 3. 再添加工作上下文(如果有,比如正在处理的代码文件) if active_file: cm.add(ContextLevel.WORKING, active_file, key="active_file", priority=5) # 4. 判定模式并做必要压缩 cm.decide_mode(estimate_tokens(cm.store)) # 5. 生成最终提示词 prompt = cm.build_prompt() # 6. 调用模型 response = call_llm(prompt) # 7. 把回复加入上下文 cm.add(ContextLevel.SHORT_TERM, response, key="assistant_response", priority=4)

这里有一个值得强调的细节:不要把“模型回复”和“用户输入”同等对待。在滑窗压缩时,我会优先保留用户输入,因为用户输入往往携带新的意图和需求,而模型回复主要是对旧意图的响应。如果你把所有内容一视同仁,压缩后很可能留下“废话”而丢掉关键问题。

build_prompt的生成顺序对模型效果也有影响。经验法则是:系统指令放在最前面,然后是长期上下文中的关键事实,最后是按时间排列的近几轮对话。

def build_prompt(self): sections = [] system_part = self._build_system() long_part = self._build_long_term() recent_part = self._build_recent() if self.summary_text: sections.append("【历史摘要】" + self.summary_text) sections.append(system_part) sections.append(long_part) sections.append(recent_part) return "\n\n".join(sections)

这样组织是因为:模型对序列开头和结尾的记忆力最强。把系统指令和关键事实放开头,最近对话放结尾,中间是历史摘要,模型在采样式生成时会更容易抓住重点。

4. 真实场景里的常见问题与排查技巧

4.1 上下文被截断:不是你token不够,是策略没选对

最常见的问题是模型报错“超出上下文长度”或者返回内容被截断。很多人第一反应是调大窗口,但窗口调大之后成本也上来了,而且模型依然会“失忆”。我排查这类问题时有个固定路径:

第一步,看日志里最近的模式切换记录。如果一直处于AUTO模式且没有任何压缩动作,说明你的ContextManager根本没触发阈值,这时要检查estimate_tokens是不是算少了。我在一个项目里发现某个版本把“字符数”当成了“token数”,导致阈值判断完全失效。

第二步,检查是否有临时上下文没有清理。最容易被忽视的是调试日志、检索结果、中间计算值,它们以高频率进入store,很快把窗口占满。我在代码里会每次请求后调用remove_ephemeral(),确保临时数据不跨请求积累。

第三步,如果窗口中明明还有空间,但模型仍然说“上下文不足”,那多半是上游框架本身对单次请求长度有额外限制,比如系统指令、工具定义也占用了窗口。这时要把所有输入片段全部计算在内,而不是只算对话历史。

还有一个技巧:在开发环境里设置一个“诊断模式”,它会把每一层的token占比、条目数量、最近一次压缩时间打印出来。看起来是小事,但能帮你省下大量猜测时间。

4.2 切换模式后“失忆”:历史的沉淀与召回

另一个高频问题:从AUTO切换到SLIDING后,模型突然不记得用户之前说过的重要信息了。比如用户在第10轮说“我偏好中文输出”,第30轮切到滑窗后,模型开始用英文。很多人会怀疑是摘要质量差,其实问题往往出在“压缩时机”和“实体沉淀”上。

滑窗模式只保留最近N轮原始消息,更早的内容统一压缩成摘要。但如果摘要只写“用户提到偏好”,而没有把“偏好中文输出”这个具体实体提取出来,那模型收到摘要也没用。解决方法是:摘要不能只压缩事件,要做实体抽取和关键事实沉淀。

我在实现中会给摘要模版加上固定结构:

【用户事实】 - 姓名: XXX - 偏好: 中文输出 - 居住地: 江南区 【对话进度】 - 当前任务: 设计登录页 - 已完成: 确定色彩方案 【未决问题】 - 需确认是否支持第三方登录

这样压缩后,模型依然能直接读到关键事实,而不是在一段泛泛的总结里自己找。说穿了,摘要的目的不是“减少信息量”,而是“转换信息形态”——把容易失效的上下文细节,转成结构化、可检索的长期记忆。这一步不做好,切换模式必然导致失忆。

4.3 成本控不住的排查清单

最后说说成本。很多人以为采用上下文管理模式后成本一定下降,实际不一定。模式切换会产生额外的摘要调用、额外的tokenizer计算,如果阈值设置不合理,这些额外开销可能比省下来的还多。我整理了一张排查清单:

症状可能原因排查方法解决建议
每次请求都触发摘要摘要阈值设置过低查看日志中summarize触发次数把summary_threshold从0.5提到0.75
摘要费用占总费用40%以上摘要内容每次都全量生成检查摘要调用是否带缓存对同一段历史摘要增加缓存
长期存储快速增长临时上下文被当成长期保存检查level归属确认所有临时数据走EPHEMERAL
多用户数据互相串味持久化缺少会话隔离检查save/load的隔离键确认load时按user_id+session_id过滤

表格里最后一行尤其重要。context-mode最大的优势是把上下文分成层级,但如果你把长期上下文直接写入全局文件,而没有任何隔离,就会出现严重事故——用户A的偏好跑到用户B的对话里。这类问题在测试时很难发现,因为测试往往只用一个用户跑,上线后才暴露。我建议从一开始就在持久化层加入“用户ID”和“会话ID”双主键约束。

5. 再往前走一步:context-mode的其他落地场景

5.1 GUI应用里的上下文状态管理

虽然context-mode是围绕LLM应用展开的,但它背后的分层思想在传统软件开发中同样适用。做过复杂界面的开发者应该都体验过“状态爆炸”:全局状态、路由状态、组件局部状态、临时UI状态全混在一起,改一个开关导致整个页面重渲染,排查起来想砸电脑。

如果用context-mode的思路来治理,就是给状态打上层级标签:跨页面共享的放全局层,某个页面自己的放页面层,弹窗里的开关放临时层。切换页面时明确回收临时层状态、按需加载页面层状态,而不是让所有状态一直堆在内存里。这和对话上下文的治理逻辑一模一样:先分层,再定模式,最后做回收。

5.2 游戏与状态机里的模式化切换

游戏开发里同样有“上下文”问题。一个NPC的行为,理想情况下应该根据当前情境切换模式:平时巡逻、看见敌人追击、血量低时逃跑、被对话时停下听玩家说话。这就是最典型的context-mode——同一套“认知”,根据模式输出完全不同的行为策略。

很多刚入门的游戏开发者喜欢把所有这些行为写到同一个update函数里,导致逻辑越堆越乱。用上下文管理模式重构之后,每种模式对应独立的处理函数,模式和模式之间通过状态机切换,每种模式的进入、退出、优先级都有明确约束。这个思维方式比具体的代码实现更重要,因为它能帮你把复杂问题切分成可单独测试的小块。

5.3 后续可以怎么扩展

最后聊聊context-mode还有什么扩展空间。

第一个方向是做成可观测系统。在每次模式切换时记录触发原因、token节省量、摘要耗时,长期收集这些数据后,你可以优化阈值参数,甚至用历史数据训练一个“模式选择模型”,让切换决策更精准。

第二个方向是做成中间件。把ContextManager封装成独立服务,通过HTTP或消息队列暴露接口,业务层只需要add和build_prompt,不用关心内部实现。这样多个应用可以共享同一套上下文治理能力,也方便升级维护。

第三个方向是结合更多的上下文来源。除了对话历史,还可以把外部知识库检索结果、工具调用结果、用户行为日志都纳入上下文分层。context-mode的分层结构天然适合做这件事,你只要新加一个层级,再定义好它与其他层级的合并策略就行。

我个人在实际研发里踩过几次坑之后,最大的体会是:context-mode不是一种“高级优化”,而是一种从一开始就该有的设计方式。只要你的应用会面对超过十轮的多轮对话,或者需要在不同任务之间切换,提前设计好上下文模式,比事后优化要省心得多。最后再分享一个小技巧:不要一上来就写完整的实现,先用最简单的枚举加字典把链路跑通,看到日志里的累计token曲线之后,再决定要不要引入摘要压缩。模式化的价值,往往要到数据呈现出来之后才能真正体现出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询