做AI应用的人心里都有一根刺:模型永远记不住三句话之前的事。这不是模型不够聪明,而是大多数人在调大模型的时候,根本没有把“上下文”当成一个需要专门设计的模块。context-mode这个项目,就是我用在多个AI应用里的一套上下文管理模式——它把一个裸的LLM API请求,变成一套知道什么时候该记、什么时候该忘、什么时候该去资料库翻旧账的对话记忆系统。如果你正在做聊天机器人、Agent,或者任何需要多轮交互的AI产品,这篇东西应该能帮你少走不少弯路。
我先说结论:上下文管理做得好的产品,和做得糙的产品,用户体验差距不是“一点点”,而是“一个能商用、一个只能demo”的差距。下面我会从需求拆解、核心实现、实操落地到问题排查,把我踩过的坑和我现在觉得最稳的方案,完整地整理出来。
1. 项目概述:context-mode到底是什么
1.1 从一次“失忆”的聊天说起
我做过一个客服知识库机器人,刚上线的时候效果特别糟糕。用户上一秒问“你们物流多久到”,下一秒说“那退款呢”,机器人完全不知道“那”指代什么,自顾自地回答一堆退款政策。问题就出在上下文上——每次调用模型接口,我只把用户当前这一句话传进去,模型当然是“失忆”的。
当时网上有很多教程教你“把聊天记录拼接一下传过去”,我照着做了,结果更糟:对话一长,Token直接突破窗口上限,模型报错;就算没报错,中间夹杂着一堆无关寒暄和旧信息,模型的注意力被稀释,回答质量反而下降。踩了这些坑之后我才意识到,上下文不是“把历史塞进去”这么简单,它需要一套完整的管理机制,而不是简单的字符串拼接。
1.2 context-mode的核心定义与边界
我给这套机制起了个名字叫context-mode,它本质上是三层结构的组合。
- 固定上下文:系统提示词、角色设定、知识库摘要、工具定义,这些是“对话开场白”,每次请求都要带,但内容相对稳定。
- 动态上下文:当前对话轮次、最近几轮用户消息、被触发工具的执行结果,这是模型生成回答时最直接的依据。
- 检索上下文:从知识库或历史会话中按需召回的相关片段,相当于人的“长期记忆”,平时不占窗口,用到的时候才调出来。
这里面最核心的点不是“存多少”,而是“每个部分在有限的Token窗口里占多少、什么时候该更新、什么时候该丢弃”。很多人以为context-mode就是“历史记录管理”,其实它比那层意思更深一层——它把“记忆”和“注意力预算”当成同一件事来处理。窗口就那么大,塞满了旧信息,新的关键信息就进不来,回答质量一定崩。
这个项目适合谁?适合那些正在用大模型API做产品的人,尤其是对话类应用、Agent工作流、客服机器人,还有想理解“为什么我的模型老是答非所问”的开发者。它不依赖某个特定模型,GPT、Claude、国产开源模型都适用,核心是一套可以独立实现的策略层。
2. 需求拆解:为什么非得做一个“上下文模式”
2.1 上下文失控的三大典型症状
我在几个项目里见过同一种崩溃路径,基本可以归纳成三个症状。
第一个症状是核心指令被淹没。System Prompt里的关键约束,比如“你是客服”“不要瞎编”“遇到纠纷要转人工”,经过几十轮闲聊之后,被一堆历史消息挤到窗口边缘甚至超出窗口被截断。模型开始“忘记”自己是客服,开始一本正经地瞎编退换货政策。我把这个现象叫做上下文稀释,不是模型变笨了,而是你喂给它的信息里,真正重要的那一部分占比太小了。
第二个症状是Token超限与成本失控。8K窗口的模型,聊天聊到第20轮,一算Token已经6K多,随便一条长消息就会爆掉。更要命的是,每次请求都要把所有历史重新发一遍,费用随轮数线性增长。我当时一个日活不到几千的客服机器人,光模型调用费一个月烧掉好几万,问题就出在这里。
第三个症状是因果混乱和幻觉。上下文里混了两条时间线上的问题,模型分不清哪次对话在前、哪次在后,开始合并矛盾信息,给出一个“四不像”的回答。这种幻觉比简单的错误更可怕,因为用户看起来逻辑通顺,实际上完全不可靠。
这三个症状单独出现还能忍,一旦叠加,基本就宣告产品没法用了。context-mode要解决的,就是这三件事同时发生的时候,怎么通过一套机制兜住。
2.2 四种上下文形态的职责划分
把上下文拆开看,它其实不是一块铁板,而是四种形态的组合体。
- 短期记忆:当前会话内最近N轮对话,直接拼接到请求里。这是模型最依赖的信息,也是优先级最高的。
- 中期记忆:从对话中提取的关键信息,比如用户姓名、偏好、订单号、投诉原因,存在结构化的KV里。它不占对话窗口,但随时可以插回prompt。
- 长期记忆:跨会话的向量索引。用户今天问过什么、上次解决过什么问题,存在向量数据库里,需要的时候按语义召回。
- 工作记忆:工具调用时的中间输出。Agent调用搜索、查数据库、执行代码,这些过程产生的上下文,既不能全丢,也不能全塞进窗口。
一个合格的context-mode,就是要让这四种形态各司其职。短期记忆负责“即时反应”,中期记忆负责“记住重点”,长期记忆负责“跨会话沉淀”,工作记忆负责“让工具链路完整可追溯”。如果只做短期记忆拼接,后面三种形态全都丢失,那产品就只能停留在demo水平。
2.3 方案选型:为什么不直接拼接历史
先放一张对比表,是我当时做选型时候整理的。
| 方案 | 效果 | 成本 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 全量拼接历史 | 前期还行,后期必崩 | 随轮数线性增长 | 最低 | 一次性问答,超短会话 |
| 固定窗口拼接 | 稳定但“失忆”明显 | 可控 | 低 | 简单闲聊,对旧信息要求低 |
| 分层管理+裁剪摘要 | 稳定且记忆效果好 | 可控 | 中 | 大部分对话产品 |
| 完整RAG | 可跨会话召回,效果好 | 检索有额外成本 | 高 | 知识库问答、复杂Agent |
我最终选择的是“分层管理+必要的语义检索”这个组合,而不是全量RAG。原因是:RAG虽然强大,但对基础设施要求高,要维护向量库、做分块、做重排,对于很多对话类场景属于“杀鸡用牛刀”。而纯拼接方案又太粗糙,连最基础的“重点保留”都做不到。context-mode取了一个折中:短期记忆用精确拼接,中期记忆用显式提取,长期记忆按需接入向量检索。复杂度和效果之间的平衡点,正好落在这里。
3. 核心实现:context-mode的工程化落地
3.1 Token预算模型:每个部分占多少,必须有数
上下文管理的第一个原则,就是在动手写代码之前,先给Token窗口做预算分配。没有预算,后面的裁剪和摘要全是拍脑袋。
以8K窗口为例,我常用的分配方式是:
- System Prompt:1200 Token,占15%,固化不变的底座。
- 历史对话:3000 Token,占37.5%,动态调整的大头。
- 当前用户消息:1000 Token,占12.5%,必须完整保留。
- 工具/检索结果:500 Token,占6.25%,按需填入。
- Buffer:2300 Token,占28.75%,给模型生成的回复留空间,也给突发长消息留余地。
total_tokens = sys + history + current_input + tool_result + buffer这套预算不是拍脑袋定的,背后有个基本原则:当前用户消息和System Prompt永远不可裁剪,历史对话是可压缩的,工具结果是最优先丢弃的。Buffer留28.75%的原因,是因为模型生成的回复也要占用输出窗口,如果输入塞满了,输出就会被截断,等于白干。
Token数怎么数?不用len(text)硬数,直接用模型对应的tokenizer算。以GPT系列为例,我用tiktoken:
import tiktoken enc = tiktoken.get_encoding("cl100k_base") def count_tokens(text: str) -> int: return len(enc.encode(text))中文场景下,直接数中文字符会低估Token数,因为一个中文字通常要算1到2个Token,英文一个单词可能要拆成几个Token。用官方tokenizer统计最准,但这玩意儿有一定开销,所以我一般在消息进入系统时就把token数算好存下来,而不是每次请求都现场算一遍。
3.2 历史裁剪:优先保谁,优先丢谁
Token预算定下来之后,就要处理“窗口装不下怎么办”的问题。我采用的裁剪策略是分优先级的,从高到低排列:
- System Prompt(绝对保留,只能优化措辞,不能删掉)
- 当前用户输入(绝对保留,这是本次请求的核心)
- 最近几轮完整对话(保留原始内容,因为模型需要即时推理)
- 较早轮次的压缩摘要(保留下来的“骨架”,牺牲细节)
- 最早期轮次的全文(最先被丢弃)
用一个简单的公式表达,就是:先把不可裁剪的部分放进去,剩下的空间再给历史。历史内部,从最新往最旧依次填,填不下的极端情况直接丢弃或压缩。
这里有一个关键点:最新消息和旧消息地位完全不对等。模型主要依据最近的用户输入和最近的交互节奏来生成回答,如果为了塞进一段三小时前的细节,把当前问题挤出窗口,那这一轮请求基本就是浪费钱的。我见过很多半吊子实现,拿着一条LRU链表从头到尾均匀裁剪历史,看起来公平,实际上把最重要的最近信息也裁掉了,效果非常差。
3.3 多轮摘要:怎么压缩才不会丢关键信息
当历史对话超过窗口预算的70%左右时,我就触发摘要任务。摘要不是简单截断,而是调用一次LLM,把一段对话压缩成要点:
请将以下对话压缩成一段简洁的对话摘要,保留: 1. 用户的意图和目标 2. 用户提到的具体约束(时间、金额、地点、订单号等) 3. 双方达成的结论或分歧点 4. 尚未解决的需求 不要保留寒暄和重复表述。然后把压缩后的摘要作为一条新消息放进历史层,原文则被释放。这相当于把“流水账”变成“会议纪要”,模型拿到的信息密度更高,Token占用更低。
摘要也有一个坑:无限压缩下去,摘要本身也会越攒越多。所以我给摘要也设了预算,当摘要总Token超过一定阈值时,就丢弃最早的摘要,只保留一个更老层的“高层摘要”。整个记忆系统像一座金字塔,底层是全文、中层是小结、顶层是半年小结,越往上信息越抽象,Token成本越低。
3.4 语义检索:上下文窗口放不下时,怎么“翻旧账”
有些场景绕不开完整历史。比如用户在第1轮问过贷款政策,第12轮回来说“那我之前说的还款方式还能改吗”,模型如果不知道第1轮的内容,根本没法回答。这种“旧账”不能靠滑动窗口解决,必须靠检索。
做法是把历史对话切成块,每块300到500个Token,做向量化存进向量库。每次新问题进来,先用当前问题去检索,召回最相关的2到3个历史片段,塞进工具结果位里。chunk大小我实测下来,300到500个Token最合适。太小了语义断裂,比如把一个完整意图切成两半;太大了召回结果不够精准,无关内容占窗口。
这个模块不是必须的,早期版本完全可以用“近N轮+摘要”顶过去。但如果你想做跨会话的记忆,或者处理长程复杂对话,检索这层迟早要上。好消息是,现代向量库如Milvus、Qdrant,甚至Redis自带的向量能力都能满足需求,不需要独立部署很重的中间件。
3.5 缓存与持久化:会话状态不能只活在内存里
context-mode在运行时要维护大量的会话状态,不能每来一个请求就重新算一遍历史摘要。我用Redis保存会话,键结构大致是这个样子:
session:{session_id}:history -> List[Msg] session:{session_id}:summary -> List[Summary] session:{session_id}:meta -> Hash (用户画像、场景标记、版本号)历史消息用List结构,只追加不删除,读取时再按预算做裁剪。摘要层单独存放,每次新摘要生成后旧摘要标记为历史。Meta里存的是用户身份证级别的关键信息,比如“用户偏好简洁回复”“用户是铂金会员”,这些在每次构建Prompt时会直接注入。
幂等性也很重要。如果同一个事件被重复推送,比如工具回调重复触发,需要保证历史不会出现双份。我给每条消息带了一个全局唯一的event_id,写入前做去重检查。这个细节看起来不起眼,但在高并发场景下,一旦出现重复消息,模型会被绕晕,回答质量直接跳水。
4. 实操记录:手把手搭一个最小context-mode
4.1 数据结构选型
先把基础数据结构定下来。我用的方案是给每条消息打上标记,记录它的层属、Token数和时间戳。
from dataclasses import dataclass import time @dataclass class Msg: role: str # "system" / "user" / "assistant" / "tool" content: str token_count: int = 0 timestamp: int = 0 layer: str = "active" # active / summary / recall def __post_init__(self): if self.timestamp == 0: self.timestamp = int(time.time())token_count在写入时就通过tokenizer算好,存下来,后面每次构建上下文直接取整数相加,不需要反复编码。层标记决定了这条消息在预算紧张时优先保留还是优先丢弃。
关于为什么要用List而不是字符串拼接存储,我的观点是:字符串拼接意味着你只能“整体读出来再重新切”,而结构化消息可以只取最近N条、只压缩老的部分、只检索命中的片段,灵活性完全不在一个量级。
4.2 核心代码:构建上下文的完整流程
下面这是一个可以跑起来的最小实现,实现了预算分配和优先级裁剪:
import tiktoken enc = tiktoken.get_encoding("cl100k_base") class ContextMode: def __init__(self, sys_prompt: str, max_input_tokens: int = 8000): self.sys_prompt = sys_prompt self.max_input_tokens = max_input_tokens self.history: list[Msg] = [] def add_message(self, role: str, content: str): msg = Msg(role=role, content=content) msg.token_count = len(enc.encode(content)) self.history.append(msg) def _budget(self): sys_tokens = len(enc.encode(self.sys_prompt)) # 给输出留 28% 左右的空间 remain = int(self.max_input_tokens * 0.72) - sys_tokens return remain def build(self, query: str): budget = self._budget() # 1. 固定上下文,绝不裁剪 msgs = [{"role": "system", "content": self.sys_prompt}] query_token = len(enc.encode(query)) budget -= query_token # 2. 从最新的历史往回填,直到预算耗尽 selected = [] while self.history and budget > 0: msg = self.history.pop() if msg.token_count <= budget: selected.insert(0, msg) budget -= msg.token_count else: # 单条对话超预算,截断(保留前一半) truncated = msg.content[: len(msg.content) // 2] selected.insert(0, Msg(msg.role, truncated, msg.token_count // 2)) break # 3. 拼上当前用户输入 for msg in selected: msgs.append({"role": msg.role, "content": msg.content}) msgs.append({"role": "user", "content": query}) return msgs def get_summary(self): # 实际项目中,这里调用LLM把最早的历史压缩成摘要 pass这个实现有三个设计取舍想特别说明一下。
第一,pop()从链表尾部取数据,天然保证留下的永远是“最近的消息”,每一条被保留的消息都挤掉了更旧的消息,符合前面说的优先级原则。
第二,单条超预算的消息没有直接丢弃,而是做了截断。这在实战中很有用,用户偶尔会贴一大段日志进来,如果整条丢掉,模型完全没法处理;截断一半虽然不完美,但至少还能捕捉到上下文。
第三,build()返回的是标准OpenAI消息格式,可以直接丢进ChatCompletion接口。其他模型只要支持类似的消息结构,改动很小就能复用。
4.3 参数调优:哪些数字需要根据场景调
跑通之后,真正的功夫在调参上。我整理了四个关键参数,每换一个场景都要重新验证。
第一个是Buffer比例。默认留28%,如果模型输出经常被截断,就把Buffer提到35%,同时压缩历史预算;如果模型输出很短,比如只是二分类判断,Buffer可以压到15%,把空间留给历史。
第二个是摘要触发的阈值。我在“70%预算占用”时触发压缩,而不是等到100%。因为LLM调用有延迟,如果每次都要等窗口爆了才去裁,用户体验会有明显卡顿。预触发相当于给系统留了缓冲时间。
第三个是语义召回的条目数。默认召回2到3个chunk,每个chunk大约400Token,总占比约1200Token。超过这个数,召回的内容开始冲淡当前问题;少于这个数,经常找不到需要的旧信息。
第四个是System Prompt的“瘦身”策略。有些业务方写System Prompt能写2000多Token,全是各种话术。我的建议是System Prompt只保留“不可变的约束”,比如角色、能力边界、安全红线。话术和可变的业务规则放进检索上下文,需要的时候再拉取。
4.4 成本对比实测
为了验证这套方案的实际效果,我做过一次对比测试。同样一个客服机器人,跑20轮对话,对比朴素拼接和context-mode的Token消耗与效果。
| 场景 | 朴素拼接方案 | context-mode | 说明 |
|---|---|---|---|
| 第10轮单次请求Token | 约14500 | 约6800 | 朴素拼接每轮累加,context-mode恒定 |
| 20轮累计模型调用Token | 约156000 | 约74000 | context-mode省了一半以上成本 |
| 关键历史信息保留率 | 低,窗口耗尽即丢失 | 高,摘要+检索兜底 | 旧信息不会丢 |
| 回答一致性 | 不稳定,越聊越偏 | 稳定,角色不丢 | 核心指令始终在窗口内 |
20轮就能省一半成本,越长的会话,收益越明显。当时我把这个方案从客服场景搬到Agent编排场景之后,一个跑批任务的平均Token消耗降了40%多,预算紧张的项目直接用这个方案就能续命。
5. 常见问题与排查实录
5.1 模型突然“忘记角色设定”
表现:聊到十几轮,模型开始用第三人称说话,或者无视System Prompt里的指令。
排查第一步,把请求体完整打出来看,检查System Prompt是否还在,是否被截断。我遇到过两次,都是裁剪逻辑把System Prompt当成普通历史消息处理了,当历史一长,系统提示词被挤出窗口。解决方法是裁剪算法里把role=="system"的消息设为不可删除、不可移动,并且在预算分配时固定占用,任何情况下都不允许历史消息挤占它的位置。
5.2 摘要丢失关键数字
表现:压缩后的摘要明明存在,但模型回答时把金额、日期、订单号说错。
原因是压缩提示词里没有强调“保留具体数值”。这里分享一个教训:摘要不是“总结重点”,而是“保留决策信息”。我在压缩提示词里把“时间、金额、订单号、地址、偏好”列为强制保留字段,并且要求“如果无法判断是否为关键数据,宁可多写一句也不要省略”。
另外更稳妥的做法是“摘要+检索并行”:摘要只负责记录骨架信息,具体数值如果需要,直接去向量库检索原文段落。两条路同时走,比单纯靠摘要一条路可靠得多。
5.3 Token预估与实际误差很大
表现:预算明明留了30%Buffer,结果请求还是发出Token超限。
原因大多数是tokenizer不一致。你对文本用的可能是某个通用tokenizer,但模型接口实际用的是另一个版本,中文场景下误差很容易超过20%。解决方法是直接使用目标模型配套的tokenizer,并且对超长文本做一次真实编码验证。也可以用经验公式:先按“一个中文字符约1.3个Token”快速估算,超限风险高的再走精确编码。
5.4 多个场景共用一个会话导致上下文混乱
表现:用户同时开了“售前咨询”和“售后投诉”两个工单,但模型把前一个场景的信息串到后一个场景里了。
这是多标签会话没有隔离造成的。我在数据结构里为每条消息加了scene字段,构建上下文时先按当前场景过滤历史。简单粗暴一点,直接在Redis的key里带上场景标识,session:{id}:scene:{scene_id}:history,让不同场景的消息物理上就不在一个List里。
5.5 并发写入导致历史串场
表现:用户连续发送多条消息,后端并发处理,最后历史记录里出现消息顺序颠倒,模型回答逻辑混乱。
这个问题出现在你把Redis的SET当成追加来用的时候。正确做法是用LPUSH或RPUSH把消息追加到List尾部,读取时按时间排序。如果还需要去重,用event_id做Set的去重检查,但不要把整个历史存在一个Hash里反复覆盖写。
5.6 质量怎么量化:context-mode的评测指标
很多人做完上下文管理,不知道该怎么衡量效果。我用的指标有四组。
- 关键信息召回率:对话结束之后,人工检查模型回答需要的旧信息里,有多少能在最终构建的context里找到。低于80%说明裁剪或摘要太激进。
- 回答一致性:用同一批历史对话,把用户最后一条消息稍微改写后重新提问,看模型回答的核心结论是否稳定。不稳定一般说明上下文里有矛盾信息。
- 单次请求平均Token数:监控这个数字持续上涨,说明有没有有效的淘汰策略。
- 端到端任务完成率:客服场景就是“用户问题是否得到解决”,Agent场景就是“目标任务是否执行成功”。这个指标最直接,也最能骂醒人。
我见过有些团队把Token指标优化得特别漂亮,但任务完成率掉得一塌糊涂。这说明上下文被压缩得“太狠了”,模型没拿到足够信息做事。优化的时候,这四组指标要一起看,别只顾着省钱。
最后分享一个我个人的实操体会:context-mode不是什么高深算法,它本质上就是把“记什么、忘什么、找什么”变成一套显式策略。如果你刚开始做,我的建议是先别急着上摘要和向量检索,把“窗口预算分配”和“优先级裁剪”这两件事做到位,项目里80%的上下文问题就能解决。摘要和检索是后面锦上添花的部分,等你的核心链路已经稳定,再一点点加上去,比一次性铺开要稳得多。
再补一个小技巧:构建与调优context-mode的过程中,一定要多打印请求体的实际内容。很多问题不用推理,看一眼发给模型的真实消息就全明白了。我到现在都会在调试环境里把每次组装好的消息完整记录一份,出问题先查日志,比瞎调参数快得多。