context-mode 这词,做 AI 应用的同学应该都不陌生。不管是写聊天机器人、知识库问答,还是做编程助手,凡是需要跑多轮对话的场景,最后一定会撞上同一个问题:模型不记得刚才说过什么,你要手动维护上下文,而且上下文越长,代价越高、越容易乱。说白了,“上下文模式”就是把“历史”变成一种可以在请求之间反复使用、随时裁剪、按需组装的数据形态,而不是每次调用都从零开始。
这篇文章就从我自己做的一个 context-mode 小项目说起,把我踩过的坑、试过有效的方案、还有最终的代码结构全部拆开讲。适合刚接触 LLM 应用开发、对上下文管理一头雾水的同学,也适合已经在用 LangChain 这类框架、但总觉得黑盒不透彻的人。读完之后,你至少能自己写出一套够用的上下文管理模块,不用再遇事就翻官方文档。
1. context-mode 到底在解决什么问题——设计思路拆解
1.1 上下文不是“对话记录”,而是模型的临时工作台
先明确一个底层事实:大模型本身没有记忆。所谓上下文,在工程实现上就是拼接到输入里的那一段文本序列。模型每次推理都是独立进行的,它“看到什么,就基于什么回答”,不记得上一个请求里你说过什么。所以 context-mode 的核心任务,不是“帮模型记住”,而是“在每次请求前,帮模型把该看的信息拼齐”。
我习惯用一个类比来理解这件事:假设你每天换一个新实习生来对接,你不能指望他记得上周你交代过什么,只能每次把项目背景、当前进度、客户偏好、禁忌事项重新讲一遍。context-mode 做的事情,就是保证这堆“交接材料”不会越堆越厚、不会漏掉关键项、也不会把过时的信息混进去。
这个理解非常重要。很多人一开始会把 context 等同于“消息历史”,这没错,但不完整。真正生产环境里,上下文的结构应该比你想象中更复杂:它包含系统设定、用户画像、历史对话、工具返回结果、临时检索片段。如果这些都混在一个 messages 数组里,那每一次请求实际上都在赌,赌模型能自己分辨哪些信息重要、哪些可以忽略。这个赌注,早期 demo 能赢,一旦对话变长、业务变复杂,就会不断翻车。
所以我在设计 context-mode 时,先给自己定了一条原则:上下文必须“分层”,每一层都有自己的生命周期和组装规则。系统层永远在场,对话层按窗口滚动,工具层按需临时插入。这样,任何时候我都能回答一个问题:“模型现在到底看到了什么?”如果一个上下文结构不能让工程师在五分钟内说清楚这个问题,那它迟早会变成一个黑洞。
1.2 三个典型的上下文失控现场
我在实际项目中见过太多上下文失控的案例,总结起来基本逃不出下面三类。
第一类:token 膨胀。对话每轮增长几十到几百 token,感觉不多,但跑到第五十轮的时候,一次请求的输入量可能就超过三千 token。如果还开了工具调用、检索增强,输入量轻松上万。后果很明显:请求变慢、账单变厚、部分 API 直接报错。更隐蔽的是,有些模型的“聪明程度”会随着输入变长而下降,它会更容易被中段信息干扰。这个问题在长对话里几乎一定会遇到,只是早晚和严重程度的区别。
第二类:截断后的“失忆”。最粗暴的方案是只保留最近 N 轮,N 取 10 或 20。实现简单,但代价是早期用户说过的关键信息会被挤出去。我之前有个客服场景,用户在前面反复强调“不要用 Markdown 格式回复”,结果到了第十一轮,模型又开始满屏输出标题和列表。用户当然觉得你很蠢,但问题本质不在模型,而是你根本没把那个约束放进上下文里。
第三类:上下文污染。系统提示、参考文档、历史问答全塞在一起,模型分不清哪些是任务指令、哪些是历史记录、哪些是“举例”。有时候它看了旧回答的措辞,就把旧回答当成新规则来执行;更严重的是多用户共用进程时,上下文串了,A 用户的问题里混进了 B 用户的数据。这类问题最难查,因为它不报错,只是行为怪,往往要排查很久才发现是数据结构的问题。
1.3 我的 context-mode 设计原则:三层分离
第一版 context-mode 我做得特别简单,就是把 messages 数组从左到右拼起来,发给模型就完事。结果跑到二十轮以后,效果和成本双双失控。后来我彻底重构,把上下文拆成了三层:
系统层放的是角色设定、固定规则、业务约束。比如“你是客服助手,回复简洁,不要用 Markdown 格式”“价格一律按美元计算”“遇到违规内容必须拒绝回答”。这一层几乎不变,只在业务规则更新时修改。
对话层放的是真正的多轮聊天记录。这一层不是全量保留,而是拆成两个部分:最新几轮完整保留,更早的内容压缩成摘要。这样无论对话多长,这一层的体积基本稳定。
工具层是可选的临时上下文。需要查数据库时,把查询结果塞进去;需要做 RAG 时,把检索到的文档片段塞进去;不需要的时候,这一层完全不出现。
拆完之后,事务性的规则非常清楚:增删系统层规则,只影响系统消息;对话层的摘要策略调整,只影响 messages 组装逻辑;临时检索内容不会污染长期历史。后面别人问我这个工具到底做了什么,我的回答是:两层缓存加一条管道。摘要缓存和最近对话缓存不动,管道负责把工具数据按需接进来。就这样。
2. 核心细节与方案选型——四种上下文处理方式对比
2.1 全量拼接:最简单,但贵且慢
全量拼接是最天然的做法:把 user 和 assistant 的历史消息全部保留,每次请求都放进 messages 数组。我早期做 demo 时就是这么干的,代码量最小,逻辑最直白,适合快速验证想法。
但它的缺点在长对话场景下非常致命。我实测过一个简单测试:模型一次回复大约 200 token,用户一轮输入大约 50 token,跑到第三十轮时,单次请求的输入 token 已经超过七千,首字延迟明显变慢,账单也跟着肉眼可见地增长。而且很多模型有上下文窗口上限,全量拼接超过上限就直接报错,你不得不回头处理截断问题。所以全量拼接只适合短对话、少量轮次的场景,不适合生产环境。
2.2 固定轮次滑动窗口:稳定但会“丢记忆”
滑动窗口是大多数人第二个想到的方案:只保留最近 N 轮完整消息,超出部分直接丢弃。N 取 10、12、20 的都有。它的好处是请求大小基本稳定,token 消耗可控,实现也简单。
但问题是:模型“失忆”的速度比你想的快。如果用户在第 3 轮说过“我叫张三”,窗口只保留最近 10 轮的话,从第 11 轮开始模型就不知道用户叫张三了。更麻烦的是,这种失忆不报错、不明显,你只会发现模型越来越“健忘”,但很难定位到底丢了哪条关键信息。我之前那个 Markdown 场景就是典型例子——约束被挤出窗口,模型重新开始用 Markdown,用户开始投诉。滑动窗口适合临时会话、客服这类“不太依赖早期信息”的场景;但在需要持续记忆用户偏好、历史决策的对话里,单靠滑动窗口是不够的。
2.3 滚动摘要 + 滑动窗口:早期记忆用摘要兜底
真正进入可生产状态的方案,是“滚动摘要 + 滑动窗口”混合模式。核心思路:最近几轮完整保留,保证模型对当前话题有精细理解;更早的轮次压缩成摘要,保证关键历史信息不丢失。摘要可以由模型生成,也可以由规则提取关键字段。
这个方案的优点很明显:输入不会随对话轮数无限增长,同时早期信息还能以“摘要”的形式参与推理。代价是要多维护一个 summary 字段,并且摘要本身也会引入新的风险——如果摘要生成质量不稳,错误信息会被长期保留在上下文里,反而误导模型。想降低风险,可以在每次生成摘要时,把旧摘要和新消息一起交给模型,让它合并成新版摘要,而不是只基于新消息重新生成。
2.4 检索式上下文:不常用但要心里有数
还有一种相对少见的做法:把历史消息切块、向量化存储,等用户提问时,用相似度检索召回相关片段,再拼进当前请求。这种方法适合超长历史(比如几十万字聊天记录)或知识库问答,能覆盖全历史而不会让输入无限膨胀。
但它的成本和复杂度很高:你需要处理切块策略、向量库、召回阈值、相关性排序等一系列问题。对于多数 AI 应用来说,这不是默认选项。我自己的 context-mode 里预留了检索接口,但默认关闭。一句话总结:如果对话历史超过一万 token,且用户会反复提起早期细节,检索式上下文才值得考虑;否则,滚动摘要方案更划算。
四种方案各有适用场景,我做了一个对比表,方便你直接对照选择:
| 方案 | 实现成本 | 输入大小 | 信息保持 | 适合场景 |
|---|---|---|---|---|
| 全量拼接 | 低 | 线性增长 | 完整 | demo、短对话、调试 |
| 滑动窗口 | 低 | 基本稳定 | 近期完整、早期丢失 | 客服、临时会话 |
| 摘要+窗口 | 中 | 稳定 | 重点保留、早期可查 | 多数生产环境 |
| 检索式 | 高 | 可控 | 全历史可覆盖 | 长历史知识库、专业问答 |
2.5 token 预算到底怎么算
项目里踩过几次 token 超限的坑之后,我总结了一个经验公式:可用上下文 = 模型窗口上限 × 0.8 − 系统提示 token − 当前问题 token − 函数定义 token − 工具返回 token。
为什么要留这 20% 的余量,而不是把窗口用满?原因有三个:第一,不同 API 的 token 统计存在偏差,你本地算的和服务端算的可能不完全一致;第二,模型输出也要占上下文窗口空间,如果你把输入塞到 99%,模型几乎没法生成内容;第三,部分模型还会把一些内部指令、格式控制信息偷偷算进 token,你根本看不见。我习惯把输入控制在窗口上限的 80% 以内,剩下 20% 作为安全冗余。
还有一个容易踩的坑:很多人只统计 messages 数组里的 token,却忘了 system prompt 和 function calling 的参数定义也要占空间。尤其是工具定义,写详细了可能上百甚至几百 token,比一条普通对话消息还贵。所以一定要用 tiktoken 或对应 SDK 自带的 count_tokens 方法,把整个 payload 完整统计一遍,再做截断判断。
3. 从零实现一个轻量 context-mode——代码与实操
3.1 核心数据结构
我用的消息格式和主流 API 保持一致:每条消息是 {“role”: “system” | “user” | “assistant”, “content”: str}。context-mode 的关键就是维护好这个序列,并且给它加上裁剪和摘要能力。
下面是核心类的骨架,我会逐个字段解释它是干什么的:
from dataclasses import dataclass, field from typing import Dict, List, Optional @dataclass class ConversationContext: system_prompt: str = "" summary: str = "" max_tokens: int = 8192 recent_rounds: int = 10 messages: List[Dict[str, str]] = field(default_factory=list) def add_user(self, content: str) -> None: self.messages.append({"role": "user", "content": content}) def add_assistant(self, content: str) -> None: self.messages.append({"role": "assistant", "content": content}) def set_summary(self, summary: str) -> None: self.summary = summary def should_trim(self) -> bool: # 消息条数超过最近轮数的两倍,就触发摘要压缩 return len(self.messages) > self.recent_rounds * 2 def to_api_messages(self) -> List[Dict[str, str]]: # 把摘要、系统提示、最近消息按顺序组装成 API 可用的 messages msgs = [] if self.system_prompt: msgs.append({"role": "system", "content": self.system_prompt}) if self.summary: msgs.append({"role": "system", "content": f"历史摘要:{self.summary}"}) msgs.extend(self.messages) return msgs这里几个字段的作用:
system_prompt是永远在场的系统设定,不会因为对话变长被挤掉。summary是早期对话的压缩结果,作为一条 system 消息放在消息序列最前面。recent_rounds控制保留多少轮完整消息。太少了容易失忆,太多了 token 成本高。我自己实测下来,8 到 12 轮是比较平衡的区间,核心业务场景可以适当放大。messages只存“需要完整保留”的最近对话,更早的内容会在should_trim()触发时被压缩进 summary。
to_api_messages()是整个模块最关键的方法:无论内部怎么裁剪,最终交给模型的永远是一个干净、有序的消息数组。你不再需要在上层业务代码里手动拼字符串,这样逻辑统一、排查也容易。
3.2 上下文模式切换:如何把历史压缩成摘要
现在说压缩逻辑。我的触发条件很简单:消息条数超过recent_rounds * 2就触发。为什么是两倍?因为每个“轮次”包含用户消息和助手消息各一条,N 轮就是 2N 条消息。超过这个阈值,说明完整保留的负担变大了,该做一次摘要更新了。
核心逻辑大概是这样:
def trim_and_summarize(self, llm_call) -> None: if not self.should_trim(): return # 把最早的一部分消息取出来,准备压缩 overflow_count = len(self.messages) - self.recent_rounds old_messages = self.messages[:overflow_count] self.messages = self.messages[overflow_count:] # 生成摘要:合并旧摘要 + 旧消息 new_summary = self._generate_summary(llm_call, old_messages) self.set_summary(new_summary)有一个细节非常关键:生成新摘要时,一定要把旧摘要和新拿出来的消息一起作为输入,而不是单独用新消息重新生成。如果不带上旧摘要,那前几次压缩进去的信息,比如“用户是 VIP”“偏好用表格回复”这些关键字段,就会在这一次压缩里彻底丢光。我见过好几个项目犯这个错,现象就是对话越聊越“失忆”,但每次看单次压缩都好像合理。
3.3 让模型自己生成摘要
摘要可以靠规则,比如“提取所有包含‘我叫/我是/我喜欢/不要’的句子”,但效果很硬。更好用的是让模型自己生成,格式固定、字段可控。
我用的摘要提示词大概是这样的:
你是对话摘要引擎。请阅读用户与助手的对话历史,提取并保留以下信息: 1. 用户身份相关:姓名、称呼、VIP 等级、所在地区; 2. 用户明确表达的偏好和禁忌; 3. 当前未完成的事务和待办事项; 4. 重要业务决策。 输出格式: 用户信息:... 偏好/禁忌:... 待办事项:... 业务决策:... 如果某项没有,写“无”。不要输出其他内容。注意,生成摘要的调用最好单独走一个小的 model 调用,不要和主对话用同一个 system prompt。如果你把摘要提示词塞进主对话的 messages 里,主模型可能会把它当成对话内容来回复,而不是执行摘要任务。这个坑我踩过一次,后来把所有摘要逻辑收敛到_generate_summary方法里,用独立的请求完成。
3.4 持久化:重启后还能继续聊
上下文管理如果没有持久化,就像聊天软件没有云端消息记录,App 一重启就全部失忆。我早期做工具时忽略了这一点,每次服务重启后用户都要重新交代一遍背景,体验非常差。
持久化方案很简单,用 JSON 就能满足小项目需求。把system_prompt、summary、messages、recent_rounds全部存下来,以session_id命名文件即可:
import json def save_context(ctx: ConversationContext, path: str) -> None: with open(path, "w", encoding="utf-8") as f: json.dump({ "system_prompt": ctx.system_prompt, "summary": ctx.summary, "recent_rounds": ctx.recent_rounds, "messages": ctx.messages, }, f, ensure_ascii=False, indent=2) def load_context(path: str) -> ConversationContext: with open(path, "r", encoding="utf-8") as f: data = json.load(f) ctx = ConversationContext( system_prompt=data["system_prompt"], summary=data["summary"], recent_rounds=data["recent_rounds"], ) ctx.messages = data["messages"] return ctx生产环境可以考虑换成 Redis 或数据库,但逻辑是一样的:以会话 ID 为维度,保存上下文快照。这里有一个很容易忽略的点:保存时要把摘要和消息分开存,不要混在一条消息里。否则读取之后你无法区分哪些是历史摘要、哪些是真实对话,后续压缩逻辑会乱套。
3.5 完整调用示例
把上面的模块串起来,一个使用 context-mode 的请求流程大概是这样的:
ctx = ConversationContext( system_prompt="你是客服助手,回复简洁,不要用 Markdown 格式", max_tokens=8192, recent_rounds=10, ) load_context(ctx, "session_123.json") ctx.add_user("你好,我要退订单 12345") api_messages = ctx.to_api_messages() # 组装好交给模型 reply = call_llm(api_messages) ctx.add_assistant(reply) if ctx.should_trim(): ctx.trim_and_summarize(llm_call) save_context(ctx, "session_123.json")这个流程里有几个顺序不能乱:先组装再调用、先调用再保存、保存前判断要不要压缩。因为压缩需要在拿到最新模型回复之后再做,否则刚说出口的话可能被直接压进摘要里,丢失细节。运行一段时间之后你会形成肌肉记忆,新增功能的时候只要问自己一个问题:模型这次调用会看到哪些上下文?如果答案含糊,那就是组装逻辑有问题。
4. 常见问题与排查技巧实录
4.1 现象:模型答非所问,且和之前说的自相矛盾
遇到这种情况,我第一反应不是怀疑模型,而是打印to_api_messages()的结果,看模型到底“看到”了什么。很多时候问题是:摘要不存在、摘要过期、或者系统提示被窗口挤出。
排查步骤很简单:
- 打印完整 messages 数组,确认摘要是否在第一位;
- 核对摘要内容是否包含关键背景(身份信息、偏好、未完成事项);
- 检查是不是有代码在某个环节直接 append 了一条 user 消息,破坏了原有的序;
- 检查摘要是否在多次压缩后累积了错误信息。
我之前有过一次很奇怪的“失忆”现象:用户从头到尾没换过,但模型在二十轮之后突然忘了业务规则。排查半天发现,是某次代码升级把system_prompt的赋值写到了空字符串,结果系统层消息就没了。这类“静默丢失”最坑人,因为代码不报错,只能靠打印上下文的习惯才能发现。
4.2 现象:请求直接报 token 超限
大多数 token 超限不是因为你消息真的超过窗口上限,而是因为你只统计了消息内容,忘了还有函数定义、工具返回结果和系统提示。特别是如果你用了 function calling,工具 schema 的描述经常写得特别详细,一个工具定义几十行描述,加起来可能几百 token,很容易被忽略。
我的排查思路是:
- 把发给模型前整个 payload 一次性 dump 出来;
- 用官方计数工具逐字段统计:system_prompt、summary、messages、functions、tools;
- 看哪一块占比最高,优先压缩那部分;
- 超限时先裁剪
messages的早期部分,再重试。
记住一个原则:凡是准备发给模型的东西,都要纳入 token 预算,没有例外。
4.3 现象:长对话响应变慢
大部分人以为响应慢是模型推理变慢了,其实在长对话场景里,输入 token 太大是首字延迟升高的主因。模型需要先处理完前面那一大堆上下文,才开始生成第一个 token。
这种问题要怎么排查?给每个 API 调用加日志,记录三个指标:输入 token 数、输出 token 数、首字延迟。跑上一天,你就能画出趋势图,看到“当输入 token 超过多少时,首字延迟开始明显恶化”。然后对应调整recent_rounds和摘要触发阈值。
我实测下来,把输入控制在两千 token 以内时,首字延迟基本平稳;超过五千 token 后会逐步恶化。不同模型表现不同,但方向一致:输入越小,首字越快。所以如果业务对响应速度敏感,不要过度追求“完美记忆”,给摘要和窗口都留一点压缩空间。
4.4 现象:并发下上下文串了
这是最隐蔽的问题之一。如果你用进程内共享变量存储对话上下文,那多个用户同时访问时,A 用户的消息可能被 B 用户的请求带到模型里。现象像“撞鬼”一样,今天好端端排查,明天又复现。
根本原因是把全局唯一的对象当会话级状态用。解决办法是让每个ConversationContext实例只属于一个 session,用session_id作为 key 存到一个字典或者 Redis 里,绝不能跨会话共享。代码上可以加一层很薄的 SessionManager,统一做 acquire 和 release,避免业务代码里随手 new 一个实例导致上下文丢失。
我做了一个速查表,方便你以后遇到问题直接对号入座:
| 现象 | 常见原因 | 优先检查项 |
|---|---|---|
| 模型失忆/前后矛盾 | 窗口裁剪丢早期信息 | summary 策略、系统提示是否完整 |
| 请求报 token 超限 | token 预算只算了消息 | 整个 payload 的 token 统计 |
| 响应变慢 | 输入 token 过大 | 日志里的输入 token 数和首字延迟 |
| 上下文串话 | ctx 实例被共享 | session_id 是否隔离、存储是否加锁 |
| 摘要里出现错误信息 | 摘要生成时没合并旧摘要 | 压缩逻辑的输入是否包含旧摘要 |
| 重启后忘记一切 | 没有持久化上下文 | save/load 是否在正确时机调用 |
4.5 我自己的踩坑清单
最后整理几条真金白银换来的经验:
- 摘要别用对话模型的主 prompt 来生成。单独写一个摘要专用提示词,否则模型会把摘要任务当成聊天内容回复,返回一长串废话,摘要字段越存越乱。
- 新摘要必须基于旧摘要加新消息生成。这是最容易犯的错误,不带上旧摘要就等于每次都把所有历史推进一个碎纸机。
- 系统提示词如果很长,建议拆成多条 role=system 消息,而不是塞成一条长文本。有些模型对单条消息长度比较敏感,拆开后更稳定。
- 我会把“用户身份、明确偏好、未完成事项”这三类信息作为摘要的强制保留字段。哪怕其他细节全丢,这三类不丢,对话质量基本不会崩。
- 每次 API 调用前后打点记录三项指标:消息轮数、输入 token 数、摘要长度。这个日志在排查和调优时价值非常大,远胜过任何花哨的可视化面板。跑几天你就能知道自己项目的临界点在哪,什么时候要压缩、什么时候要扩充,完全不用靠猜。
我自己的体会是,context-mode 做得好不好,关键不在某个算法有多高级,而在于你对“哪些信息值得保留”这件事有没有清晰判断。有一次线上对话,用户在第 20 轮提到“我是 VIP”,第 35 轮问“我有没有折扣”,如果摘要没有在这个中途把这个信息捞进来,模型必答错。后来我坚持在摘要里强制保留“用户身份”这个字段,类似的问题再也没出现过。这个设计很小,但对所有业务的上下文管理都通用:上下文的价值,是把关键信息在以正确的顺序送到模型面前,而不是把一切都堆给它。