ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型上下文管理实战:context-mode模式切换与压缩策略

大模型上下文管理实战:context-mode模式切换与压缩策略

1. context-mode 到底是什么:先从一个真实痛点说起

1.1 大模型应用里的“上下文失控”问题

我在做某AI客服系统的时候,遇到一个特别典型的问题:用户对话超过十轮之后,回答质量肉眼可见地下降。前面还在讨论订单退款流程,后面突然问了一句“那你们物流一般几天到”,系统居然还在围绕退款展开长篇大论。更闹心的是,每次把整个对话历史都塞给模型,Token 消耗直接翻倍,账单数字非常难看。

这个问题不是个例,凡是做过大模型应用落地的人,几乎都撞上过。“上下文失控”这个词是我自己总结的,它包含几层意思:一是上下文太长,超出模型窗口,早期直接把超出的部分截断,导致模型“失忆”;二是上下文太长太杂,模型分不清哪些是当前任务的重点,注意力被无关信息稀释;三是多个会话之间互相干扰,尤其是用同一个全局上下文对象的时候,A 用户的问题跑到 B 用户那里,引发严重的数据串扰。

后来我把这些痛点归拢了一下,发现所有问题的根源都是同一个:把上下文当成一个“无限的、全局的、越长越好”的东西来用。实际上上下文应该是有边界、有切换、有压缩策略的。于是就有了 context-mode 这个模块,专门用来管理大模型应用里的上下文。

1.2 context-mode 的核心定义与定位

context-mode 不是一个模型,也不是一个独立框架,它是一套会话上下文管理的设计规范加轻量实现。核心思路是把上下文分成多种模式,在不同场景下启用不同的上下文策略,并且模式之间可以动态切换。

我定的三种基础模式是:

  • 连续模式:用于普通多轮对话,保存完整消息列表,但设置长度上限,超过后自动滚动窗口。
  • 摘要模式:用于长会话续接,把历史消息压缩成结构化摘要,原始消息可以归档或丢弃。
  • 聚焦模式:用于单任务场景,比如代码生成或一次性的精确问答,上下文里只保留当前任务相关的消息,其他全部隔离。

定位上,context-mode 是介于模型和业务逻辑之间的一个中间层。业务后端只需要把用户消息丢进来,由 context-mode 决定“当前该给模型看哪些内容”“用什么形式组织这些内容”。这样做的好处是和具体模型解耦,不管底层用哪个大模型,只要它支持标准的 messages 输入格式,就能无缝接入。

说得接地气一点,context-mode 解决的就是“喂给模型什么”的问题。模型本身没有记忆,上下文完全靠外部组装,那组装这件事既然这么重要,就该单独抽出来做成一个可维护、可测试、可切换的模块,而不是散落在各个业务函数里拼接 prompt。

2. 整体设计思路:为什么需要上下文模式,而不是一条路走到底

2.1 三种典型场景下的上下文需求差异

我最初的设计只有一套“全量历史 + 截断”的方案,结果在三个不同项目里都碰了壁。

第一个项目是智能客服。用户的对话节奏很随意,经常聊着聊着突然岔开话题。这种场景需要的是“连续模式”——既要保留足够的历史让模型理解前因后果,又不能把无关紧要的寒暄全塞进去。比如用户先问“我这个订单 15678 为什么还没发货”,中间聊了几句天气,然后又问“换地址会影响发货吗”,这时候上下文里必须留有订单号和“还没发货”这个核心事实,天气那几句可以丢掉。

第二个项目是长文分析。用户上传了一份几十页的产品说明,分好几次追问细节。这种场景如果用连续模式,翻几轮就超出窗口了。需要的是“摘要模式”,每轮对话后把关键结论、涉及条款、用户关注点提炼成摘要,持续累积。原始对话可以滚动丢弃,但摘要必须完整。

第三个项目是代码辅助工具。程序员问“把这段函数改成异步”,然后紧接着问“再帮我把上一个问题里的函数写个测试”,这里强调的是任务聚焦。如果上下文里混入了之前无关的代码重构记录,模型很容易分心。聚焦模式只保留当前任务相关的上下文,其他统统隔离。

这三种需求差异极大,不可能用同一个上下文策略覆盖。这也是为什么我最终决定把“模式”概念引入——让上下文策略像手机里的“普通模式”“省电模式”“高性能模式”一样,由用户或系统按需切换。

