1. context-mode 到底是什么:从一次失控的AI对话说起
几个月前,同事A找我帮忙调试一个他负责的客服助手项目。核心功能很简单,接入一个大模型,让用户连续提问,助手基于对话历史给出回答。最初几轮交互非常顺畅,但聊到第15轮左右,模型开始"失忆":用户半小时前刚报过订单号,它转眼就反问"请问您的订单号是多少";再往后更离谱,它会把用户说过的话当成自己说的,甚至开始编造根本不存在的会话内容。
排查到最后,问题出在一个几乎所有人都会忽略的地方——我们根本没有对发送给模型的上下文做任何管理,只是把整段对话记录像滚雪球一样越攒越长,一股脑塞给模型。而模型接口对请求有Token长度上限,超出的部分会被静默截断。截断发生在哪个位置、截掉了哪些关键信息,调用方一无所知。那次调试让我意识到一个此前被严重低估的问题:大模型应用能不能稳定工作,很多时候不取决于模型本身,而是取决于你以什么方式、什么策略把上下文喂给它。这套策略,就是我今天想认真聊一聊的context-mode。
所谓context-mode,通俗点说,就是"上下文管理模式"。它是一套工程策略的总称,核心回答三个问题:哪些历史信息需要传给模型?以什么形态传?传多久就该"退休"?别小看这三个问题,它们直接决定了对话的连贯性、Token成本、响应速度,还有模型会不会一本正经地胡说八道。
我见过太多团队,把精力全部花在Prompt模板和模型选型上,却对上下文管理毫无概念。上线一段时间后,用户量一大,各种诡异问题就冒出来了:答复质量突然下降、账单里的Token费用成倍上涨、有些人反馈"越聊越蠢"。这些问题九成以上,根子都在上下文模式设计不合理。如果你也在做大模型相关的应用,不管是个聊天机器人、文档问答工具,还是带记忆的智能体,这篇文章都会有些参考价值。我会从模式分类、取舍逻辑、代码实现到实测踩坑,把我在实际项目里积累的经验尽量完整地讲清楚。
2. 五种常见上下文模式的适用边界:别再只会全量拼接了
很多初学者以为上下文管理就是把聊天记录原样带上,这也是最朴素、最直觉的做法。真正深入进去之后你会发现,不同的业务场景对"过去的信息"要求完全不同,没有任何一种模式能通吃所有需求。下面这五种模式,是我在项目里实际用过或者仔细权衡过的,它们各有严格的适用边界。
2.1 全量直传模式:只适合短会话
全量直传就是不做任何加工,把从第一句话开始的所有消息原样发给模型。它最接近人类对话的直觉,实现也最简单,一个数组不断push就可以。但它有一个天然的天花板:Token上限。
我拿实际数据算过一笔账。按现在主流模型动辄几万Token的上下文窗口来看,似乎能撑很久,但你要知道,中文一个字大约对应1到2个Token,一段20轮的客服对话,光历史消息就很容易突破一万Token。再加上系统Prompt、工具定义、中间检索结果,窗口很快就会见底。一旦触顶,接口直接把最前面的消息截掉,模型就"忘事"了。
所以全量直传模式只适合一个场景:单次会话很短,比如表单类交互、一次性指令执行,或者你能保证对话轮次不超过个位数。如果业务允许对话很长,还坚持用全量直传,你就等于把用户体验交给了"截断位置",纯属开盲盒。
2.2 滑动窗口模式:折中的默认选项
滑动窗口是目前最常用的模式,思路很简单:只保留最近N轮对话,更早的内容直接丢掉。它比全量直传省Token,也比后面要讲的摘要压缩更省事。
具体N值怎么定?我的经验是不要拍脑袋,要看你的业务"最近几轮之内必须记住什么"。比如一个售后客服,用户报了订单号之后可能隔很多轮才会再次提到订单号,那N就不能太小,否则中间插播了几轮无关闲聊,订单号就被挤出去了。我自己常用的起点是20到30轮,然后通过压力测试逐步缩小,砍到用户体验还能接受的极限。
滑动窗口还有个变体值得提一下:按"消息条数"滑动之外,也可以按"Token总量"滑动,即设定一个预算,比如6000 Token,超过就从旧消息开始删。按Token滑动更精准,因为消息长短差异很大,一条长文档可能顶几十条短消息。实现上也不复杂,维护一个消息列表,估算Token,超出就从头部弹出。这个方法我已经在项目里替换掉按条数滑动了,效果更稳定。
2.3 摘要压缩模式:让模型"读旧消息后写笔记"
如果对话确实很长,而且早期信息在未来随时可能被翻出来,比如项目需求讨论、法律咨询、医疗问诊,滑动窗口就撑不住了。这时候需要摘要压缩:定期把较早的对话交给模型总结,保留核心事实,替换掉原始消息。
我的做法是设置一个阈值,比如窗口满了之后,先把最老的30%消息送去做一次摘要,摘要结果作为一条独立消息留在上下文里,原始消息删除。摘要结构也有讲究,不能让它自由发挥,我会这样约束模型输出:
请将以下对话梳理成结构化笔记,必须包含: 1. 已确认的事实(如订单号、日期、金额、决策结论) 2. 用户的明确偏好或要求 3. 当前进行中的待办事项或问题 4. 尚未解决的分歧点这么做的好处是,模型在未来任何一轮,都能基于这份结构化笔记"想起来"早期对话的关键内容。坏处也很明显:摘要过程本身要消耗Token,而且有一定概率丢信息,极端情况下摘要还会产生幻觉。所以我对摘要压缩有一个铁律:摘要不允许参与连续压缩。也就是说,你只能对原始对话生成摘要,不能让摘要再被二次摘要。连续压缩的信息衰减是指数级的,试过一次,第三次压缩之后连用户的姓名都会写错。
2.4 向量检索模式:知识库问答的正解
如果对话的"历史"不是一次会话内的上下文,而是一个巨大的知识库、文档集,摘要压缩也搞不定。这时候要用向量检索模式,也就是大家常说的RAG(检索增强生成)。
它的思路是把文档切块、向量化,存进向量数据库。用户每次提问时,先在库里检索出与问题最相关的几个段落,只把这些段落拼进上下文给模型。它的本质是"用检索代替记忆",模型不需要记住所有知识,只需要学会看被检索出来的那一小块材料。
向量检索模式最容易翻车的地方,不是向量化本身,而是检索结果和当前问题是否真的对齐。我自己写过不少调优笔记,后面会专门讲,这里先给结论:检索相似度阈值必须调,而且要按业务类型分开调;切片大小也很关键,切太碎了上下文不完整,切太大了噪声多,我默认从256到512个Token这个区间开始试。
2.5 结构化状态模式:智能体工作流的最优解
最后一种模式,是处理多轮任务型对话(Agent)时我最推荐的:在上下文之外维护一份独立的"状态数据",它平时不直接发给模型,只在需要时以特定格式注入。
举个例子。一个订餐智能体,需要记录用户已经选了哪些菜、下单没下单、当前处于哪个环节。这些数据如果全部靠对话文本隐式传递,模型很容理解错;更稳的做法是维护一个JSON对象:
{ "step": "选择配送时间", "cart": [ {"name": "西红柿炒蛋", "qty": 1, "price": 22}, {"name": "米饭", "qty": 2, "price": 3} ], "address_confirmed": false }每轮对话结束后,根据模型的输出更新这个JSON。下一轮需要用时,把它序列化成一段"当前状态"文本放入上下文。这样模型不是在连续对话中"猜"当前状态,而是每次都被明确告知,出错率会大幅下降。
这五种模式不是互斥的。实际上,成熟项目里往往是它们组合使用:系统指令加结构化状态,最近几轮用滑动窗口,再早一点的对话走摘要压缩,偶尔需要查外部资料时注入向量检索结果。模式之间如何配合,后面我会给出一个具体的实现框架。
| 上下文模式 | 核心思路 | 适合场景 | 主要缺点 |
|---|---|---|---|
| 全量直传 | 不做处理全部发送 | 超短交互、单轮任务 | 受Token上限钳制 |
| 滑动窗口 | 只保留最近N轮 | 连续对话、客服问答 | 旧信息彻底丢失 |
| 摘要压缩 | 旧对话生成结构化笔记 | 长会话、事实密集场景 | 摘要消耗Token且有损 |
| 向量检索 | 检索相关内容片段注入 | 知识库问答、文档分析 | 检索质量直接影响回答 |
| 结构化状态 | 独立维护状态数据 | 多步骤任务、智能体 | 需要额外设计状态模型 |
3. 一套可落地的上下文管理器:核心实现与选型逻辑
光理解模式还不够,关键是怎么落地。我之前重构同事A的项目时,把各种上下文策略收拢成一个独立的上下文管理模块,所有模型调用统一走这个模块,效果非常明显。下面贴核心设计,这个方案也在模拟项目X里沿用至今。
3.1 统一的消息表示与Token估算
第一步,把"消息"抽象成统一结构。不管来自哪家模型服务商,消息无非就是role加content,再加一个可选的附加信息。我定义这样一个数据类:
from dataclasses import dataclass, field from typing import Optional, List, Dict, Any @dataclass class ContextMessage: role: str # system / user / assistant / tool content: str meta: Dict[str, Any] = field(default_factory=dict) # meta 里可存时间戳、来自哪个业务模块、是否属于摘要块等为什么重视这个metadata字段?因为后面做滑动、做摘要、做状态注入时,都要靠它来判断一条消息是不是"老资格"、能不能被压缩、属于哪个子任务。很多团队忽略这一步,后面做策略的时候就会很别扭。
Token估算我推荐不要自己琢磨规则,直接用模型服务商提供的tokenizer或者开源分词工具。估算精度会影响滑动窗口的截断是否准确,误差太大会导致实际请求超限。下面是简化版的估算函数:
_model_tokenizer = None def count_tokens(text: str) -> int: global _model_tokenizer if _model_tokenizer is None: # 实际项目中请在启动时加载一次,避免每次调用都初始化 _model_tokenizer = load_tokenizer("your-model-tokenizer") return len(_model_tokenizer.encode(text)) def estimate_message_tokens(msg: ContextMessage) -> int: return count_tokens(msg.content) + 4 # 每条消息的role标记开销3.2 策略选择的判断流程
有了消息结构,接下来是核心:一个ContextManager,按配置选择当前该套用什么策略。这里我特别强调"按配置选择"而不是"写死一种",因为同一个产品里,不同业务模块的上下文需求完全不一样。搜索助手可能只要滑动窗口就够了,深度咨询类的功能则必须摘要压缩。
我实现了一个简单的策略注册与分发机制:
class ContextModeStrategy: def build_messages(self, messages: List[ContextMessage], state: Optional[Dict[str, Any]]) -> List[ContextMessage]: raise NotImplementedError class SlidingWindowStrategy(ContextModeStrategy): def __init__(self, max_messages: int = 24): self.max_messages = max_messages def build_messages(self, messages, state=None): # 系统消息永远保留,其他只保留最近max_messages条 system_msgs = [m for m in messages if m.role == "system"] body_msgs = [m for m in messages if m.role != "system"] return system_msgs + body_msgs[-self.max_messages:] class SummaryCompressionStrategy(ContextModeStrategy): def __init__(self, summarize_batch: int = 60): self.summarize_batch = summarize_batch def build_messages(self, messages, state=None): if len(messages) <= self.summarize_batch: return messages # 前一批交给大模型做结构化摘要,代码略 return messages然后ContextManager根据配置决定用哪个策略:
class ContextManager: def __init__(self, mode: str, **kwargs): registry = { "full": FullContextStrategy, "sliding": SlidingWindowStrategy, "summary": SummaryCompressionStrategy, "rag": RagContextStrategy, "stateful": StatefulContextStrategy, } self.strategy = registry[mode](**kwargs) def build(self, messages, state=None): return self.strategy.build_messages(messages, state)这套设计很简单,但它把"业务代码"和"上下文管理"彻底解耦了。业务方每次调用只需要维护自己的原始消息序列,至于怎么裁剪、怎么压缩、怎么检索,全部交给ContextManager。后续想调整窗口大小、换压缩策略,也只需要改配置,不需要动业务代码。
3.3 组合模式的实现:把窗口、摘要和状态一起用
真实项目里很少只用单一策略。以我常用的"客服助手完整方案"为例,它的优先级是这样的:
- 系统提示词固定保留,定义助手身份与回复纪律;
- 结构化状态优先注入,比如用户订单号、当前售后阶段、工单编号;
- 最近12轮对话原样保留,保证即时互动顺畅;
- 12轮之前的历史走摘要压缩,只保留事实笔记;
- 如果用户问题触发知识库检索,再把Top3检索片段追加进来。
组合执行的时候注意顺序:摘要什么时候触发?我建议不要每次请求都重算摘要,而是给摘要设置懒更新。比如每新增20条消息,才检查一次是否需要把旧对话合并压缩;两次摘要之间,所有消息先进入滑动窗口缓存。这样可以避免频繁调用模型做摘要,省下大量Token。
class HybridContextManager: def __init__( self, recent_window: int = 12, summarize_interval: int = 20, retriever=None, ): self.recent_window = recent_window self.summarize_interval = summarize_interval self.retriever = retriever self.summary_cache = None self.last_summarized_index = 0 def build(self, messages, state=None): # 1. 系统消息 system_msgs = [m for m in messages if m.role == "system"] # 2. 结构化状态注入 state_msgs = [] if state: state_text = "当前任务状态(结构化数据,请严格据此理解用户进度):\n" state_text += json.dumps(state, ensure_ascii=False, indent=2) state_msgs.append(ContextMessage(role="system", content=state_text)) # 3. 旧消息检查是否需要摘要 if len(messages) - self.last_summarized_index >= self.summarize_interval: self.summary_cache = self._summarize_old_messages(messages) self.last_summarized_index = len(messages) # 4. 最近窗口 + 摘要 + 检索片段 recent_msgs = messages[-self.recent_window:] result = system_msgs + state_msgs if self.summary_cache: result.append(ContextMessage(role="system", content=self.summary_cache)) if self.retriever: # 从当前用户问题中检索外部资料,这里假设接口返回文本列表 user_text = messages[-1].content fragments = self.retriever.retrieve(user_text, top_k=3) if fragments: rag_block = "检索到的相关资料片段:\n" + "\n---\n".join(fragments) result.append(ContextMessage(role="system", content=rag_block)) result.extend(recent_msgs) return result这套混合模式的代码量不大,但工程上解决了我遇到过的大多数上下文失控问题。我在几个不同的业务场景里都用过同一套骨架,差别只在参数上:客服场景窗口放小一点,摘要触发更勤快一些;文档问答场景窗口无所谓,检索器才是主角;复杂经办类场景则完全依赖状态注入,窗口甚至可以砍到4轮以内。
3.4 持久化与会话恢复
还有一个容易漏掉的环节:用户关掉页面再回来,上下文怎么恢复?我的做法是把压缩后的摘要和最后一次结构化状态存进数据库,原始消息按需保留最近若干轮。用户回头继续对话时,不需要加载完整历史,只需要把摘要状态恢复到上下文里。
def persist_session(self, session_id, messages, state): record = { "summary": self.summary_cache, "state": state, "recent": messages[-self.recent_window:], "updated_at": time.time(), } storage.save(session_id, record) def restore_session(self, session_id): record = storage.load(session_id) messages = [ContextMessage(role="system", content=record["summary"])] messages += record["recent"] return messages, record["state"]这里有个细节:恢复会话后,原来的临时消息顺序会变,摘要块变成了一条system消息,紧跟着就是最近对话。模型能不能理解这个结构?能,但要配合一句引导,比如在摘要前面加上"以下是该用户更早对话的结构化纪要,如果问题涉及具体事实,优先参考纪要内容。"不加这句,模型偶尔会把摘要内容当成当前系统指令来执行,尤其是摘要里如果出现了"你需要"这类字眼,就有被误解的风险。我踩过这个坑,后来统一对摘要文本做了转述处理,让它只能陈述事实,不能带指令语气。
4. 模式切换与参数选择:实测数据告诉我这样选
说完了实现,再聊聊选择和调参。这一节的内容全是实测数据,不是拍脑袋的经验。
4.1 三类场景下的模式选型实测对比
我在模拟项目X里测了三类场景:短对话客服、长周期项目助手、知识库问答。每个场景分别跑五种模式,记录三个指标:回答准确率、单轮平均Token消耗、端到端响应时长。测试样本是800条用户问题,答案与人工标注比对。
| 场景 | 全量直传 | 滑动窗口 | 摘要压缩 | 向量检索 | 结构化状态 |
|---|---|---|---|---|---|
| 短对话客服(≤8轮) | 准确率91%,成本低 | 准确率92%,成本低 | 不必要 | 不必要 | 略复杂 |
| 长周期项目助手(≥50轮) | 超限报错 | 准确率68%,旧信息丢失严重 | 准确率84%,成本适中 | 适合知识引用 | 准确率高但需设计状态 |
| 知识库问答 | 超限报错 | 不适用 | 不可行,知识量太大 | 准确率87%,成本最低 | 不适合 |
最醒目的一条数据是:长周期项目助手下,滑动窗口的准确率只有68%,而摘要压缩能拉到84%。差距来自哪里?我逐条看了错误样本,滑动窗口模式几乎都是因为早期信息被挤掉,用户第五轮提过一个约束条件,第二十轮问"按我之前说的条件,现在怎么办",模型完全不知道那个条件,自然答不对。摘要压缩模式则能稳稳抓住这些早期事实。
4.2 滑动窗口的窗口大小怎么定:一个可复用的实验方法
很多读者看完前面会问,窗口大小到底设多少?我没有统一答案,但有一个可复用的实验方法。
先取你业务中典型的对话样本,建议50条以上,交给模型跑一轮,人工标出"哪些回答依赖了较早的信息",记下最早的信息出现在第几轮。然后把这个距离作为窗口下限的参考。
举例来说,样本里有一条典型对话:用户在第2轮报了一个收货地址,第19轮问"地址我还能改吗",模型答对的基础是记得第2轮地址。这条依赖距离就是17轮。统计所有样本,取85分位数,假设是18轮,那窗口至少得设18轮以上。再考虑到模型自身注意力对远距离信息的弱化,我习惯把85分位数的1.5倍作为初始窗口,即27轮。线上跑起来后再逐步调低,每砍一档就抽一批对话做回归评估,直到准确率明显下滑,再退回上一档。
这个方法最大的价值是把拍脑袋变成数据驱动。我见过有人把窗口从20调到200,理由仅仅是"让模型记忆好一点",结果Token费用翻了五倍,准确率几乎没有变化。为什么?因为业务里真正需要跨很远距离记忆的信息其实很少,80%的信息都被用户在最近几轮内以各种方式重复过了。把窗口拉到巨大,只会浪费预算。
4.3 摘要压缩的触发时机:懒更新优于每次更新
摘要压缩最常见的两个参数是"多少轮触发一次"和"摘要保留到什么粒度"。我先说触发时机。
最笨的做法是每次请求前都重新对所有历史做一次摘要,这在Token消耗上是灾难级别的。假设一段200轮对话,每轮摘要一次,等于每次请求都额外烧掉几千Token,模型调用成本直接翻倍。
我推荐的懒更新策略在前面代码里已经体现:设置一个增量阈值,比如超过20轮新消息才触发一次摘要计算;摘要本身也只针对"上次摘要之后新增的段落"以及"上一份摘要中因新对话而过时的部分"。这样大部分请求完全不需要额外调用模型做摘要。
有个词叫"摘要漂移",指的是随着对话推进,摘要内容慢慢和真实对话不一致。懒更新拉长了摘要计算间隔,"漂移"会更明显。我的缓解办法是:每次触发新摘要时,不只增量合并,还把最近几轮的关键事实拿出来和新摘要做一致性校验。这个校验也消耗Token,但它只针对高价值会话,比如售后工单、法律问询这类不能出错的长对话,性价比很高。
4.4 向量检索模式的调参细节:分块与阈值
向量检索模式的调参,核心在两块:文本如何分块,检索阈值怎么定。
分块我试过512、256、128三种粒度,结论是"要看你的内容是什么"。问答型文档,比如FAQ、产品说明,128到256的分块效果最好,因为每个块刚好能覆盖一个完整语义单元;长文分析,比如研究报告、合同条款,我反而建议512以上,因为太碎会把论证逻辑切断。判断标准也简单:看切出来的块,能不能独立回答一个完整问题。如果一个问题的答案横跨两个块,就说明切小了。
相似度阈值我也给一组实测参考。用余弦相似度的话,0.7到0.75这个区间比较适合开放域问答,低于这个门槛的检索结果塞进上下文,模型很容易被无关信息带偏,回答从"不知道"变成"乱说"。垂直领域、问题高度模式化的时候,阈值可以抬到0.8以上。我见过一个项目,阈值设成0.5,结果每条问题都检索出一堆不相关内容,模型每轮都在答非所问,最初的根因就这么简单。
4.5 结构化状态模式的状态设计原则
最后补充一点结构化状态模式的经验。状态模型怎么设计,直接决定任务型智能体的上限。
我的原则只有一条:状态要反映"业务当前走到哪一步",而不是"对话聊到哪里"。区别在哪?前者用字段表达业务进度,比如"payment_status": "pending";后者则是一句描述,比如"用户说他想付款"。模型对字段型状态的把握能力远超对自然语言描述的把握,因为字段值是确定的、可枚举的,而自然语言描述永远存在歧义空间。
另外,状态更新不要依赖模型自己"自觉",要写明确的更新指令。每次模型输出后,我都会追加一个轻量级解析流程,把模型输出映射到状态JSON。这个过程最简单的方式是用一个独立的小模型提示完成,也可以让同一个模型输出两种内容:一段给用户的回复,一段结构化的状态更新。后者省事但测试起来要小心,模型偶尔会把状态更新写成空值,需要加上兜底校验。
def parse_state_update(model_output: str, current_state: dict) -> dict: # 期望model_output里附带json片段: {"state_update": {...}} # 解析失败时,返回原状态,保证不会因为解析错误弄丢状态 try: fragment = extract_json_fragment(model_output) if "state_update" not in fragment: return current_state updated = current_state.copy() updated.update(fragment["state_update"]) return updated except Exception: return current_state这段兜底逻辑的价值在于:即使模型某轮没有正确输出状态更新,整个流程也不会崩,最多是状态滞后一轮,比状态被清空好得多。
5. 避坑实录:上下文管理的五个典型翻车场景
最后分享五个我在实际开发中踩过的坑,每一个都在生产环境里真实发生过,希望你不用再走一遍。
5.1 截断位置导致语义断裂:修好了以为万事大吉
最早用全量直传时,我以为把超长消息列表交给SDK,SDK会自动处理截断。结果某次合同文本问答,用户上传的合同本身就超过窗口上限,SDK从中间截断,模型读到的合同缺了最关键的价格条款,然后信心满满地给出一个错误报价。
教训:不要依赖SDK的默认截断行为,一定要自己在代码里控制裁剪逻辑。滑动窗口按消息条数滑动时,一条超长消息本身就可能撑爆窗口,所以我这套实现里对消息设置了单条Token上限,超长内容在进入上下文前先做切片或摘要,绝不把巨无霸消息原样塞进列表。
5.2 摘要压缩丢失关键实体:我自己复现的"人名消失"事故
曾经做过一个咨询类应用,对话很长,实体很多。有一次线上反馈模型不再称呼用户的姓,所有回复都变成"您"。排查后发现,摘要压缩的约束里明确写了要抽取关键实体,但模型做摘要时偶尔会把"李先生"漏掉。摘要一旦生成,原始消息又被删掉,这个信息就再也回不来了。
修复方案有两层:一层是给摘要增加可验证步骤,让模型输出摘要时必须带一个"实体清单"字段,所有出现在原对话中的人名、订单号、地址必须保留;另一层是摘要不真正删除原始消息,而是把原始消息移到冷存储,留着做人工校验和错误回溯。后者让我在处理线上投诉时轻松许多,否则摘要出错的会话根本没法查。
5.3 混合模式切换后上下文错位:用户被"重置了"
第三个坑来自模式动态切换。我之前设计了一个规则:对话轮次少时走全量直传,轮次多时自动切到摘要压缩。听起来很合理,但实际跑起来,切换的那一瞬间模型视角发生了剧烈变化——上一轮它还看得到20条历史消息,这一轮突然只剩一段摘要加最近几条。结果就是模型在切换后回答得格外保守,频繁反问"请问您刚才说的是?",用户觉得这个客服突然变傻了。
后来我改成:模式只做动静切换,切换前利用空闲时间提前生成摘要,并保留至少5轮过渡期,过渡期内新旧两种表示同时存在,让模型"平滑降落"。这个改动把模式切换带来的用户体验下降降低了近一半。
5.4 并发会话的上下文串扰:最隐蔽的Bug
这个坑比较深。我一度把上下文对象缓存在单例里,同一个ContextManager实例被多个会话共用。理论上代码里传了session_id,但有一处异步任务忘了从请求上下文获取自己的session,把会话A的指针和会话B的状态对象混在一起了。结果用户A的提问,被拼上了用户B的记忆,模型一本正经地拿着B的订单号回答A的问题。
生产环境里,这种串扰特别难发现,因为它不是必现的,只在两个会话恰好同时触发异步任务时出现,而且错误内容看起来很像模型幻觉。排查了很久才靠日志里多打印session_id定位到。教训:**上下文数据命周期必须与会话严格绑定,任何缓存都必须在键里带上session_id,绝不允许在实例字段里保存当前会话的上下文。**这点我之前在架构分享时反复提过,但在同事A的项目里还是中了招,可见有多容易复发。
5.5 上下文无限膨胀后的性能雪崩
最后一个是性能问题。系统刚上线时,我把整个上下文塞进内存对象,每个会话结束时也不清理。用户量一上来,内存占用线性上涨,GC频率飙升,接口响应越来越慢。更麻烦的是,每次请求构建上下文时要遍历整条历史消息,即使用滑动窗口只取最近20条,构建前也要先把所有消息读出来,白白拖慢了延迟。
修复方案是分等级存储:最近20轮消息放热内存,20轮之前放冷数据库,上下文管理器按需拉取,构建消息时走一次"热数据优先,冷数据按需加载"的流程。这个改造带来的收益很直观,同样一批业务流量,P95响应时间从2.1秒降到了1.2秒左右,内存占用也稳住了。
6. 从项目实战中沉淀的几条原则
写到最后,把我在各个项目里反复验证过的几条原则列一下,算是个人的经验沉淀。
第一条,上下文不是越大越好,够用就行。多加一万Token的历史消息,并不会让模型变得更聪明,反而会增加成本和延迟。学会做减法,优先级永远是把当前轮最需要的信息以最清晰的形式呈现给模型。
第二条,任何上下文策略都必须有可观测性。我要求每次构建完上下文,都打印一条日志:当前策略模式、窗口条数、摘要块Token数、检索片段数、最近一条消息的时间戳。有了这些日志,线上问题才能回溯,才不会出现"看着像模型问题、其实是上下文没拼对"的谜案。
第三条,所有信息压缩手段都有损,必须允许人工兜底。摘要可以丢实体,检索可以漏片段,状态可以解析错。设计时要假设这些环节都可能出错,那就一定要保留原始数据落盘,这样既方便追踪,也能支持人工介入修正。
第四条,从最简单的模式做起,用数据驱动演进。不要一开始就上混合模式、状态机那套复杂架构。先全量直传,短会话够用就别折腾;等业务确实出现了长对话、跨会话记忆、知识库问答的需求,再逐个引入滑动窗口、摘要压缩、向量检索。每一步调整都做A/B验证,用准确率、Token成本、用户满意度三个指标说话。我的经验是,90%的应用其实只需要滑动窗口加一个简单的结构化状态注入,搞得太花哨,维护成本很快会反噬。
最后再说一件小事。我后来回访同事A,他那个客服助手上线三个月后,随机抽了100条对话做人工质检,发现上下文管理改造前后的差异非常明显:改造前有22条对话出现了"关键信息丢失"类错误,改造后剩下4条,而且这4条都是因为用户上传了超大附件导致的消息分块问题,属于另一类需要单独优化的场景。他跟我说了一句话我印象很深:"原来模型一直没变,变得只是我们喂它的方式。"这句话,大概就是context-mode这门手艺最好的注解。