看到“context-mode”这个词,我第一反应是很多聊天类工具、AI辅助编程IDE里那个“上下文开关”。不少人把它当成一个按钮,开了就完事,但真到要做自己的Agent、写自己的对话式应用时,才发现这个模式背后藏着一整套上下文管理工程。这篇文章就以我实际开发中做的context-mode模块为蓝本,把上下文模式怎么设计、怎么实现、怎么踩坑讲清楚,适合正在做AI助手、RAG应用、Agent编排的开发者,也适合想搞明白聊天工具“上下文按钮”原理的产品和技术爱好者。
1. context-mode到底解决什么问题
1.1 从“单轮问答”到“多轮对话”
我第一次意识到context-mode非做不可,是在做客服问答机器人测试时。单轮问答模式下,用户问“怎么退款”,系统查退回规则,返回答案,干净利落。但用户紧接着说“我刚才那个订单呢”,模型完全懵了——它根本不知道“刚才”指什么,因为每次请求都是无状态的。
传统做法是把最近几轮对话一股脑塞进prompt,但这会迅速遇到天花板。模型输入有token上限,对话一长就截断;全部塞进去又会让模型注意力被无关信息分散,回答质量断崖式下跌。context-mode要解决的核心问题,就是在这个矛盾里找到平衡:既要让模型记住该记住的,又要让它在有限上下文窗口内高效工作。
这等于给AI做了一套“工作记忆”管理系统。人有短期记忆和长期记忆,该忘的忘,该重点记的反复强化。AI的context-mode也是同一套逻辑,只是执行得更机械、更依赖规则和算法。
1.2 上下文模式的三种典型形态
我在项目里把context-mode拆成三种典型形态,对应不同使用场景:
会话模式(chat mode):这是最常用的默认形态。系统维护一个消息队列,按时间顺序保存用户和AI的对话,通常配合滑动窗口裁剪。适合闲聊、问答、头脑风暴,特征是重顺序、重最近。
任务模式(task mode):围绕某个目标组织上下文。系统会提取当前任务的背景、需求、约束、已完成步骤,形成结构化的任务卡片,而不是简单堆对话记录。适合写代码、写文档、做分析,特征是重目标、重状态。
知识模式(knowledge mode):以检索为核心,上下文里是用户问题相关的知识片段,通常接向量数据库。适合RAG问答、文档解读、法律法规查询,特征是重相关性、重引用。
一个成熟的context-mode实现,并不是三选一,而是三者在不同时机动态切换。比如用户先问“帮我看看这份合同的风险”,这是知识模式,系统检索合同条款;用户接着问“那第三条改一下”,这就切到任务模式,上下文里要带上正在修改的合同条款和修改意图;用户中间随口问“你今天状态如何”,又得临时回到会话模式。模式切换稍有不慎,前面积累的上下文就全乱了。
2. 核心设计:context-mode的四个关键模块
2.1 上下文窗口管理策略
上下文窗口就是模型一次能看到的token数量上限。context-mode的第一件事,就是决定哪些内容进入这个窗口。我见过最粗糙的做法是“取最近N轮”,简单但问题很大。假设用户在40轮对话前详细描述了自己的需求,现在说“按那个需求改”,如果只看最近5轮,模型根本不知道“那个需求”是什么。
我采用的是分层滑动窗口,把上下文分成三层:
- 刚性层(pinned):用户主动固定住的信息,比如任务目标、关键约束、用户身份描述。这层内容默认不被裁剪,除非用户明确移除。
- 动态层(rolling):最近的对话记录,按顺序排列,超出预算时从最旧开始淘汰。
- 摘要层(summary):被淘汰的旧对话,不是直接丢掉,而是压缩成摘要,保留关键事实和决策。
这三层加起来,就是提交给模型的实际prompt。刚性层保证核心信息不丢,动态层保证对话连贯,摘要层保证历史不蒸发。预算分配我一般按1:3:2的比例切分,假设总预算4000 token,刚性层800,动态层1800,摘要层1400。这个比例不是拍脑袋定的,是我在日志里统计了300多轮真实对话后得出的经验值,刚性层太大挤压对话空间,太小则固定信息放不下。
2.2 记忆分级与遗忘机制
窗口管理解决的是“放得下多少”,记忆分级解决的是“哪些值得放”。我参考了认知科学里工作记忆和长期记忆的分工,给每条消息打了三个标签:重要性、时效性、类型。
重要性由用户行为和内容特征共同决定。用户手动收藏/置顶的消息,重要性直接拉满;含有“记住”“重点是”“必须”这类指令词的消息,重要性加一级;AI总结出的结论性内容,重要性高于过程性解释。时效性则和消息类型绑定,闲聊消息的时效性衰减极快,两小时后基本可以淘汰;任务类的状态信息(比如“我已经改好config文件”)时效性可以维持到任务结束。
遗忘机制不是等窗口满了才触发,而是每隔固定轮数就做一次“记忆体检”。体检时逐条扫描消息,把低重要性低时效的旧日志压缩进摘要层;把高重要性但被动态层挤掉的,提升到刚性层;把已经完成的历史任务整体收进长期存储,只保留一条任务结果摘要。这个机制最大的好处,是让context-mode从被动裁剪变成主动管理,模型始终看到的是“最新+最重要”,而不是“最新+碰巧没被挤掉”。
2.3 模式切换与提示词注入
模式切换是整个context-mode里最容易被低估的部分。很多人以为切换就是换一套prompt模板,实际远不止如此。
我实现了一套**模式描述符(mode descriptor)**机制。每个模式有三个组件:
- 系统提示模板:定义该模式下AI的角色和行为准则。会话模式强调自然、友好;任务模式强调结构化输出、分步骤推进;知识模式强调基于引用内容回答、不臆造。
- 上下文投影规则:定义该模式如何“读取”上下文。知识模式优先读检索结果和用户问题,对话历史只留最近一轮;任务模式优先读任务卡片、刚性层和最近的进展;会话模式则读完整的动态层。
- 输出格式约束:知识模式要求给出引用来源编号,任务模式要求先列计划再执行,会话模式无特殊约束。
切换发生时,我会先执行“上下文快照”,把当前模式的重要状态存入一个临时槽位,比如会话模式下聊到一半的偏好、任务模式下进行到第几步。然后以快照为基础构造新模式的首轮prompt。这样一个用户从知识模式切回任务模式时,AI能准确说出“我们刚才在改合同第三条,之前已经确定了赔付比例,现在继续看违约条款”,而不是一脸茫然地重新开始。
提示词注入也不是简单拼字符串。我会在每次请求前计算当前已消耗的token,预留出系统提示的位置,避免系统提示本身被裁剪。曾经有段时间我没做这个预留,结果长对话时系统提示被窗口挤掉,模型立刻丢失角色约束,回答风格完全失控。
2.4 上下文隔离与角色区隔
多用户、多任务的场景下,上下文隔离是硬需求。同一个AI实例同时服务A用户和B用户时,如果上下文互相串味,A用户的信息出现在B用户的对话里,这是严重事故。
context-mode里我用**隔离域(isolation domain)**来做区隔。每个用户、每个任务、每个会话都有独立的domain ID,上下文存储、摘要生成、模式状态全部按domain隔离。这个设计类似给每个会话开了一个单独的小房间,房间之间的墙壁不能打通。
同一会话内还要做角色区隔。用户消息、AI消息、工具调用结果、系统内部状态,我都用不同的数据结构存储,提交给模型时用清晰的标识符区分。这样做有两个好处:一是模型能准确理解每段文本的来源和权威性,不会把工具运行的中间输出当成最终答案;二是调试时能快速定位问题,出错了看日志就知道是哪类消息污染的。
3. 手写一个轻量context-mode模块(附代码)
3.1 数据结构:消息队列与优先级索引
光讲概念不给代码等于没讲。我把我项目里精简后的context-mode核心模块展示出来,去掉业务逻辑,保留了设计骨架。
# context_mode.py from dataclasses import dataclass, field from typing import Optional from collections import deque import time import hashlib @dataclass class Message: role: str # "user" | "assistant" | "tool" | "system" content: str importance: float = 0.0 # 0.0 - 1.0 ttl: int = 180 # 有效时长,单位分钟 msg_type: str = "chat" # "chat" | "task_state" | "conclusion" | "knowledge" created_at: float = field(default_factory=time.time) msg_id: str = field(default="") def __post_init__(self): if not self.msg_id: self.msg_id = hashlib.md5(f"{self.role}-{self.content}-{self.created_at}".encode()).hexdigest()[:8] @dataclass class ModeDescriptor: name: str system_template: str projection_rule: str # "chat" | "task" | "knowledge" output_constraint: str class ContextMode: def __init__(self, max_tokens=4000, token_budget=None): self.max_tokens = max_tokens # 预算比例: 刚性层 / 动态层 / 摘要层 = 1 : 3 : 2 ratio = token_budget or {"pinned": 0.17, "rolling": 0.5, "summary": 0.33} self.ratio = ratio self.pinned = [] # 刚性层 self.rolling = deque() # 动态层 self.summary = [] # 摘要层 self.modes = {} self.current_mode = None self.snapshot = None def add_message(self, role, content, importance=0.0, ttl=180, msg_type="chat"): msg = Message(role=role, content=content, importance=importance, ttl=ttl, msg_type=msg_type) # 高重要性消息自动提升到刚性层 if msg.importance >= 0.8: self.pinned.append(msg) else: self.rolling.append(msg) self._trim_if_needed() return msg核心是Message和ContextMode两个类。Message里有importance和ttl两个字段,这就是记忆分级的落地点。ContextMode维护三层存储,add_message时高重要性的直接进刚性层,其他进动态层,然后触发裁剪检查。msg_id用哈希生成,是为了后续做去重和引用,避免同一条消息被反复提交。
3.2 实现滑动窗口裁剪
裁剪逻辑是整个模块的心脏。我按token预算比例把容量分配给三层,每次添加消息后检查是否超额。超额时不是一刀切,而是先尝试把旧消息降级压缩。
def _estimate_tokens(self, text): # 简化的token估算,生产环境建议替换为分词器实际计数 # 中文按字符数约1.2倍估算,英文按单词数约1.3倍估算 zh_chars = sum(1 for ch in text if '\u4e00' <= ch <= '\u9fff') en_words = len([w for w in text.replace('\n', ' ').split(' ') if w.strip()]) return int(zh_chars * 1.2 + en_words * 1.3) def _trim_if_needed(self): pinned_budget = int(self.max_tokens * self.ratio["pinned"]) rolling_budget = int(self.max_tokens * self.ratio["rolling"]) summary_budget = int(self.max_tokens * self.ratio["summary"]) pinned_tokens = sum(self._estimate_tokens(m.content) for m in self.pinned) rolling_tokens = sum(self._estimate_tokens(m.content) for m in self.rolling) summary_tokens = sum(self._estimate_tokens(s) for s in self.summary) # 动态层超额时,淘汰最旧且低重要性的消息,尝试压成摘要 while rolling_tokens > rolling_budget and len(self.rolling) > 1: oldest = self.rolling.popleft() rolling_tokens -= self._estimate_tokens(oldest.content) # 低时效低重要的消息直接丢弃 expiry = time.time() - oldest.created_at > oldest.ttl * 60 if oldest.importance < 0.4 or (expiry and oldest.msg_type == "chat"): continue # 其余进入摘要缓冲区,等待压缩 self._push_to_summary_buffer(oldest) # 摘要层超额时,合并旧摘要 if summary_tokens > summary_budget: self._merge_summaries()这里有个关键决定:动态层淘汰的不是直接扔掉,而是判断消息是否还有“存档价值”。过期且低价值的闲聊直接丢弃,避免摘要层堆积垃圾;有价值的进入摘要缓冲区。实际线上环境中,pinned层很少超限,因为用户主动固定的消息量通常不大,但为了防止用户狂置顶,我也做了保护——pinned层超额时按时间倒序保留,最早的降级到动态层。
3.3 摘要压缩与知识抽取
摘要压缩理论上应该调LLM,但为了模块轻量,先写一个基于规则的占位实现。生产环境我会在这里接LLM调用,规则版的价值在于快速验证流程。
def _push_to_summary_buffer(self, msg): # 摘要缓冲区,简化实现直接进summary content = msg.content # 提取关键词作为摘要雏形 keywords = [w for w in content.split() if w and w not in {"的", "了", "是", "我", "你", "吗", "呢", "啊"}][:10] summary_line = f"[{msg.msg_type}][{msg.role}] {msg.content[:50]}..." self.summary.append(summary_line) def _merge_summaries(self): # 当摘要层超限时,把旧摘要合并成更高层的概括 if len(self.summary) > 3: merged = "之前对话的核心要点是:" for s in self.summary[:2]: merged += s + "; " self.summary = self.summary[2:] + ["MERGED: " + merged] def build_prompt(self, user_input): descriptors = { "chat": ModeDescriptor( name="chat", system_template="你是一个友好且信息密度高的助手。", projection_rule="chat", output_constraint="直接回答用户问题。" ), "task": ModeDescriptor( name="task", system_template="你是严谨的项目助手,请分步骤推进并汇报进展。", projection_rule="task", output_constraint="先列出执行计划,再输出结果。" ), "knowledge": ModeDescriptor( name="knowledge", system_template="你是知识库问答助手,严格基于引用内容回答。", projection_rule="knowledge", output_constraint="若引用内容不足,明确说不知道,并附引用ID。" ) } mode = descriptors.get(self.current_mode, descriptors["chat"]) pinned_text = "\n".join(f"[固定] {m.role}: {m.content}" for m in self.pinned) rolling_text = "\n".join(f"{m.role}: {m.content}" for m in self.rolling) summary_text = "\n".join(f"[摘要] {s}" for s in self.summary) prompt = f"{mode.system_template}\n{mode.output_constraint}\n" prompt += f"=== 会话摘要 ===\n{summary_text}\n" prompt += f"=== 关键信息 ===\n{pinned_text}\n" prompt += f"=== 对话记录 ===\n{rolling_text}\n" prompt += f"=== 用户最新消息 ===\n{user_input}\n" return prompt def switch_mode(self, mode_name): # 切换前保存快照 self.snapshot = { "pinned": list(self.pinned), "rolling": list(self.rolling), "summary": list(self.summary), "last_mode": self.current_mode } self.current_mode = mode_name return self.snapshot def restore_snapshot(self): if self.snapshot: self.pinned = self.snapshot["pinned"] self.rolling = self.snapshot["rolling"] self.summary = self.snapshot["summary"] self.current_mode = self.snapshot["last_mode"]build_prompt最终把三层上下文按“优先级从低到高、越靠近末尾越重要”的顺序拼装。这个顺序是有讲究的,模型对靠近输入末尾的内容通常注意力更强,所以最新的、最关键的固定信息放在最后,系统提示放在最前。
3.4 模式切换与API示例
最后补一个调用示例,展示context-mode在实际服务里的用法。比如一个客服场景,用户刚问完产品知识(knowledge模式),然后转入售后处理(task模式)。
# 使用示例 cm = ContextMode(max_tokens=4000) cm.current_mode = "knowledge" cm.add_message("user", "音箱怎么连接蓝牙?", importance=0.3) cm.add_message("assistant", "步骤:1. 按电源键开机;2. 同时按住音量加减3秒进入配对;3. 在手机蓝牙列表选择设备。", importance=0.6, msg_type="conclusion") # 用户切入新问题,切换到任务模式 cm.switch_mode("task") cm.add_message("user", "我试了还是连不上,报错代码E2", importance=0.85) prompt = cm.build_prompt("帮我排查E2错误对应的售后流程") print(prompt)运行这段代码,生成的prompt里会保留之前的知识结论摘要,并把用户的新问题作为最新输入。模型就能理解用户是先遇到蓝牙连接问题,再报错E2,从而给出针对性的排查而不是泛泛而谈。这就是context-mode最基本的价值:让多轮对话不再“失忆”。
4. 生产环境避坑:context-mode常见问题速查
4.1 上下文污染与串台
这是上线后遇到最多的事故类型。现象是A用户的消息出现在B用户的回复里。排查到最后,九成是因为缓存键设计不完整。我最初只用user_id做隔离,没想到同一个用户同时开两个会话时会串台,后来改成user_id + session_id + task_id三重key,才算彻底解决。
这事的教训是:隔离域的划分必须结合业务流程,不能想当然只按用户分。特别是Agent自动执行任务的场景,一个用户可能同时跑多个任务,每个任务必须有独立domain,任务结束后domain要主动销毁,不能堆积在内存里等垃圾回收。
4.2 关键信息被截断丢失
滑动窗口最容易误伤的就是“重要但旧”的信息。用户在第20轮提到的关键偏好,到第100轮时肯定被滚出去了。我踩过最狠的一次,是用户在一个长会议纪要里说过“预算不超过5万”,结果对话进行到60轮,模型开始推荐8万的方案,用户当场炸毛。
解决方案就是在刚性层上下功夫。我做了一条规则:凡是用户以“记住/注意/一定/别”开头或包含这些词的消息,自动给importance加到0.9以上并进刚性层。另外在做摘要压缩时,强制保留数字、否定词、专有名词这三类信息。因为从经验看,被压缩后丢失最多的就是数字约束和否定性条款。
- 数字约束:“预算不超过5万”“最多3次”“截止本周五”
- 否定性条款:“不要用方案B”“别再发短信”“不能透露给第三方”
- 专有名词:“项目代号银河”“用户王总”“服务器IP 192.168.x.x”
摘要生成时如果发现这些信息,必须原样保留,禁止概括。我在压缩模块里用正则预先扫描这些模式,查到就单独摘出来放进pinned层,效果立竿见影。
4.3 token预算反复超支
token超支通常不是突发,而是逐步累积的。最常见的原因是工具调用结果塞爆了上下文。我的Agent每次调用数据库查询或API,会把原始返回完整塞进消息队列,有些查询结果几千上万token,两三次调用就把预算吃光。
后来我加了一条规则:工具调用结果必须经过“提炼”才能进入上下文。只保留schema结构、影响行数、前N条记录和错误信息,完整结果写入外部临时存储供需要时检索。提交给模型的是精简后的结构化内容。这个改造直接把上下文消耗降了60%。
另外还要防“自我膨胀”。模型生成长回答时,如果每次都整段塞入动态层,同样会快速耗尽空间。我现在的做法是,对assistant消息按段落拆分,只保留结论段落完整token,过程性推导转成一句话摘要。用户真的需要细节时,再展开历史记录。
4.4 调试上下文状态的高效技巧
context-mode逻辑链路长,出问题很难一眼定位。强烈建议从一开始就把三类日志打全:
- 模式切换日志:什么时间、从什么模式切到什么模式、快照是否生成。
- 裁剪日志:哪条消息被裁剪、裁剪原因(过期/低重要/超预算)、进了摘要层还是直接丢弃。
- token预算日志:每次请求前各层实际token数、估算值、剩余预算。
我还会在开发环境加一个“上下文透视”接口,返回当前完整的pinned/rolling/summary内容,模拟用户视角审查。实际调试时,我是靠这个接口对比“模型看到的上下文”和“用户期望的上下文”之间的偏差,才逐步定位出很多隐藏问题。
另外一个实用技巧:给每条消息加msg_id不光是为了去重,还能用来做上下游链路追踪。用户反馈“回答不对”时,我可以直接反查出是哪几条消息进入了prompt、在哪一步被压缩、当时用的什么模式。没有id的情况下复盘,只能靠猜。
5. context-mode后续可以怎么扩展
我目前这个版本其实还有很多可玩的地方。比如让模式切换自动化,通过意图识别判断用户当前是需要知识检索还是任务执行,不需要用户手动指定。我正在做的是把LLM意图识别结果和context-mode的switch_mode联动,识别到“帮我查资料”就自动落到knowledge模式,识别到“开始改方案”就切到task模式。
另一个想做的方向是引入“多Agent共享上下文”。多个Agent协作时,每个Agent有自己的局部上下文,但需要共享一个全局状态。这需要在现有context-mode外加一层分布式状态同步,我没有找到特别优雅的开源方案,目前是用事务日志的方式把关键状态变更广播给所有Agent,感兴趣的同行可以沿着这个方向试试。
我踩过几次坑之后最大的体会是:context-mode不是简单给AI加记忆,而是让AI学会像人一样区分“什么该记”“什么该忘”“什么时候回忆”。窗口管理是骨架,记忆分级是灵魂,模式切换是动作,隔离设计是边界,四者缺一不可。如果你也在做类似的东西,建议别先追求大而全的框架,把我的最小闭环跑通,再一层层往上加策略。