做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_factory=time.time) meta: dict = field(default_factory=dict) # meta 里可带 message_id、topic_id、重要标记 pin 等 @dataclass class Session: session_id: str mode: str = "hybrid" # full / window / summary / hybrid history: list[Message] = field(default_factory=list) budget: int = 24000 # 每轮构造 prompt 的 token 上限 summary: str = "" # 早期历史的压缩摘要 topic_stack: list[str] = field(default_factory=list)Message 里的 meta 字段是后期救命的。我会在 meta 里记录这段话属于哪个话题、是否被用户标记为重要、是否已经进入过向量索引。Session 里的 topic_stack 用来跟踪当前对话在哪个主题上,一旦用户切换主题,这个栈会帮你决定哪些上下文要被归档。
再写一个核心类 ContextManager,它是所有上下文操作的入口:
import redis import sqlite3 import json class ContextManager: def __init__(self, redis_host="localhost", db_path="./context.db"): self.r = redis.Redis(host=redis_host, decode_responses=True) 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 = f"msg:{session_id}" self.r.rpush(key, json.dumps(message.__dict__, ensure_ascii=False)) 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_block | 2000 | 角色定义 + 回答规则 + 输出格式 |
| user_block | 1000 | 当前这轮用户输入,溢出则截断 |
| history_block | 12000 | 最近窗口 + 早期摘要 + 关键信息召回 |
| evidence_block | 3000 | 检索到的知识库片段,最多放 3 条 |
| tool_block | 2000 | 工具调用参数和返回结果摘要 |
| reserve | 4000 | 留给模型输出和突发增量 |
| total_budget | 24000 | 远低于模型理论窗口,留出安全边际 |
这里有个容易搞错的细节:模型输出也要占用上下文窗口。很多团队把预算算到刚好满,结果模型一生成输出就直接超窗报错。我通常要求总预算不超过模型理论窗口的 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_tokens=6000, max_evidence_tokens=3000): 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(f"msg:{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, limit=20) 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_k=3) return f""" 【最近对话】 {window_text} 【历史纪要】 {summary_text} 【关键信息】 {evidence} """这个方法里,_truncate_by_tokens 负责把最近消息砍到 6000 token 以内,_retrieve_evidence 从向量库捞和当前问题相关的旧信息。summary_text 是早期历史的压缩,由一个定时任务或异步任务在后台生成,别在请求主链路里现算摘要。
摘要生成我单独起了一个 worker:
def 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_tokens=800) 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 字段。在消息进入系统时,跑一个轻量的规则引擎或者小模型分类器做关键信息抽取,命中规则就自动把该消息标记为 pin=True。构造 history_block 时,所有 pin 消息跳过窗口截断,直接放进证据区块。
def _collect_pinned_messages(self, session_id: str) -> list[Message]: all_messages = self._get_recent_messages(session_id, limit=100) 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 当成一等公民来设计,你会少走很多弯路。