2.2 方案选型比较:单上下文、多上下文还是动态模式切换

在确定 context-mode 之前,我对比过几种常见做法。

第一种是“单上下文 + 全量拼接”。实现最简单,把所有历史消息按时间顺序拼进 prompt。优点是信息完整,缺点也突出:成本随轮数线性上涨、模型注意力涣散、超出窗口只能粗暴截断。实测里超过二十轮之后,回答的连贯性明显下降,而且很多无关信息会污染输出。

第二种是“多上下文隔离”。每个会话或任务维护独立的上下文对象,互不干扰。这解决了会话串扰问题,但没有解决单会话内部长度膨胀的问题。相当于把一个大垃圾堆分成了多个小垃圾堆,垃圾还要清理。

第三种是“动态模式切换”,也就是 context-mode 最终采用的方案。核心思想是:上下文不是只有一条管道,而是多条管道并行,每条管道服务于不同目的。用户切换话题时,系统判断当前话题与哪个模式匹配,把消息路由到对应上下文,再根据模式策略组织 prompt。同时支持自动切换——检测到明显的话题转移后,自动把旧上下文归档,新上下文启用。

对比之后,第三种方案显然更贴近真实业务需求。多模式之间存在清晰的边界,既不像单上下文那样糊涂账,也不像多上下文那样缺乏内部优化。代价是复杂度上去了,需要在设计时考虑模式间的数据流动、摘要更新、切换时机,但这些都是可以收敛的。

2.3 context-mode 的设计原则

在实际编码前,我给自己定了几条原则,后续所有细节都围绕这些原则展开。

原则一:模式切换之间互不污染。每个模式维护独立的上下文存储。连续模式里的消息不会自动进入聚焦模式,聚焦模式里的任务信息在切换后也不能残留在摘要模式里。要做到这一点,关键是上下文对象必须是按模式实例化的,而不是所有模式共用一个列表再靠标记区分。

原则二:摘要与原始消息分离存储。摘要不是简单地把原始消息缩短,它应该是独立的、结构化的记录。原始消息适合做精确回溯,摘要适合做长期记忆。两者分开,才能在压缩时知道哪些原始消息可以丢弃,而不会影响摘要的维护。

原则三:上下文策略可插拔。context-mode 不应该绑定某个具体的大模型或者向量数据库。模型可以换,摘要算法可以换,检索方式可以换,但管理结构保持稳定。这样在不同项目里复用的时候,只需要替换策略实现。

原则四:一切以 Token 预算为准。这是最务实的一条。每个模式在组装 prompt 前,先检查当前已用 Token 与预算的关系。如果接近上限,就触发压缩、截断或切换。Token 预算像冰箱的容量,超过容量就要先扔一些东西才能再放新的。

3. 核心实现拆解:一个可落地的 context-mode 模块

3.1 模块架构与数据结构

context-mode 的整体结构可以分三层:存储层、策略层、调度层。

存储层负责保存原始消息、摘要、模式标记、会话元信息。我用一个ContextRecord数据结构表示单条上下文记录,里面包含角色、内容、时间戳、模式标记和唯一ID。

策略层是核心,每种模式对应一个策略类,实现统一接口。接口定义是build_messages()和update(),前者负责把当前存储转化为模型输入的消息列表,后者负责在收到新消息时更新内部状态。

调度层负责判断消息应该进入哪个模式、什么时候触发切换、切换时怎么保存和恢复上下文。

下面给出存储层的关键数据结构,用 Python 简化表示:

from dataclasses import dataclass, field from typing import List, Optional, Dict, Any import time @dataclass class ContextRecord: role: str # "user" / "assistant" / "system" content: str # 原始文本 mode: str = "general" # 所属模式:continuous / summary / focused created_at: float = field(default_factory=time.time) metadata: Dict[str, Any] = field(default_factory=dict) @dataclass class SessionState: session_id: str mode: str = "continuous" records: List[ContextRecord] = field(default_factory=list) summary: Optional[str] = None summary_records: List[ContextRecord] = field(default_factory=list) token_budget: int = 4000

这里我把摘要也设计成ContextRecord列表,而非单个字符串。是为了后续能对摘要本身做更新、替换或者分段检索,灵活性更大。

