
这些年做大模型应用开发最容易被低估、却又最能拉开体验差距的就是上下文模式。很多人把 context window上下文窗口当成一个单纯的数字—— 比如 128K、200K以为把内容一股脑塞进去就完事。但真正落地一个项目、一个 agent、一个需要多次交互的产品时你会发现窗口能装多少和你会装多少完全是两码事。我最近重构的一个对话式工具核心就是把裸调接口改成了context-mode上下文管理模式整个交互质量和稳定性上了一个台阶。这篇文章就把我在这个项目里的完整思路、代码实现和踩过的坑一次性摊开讲清楚。这篇文章适合两类人一类是刚开始接触大模型 API 开发准备从单轮调用往多轮对话上跨的开发者另一类是已经在做 agent、RAG 应用但经常被 token 超限、上下文污染、多轮记忆丢失搞到头秃的工程师。你会看到我为什么放弃每次拼接全部历史的粗暴方式转而设计了一个带缓存、带预算、带遗忘策略的 context manager以及整个过程里实测下来的关键参数和避坑经验。1. 项目动机与整体设计思路1.1 从一次真实的翻车经历说起先说一个我自己的真实案例。早期做过一个客服问答机器人当时的实现方式极其朴素每次用户提问就把当前问题拼上最近几轮的历史消息全部发给模型接口。单看每一轮调用逻辑完全没问题prompt 也是对的模型也确实理解了上下文。但问题出在两方面。第一是token 成本失控。每轮对话都把历史消息完整重发一遍对话越往深处走输入越长费用成倍上涨。一个 30 轮的聊天最后一轮可能要把前 29 轮全部重新计费这里面有大量重复计算的 token。第二是上下文被污染。因为是朴素拼接历史消息里的拼写错误、用户中途改主意、甚至是无关的闲聊内容都会沉淀在上下文里。模型在前面的错误信息影响下后面的回答开始跑偏。最典型的情况是用户先问帮我查一下A产品的价格得到回答后又问那B呢模型有时候会把 B 和 A 混淆在一起回答——因为历史里信息太多、太杂模型不知道该以哪条为准。这次翻车让我意识到一个问题多轮对话的真正难点不在模型的接口能力而在于你怎么组织上下文本身。我们需要的不是简单的按时间顺序堆消息而是一个有结构、有取舍、有预算意识的管理模式。这就是我决定做 context-mode 这个模块的直接动机。1.2 context-mode 要解决的核心问题所谓 context-mode字面意思就是上下文模式。但在我这套设计方案里它具体指代的是用一套独立的上下文管理器统一负责对话历史的存取、裁剪、刷新和预算控制让模型只看到该看的内容而不是所有的内容。它核心解决的问题有三类多轮记忆的连续性问题。让模型在长对话里不忘记最初的目标和关键约束条件。成本与性能的平衡问题。在有限的上下文窗口内用最少的 token 保留最多的有效信息。历史信息的去噪问题。自动过滤掉无关内容避免前面轮次的错误、噪音干扰当前判断。这三个问题如果没有一个好的模式来统一管理随着对话轮次增加系统的表现会肉眼可见地变差。而很多开发者的误区是以为加大上下文窗口就能解决一切。实际上200K 的窗口你塞进去 150K 的废话和日志效果比 8K 窗口里塞 3K 的精准信息还要差。窗口大不等于效果好信息密度和信息相关性才是决定输出质量的关键。1.3 方案选型为什么不做全量拼接也不做纯断线式无记忆在正式动手写代码之前我先对比了两类常见方案全量拼接和完全无状态。这两种我都实测过各有各的问题。全量拼接的最大优点是实现简单而且信息无损。任何一轮的历史细节模型都能看到。但缺点前面说了成本高、噪音大、模型注意力被稀释。对话超过一定长度后token 预算和管理复杂度都是灾难。完全无状态则相反每次请求都是全新会话省事了但模型完全没有记忆能力用户刚说过的名字、喜好、背景信息下一轮就全忘了根本撑不起对话式应用。所以 context-mode 本质上走的是中间路线有状态但有选择性保留记忆但不保留全部细节。在这个方向下我还对比过两个具体实现策略一个是固定轮次窗口只保留最近 N 轮另一个是关键信息抽取保留最近 N 轮 从历史里提炼的摘要。最终我选了后者。原因是固定轮次窗口虽然简单但它有一个致命缺陷如果用户在第 2 轮提出了一个关键约束比如价格控制在 5000 以内到了第 15 轮这个约束早已滚出固定窗口模型会把最重要的限制信息忘掉。而最近 N 轮 长期摘要的结构既能保持近期对话的细节连贯性又能通过摘要机制保住长期任务的核心目标。这个设计思路就是 context-mode 的核心骨架。2. 核心细节解析context-mode 的三层结构设计2.1 第一层短期记忆层负责最近在聊什么我的是把 context-mode 划分成三层。第一层叫短期记忆层其实就是一个固定大小的消息队列专门存放最近几轮的完整对话消息。为什么需要这一层因为模型在回答当前问题时最依赖的就是紧邻的上下文。比如用户刚刚说嗯这个方案可以你如果只把这句话的文本发给模型模型根本不知道这个方案指的是哪个方案。所以最近几轮的消息必须原样保留不做修剪。在实现上我选用的是一个deque双向队列设置了maxlen参数来控制最大保留条数。实测下来短期记忆窗口保留 10 到 12 条消息即 5-6 轮对话是一个比较合理的平衡点。少于 6 条对话稍微绕几个弯就接不上了多于 12 条成本开始变得明显而且边际收益递减——因为再往前的内容很多已经可以被长期摘要覆盖。2.2 第二层长期记忆层负责整件事的目标是什么第二层是长期记忆层学名可以叫对话摘要conversation summary。这一层的职责很明确把短期记忆里即将滚出窗口的旧消息提炼成一到两句凝练的摘要保留下核心事实、关键约束和当前进度然后把摘要放在系统提示词里持续影响模型的全局判断。这一层的设计灵感来自经典的对话摘要模式——也就是一批大模型产品比如 ChatGPT内部实际采用的做法。核心思想是与其让模型看一万个 token 的原始历史不如让它看一百个 token 的精华摘要。我实现在具体代码里是这样的逻辑每当下次请求到来时先检查短期记忆队列如果发现有多条消息即将被挤出队列就调用一次额外的模型请求输入被挤出的这些消息输出一段摘要。然后这个摘要会追加到前一轮摘要的末尾形成滚动式累积。我管这个方法叫 rolling summary它在实际使用里非常有效。2.3 第三层任务上下文层负责始终不变的系统指令第三层恐怕是最容易被忽略的一层却是整个 context-mode 里对稳定性贡献最大的一层——我称之为任务上下文层。它保存的是整个对话生命周期内都不应该变的东西比如系统角色设定、用户的核心目标、必须遵守的输出格式和禁止事项。这层的最大的好处是防止对话跑偏。举个具体例子我做这个工具的时候在任务上下文里写了一条你是中文技术写作助手所有回答必须使用简体中文且严禁使用 emoji。如果没有这层任务上下文模型在聊到某个话题时可能会自动切到英文或者偶尔蹦出几个 emoji。有了这一层不管对话走多远模型都会像锚一样被固定在这个任务基线上。另一类被打进这层的内容是用户明确表达的长期偏好。比如以后所有回复都用表格形式对比这句话是用户在第 3 轮说的但它影响的是后面所有的回复。它应该被放到全局任务上下文里而不是仅仅停留在短期记忆里。2.4 三层结构如何协同工作一条消息的完整旅程我把三层结构搞明白之后还需要把它们组装起来。在 context-mode 的完整运行流程里一条新消息是怎么被处理的这里的链路是这样的用户新消息进来先追加到短期记忆队列尾部。构造请求上下文时按 任务上下文 长期摘要 短期记忆 当轮消息 的顺序拼装。模型基于完整上下文生成回答回答追加进短期记忆队列。检查短期记忆队列长度如果超过预设阈值把最先挤出去的一组消息抽出来调用摘要接口生成新摘要。新摘要与旧摘要合并更新长期记忆层。这个流程里最关键的设计理念是每一步都是在存取与取舍之间做平衡。短期记忆取的是细节长期记忆取的是精华任务上下文存的是根基。三者各司其职上下文才不会被历史垃圾填满。3. 实操过程与核心实现从零搭建一套可复用的 context-mode 模块3.1 项目结构总览和依赖准备直接上代码。我的项目命名是context-mode实际开发里我建议把它做成一个独立的 Python 模块方便多项目复用。目录结构如下context-mode/ ├── context_manager.py # 核心上下文管理器 ├── llm_client.py # 负责模型接口调用兼容 OpenAI 格式 ├── summarizer.py # 负责历史摘要生成 ├── config.py # 参数配置区 └── demo.py # 一个可直接运行的最小示例我这边用的语言是 Python模型接口调用全部走 OpenAI 兼容的 HTTP 接口格式所以不管你是接哪家的大模型服务只要支持 /chat/completions 格式这套代码都能适配。唯一需要自己填的是api_key和base_url。依赖方面只需要一个第三方库openai的 Python SDK或者httpx自己撸。3.2 参数配置文件为什么这些默认值是最佳起点先来看看config.py里的核心参数。这些参数我全是实测过后给出来的默认值但不是死值你完全可以根据自己的场景调整。# config.py class Config: LLM_BASE_URL https://your-llm-service.com/v1 LLM_API_KEY your-api-key LLM_MODEL your-model-name # 短期记忆队列最大消息条数每条消息算一个 role/content 对 SHORT_TERM_MAX_MESSAGES 12 # 触发压缩的阈值当短期队列长度超过该值将最早的几条送入摘要 COMPRESS_THRESHOLD 15 # 每次压缩时一次性送入摘要的消息条数 SUMMARIZE_BATCH_SIZE 6 # 长期摘要最大字符数近似上限超过后触发二次压缩 SUMMARY_MAX_CHARS 2048 # 是否启用自动压缩 AUTO_COMPRESSION True这几个参数为什么这么定背后的理由我来逐一说清楚。SHORT_TERM_MAX_MESSAGES 12这个值是短期队列的目标长度。12 条消息意味着约 6 轮完整对话。6 轮的对话量对于大多数场景客服咨询、方案讨论、代码调试来说足够覆盖用户当前在想什么的完整脉络。如果再短模型很容易丢失刚刚才讨论过的细节如果太长token 消耗增速明显。COMPRESS_THRESHOLD 15这个值设得比目标值大一些留出缓冲区间。当队列长度在 12 到 15 之间波动时我们不做压缩操作避免每来一条消息就触发一次额外的摘要接口调用那个成本也是钱。到了 15 条再触发压缩每次压掉一批一次性回到 9 条的位置给后续留足空间。SUMMARIZE_BATCH_SIZE 6每次把 6 条消息一起送去生成摘要这个批量是为了保证摘要的连贯性。如果一次只压 1-2 条摘要粒度太碎会失去整体把握如果一次压 10 条以上摘要信息密度太高容易丢关键细节。6 条是一个比较顺手的中间值。SUMMARY_MAX_CHARS 2048长期摘要不能无限膨胀。虽然摘要比原始消息信息密度高但积累上百轮后仍然可能变成一大坨。设置字符上限后如果超过 2048 字符我们把旧摘要再整体做一次摘要的摘要把更早的内容再压缩一层。这本质上是多级摘要策略。3.3 核心类实现ContextManager 的完整代码解析接下来是核心模块。我把整个 context-mode 逻辑封装成一个类名字就叫ContextManager。这个类的对外接口尽可能简单add_user_message添加用户消息add_assistant_message添加助手回复build_messages构造发送给模型的最终消息列表。你不需要关心内部的三层结构和压缩细节。# context_manager.py from collections import deque from typing import List, Dict, Optional from config import Config from summarizer import generate_summary class ContextManager: def __init__(self, system_prompt: str , user_profile: str ): # 任务上下文层系统指令 用户长期偏好 self.system_prompt system_prompt self.user_profile user_profile # 长期记忆层滚动摘要 self.long_term_summary # 短期记忆层消息队列deque 自动控制长度 self.short_term_messages: deque deque(maxlenConfig.SHORT_TERM_MAX_MESSAGES) # 记录角色区分用于压缩时判断谁说的 self.role_flags: deque deque(maxlenConfig.SHORT_TERM_MAX_MESSAGES) def add_user_message(self, content: str): self.short_term_messages.append(content) self.role_flags.append(user) def add_assistant_message(self, content: str): self.short_term_messages.append(content) self.role_flags.append(assistant) def _maybe_compress(self) - Optional[str]: # 如果队列长度达到压缩阈值触发滚动摘要 if len(self.short_term_messages) Config.COMPRESS_THRESHOLD: return None # 取出最早的一批消息 batch_messages [] batch_roles [] for _ in range(Config.SUMMARIZE_BATCH_SIZE): if not self.short_term_messages: break batch_messages.append(self.short_term_messages.popleft()) batch_roles.append(self.role_flags.popleft()) # 生成这批消息的摘要 new_digest generate_summary(batch_messages, batch_roles, self.long_term_summary) # 合并进长期摘要 if self.long_term_summary: self.long_term_summary self.long_term_summary new_digest else: self.long_term_summary new_digest # 如果摘要过长做二次摘要简化版直接截断 if len(self.long_term_summary) Config.SUMMARY_MAX_CHARS: self.long_term_summary generate_summary( [self.long_term_summary], [assistant], ) return new_digest def build_messages(self) - List[Dict[str, str]]: # 触发一次压缩检查 self._maybe_compress() messages [] # 第一层任务上下文 if self.user_profile: messages.append({ role: system, content: f用户长期偏好与约束{self.user_profile} }) # 第二层长期摘要 if self.long_term_summary: messages.append({ role: system, content: f对话历史摘要{self.long_term_summary} }) # 第三层短期记忆按顺序还原 user/assistant for content, role in zip(self.short_term_messages, self.role_flags): messages.append({role: role, content: content}) # 最前面放系统指令确保模型先读到任务定义 system_message {role: system, content: self.system_prompt} messages.insert(0, system_message) return messages def reset(self): self.short_term_messages.clear() self.role_flags.clear() self.long_term_summary 这段代码是整套 context-mode 的骨架。核心动作其实就是两个build_messages里做压缩检查并拼装三层内容add_user_message/add_assistant_message里维护短期队列。有个细节值得单独讲讲为什么要用deque(maxlen...)。因为 deque 在超过 maxlen 时会自动丢弃最旧元素这个特性天然适配我们的短期记忆窗口。不过要注意它自动丢弃的元素是无声无息的如果不配合压缩被挤走的消息就彻底丢了。所以我在build_messages开头调用_maybe_compress()确保在构造请求前队列长度被控制在一个健康的范围内而不是等deque因为 maxlen 强制裁剪后才后悔莫及。3.4 摘要生成模块如何用一次额外调用提炼历史精华摘要生成是 context-mode 的灵魂。这个summarizer.py模块的职责是输入一批旧消息输出一段凝练的摘要。这里的关键在于 prompt 的设计比你想象的更讲究。# summarizer.py from llm_client import chat_completion SUMMARY_SYSTEM_PROMPT ( 你是一个对话历史摘要引擎。你的任务是把提供的对话记录提炼成简洁、 信息密度高的摘要。你需要保留以下信息\n 1. 用户的核心目标与当前进度\n 2. 所有明确表达过的偏好或约束条件\n 3. 已确认的关键事实如日期、数字、名称\n 4. 尚未解决的待办事项。\n 要求使用第三人称叙述过去时态控制在 200 字以内 不要包含寒暄和无关细节。 ) def generate_summary( messages: List[str], roles: List[str], previous_summary: str ) - str: prompt_parts [] if previous_summary: prompt_parts.append(f已有的对话摘要\n{previous_summary}) if messages: history_lines [] for msg, role in zip(messages, roles): speaker 用户 if role user else 助手 history_lines.append(f{speaker}{msg}) prompt_parts.append(新增的历史消息\n \n.join(history_lines)) user_prompt \n\n.join(prompt_parts) \n\n请生成/更新摘要 response chat_completion([ {role: system, content: SUMMARY_SYSTEM_PROMPT}, {role: user, content: user_prompt}, ]) return response.strip()这个 prompt 设计里最核心的一招是把已有的摘要一起送入。它不是让模型只看新的一批消息凭空生成新摘要而是让它结合旧摘要和新增消息做一次更新操作。这样长期摘要就不只是碎片的拼接而是一个连续演进的知识压缩体。举一个实测中的直观例子。用户前 10 轮一直在讨论智能家居中控屏的尺寸选型最后确定了 10.1 英寸的方案。如果只把第 9、10 轮消息送去摘要模型看到的只是讨论了尺寸定了 10.1 英寸但为什么定这个尺寸背后的原因——因为墙体的预埋底盒是标准 86 盒只能装下 10.1 英寸以内——可能在第 3、4 轮就出现过。结合旧摘要之后模型能把这个原因链条保留下来这对于后续回答那这个屏能不能装到厨房这种问题时有非常大的帮助。3.5 模型调用客户端统一封装接口调用llm_client.py是基础通信层。它做的事情很简单统一处理模型接口调用统一管理超时、重试、错误信息解构。代码实现如下# llm_client.py from openai import OpenAI from config import Config _client None def get_client() - OpenAI: global _client if _client is None: _client OpenAI( api_keyConfig.LLM_API_KEY, base_urlConfig.LLM_BASE_URL ) return _client def chat_completion(messages: List[Dict[str, str]]) - str: client get_client() try: resp client.chat.completions.create( modelConfig.LLM_MODEL, messagesmessages, temperature0.3, ) return resp.choices[0].message.content or except Exception as e: # 生产环境这里应该做更细致的错误分类比如限流 vs 超时 raise RuntimeError(fLLM call failed: {e})注意我把temperature设在 0.3这个值在对话生成场景里偏保守但有几个好处回答更稳定、更少跑题、对上下文的遵从度更高。如果你的场景是创意写作之类可以适当往上调但 context-mode 核心是言行一致地完成用户任务稳定性优先于创造性。4. 常见的坑与排查技巧实录4.1 坑一角色错位导致对话人格分裂我在第一版代码里犯过一个大错误构造短期记忆消息时用zip(short_term_messages, role_flags)确实能一一对应但一旦某条消息被踢出去role_flags也要同步踢。我最初用的是两个独立的list在一个地方通过pop(0)弹出消息另一个地方却忘了同步弹出角色结果就是模型看到user我是张三 后面紧接着又一条 user好的就这样——连续两条用户消息没有人回复。这个问题要是在长对话里发生模型的回答会变得极其怪异甚至来回自我切换立场。排查这类问题最有效的办法是在build_messages()返回前打一行日志打印完整消息列表和角色序列肉眼扫一遍就能发现角色错位。4.2 坑二摘要串味——把摘要内容当成了当前用户输入还有一次我发现模型开始以第三人称回答用户您需要的助手正在为您查找资料。排查后发现我把长期摘要用role字段塞进了消息列表用的是user角色。模型看了一条 roleuser 的历史摘要会以为这是用户说的话于是顺着这个用户的第三人称语境来说话。摘要信息是辅助模型理解背景用的它不属于任何一方的具体发言。如果当 user 消息发模型会认为那是用户真的说过的话如果当 assistant 消息发模型会把摘要里的描述误认为成自己之前的承诺。最安全的方式是封装在 rolesystem 里并明确标注对话历史摘要让模型知道这是背景资料。4.3 坑三摘要越滚越长最后把窗口撑爆几个月的对话摘要再压缩也会积累。我在真实项目里遇到过用户每天使用、连跑两个月后摘要达到了近 10000 字符直接把单轮请求的 token 预算干爆。我的解决方法是引入二级摘要归档机制。具体做法是给摘要设置一个硬顶当超过上限时调用摘要接口把当前摘要整体压缩成一份终极摘要再把详细摘要存档到本地数据库以后不再参与请求拼装。这一步的代价是最多丢失一些远古细节但换来的是整个系统的稳定性。对于绝大多数对话应用用户根本不会在意 20 轮之前的具体措辞更在意的是任务目标是否被一直记住而这正是摘要住挡住的核心。4.4 坑四过度压缩导致上下文丢失关键信息压缩算法本质上是有损压缩。如果压缩太激进比如每次只允许摘要保留 50 字那模型很快就只剩一个概念框架而没有血肉。实测中我推荐摘要的保留粒度是每条关键事实日期/名称/数字/约束独立保留不要用等等、之类这种模糊词汇概括。摘要的质地远比长度重要。目前最可靠的做法是摘要 prompt 里明确要求只保留事实不保留过程。举个例子用户花 20 轮讨论一个方案怎么做过程中的头脑风暴、试错、反复都不需要进摘要但最终敲定的方案、选择的参数、确认的截止时间必须逐字保留。4.5 完整排查速查表现象可能原因排查与修复方法模型突然用第三人称回答摘要被错误放在 user/assistant 角色里检查消息列表里 rolesystem 的位置把摘要统一放 system用户说过关键约束被遗忘该内容可能在压缩时被摘要丢弃把关键约束提升到任务上下文层user_profile不走压缩对话越长回答越慢短期/长期累积的 token 过多检查SHORT_TERM_MAX_MESSAGES和SUMMARY_MAX_CHARS是否过大的摘要接口调用费用偏高压缩过于频繁调大COMPRESS_THRESHOLD或提高摘要触发间隔如每 10 条才压一次同一问题反复确认短期记忆窗口太小适当调大SHORT_TERM_MAX_MESSAGES到 16-20 条连续两条 user 消息无 assistant 回复角色列表不同步在弹出处增加日志确保 role_flags 和消息同步弹出4.6 一点额外的实战调试技巧最后分享一个我在调试 context-mode 时最常用的小技巧把所有层级的最终内容打印成一个 JSON 快照。即把build_messages()返回的结果整个 dump 到文件里包括 system prompt、摘要、短期消息。出问题时我第一件事就是看这个快照配合diff两次快照很快能定位是哪一个环节引入了错误信息。这个方法比盯着模型输出猜原因靠谱得多。5. 扩展路径从对话机器人到 agent 系统的上下文管理context-mode 这套设计不光能用在聊天机器人上。我现在已经把它迁移到了一个偏 agent 性质的工具里——一个能自主调用工具、查询数据库、写代码的小助手。在这种场景下上下文管理模式的价值体现得更充分因为它不仅管对话历史还要管工具调用的过程记录。agent 场景和普通对话最大的区别是消息里会出现大量工具返回的结构化数据可能是 JSON、日志、函数返回值。这些内容不适合全部摆在短期记忆里否则模型会在下一次推理时被巨大的 JSON 淹没。我的处理方式是把这类中间结果也纳入压缩调度——一旦工具返回的数据量超过某个阈值立即启用工具摘要策略只保留工具执行结果的核心指标和状态。另外context-mode 还可以和 RAG检索增强生成结合。当用户的问题需要检索知识库时检索到的片段可以作为临时上下文注入到任务上下文层的下方但这类临时内容只在一轮内有效下一轮就要清理掉。这个临时上下文生命周期的理念也是 context-mode 的一个重要扩展方向。在我看来上下文管理是大模型应用中最值得持续投入深挖的领域之一。它不像模型选型那样一眼就有答案也不像 prompt 工程那样靠灵感就能优化它更像一门系统的工程学——成本控制、记忆策略、信息生命周期管理都需要有意识地设计。这一版 context-mode 的实现只爬到了半山腰后续我计划在 multi-session 持久化、深度摘要压缩、上下文路由这几个方向继续迭代。有同样在做相关项目的朋友欢迎分享你的实践经验一起把这条路走得更远。