
做AI应用这一两年我踩过最深的坑就是对“上下文”三个字的轻视。Context-mode 这个提法最近在圈子里频繁出现最初我也只是把它当个流行词看直到自己负责的客服问答系统连续聊了十几轮之后开始答非所问我才真正意识到上下文从来不是天然存在的东西它必须在代码里被显式设计、维护、收敛。这篇博文把我在实际业务里折腾 context-mode 的经验完整整理出来适合正在做 Agent、智能客服、知识库问答、以及任何需要长期记忆功能的 AI 应用开发者参考。所谓 context-mode按我的理解就是“在不同会话阶段决定哪些信息应该带入模型决策、哪些信息必须丢弃、以及用什么结构组织这些信息”的一套模式和方法。它不是某一个具体算法而是一种系统设计的视角当你开始用 context-mode 去思考时你会把上下文当作一段有生命周期的数据而不是一个可以无限塞东西的袋子。它要解决的问题很直接大语言模型本身没有记忆力你给模型什么它就只能基于什么来回答。你怎么给、给多少、先给什么后给什么、哪些该压缩、哪些该保留直接决定回答质量、响应速度和整体成本。这也是为什么 context-mode 值得单独拿出来聊一聊。1. 理解 context-mode它到底在解决什么问题1.1 大模型的“金鱼记忆”与上下文缺失的代价所有用过 ChatGPT 的人都有体验短对话里它聪明得像私人助理一旦聊得长了它会忘记你最开始说的关键背景甚至开始一本正经地编造它“记得”的内容。这不是模型变笨了而是它在技术上就没有“记忆”这个模块——每一次请求都是一次无状态的函数调用模型只能看到当前请求里塞进去的文本。我最早做客服机器人时就吃过这个亏。用户在企业微信里问“我昨天买的手机怎么申请退款”我最初只把这一句丢给语言模型结果模型开始反问“您在哪家店买的订单号是多少什么手机”。用户非常恼火因为这些信息在十分钟前的对话里已经说过一遍。而这个机器人根本不知道“十分钟前说过什么”。这个问题的代价是三层叠在一起的。第一层是体验层用户需要反复重复信息信任感迅速崩塌。第二层是成本层很多团队应付的办法是“那我把整个对话历史都塞进去”结果每次请求都带着几万 token 的重复内容账单肉眼可见地涨。第三层是质量层历史塞得越多模型越容易被无关信息带偏尤其当上下文超过一定长度后开头那些真正重要的细节反而会被淹没。context-mode 要解决的就是这三层问题的系统化方案。它要求你在产品设计阶段就想清楚这个应用到底需要记住什么、记住多久、用什么形式去记住。1.2 四种典型 context-mode 形态我在不同项目里见过、也自己实现过四种常见的 context-mode它们各有适用场景也各有代价。模式核心策略适用场景明显短板全量模式把完整对话历史始终放在上下文里短会话、评估演示、对质量要求极高的场景token 消耗大长对话不可持续滑动窗口只保留最近 N 轮或最近 M 个 token客服、闲聊等实时性强的场景早期关键信息会丢失摘要模式把早期历史压缩成摘要保留结论长文档分析、多轮项目协作摘要过程可能丢失细节混合模式窗口 摘要 向量召回一起上知识库问答、Agent 长期记忆实现复杂度最高全量模式最朴实适合对话轮次短、上下文不超过模型窗口一半的场景。真正到了生产环境绝大多数团队会切换到滑动窗口或摘要模式。我现在的项目用的是混合模式后面第三部分会给出具体代码实现。选择哪种模式不是拍脑袋决定的核心是看你的产品里“旧信息”到底还有没有价值。闲聊机器人里一小时前的天气话题基本没价值滑动窗口就够了医疗咨询机器人里用户三分钟前报过的过敏史价值极高必须从窗口里抢救出来。这个判断决定了整个架构的方向。2. 核心设计思路先拆解再整合2.1 上下文的完整生命周期注入、存取、修剪、切换把上下文当作一种需要在系统里流转的数据后它就拥有了完整的生命周期。我一般把它拆成四个阶段来设计。注入阶段要回答的问题是什么信息有资格进入上下文不是所有用户的每一句话都值得传给模型。我见过很多团队傻乎乎地把日志、调试信息、用户无心输入的错别字全塞进去结果模型被垃圾信息牵着走。实践中至少要做一轮过滤——去掉与当前任务无关的闲聊、拦截明显的敏感词、把用户重复表达的内容去重。存取阶段决定信息放在哪里。实时会话上下文放在内存或 Redis 里追求低延迟跨会话的长期记忆放在数据库或向量库等待被检索召回。我见过一个非常矛盾的场景有的团队把所有上下文都放向量库每次请求先检索一轮延迟多了几百毫秒也有的团队所有东西都堆内存服务一重启用户连自己是谁都忘了。存取设计的关键是冷热分离热数据走快通道冷数据走慢通道。修剪阶段是 context-mode 的灵魂。token 预算有限总要有人被踢出上下文。怎么踢、踢谁、是按时间踢还是按重要性踢这就是模式与模式之间的本质区别。我常跟团队说一句话你写 prompt 的时候是个文案做修剪的时候是个编辑——编辑的核心能力不是写是删。切换阶段最容易被忽略。用户聊着聊着突然换了话题旧话题的上下文如果不做处理就会成为新话题的噪音。有的系统需要主动“关闭”旧上下文比如客服工单关闭后与此相关的中间讨论不该继续占用 token有的系统需要“归档”旧上下文比如用户查完订单又回来问产品推荐订单信息要归档而不是彻底删除。切换设计得不好系统最直观的表现就是“反应慢半拍”或者“答非所问”。2.2 给上下文分好“隔间”五类区块的职责划分早期我写 context-mode 时就是简单地把用户历史消息拼成一长串文本丢给模型。后来发现 prompt 一旦变长模型经常抓不住重点。问题出在信息没有分区块全混在一起。后来我把上下文拆成五个独立区块每个区块有明确的职责和预算上限。系统区块放角色设定和全局规则比如“你是某品牌的客服助手回答必须基于提供的资料不确定就说不确定”。用户区块放当前这轮用户实际输入的问题和诉求。记忆区块放从历史对话中提取出来的用户偏好、事实信息比如“用户之前反映过 app v2.3.1 版本有闪退问题”。证据区块放从知识库或工具返回中检索到的支撑材料比如商品退换货政策原文。工具区块放最近几轮工具调用的结果和状态比如“已调用查询接口订单状态是已发货”。这样的分区块设计本质上是给模型划清了阅读重点。模型在处理 prompt 时对排在前面的指令性内容会更敏感把系统规则放在最前、把检索证据放在靠近用户问题的地方能显著提升回答准确率。我实测下来只做这一步改动客服机器人的相关指标就能提升好几个百分点。2.3 为什么不能无脑全塞成本、延迟与注意力稀释有个很常见的错觉反正现在很多模型支持 100k、200k token 的上下文窗口那我把全部历史都塞进去不就行了这个想法在演示阶段完全没问题一上生产就会被打脸。首先是成本那是肉眼可见的线性增长。每次请求的 token 消耗哪怕只是多 20k 输入 token按常见定价乘以并发用户数再乘以一天请求量一个月下来就是一笔不小的开支。其次是延迟输入 token 越多模型处理首字返回的时间越长。你别小看这个延迟用户在客服窗口等 3 秒和等 8 秒是完全不同的心理体验。还有一个更容易被忽略的问题注意力稀释。现代 Transformer 结构对长上下文的注意力分布并不均匀模型很容易过度关注中间或靠近末尾的内容而忽略开头最关键的信息。我做过一个实验同样一个问题把关键背景放在 prompt 中间和放在 prompt 末尾模型回答的正确率有明显差距。这说明上下文越长模型越可能“看漏”重点而不只是“看得慢”。更严重的一层是幻觉风险。当上下文超过一定阈值模型倾向于编造一些“看起来合理但实际不存在”的细节来填补矛盾。在我那个客服项目里这个现象尤其明显上下文超过 30k token 后模型开始虚构订单状态把已退货的订单说成已发货。从那以后我对“历史全塞”这条路径彻底失去了兴趣。3. 实操从零搭一套可用的 context-mode 框架3.1 数据模型与核心类设计直接给一套我在项目里验证过的简化实现语言用 Python存储用 SQLite 加 Redis。整个框架的核心是三个数据类Message、Session、ContextSnapshot。from dataclasses import dataclass, field from typing import Optional import time dataclass class Message: role: str # user / assistant / tool / system content: str ts: float field(default_factorytime.time) meta: dict field(default_factorydict) # meta 里可带 message_id、topic_id、重要标记 pin 等 dataclass class Session: session_id: str mode: str hybrid # full / window / summary / hybrid history: list[Message] field(default_factorylist) budget: int 24000 # 每轮构造 prompt 的 token 上限 summary: str # 早期历史的压缩摘要 topic_stack: list[str] field(default_factorylist)Message 里的 meta 字段是后期救命的。我会在 meta 里记录这段话属于哪个话题、是否被用户标记为重要、是否已经进入过向量索引。Session 里的 topic_stack 用来跟踪当前对话在哪个主题上一旦用户切换主题这个栈会帮你决定哪些上下文要被归档。再写一个核心类 ContextManager它是所有上下文操作的入口import redis import sqlite3 import json class ContextManager: def __init__(self, redis_hostlocalhost, db_path./context.db): self.r redis.Redis(hostredis_host, decode_responsesTrue) self.conn sqlite3.connect(db_path) self._init_db() def _init_db(self): self.conn.execute( CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, mode TEXT DEFAULT hybrid, summary TEXT DEFAULT , updated_at REAL ) ) def create_session(self, session_id: str, mode: str hybrid): self.conn.execute( INSERT OR REPLACE INTO sessions (session_id, mode, updated_at) VALUES (?, ?, ?), (session_id, mode, time.time()), ) self.conn.commit() def append_message(self, session_id: str, message: Message): key fmsg:{session_id} self.r.rpush(key, json.dumps(message.__dict__, ensure_asciiFalse)) self.conn.execute( UPDATE sessions SET updated_at? WHERE session_id?, (time.time(), session_id), ) self.conn.commit()这里我用了 Redis 列表存热消息理由是客服类场景需要频繁读取最近几十轮对话Redis 的列表操作延迟极低。SQLite 存会话元数据用于服务重启后恢复模式配置和摘要。如果你要支撑多实例部署把 Redis 换成云 Redis、把 SQLite 换成分库分表也是同样的结构。3.2 Token 预算怎么算一个可以抄的公式很多团队做 context-mode 失败不是因为不会写代码而是从来没认真算过每个区块该分多少 token。我一般用一个带预留量的公式来算total_budget system_block user_block history_block tool_block evidence_block reserve不同应用场景每个区块的比例差别很大。我以知识库客服机器人为例给一套经过验证的预算参考区块预算token说明system_block2000角色定义 回答规则 输出格式user_block1000当前这轮用户输入溢出则截断history_block12000最近窗口 早期摘要 关键信息召回evidence_block3000检索到的知识库片段最多放 3 条tool_block2000工具调用参数和返回结果摘要reserve4000留给模型输出和突发增量total_budget24000远低于模型理论窗口留出安全边际这里有个容易搞错的细节模型输出也要占用上下文窗口。很多团队把预算算到刚好满结果模型一生成输出就直接超窗报错。我通常要求总预算不超过模型理论窗口的 60%-70%。history_block 是核心我把它再切成三段最近完整窗口占 50%早期摘要占 25%关键信息召回占 25%。这样一个 12000 token 的 history_block就有 6000 token 给最近对话原文、3000 token 给老对话的压缩摘要、3000 token 给从向量库和关键信息表里捞出来的重要事实。3.3 混合模式实现窗口、摘要与向量召回的协作现在看 ContextManager 里最核心的方法——构造当前轮次的 prompt。它把滑动窗口、摘要、向量召回三者融合在一起。class ContextModeHybrid(ContextManager): def __init__(self, max_window_tokens6000, max_evidence_tokens3000): super().__init__() self.max_window_tokens max_window_tokens self.max_evidence_tokens max_evidence_tokens def _get_recent_messages(self, session_id: str, limit: int 20) - list[Message]: raw_list self.r.lrange(fmsg:{session_id}, -limit, -1) return [Message(**json.loads(item)) for item in raw_list] def get_history_block(self, session_id: str, query: str) - str: recent self._get_recent_messages(session_id, limit20) window_text self._truncate_by_tokens(recent, self.max_window_tokens) session_row self.conn.execute( SELECT summary FROM sessions WHERE session_id?, (session_id,) ).fetchone() summary_text session_row[0] if session_row else evidence self._retrieve_evidence(session_id, query, top_k3) return f 【最近对话】 {window_text} 【历史纪要】 {summary_text} 【关键信息】 {evidence} 这个方法里_truncate_by_tokens 负责把最近消息砍到 6000 token 以内_retrieve_evidence 从向量库捞和当前问题相关的旧信息。summary_text 是早期历史的压缩由一个定时任务或异步任务在后台生成别在请求主链路里现算摘要。摘要生成我单独起了一个 workerdef build_summary(session_id: str, history: list[Message]) - str: joined \n.join( f{m.role}: {m.content[:200]} for m in history ) prompt f请把下面这段客服对话记录压缩成一份纪要。 要求保留用户的核心诉求、已提供的商品信息、已答应的事项、未解决的问题。 不要流水账直接输出结构化摘要。 对话记录 {joined} # 调用语言模型生成摘要 summary call_model(prompt, max_tokens800) return summary触发生成摘要的时机很关键。我是在 Redis 里存了一个计数器每追加一条消息就加一当消息数超过 60 条或者累计 token 超过阈值时触发一次摘要并把旧消息从 Redis 列表里裁剪掉只保留最近 20 条全文和新的摘要。这样“最近窗口 早期摘要 关键信息召回”的三层结构就一直能维持在预算内。4. 常见问题与排查技巧实录4.1 翻车现场症状与原因对照表context-mode 这类系统的问题往往不是突然崩溃而是“回答质量慢慢变差”特别难定位。我整理了项目里踩过的几个典型问题和排查方向。现象可能原因排查手段解决方案对话变短后突然忘记用户需求滑动窗口把关键信息挤掉了打印每次请求的 history_block 内容确认关键信息是否还在给关键 message 打 pin 标记单独放入 evidence 区块回答中出现了捏造的订单信息上下文过长导致模型幻觉检查超窗前的历史量复现长对话场景降低 total_budget增加摘要压缩频率用户换话题后回答被旧话题带偏缺少 topic 切换处理查看 topic_stack 是否被正确更新检测到新话题时把旧话题消息归档到摘要并发用户多时A 用户看到 B 用户的信息上下文缓存 key 设计错误检查 Redis key 是否包含 session_id统一所有 key 前缀强制校验 session_id响应延迟越来越严重向量召回或摘要处理被放在请求主链路用链路追踪看耗时分布摘要异步化向量召回加缓存第一个现象最值得细说。有一次用户问“我之前说的那个红色款还有货吗”系统回复“请问您指的是哪一款”用户直接投诉。后来我看日志发现“红色款”这条消息在 15 轮之前早被滑动窗口截掉了。问题不是模型不行是我们的窗口策略太粗暴。从那以后我加了一套关键信息标记机制后面第五部分详细讲。4.2 可复用的排查方法给上下文做“快照”定位 context-mode 问题最忌讳空想。我的习惯是设计一套“上下文快照”日志每次请求只记录快照结构的摘要不记录全量内容。快照日志大概长这样session_id、请求时间、mode、total_tokens、各区块 token 分布、history_block 里是否有标记为关键的信息、向量召回返回了几条、摘要文本的前 100 字。这些数据打进日志里用 log 查询工具就能按 session_id 回放整个会话的上下文变化。我还会做一种 A/B 现场对比同一个 session_id跑两套 context-mode 参数分别记录回答内容和 token 消耗。很多问题用肉眼对比回答质量就能定位比如同样的提问参数 A 的回答提到“红色款有货”参数 B 的回答说“未找到对应商品”那说明参数 B 的关键信息召回链路出了问题。4.3 避坑那些不起眼但致命的细节第一个坑是摘要的时态和人称问题。模型生成的摘要默认用第三人称过去时比如“user reported an issue”。但用户当前正在跟你说话你要的是“用户反馈的问题”而不是“曾经有个用户”。我在摘要 prompt 里强制要求使用第二人称视角和当前时态并保留第一人称的关键原话比如用户自己说的“我昨天买的红色手机”。这样混合模式里模型看到的历史纪要就和人称、时态保持一致不会产生“你在替谁说话”的错乱感。第二个坑是并发会话的隔离。有些人图省事把所有用户的上下文放在同一个 Redis 列表里再用一个全局变量标记当前用户这在单机演示没问题一上生产就是严重的事故。session_id 必须贯穿所有数据结构的 key所有读写方法必须强制显式传 session_id。这个原则我写在团队代码规范第一行。第三个坑是覆盖信息的处理。用户在十轮前说“我要退款”五轮前改口“先不退了换个货”。如果你只是简单地把两条消息都塞进上下文模型很可能因为信息矛盾而给出错误建议。我加了一个“状态覆盖”机制在证据区块里相同主题的新信息会标注为“最新状态”旧信息只在摘要里保留为背景不再作为决策依据。5. 让 context-mode 更耐用的几个进阶技巧5.1 关键信息标记不是所有内容都该被时间淘汰滑动窗口最大的毛病是“一刀切”——所有消息都按时间被淘汰不管它重不重要。客服场景里的订单号、用户地址、过敏史、退款诉求这些信息无论出现在第几轮都必须一直留在上下文里。我的方案是给 Message 的 meta 加一个 pin 字段。在消息进入系统时跑一个轻量的规则引擎或者小模型分类器做关键信息抽取命中规则就自动把该消息标记为 pinTrue。构造 history_block 时所有 pin 消息跳过窗口截断直接放进证据区块。def _collect_pinned_messages(self, session_id: str) - list[Message]: all_messages self._get_recent_messages(session_id, limit100) pinned [m for m in all_messages if m.meta.get(pin)] return pinned[:5] # 最多保留 5 条关键信息同时关键信息一旦被 pin我会额外写一份到 SQLite 的关键信息表防止它被后续的消息整理误删。这个机制上线后客服机器人“忘记刚才说过的话”的投诉量直接下降了一大半。5.2 上下文质量测试像测缓存一样测上下文context-mode 做得对不对不能靠感觉。我建议每个团队都建一套“上下文回归脚本”设计一段 20 轮以上的标准对话剧本包含几个关键问题点然后每次修改 context-mode 逻辑后跑一遍记录“第几轮时模型还记得哪条关键信息”。这套测试要覆盖四种情况短对话内的信息保持、超长对话后的关键信息回召、用户主动纠错后的覆盖逻辑、话题切换后的上下文隔离。我一般会把测试结果做成一张遗忘曲线表横轴是对话轮数纵轴是模型还能正确回答关键问题的比例。窗口策略改一下遗忘曲线立刻就有反应非常直观。还有一个成本维度的测试每次跑完剧本统计总 token 消耗。上下文策略修改最怕只提质量不提成本。我给自己定过一个红线质量指标提升必须大于 5%成本增幅才可接受如果质量基本持平但成本降了 20%这个优化也值得上。5.3 我做 context-mode 取舍时的三条原则第一宁可多切几刀不要一次全塞。模型上下文窗口再大也要用编辑思维去砍内容。每一轮请求的 token 不是越多越好而是越准越好。第二上下文策略必须前置设计。我吃过最大的亏就是先写业务逻辑、后补上下文管理结果所有代码都变成“线程里塞一个全局会话列表”的临时方案最后全部推倒重来。context-mode 不是一个可选优化它是 AI 应用的地基。第三永远给“用户明确表达的”和“系统检索到的”留独立区块。这两类信息混在一起是灾难——模型分不清哪些是用户亲口说的、哪些是你去资料库里查的。分开之后模型引用资料时会更严谨回答也会更有依据。我在实际项目中还有一个很深的感觉上下文管理做得好不好用户说不出来但能感觉到。它不会像推荐算法或搜索排序那样被高频感知但它决定了产品是“聪明得让人觉得贴心”还是“蠢得让人想摔手机”。如果你也在做聊天类的 AI 应用建议从今天开始把 context-mode 当成一等公民来设计你会少走很多弯路。