设计与工程实践)
做了一段时间大模型应用开发我发现大家问得最多的其实不是“怎么调 prompt”而是“为什么我的机器人聊着聊着就失忆了”。前一秒还记住的需求后一秒就忘得干干净净甚至把 A 项目的要求套到 B 项目上。这种问题十有八九出在上下文管理上也就是今天想聊的 context-mode中文可以叫“上下文模式”。它不是某个库也不是某个模型特性而是一套管理对话上下文的工程方案明确告诉系统“当前这段对话该记住什么、该丢掉什么、该从哪取信息”。这篇文章我会把它的设计思路、关键参数、可复现代码和踩坑记录都摊开讲一遍适合正在接大模型 API 做 ChatBot、Agent、AI 编辑器的开发者参考。1. 为什么需要上下文模式从“失忆”到“选择性记忆”1.1 大模型的上下文到底是怎么工作的先花两分钟把底层机制讲清楚不然后面谈设计都是空的。大模型生成回复时并不是“记住”了你所有的对话而是把你发给它的全部内容一次性放进一个固定大小的窗口里这个窗口叫上下文窗口。窗口里放的文字会被拆成一个个 token模型每生成一个新 token都要重新看一遍窗口里的所有内容靠注意力机制决定“此刻最该参考哪部分”。你可以把它想象成一个人桌面上能摊开的便签纸便签总数有限窗口就是桌面大小。桌面越大能摊开的便签越多但不管多大总有放不下的时候。更麻烦的是模型对便签的注意力不是平均分配的离当前时间越近的内容越容易被重视早期内容哪怕还在桌面上也可能被“视而不见”。这就解释了为什么长对话聊到最后模型表现越来越像失忆不是它丢了而是早期便签被挤到角落或者干脆被系统截掉了。1.2 暴力方案的代价全塞、只留最近、手动挑很多开发者的第一反应是“那我全塞进去不就行了”。窗口不够大就换更长上下文的模型这确实是最省事的方案但代价是成本和质量的同步恶化。每次请求都要把所有历史重新处理一遍Token 消耗随对话轮数线性增长调用费用越来越贵响应延迟也越来越高。而且就算换成超大窗口的模型注意力分散的问题依然存在早期关键信息往往被海量闲聊稀释模型抓不住重点。第二类做法是只保留最近 N 轮对话。这个方案省 token但问题同样明显用户在第 3 轮提过一个核心约束比如“不要用 Markdown 表格”到了第 20 轮你只回传最近 10 轮模型压根不知道这条约束的存在自然就违反了。还有第三类做法让开发者或者用户手动挑选重要的历史消息重新粘贴进去这在小范围测试时可行一旦面向真实用户体验就是灾难。这三类方案的共同症结是一样的上下文是“一锅乱炖”没有结构、没有优先级、没有来源区分。context-mode 的思路就是给上下文建立管理模式像整理文件夹一样按需组装而不是把所有东西都堆在桌面上。2. 核心细节解析上下文模式的架构与关键参数2.1 三种基础模式的定义与选型我在实际项目中把上下文模式收敛为三种分别对应不同的对话场景简单好记也够用。模式名称适用场景上下文来源优点缺点全局模式Global开放性闲聊、头脑风暴、早期探索全部历史消息信息完整上下文连续Token 消耗大注意力易分散任务模式Task代码生成、文档撰写、数据分析当前任务相关的消息、系统指令聚焦、省 Token、响应质量高需要识别任务边界记忆模式Memory需要长期事实、用户偏好、项目背景外部存储向量库/结构化记录低成本获取长期信息依赖检索质量需额外构建全局模式适合对话刚开始、历史还不多的时候这时候全量塞入没问题信息也最完整。任务模式适合中后段对话已经积累了大量的无关内容比如寒暄、试错过程、中间态输出只保留和当前任务直接相关的消息模型反而更专注。记忆模式是三者里最有长期价值的它不依赖当前对话历史而是从外部记忆系统里拉取与本次请求相关的背景信息比如用户的身份偏好、历史结论、项目约定。需要说明的是这三种模式不是非此即彼的关系而是可以在一次请求里组合使用。我的做法是先用记忆模式检索出相关背景再按任务模式选取当前对话片段最后拼成一份精简后的 messages 数组发给模型。2.2 Token 预算的量化计算方式很多人上来就写代码结果上下文拼到一半才发现超了窗口只能硬截断截出来的内容读起来都是半句话。正确做法是先算预算再决定装什么内容。预算公式非常简单可用上下文 模型窗口总大小 − 系统提示词 − 固定指令模板 − 回复预留举个例子假设我用的是一个 4K 窗口的模型系统提示词占了 600 token固定指令模板比如输出格式要求占了 400 token我还要给模型预留 800 token 用来生成回复那么真正可用的历史上下文只有4096 − 600 − 400 − 800 2296 token。这 2296 token 才是你能分配给对话历史和检索记忆的全部预算。拿到这个数字之后再拆分内部结构。我一般会留 60% 给最近对话、30% 给记忆召回、10% 给工具调用结果。按上面的例子就是大约 1377 给对话、689 给记忆、229 给工具结果。这里有个估算 Token 的小技巧中文场景下 1 个汉字大约等于 1.5 到 2 个 token英文 1 个单词约等于 1.3 个 token。代码场景波动很大最好直接用 Tokenizer 工具去算不要凭感觉。预算算好之后所有模式的选择和裁剪策略才有依据。否则你根本不知道当前到底能装多少内容只能走一步看一步迟早翻车。2.3 模式切换的决策逻辑模式切换有两种触发方式显式切换和自动切换。显式切换是用户在输入里带指令比如“切换任务模式”“记住这条规则”系统识别到后立即生效。自动切换则是系统根据对话内容判断比如检测到用户开始问一个与当前对话无关的新问题时自动把任务模式切换回全局模式或者记忆模式。我实际落地的时候采用了一个简单的规则引擎优先级从高到低用户显式指令优先识别到类似“进入任务模式”的指令就立即切换。系统检测到新的任务关键词且当前模式不是全局模式时自动降到全局模式重新收集信息。如果对话消息数量超过窗口预算的 80%强制切到任务模式并压缩历史。这里要特别提一点自动切换是锦上添花不要让它在初期做得太复杂。我见过不少项目想一开始就做“智能路由”让模型自己决定用什么模式结果模式切换的逻辑混乱用户完全无法预期机器人的行为。最稳妥的做法是先做显式切换跑通以后再逐步增加自动规则并且每次切换都要有清晰的日志记录。3. 实操过程实现一个最小可用的 context-mode3.1 会话数据的结构化设计context-mode 能不能落地关键在于数据是不是结构化的。如果你把整个会话当做一个纯文本数组那无论设计什么模式都白搭因为没有办法按模式筛选。一定要给每条消息加上元数据。一个建议的会话存储结构# 会话结构示意 session { session_id: sess_0001, mode: global, # 当前模式 mode_history: [], # 模式切换记录便于回溯 messages: [ { role: user, content: 帮我把这段数据按月份汇总, mode: task, # 这条消息产生时所处模式 timestamp: 1720000000, importance: high, # 手动/自动标记的重要度 }, { role: assistant, content: 这是汇总结果..., mode: task, timestamp: 1720000006, importance: normal, } ], }其中mode字段记录消息产生时所在的模式importance用于高价值信息标记。注意importance字段初期可以先不依赖因为自动判断重要度是个不小的工程如果判断错了带来的负面影响比不加还大。先保留字段但默认全部给normal后续再训练一个简单的分类器去标。3.2 上下文组装函数与核心代码有了结构化会话接下来就是写上下文组装函数。核心逻辑很简单根据当前模式和 token 预算从会话中筛选出符合条件的消息再组装成 messages 数组。def select_messages_by_mode(messages, mode, max_tokens): 按模式筛选消息返回不超预算的消息列表 # 任务模式优先取重要性高、且产生于同一任务范围内的消息 if mode task: candidates [m for m in messages if m.get(importance) high] if not candidates: candidates messages[-10:] # 没有标记时兜底取最近10条 # 全局模式取最近的N条直到预算用尽 elif mode global: candidates list(messages) # 记忆模式不直接取历史由外部检索结果填充这里只保留当前轮 else: candidates messages[-2:] selected [] total 0 for msg in reversed(candidates): # 从最新往旧遍历 token_len estimate_tokens(msg[content]) if total token_len max_tokens: break selected.append(msg) total token_len return list(reversed(selected)) # 再反转回时间正序 def build_context(session, memory_hits, model_window4096): 组装最终发给模型的 messages # 固定占用 system_prompt load_system_prompt() # 约600 token instruction load_instruction_template() # 约400 token reserve_reply 800 # 模型回复预留 available model_window - system_prompt - instruction - reserve_reply # 分配预算历史对话60%、召回记忆30%、工具结果10% history_budget int(available * 0.6) memory_budget int(available * 0.3) tool_budget int(available * 0.1) history_msgs select_messages_by_mode( session[messages], session[mode], history_budget ) messages [{role: system, content: system_prompt}] messages [{role: system, content: instruction}] messages build_memory_messages(memory_hits, memory_budget) messages history_msgs messages build_tool_messages(tool_results, tool_budget) messages.append({role: user, content: current_query}) return messages这段代码做了三件事预先分配预算、按模式筛选历史、按固定顺序拼接消息。顺序很关键回忆、历史、工具结果的相对位置会影响模型注意力我调试下来把记忆放在历史前面、紧接着是最近对话的排列效果最稳定。3.3 模式切换时如何避免“断片”我见过的另一个高频翻车点用户在任务模式里聊了一个小时突然说“切回全局模式”模型完全不记得之前聊过什么。为什么因为切换模式时历史消息被大幅裁剪了模型等于失忆。解决方案是切换时主动告诉模型一次“发生了什么”。具体做法是在切换模式时往消息队列里插入一条系统级消息内容是当前状态的摘要。例如def switch_mode(session, new_mode): session[mode_history].append({ from: session[mode], to: new_mode, time: datetime.now().isoformat(), }) summary generate_session_summary(session[messages]) session[messages].append({ role: system, content: f[上下文模式切换] 从 {session[mode]} 切换到 {new_mode}。 f此前对话的关键信息摘要{summary}, mode: new_mode, timestamp: int(time.time()), importance: high, }) session[mode] new_mode这样切换之后哪怕后续组装上下文时旧消息被裁剪掉模型依然能从这条系统摘要里获得关键背景。要注意摘要生成得控制 token 数我的经验是 150 到 300 token 之间比较合适太短丢信息太长喧宾夺主。3.4 与模型 API 对接的完整示例最后把组装好的上下文交给模型这一步没什么玄机直接看代码messages build_context(session, memory_hits, model_window4096) response openai_client.chat.completions.create( modelgpt-4o-mini, messagesmessages, temperature0.7, max_tokens800, ) reply response.choices[0].message.content唯一想提醒的是max_tokens必须和预算里预留的回复空间一致。我在上文预留了 800 token 给回复这里就设 800。如果两者不一致要么实际输出被截断要么窗口超限报错这类问题在日志里非常隐蔽排查起来很痛苦。4. 常见问题与排查技巧实录4.1 问题速查表把我在调试和线上遇到的问题整理成一张速查表按照现象、原因、解决方案的格式记录遇到同类问题时可以直接对照。现象可能原因排查与解决切换模式后模型忘记之前的关键约定切换时没有生成摘要旧消息被裁剪参照 3.3 节切换时插入系统级状态摘要上下文被截断最后一句总是半截话预留回复 token 不够或裁剪逻辑截断了最后一条消息检查回复预留值与 API 的 max_tokens 是否一致裁剪时对超预算的消息做整体丢弃而不是切一半Token 消耗增长异常费用失控所有历史消息都在往全局模式里塞检查消息是否都带 mode 标记确认自动切换规则的阈值是否生效召回的记忆内容和当前问题无关向量检索的 top_k 过大或检索引擎没有按时间衰减减小 top_k对召回结果按相关度阈值过滤必要时加时间衰减权重模型变得“话痨”重复已知信息上下文里记忆和近期对话重复度太高做好去重检索到已经在历史中出现过的内容时跳过明明设了任务模式模型还是抓不住重点任务边界识别不准把无关消息选进来了检查 importance 标记规则或者把任务模式的候选范围从“高重要度”放宽到“最近 N 条”4.2 容易被忽略的三个坑第一个坑系统提示词的隐性占用。很多人算预算时只算了对话历史忘了系统提示词、指令模板、few-shot 示例也都是 token。等到请求报错才发现超限。我在项目里养成一个习惯每次变更系统提示词后跑一遍 token 统计脚本把实际占用打印出来写进 CI 检查避免悄悄超标。第二个坑工具调用结果也得算进上下文。如果你的 Agent 会调用搜索、数据库查询、代码执行这些工具返回的结果最终都会拼进 messages 里而且通常内容很长。我在预算分配里特意留了 10% 给工具结果就是为了防止这部分把历史挤掉。第三个坑摘要要结构化不要让人工智障自由发挥。用模型生成摘要时如果 prompt 只是“总结一下前面的内容”模型往往会生成一大段流畅但没重点的叙述。我改用结构化模板固定输出“用户目标 / 已确认约束 / 待办事项 / 已否决方案”四个字段每条不超过 20 个字。实测下来这种摘要对模型的支撑效果比长叙述好得多也更容易做后续的去重和检索。另外再分享一个排查技巧给每次请求都记一份 token 构成日志包括系统提示词占比、历史占比、记忆占比、回复占比。一旦发现问题直接翻日志看是哪部分超了或少了比对着线上出问题的时间盲猜高效得多。4.3 关于模式自动切换的一点个人建议市面上有不少项目想一步到位做“全自动上下文管理”让模型自己决定要什么信息。我试过几次之后态度变得比较保守。全自动方案在 demo 里效果惊艳但线上会遇到一个致命问题不可预期。用户不知道模型下一轮会记住什么、忘掉什么也没法主动干预。上下文管理的本质不是让系统变得多聪明而是让系统行为变得可预期、可控制。所以我的最终建议是先让用户显式切换模式把控制权交出去自动规则只做兜底比如预算超限时自动压缩。等数据积累到一定量再慢慢加智能路由而且每次路由决策都要留日志、可回滚。这也是 context-mode 这套方案对我来说最有价值的地方它不追求神奇而是把“该记什么”这个问题变成了一个工程问题。我在实际项目里把 context-mode 实现成独立的中间层之后最大的感受是调试成本大幅下降。以前出了失忆问题只能对着 log 猜是模型抽风还是 prompt 写得不行现在打开日志一眼就能看到请求里到底拼了哪些消息、每条消息属于什么模式、分别占了多少 token问题定位基本按分钟计算。如果你也在做需要长期对话记忆的应用不妨先照着上面的代码搭一版最小实现把模式切换的开关交到用户手里感受一下“选择性记忆”和“全量硬塞”的差别。后面还可以继续扩展多级记忆、自动模式路由但第一步先把预算算清楚把模式切换的日志记下来。