3.2 上下文切换的触发机制与流程

切换是 context-mode 最核心的动作。我把它分为三类:

  • 手动切换:用户通过接口明确指定新模式,比如前端按钮选择“进入专注模式”。
  • 自动切换:系统通过话题检测判断当前对话主题与旧上下文无关时,自动归档旧模式并启用新模式。
  • 预算触发切换:当连续模式的 Token 占用超过阈值,自动降级到摘要模式,将冗余历史压缩。

切换流程统一封装成一个函数,核心伪代码如下:

def switch_mode(session: SessionState, new_mode: str): # 1. 保存当前模式的最终状态 if session.mode == "continuous": # 把当前完整记录归档到历史区,便于回溯 session.summary_records.extend(session.records) elif session.mode == "summary": # 更新摘要 session.summary = generate_summary(session.summary_records) # 2. 重置当前上下文区 session.records.clear() # 3. 切换到新模式 session.mode = new_mode # 4. 注入系统提示,让模型感知模式变化 system_msg = ContextRecord( role="system", content=f"上下文模式已切换为:{new_mode}。请根据当前模式调整回答策略。", mode="system", metadata={"switch": True} ) session.records.append(system_msg)

这里有一个很容易被忽略的坑:切换模式时,必须在上下文里显式加入一条系统消息告知模型“你已经切换了”。如果不加,模型还拿之前的对话惯性回应新任务,就会出现“都切到代码模式了,还在用客服语气回应”的尴尬情况。

3.3 上下文压缩与摘要策略

摘要策略是摘要模式的核心。我尝试过两种做法,效果差异很大。

第一种是“固定步长压缩法”——每新增 N 条消息,就对区间内所有消息生成一次摘要,然后丢弃原始消息。优点是简单,缺点是摘要叠加误差大,早期细节很容易在后期的重复压缩中丢失。

第二种是“层级摘要法”——维护一个摘要树,新消息先生成小节摘要,小节摘要聚合成大节摘要,只在必要时对跨章节的部分做二次摘要。这样早期信息虽然经过压缩,但不会因为反复压缩而失真。

实际项目中我更推荐第二种。它的实现稍微复杂,但可以在摘要丢失率这个指标上获得明显改进。我在 context-mode 里实现了简化版:用单独的summary_records列表存放最近一次生成的摘要,当摘要记录数量超过 20 条时,再对摘要列表做一次整体凝练。

摘要生成的 prompt 模板也很关键。如果只让模型“总结一下”,往往抓到一些无关紧要的细节。我最终用的模板包含几个强制字段:核心主题、关键事实、待办事项、用户明确偏好。例如:

请根据以下对话内容生成结构化摘要,只保留重要信息: 1. 当前核心主题是什么? 2. 涉及哪些关键数据、订单号、代码片段或专有名词? 3. 用户做出了哪些决定或要求? 4. 是否存在未解决的问题? 请严格按照格式输出。

这样生成的摘要质量稳定得多,后续检索摘要也方便。

3.4 与检索增强(RAG)的融合方式

RAG 是很多 AI 应用的标配,但很多人用 RAG 时犯了一个错:每次回答都把检索结果强制塞进上下文,结果又长又乱。context-mode 的思路是“按需检索”。

具体做法是:在调度层维护一个检索开关。当当前模式为聚焦模式,并且用户问题中包含明确的知识库关键词时,才触发向量检索;如果是连续模式下的一般闲聊,直接屏蔽检索,减少干扰。

例如,客服系统中,用户问“你们退货政策是什么”,这会命中知识库关键词“退货政策”,系统从向量库拉取相关政策片段,注入到聚焦模式上下文中。用户说“今天天气真好”,则完全不会触发检索,保持轻量。

融合的实现方式是在构建 prompt 时,把检索到的内容作为 system 消息注入,而不是拼接在 user 消息后。这样模型会把检索内容当作一种“背景知识”,而非用户原话,回答时不容易混淆。

4. 实操过程:从零搭建一个 console 版的 context-mode

4.1 环境准备与依赖

context-mode 本身不强依赖某个大模型 SDK,所以核心模块只用 Python 标准库就能跑通。demo 演示需要调用大模型时,可以装一个轻量 HTTP 客户端,比如 requests,或者直接用 OpenAI SDK(前提是你有合法的模型服务地址)。

