
最近我把自己的命令行 AI 助手重构了一版最核心的改动就是加了 context-mode。说起来挺玄乎其实就是一套“上下文管理模式”决定哪些历史信息该留给模型、哪些该丢进长期记忆、哪些干脆丢掉。如果你也在做 AI 对话产品、知识库问答或者单纯被“AI 记不住前面聊了什么”搞到抓狂那这篇应该能给你一个比较完整的思路甚至可以直接照着搭一版。我最初遇到的问题可能和你很像对话稍微长一点模型就开始胡言乱语用户提了个旧需求系统完全想不起来为了让它记住我只能无限堆 prompt 内容结果响应又慢又贵。后来我把 context-mode 当成独立模块来做才发现这根本不是什么“加个记忆数组”能解决的事本质上它是在做上下文资源管理。下面我会把设计思路、数据结构、组装流程、参数调整以及我踩过的坑完整展开分享。1. context-mode 到底解决什么问题1.1 从“AI 失忆”和“上下文窗口焦虑”说起大语言模型的上下文窗口是有限资源不管是 8k、32k 还是 128k窗口总会被聊满。窗口一满早期的内容就会被截断模型自然就“失忆”了。用户不会体谅你的 token 限制他们只会觉得“你刚说完转头就不认账”。这才是 context-mode 出现的真正原因不是让模型记住所有话而是让它始终能看到最应该看到的那部分信息。从工程角度来看context-mode 更像一个信息调度系统。每次请求不能只会傻乎乎地把整段历史塞给模型而是先梳理出三样东西当前任务目标是什么、已经确认过哪些事实、最近几轮对话里有哪些瞬时状态。我习惯把它类比成一位临时接手工作的同事你要给的不是三个月的会议纪要而是“现在要干什么、已经定下来哪些结论、最近发生了什么”。这个模式也有很明显的收益第一响应速度变快因为输入 token 少了第二成本明显下降尤其在企业级高并发场景第三回答质量反而更稳定因为模型不再被大量无关历史干扰。我见过不少团队一开始用最简单的“把聊天记录全拼进去”方案短时间没问题但对话超过二十轮后就开始失控。context-mode 就是想从根上解决这种“聊天记录越攒越多反而什么都说不清”的问题。1.2 适合用在哪不适合用在哪我建议先想清楚场景再决定要不要上 context-mode。它特别适合这几类应用一是知识库问答用户会反复追问不同细节二是客服或销售助手客户历史信息和当前诉求需要同时考虑三是代码辅助和写作辅助上文的代码风格、变量定义、需求变化都要持续跟踪四是个人助理类工具比如“记住我喜欢喝美式不加糖”这种跨会话偏好。不适合的场景也有不少。如果每次任务都是独立生成的比如批量翻译、一次性文案改写那引入 context-mode 反而可能引入噪声如果系统有严格的确定性要求比如金融计算、接口调用那上下文里多一段模糊的历史描述就可能造成严重误判。我在项目中就吃过亏把用户上一轮随口说的“可能改天”也放进上下文结果模型在关键操作前犹豫了差点耽误流程。记住context-mode 的目的不是把所有信息都留住而是把有效信号筛出来。另外context-mode 对用户无关的相对隔离也很重要。如果同一个模型服务给多个用户用A 用户的偏好混进 B 用户的上下文里就会出现“串味”。后面我会专门讲这个坑但场景设计阶段就要确认你是单用户本地玩具还是多租户在线服务两者的上下文结构完全不同。2. 整体设计思路把上下文当资源来调度2.1 三层上下文结构我最终是把上下文拆成了三层短期上下文、工作上下文、长期上下文。三者不是互相替换的关系而是按不同生命周期做协同。短期上下文就是最近的对话轮次比如最近 6 到 10 轮主要保证对话连贯性。它更新最快每轮对话都会追加满了就滚动淘汰。工作上下文则负责“当前任务的最新状态”比如用户正在配置一套 CI 流程已经选了 Python 3.11、允许失败重试、日志级别改成 debug这些结论必须随时能被模型看到。长期上下文则是跨会话的知识沉淀包括用户偏好、历史总结、常用术语、项目背景等它们通常不会因为一轮对话结束就失效。这三层对应到代码里就是三种不同的存储策略。短期可以用内存队列工作上下文用结构化对象长期则要落库并做向量索引。很多教程只教你“把历史聊天的文本拼起来”那只是短期上下文的一部分根本算不上 context-mode。真正的上下文模式必须明确每一条信息的来源、用途和有效期。我整理了一个对比表方便你判断自己系统里该把信息放哪层上下文类型典型信息生命周期更新方式进入 prompt 的方式短期上下文最近几轮问答、临时修正分钟级每轮追加、滚动淘汰按最近顺序截断工作上下文当前任务目标、中间结论任务级边聊边更新结构化字段汇总成条目长期上下文用户偏好、历史知识点跨会话异步写库、定期摘要向量召回后追加2.2 核心原则不是“越全越好”而是“越准越好”刚开始做 context-mode 时我的直觉是上下文越全模型就懂得越多。结果测试下来发现上下文一旦超过某个量级模型的有效注意力反而会下降。这个现象很像人开会桌上摆了一百份文件你以为自己信息很大实际上大多数人只会翻最上面那三份。所以我定下了一个原则每条进入上下文的文本都必须回答一个问题——它对完成当前任务有没有直接帮助没有帮助就砍掉。具体执行时我会给每条消息打一个“价值标签”比如“需求确认”“偏好设置”“闲聊”“脱敏信息”“报错日志”。组装 prompt 时不看资历只看当前任务类型是否命中标签。这带来一个副作用我不得不频繁清理短期上下文里的“废话”。比如用户某轮说“今天天气不错”如果不加过滤它会在后面十轮里持续占着 token。用 context-mode 后我会把这类与任务无关的内容直接标记为 noise不进入模型输入。这里的取舍很现实模型原本可能从闲聊里理解用户情绪但为了稳定性和成本我必须优先保障核心业务信息的完整。这个原则也改变了产品需求文档的写法。过去我们说“支持多轮对话”现在应该写“支持在多轮对话中切换任务时仍然保留关键上下文”。比如用户先问代码怎么写再问怎么部署最后又回来修改代码系统要能把“部署环节的关键参数”和“代码功能需求”分开保存而不是揉成一团。能做到这一点才算真正理解了 context-mode。2.3 会话隔离与任务标识如果做的不是单用户工具那就必须在 context-mode 里加入一层“隔离”概念。最基础的是会话 IDsession_id每个用户或每轮客服工单都拥有独立的上下文空间。但只有 session_id 还不够因为一个会话里可能涉及多个任务。比如用户在同一个对话里既问财务报销制度又开始聊项目排期两者的上下文如果混在一起模型再聪明也会混乱。我的做法是引入 task_id用来标记当前正在处理的业务主题。每次用户发消息时先做意图识别判断该消息属于哪个任务如果新任务开启就生成新的 task_id并把旧的中间结论留存在工作上下文中后续如果用户切回旧任务再按 task_id 把那一组信息取回来。这个设计让我少踩了很多坑。以前我遇到过用户在一个对话框里连续问了十几个不同问题模型到了后面完全忘记第一个问题的答案原因不是模型能力差而是所有上下文都被塞进同一个序列任务切换时没有任何标记。加入 task_id 后上下文变得像文件夹而不是一团流水账。每个任务文件夹里装着各自的短期窗口、确认过的条件、生成过的摘要。组装 prompt 时只取当前任务相关的内容再加上必要的全局偏好效果立刻好了一大截。3. 核心实现把 context-mode 做成能跑的流程3.1 数据结构先让每条消息变成可管理对象写代码之前首先要定义清楚“一条上下文消息”长什么样。我用的是 Python直接定义成一个 dataclass。别小看这一步很多项目没做好上下文管理就是从一开始就把消息当成纯字符串存着后来想加标签、加时间、加任务归属都无从下手。from dataclasses import dataclass, field from typing import Any, Dict, Optional dataclass class Message: role: str # system / user / assistant content: str timestamp: float task_id: str # 当前任务标识 session_id: str # 会话标识 memory_type: str short # short / working / long tags: list field(default_factorylist) # [requirement, preference, noise] meta: Dict[str, Any] field(default_factorydict)这里有几个字段容易忽略。tags 尤其重要我在前文提到的“价值标签”就是靠它实现的。memory_type 则用来决定这条消息是进入短期队列、更新工作摘要还是写入长期向量库。timestamp 用于时间衰减和过期回收防止旧数据永久占据空间。from collections import deque class ContextStore: def __init__(self, max_short_turns: int 10): self.short_memory: deque[Message] deque(maxlenmax_short_turns) self.working_memory: Dict[str, Message] {} self.long_memory: list[Message] [] def add(self, msg: Message): if msg.memory_type short: self.short_memory.append(msg) elif msg.memory_type working: self.working_memory[msg.task_id] msg elif msg.memory_type long: self.long_memory.append(msg) def get_short(self, task_id: str) - list[Message]: # 只取匹配当前任务且未被打上 noise 标签的消息 return [m for m in self.short_memory if m.task_id task_id and noise not in m.tags]这个数据结构最直接的好处是组装 prompt 时我能按 memory_type 分批取用而不是把 300 条消息一股脑全拿出去。注意我没有一开始就引入数据库如果单机内存能支撑就先跑内存版等长期记忆量大了再迁移到 SQLite 或向量数据库。3.2 Prompt 组装按 token 预算动态分配有了数据结构下一步就是把上下文“拼装”成最终发给模型的 prompt。这个步骤我甚至不建议直接在业务代码里随手写因为拼接顺序和长度控制对效果影响极大。我的经验是把组装函数独立出来输入是 ContextStore 和当前用户的输入输出是完整的 messages 数组。关键点在于预算分配。假设当前模型输入上限是 8k tokens我不会真的把 8k 全用完因为输出也要占 token而且往往需要留 10% 到 20% 给格式、工具调用和意外情况。我把 8k 的 80%也就是 6.4k作为实际可用预算然后按比例分配def build_messages( store: ContextStore, task_id: str, user_input: str, total_budget: int 6400, ) - list[dict]: system_prompt 你是我的助理回答尽量简洁必要时追问。 system_tokens estimate_tokens(system_prompt) working_items store.get_working(task_id) working_summary format_working_memory(working_items) working_tokens estimate_tokens(working_summary) # 长期记忆常用向量召回得到这里先占 20% 预算 recalled store.recall_for_task(task_id, top_k3) recalled_text \n.join([m.content for m in recalled]) recalled_tokens estimate_tokens(recalled_text) # 短期上下文占 50%其余给用户输入 recent_messages store.get_short(task_id) short_tokens min(estimate_tokens(format_msgs(recent_messages)), int(0.5 * total_budget)) remaining total_budget - system_tokens - working_tokens - recalled_tokens - short_tokens # remaining 中还要扣除用户输入自身的 token最终得到历史部分可用长度 user_tokens estimate_tokens(user_input) history_tokens max(0, remaining - user_tokens) # 在 short_memory 里从后往前截断到 history_tokens 以内 trimmed truncate_from_tail(recent_messages, history_tokens) messages [{role: system, content: system_prompt}] if working_tokens: messages.append({role: system, content: working_summary}) for m in recalled: messages.append({role: system, content: f[记忆] {m.content}}) for m in trimmed: messages.append({role: m.role, content: m.content}) messages.append({role: user, content: user_input}) return messages不要被代码吓到核心逻辑其实就是五个字按预算切分。为什么这么切因为我实践下来system prompt 保证全局规则工作上下文保证任务方向长期记忆补充背景知识短期上下文维持最近语气和细节。四个部分各司其职任何一块超长都会挤压其他部分。比如你从长期记忆召回一堆百科词条短期多轮对话就被截断了用户上一轮刚说的关键条件反而被丢掉。3.3 长期记忆用向量召回把旧信息拉回来context-mode 和“把所有历史都存储起来”最大的区别在于长期记忆通常不是直接全部进 prompt而是通过检索按需进入。我用最简单的方式做这件事把每条长期记忆文本转成 embedding 向量用户新输入也转向量然后算余弦相似度召回 top-k。def cosine_sim(a: list[float], b: list[float]) - float: dot sum(x * y for x, y in zip(a, b)) norm_a sum(x * x for x in a) ** 0.5 norm_b sum(x * x for x in b) ** 0.5 return dot / (norm_a * norm_b 1e-9) def recall(context_store: ContextStore, query_vector, top_k3): scored [] for item in context_store.long_memory: if item.meta.get(vector): sim cosine_sim(query_vector, item.meta[vector]) scored.append((sim, item)) scored.sort(keylambda x: x[0], reverseTrue) return [item for _, item in scored[:top_k]]这里有个容易被忽略的经验长期记忆不能只靠一次向量召回就结束。如果用户现在问的是“上次那个数据库连接超时怎么解决的”直接检索原文可能是“连接超时”但真正有用的记忆其实是“当时确认了是防火墙端口没开”。所以我后来加入了事实抽取环节在写入长期记忆前先用模型把每段对话压缩成几条结构化事实比如“防火墙端口 3306 未开放导致连接超时”。这样向量召回命中率会高很多。召回策略我采用的是“混合调度”既要做向量召回又要引入时间衰减。一条记忆即使相关度很高如果已经过去了三个月而当前任务又和它没有强关联它的优先级应该下降。我给相似度乘了一个时间衰减因子基本是score similarity * pow(0.95, days)。这个衰减函数不用太精确目标是让近期信息稍微占优同时保留真正重要的旧信息。3.4 上下文压缩让记忆“越用越薄但越准”长期记忆如果一直追加最终仍然会爆炸。所以 context-mode 必须有一个“压缩”机制。我的做法是每当一个任务的短期上下文累积超过 N 条比如 20 条就触发一次对话摘要把这段历史变成一份结构化 summary再清空短期队列前的一部分内容。压缩有两种方式抽取式和摘要式。抽取式是直接保留那些被你打过“requirement”标签的句子删掉闲聊和重复内容摘要式则是调用模型用一小段话描述整段对话。我建议先做抽取式因为它不消耗额外的模型调用也不改变原句信息。只有当抽取结果太长才升级到摘要式。def compress_short_memory(store: ContextStore, task_id: str): messages store.get_short(task_id) if len(messages) 20: return # 抽取带关键词的原始句子作为长期记忆候选 long_items [m for m in messages if requirement in m.tags] for item in long_items: item.memory_type long item.meta[kind] extracted_fact store.add(item) # 旧消息标记为已压缩下次组装时忽略 for m in messages: m.meta[compressed] True压缩本身还会解决另一个问题短期窗口里存了很多重复内容。用户会在不同时间用不同措辞表达同一个需求比如“用 Python”和“python 就行”。如果没有抽取压缩这两条会同时出现在 prompt 里浪费 token。压缩后只保留“语言Python”这样一条事实反而让模型更明确。我一直认为context-mode 的精髓就在于“越用越薄但越准”而不是让记忆无限膨胀。4. 实操中容易踩的坑4.1 上下文污染不同任务和不同用户的信息串味我敢说绝大多数 context-mode 翻车现场都是因为上下文污染。最常见的现象是用户 A 的偏好被拼进了用户 B 的会话或者上一个任务没清理干净当前任务还在引用旧任务里的临时变量。模型看起来是在胡说八道实际是输入数据出了错。解决方案是“双重隔离”。第一层是 session_id确保不同用户的数据物理隔离第二层是 task_id确保同一用户的不同任务逻辑隔离。组装 prompt 时必须把 session_id 和 task_id 当成过滤条件来使用。以我上面的get_short函数为例我专门加了对task_id的过滤就是这个原因。如果发现模型突然提到“你之前说要买那个”第一反应不是怀疑模型而是去查上下文里是否混入了其他人的消息。另外还要小心“隐形污染”比如 system prompt 模板里的全局例子。有些团队为了让模型更像客服会在 system prompt 里写入各种业务示例这些示例也算上下文。如果示例包含了不适合当前用户的信息比如其他客户的名字那同样属于污染。我会把所有业务示例和真实用户上下文分开存放用 placeholder 动态传入。4.2 token 预算被“看不见的损耗”吃掉我在做 3.2 的代码时估算 token 用的estimate_tokens有很多种实现但最初我用的是“字符数除以 4”这种粗略公式。中文还好英文容易炸因为英文一个词常常就是一个 token字符数除以 4 误差很大。建议接入真实 tokenizer比如 OpenAI 的tiktoken或者其他模型的 tokenizer。没有条件实时 token 化时宁可把 token 数估高 20%给自己留余量。另一个损耗点是结构化内容。为了让模型输出 JSON我会在 system prompt 里塞一大段 JSON Schema 描述。这部分也是 token而且它不参与“历史信息”的调度应该在预算分配前单独拿出来。你如果不单独扣掉很快会发现实际可用的历史 prompt 比想象中少很多。我的经验是总预算最多用 85%剩下的 15% 永远留给格式、函数调用和补全中断情况。4.3 向量召回不精准甚至拉低回答质量有的 long memory 内容看着相似但对当前任务没有帮助。比如用户之前问过“如何部署两个服务”你把这个记忆存下来了后来用户问“如何部署数据库”虽然“部署”两个字很像但两条记忆的上下文完全不同。机械地通过相似度召回会把不相关的部署经验塞进数据库对话里反而让模型分心。我的改进是加入“召回后过滤”。向量召回找出 top 20再用一个轻量判断对每个候选题做二次筛选主要看 task_id、标签、时间衰减和实体重叠。比如当前任务里有 “数据库”长期记忆的 meta 里如果同时包含 “数据库” 关键词就提升排序否则直接降权。如果二次筛选后仍然没有高置信候选宁可只给工作上下文和短期上下文不硬塞长期记忆。4.4 排查与调试怎样“看见”context-modecontext-mode 是一个中间层用户能感知到的只有模型的回答。出错时如果只看最终回答很难定位是模型本身问题还是上下文组装问题。我强烈建议在开发阶段加一个 debug 开关打印出最终发给模型的完整 messages并在每条消息前标注来源。比如[system] 全局规则你是我的助理回答尽量简洁。 [system] 工作上下文[task: deploy-db]数据库类型MySQL, 端口3306, 需求排查超时 [system] 长期记忆[recall-0]用户曾在2023-06-01提到防火墙端口开放问题 [user(short)] 当前输入数据库还是连不上怎么办看到这个输出我就能一眼判断出问题是短期上下文把旧任务“deploy-web”的消息混进来了还是长期记忆召回了无关内容还是预算不足把用户的输入截断了。除了打印我还会记录每次请求的 token 统计system 用了多少、回忆用了多少、短期用了多少、用户输入多少。比例异常时主动告警。经过这些调试我的 context-mode 从“碰运气”变成“可观测”。5. 写在后面一点个人体会做 context-mode 做了几个月我最深的体会是这个功能不是“加个记忆数组”而是重新思考了“模型该看什么”。最开始我也迷信大窗口后来发现即使给了 128k 上下文模型也会在无用信息里迷失。真正决定对话质量的反而是信息取舍逻辑。我建议你做的时候先跑一个没有向量检索的版本只靠短期上下文工作上下文把截断逻辑调顺再逐步加入长期记忆。一开始就上完整版你根本分不清是召回问题还是截断问题。我吃过这个亏前期所有 bug 都堆在一起排查时恨不得把整段代码重写。最后分享一个小技巧我会给长期记忆里的每条信息加一个last_accessed_at字段每次被召回成功就把这个时间戳刷新。这样既能用来做时间衰减又能发现“哪些记忆永远召不回”——如果半年都没被访问过说明它要么不够重要要么索引方式不对。顺着这个思路去清理记忆库context-mode 才会越跑越顺手而不是越跑越臃肿。如果你也在调类似的东西欢迎对比这些细节很多时候差别不在模型就在这些没人愿意写的“上下文管理”代码里。