最小依赖清单:

pip install requests

如果你用的是 OpenAI 兼容接口(很多模型服务都提供这个协议),安装 openai 包会更方便:

pip install openai

我下面演示的代码尽量不绑定具体 SDK,用requests做接口调用,方便你在任何模型上测试。

4.2 核心代码实现:一个简化版的 context-mode

这里给出一个能直接跑通的 console 版。完整功能包括:创建会话、添加消息、切换模式、构建 prompt、调用模型。

import os import json import requests from typing import List, Optional, Dict, Any class ContextModeEngine: def __init__(self, session_id: str, token_budget: int = 4000): self.session_id = session_id self.mode = "continuous" self.records: List[Dict[str, Any]] = [] self.summary: Optional[str] = None self.token_budget = token_budget self.history_archive: List[Dict[str, Any]] = [] def add_message(self, role: str, content: str): record = { "role": role, "content": content, "mode": self.mode, "ts": __import__("time").time() } self.records.append(record) def switch_mode(self, new_mode: str): if new_mode not in ("continuous", "summary", "focused"): raise ValueError(f"未知模式: {new_mode}") # 保存旧模式状态 if self.mode == "continuous": self.history_archive.extend(self.records) elif self.mode == "summary": if len(self.records) > 0: self.summary = self._generate_summary(self.records) # 清空当前上下文 self.records.clear() self.mode = new_mode # 注入模式切换提示 self.records.append({ "role": "system", "content": f"上下文模式已切换为:{new_mode}", "mode": "system", "ts": __import__("time").time() }) def build_messages(self) -> List[Dict[str, str]]: messages = [] if self.summary: messages.append({ "role": "system", "content": f"历史摘要:\n{self.summary}" }) for rec in self.records: messages.append({ "role": rec["role"], "content": rec["content"] }) # 限制 token 预算,简单按字符数估算 while self._estimate_tokens(messages) > self.token_budget: if len(messages) <= 1: break messages.pop(1) # 删除最早的普通消息 return messages def _estimate_tokens(self, messages: List[Dict[str, str]]) -> int: total = 0 for msg in messages: total += int(len(msg["content"]) / 1.5) # 中文约1.5字符/Token return total def _generate_summary(self, records: List[Dict[str, Any]]) -> str: text = "\n".join(f"{r['role']}: {r['content']}" for r in records) # 这里可以接入远程模型,简化版返回截断文本 if len(text) > 2000: return text[:2000] + "……" return text def chat(self, user_input: str, endpoint: str, api_key: str, model: str): self.add_message("user", user_input) messages = self.build_messages() resp = requests.post( endpoint, headers={"Authorization": f"Bearer {api_key}"}, json={"model": model, "messages": messages} ) resp.raise_for_status() reply = resp.json()["choices"][0]["message"]["content"] self.add_message("assistant", reply) return reply # 使用示例 if __name__ == "__main__": engine = ContextModeEngine(session_id="test-001", token_budget=2000) engine.add_message("system", "你是一个智能助手。") reply = engine.chat("你好,我的订单 15678 还没发货吗?", endpoint=os.getenv("LLM_ENDPOINT", "http://localhost:8000/v1/chat/completions"), api_key=os.getenv("LLM_API_KEY", "EMPTY"), model=os.getenv("LLM_MODEL", "test-model")) print(reply) # 手动切换模式 engine.switch_mode("focused") print(engine.build_messages())

这段代码是高度简化的,主要演示结构。实际项目中需要将_generate_summary替换成真正的模型调用,并把 token 估计算法换成分词器。

4.3 关键参数选择与调优

我在调参时踩过几个坑,分享出来。

Token 预算(token_budget):建议按模型实际窗口的 60%~70% 设置。比如模型窗口是 8000,那预算设为 5000 左右,留出余量给回复生成。设太满,模型输出可能被截断。

摘要触发阈值:连续模式下,我通常设定当消息条数超过 20 条,或估算 token 超过预算的 80% 时触发摘要。阈值太小会导致频繁压缩,成本高;阈值太大又起不到控制作用。可以从实际对话长度统计里得出合理值。

模式切换的系统提示内容:切换提示尽量简洁,不要写一堆说教式的内容。给模型一个明确、简短的指令即可,例如“现在开始进入聚焦模式,请忽略之前的非相关对话”。长提示会占用 token,且效果不一定更好。

4.4 接入现有 Agent 项目的步骤

如果你的项目已经有自己的 prompt 构建逻辑,接入 context-mode 并不需要重写。

第一步,替换 prompt 构建函数:原来你可能在代码里手动拼messages,改成调用engine.build_messages()。

第二步,在用户输入的位置调用engine.add_message("user", user_input),替换原来的messages.append(...)。

第三步,为前端或业务层增加模式切换入口:可以做成一个下拉框或按钮,也可以做成自动话题检测后回调engine.switch_mode()。

第四步,处理会话持久化:context-mode 的SessionState需要能保存到 Redis 或数据库。我建议保存时把records、summary、mode全部序列化成 JSON,恢复时直接反序列化。

接入完成后,原来的模型调用逻辑几乎不用改。这就是把上下文管理拆出来的最大好处:对现有代码干扰小,收益却很直接。

5. 常见问题与排查技巧实录

5.1 上下文切换后回答“失忆”怎么办

这是最容易遇到的问题。明明切换到了新模式,模型却还在用旧上下文里的信息回答。排查思路:先看切换时有没有执行records.clear()。如果只改了mode属性,没有清空旧消息,那模型自然会看到旧内容。

另一个原因是切换动作发生在新消息加入后,而不是暂停后。切换前必须确保已经处理完当前消息,否则前一条消息可能还残留在旧上下文中没有归档。我调试时用的是加日志的方式,把每次切换前后的记录条数和内容都打出来,很快发现问题。

5.2 压缩摘要导致关键信息丢失

摘要模式最大的痛点是“压缩后用户提到的重要信息不见了”。比如用户报了一个订单号,压缩后订单号被模型忽略。

解决办法是结构化摘要模板里强制要求列出所有数字和专有名词。我在模板里加了“请把订单号、金额、日期、邮箱等数字类信息一字不差地列在‘关键数据’字段”。这样即使上下文被压缩,关键数据依然能通过摘要检索回来。

还有一种兜底做法:摘要只作用于常规消息,包含数字、代码块、网址的消息单独存到“重要信息区”,永不压缩。这个区只增不减,体积可控。

5.3 并发会话下上下文串扰

场景:同时有 100 个用户在线,每个用户有自己的会话。如果用一个全局的ContextModeEngine实例,消息就会互相污染。

解决方式很简单:按 session_id 维护一个字典,每个会话独立实例。实现上可以用一个SessionManager类,内部持有Dict[str, ContextModeEngine],提供get_session(session_id)方法。注意访问时要加锁,尤其在多线程环境下。

import threading class SessionManager: def __init__(self): self._lock = threading.Lock() self._sessions = {} def get_session(self, session_id: str) -> ContextModeEngine: with self._lock: if session_id not in self._sessions: self._sessions[session_id] = ContextModeEngine(session_id) return self._sessions[session_id]

这个坑我在上线第一天就踩过,当时用户 A 的上下文里出现了用户 B 的聊天内容,直接导致客服系统给出错误订单信息,反馈相当严重。

5.4 性能与成本平衡

context-mode 本身会增加一些开销,主要是摘要生成和话题检测。如果每次用户输入都触发一次大模型摘要调用,成本会迅速失控。

我的经验是给摘要调用加一个“冷却时间”。比如 5 分钟内最多触发一次摘要生成,其他时候只做简单计数。另外把摘要调用放在后台异步执行,不阻塞正常对话。用户不会有感知,成本也降下来了。

话题检测不一定要用大模型。如果对话中出现了明显的主题词变化,可以用关键词匹配或向量相似度做初筛,只有初筛结果存疑时才调大模型做二次判断。这样大部分场景下检测成本极低。

我个人在实际操作中的体会是:context-mode 看起来只是一个上下文管理模块,但它改变了我搭建 AI 应用时对“对话状态”的理解。以前我总是纠结于怎么把提示词写得更好,后来发现提示词写得再好,上下文一乱,效果照样稀烂。把上下文当成一等公民来管理,很多诡异问题会自然消失。最后再分享一个小技巧:无论怎么切换模式,都让系统保留一条不可见的“模式切换日志”,记录每次切换的时间、原因、上下文条数。遇到线上问题排查时,这条日志往往是最快的溯源入口。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进