
做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 Prompt1200 Token占15%固化不变的底座。历史对话3000 Token占37.5%动态调整的大头。当前用户消息1000 Token占12.5%必须完整保留。工具/检索结果500 Token占6.25%按需填入。Buffer2300 Token占28.75%给模型生成的回复留空间也给突发长消息留余地。total_tokens sys history current_input tool_result buffer这套预算不是拍脑袋定的背后有个基本原则当前用户消息和System Prompt永远不可裁剪历史对话是可压缩的工具结果是最优先丢弃的。Buffer留28.75%的原因是因为模型生成的回复也要占用输出窗口如果输入塞满了输出就会被截断等于白干。Token数怎么数不用len(text)硬数直接用模型对应的tokenizer算。以GPT系列为例我用tiktokenimport 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-mode4.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(rolerole, contentcontent) 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约74000context-mode省了一半以上成本关键历史信息保留率低窗口耗尽即丢失高摘要检索兜底旧信息不会丢回答一致性不稳定越聊越偏稳定角色不丢核心指令始终在窗口内20轮就能省一半成本越长的会话收益越明显。当时我把这个方案从客服场景搬到Agent编排场景之后一个跑批任务的平均Token消耗降了40%多预算紧张的项目直接用这个方案就能续命。5. 常见问题与排查实录5.1 模型突然“忘记角色设定”表现聊到十几轮模型开始用第三人称说话或者无视System Prompt里的指令。排查第一步把请求体完整打出来看检查System Prompt是否还在是否被截断。我遇到过两次都是裁剪逻辑把System Prompt当成普通历史消息处理了当历史一长系统提示词被挤出窗口。解决方法是裁剪算法里把rolesystem的消息设为不可删除、不可移动并且在预算分配时固定占用任何情况下都不允许历史消息挤占它的位置。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的过程中一定要多打印请求体的实际内容。很多问题不用推理看一眼发给模型的真实消息就全明白了。我到现在都会在调试环境里把每次组装好的消息完整记录一份出问题先查日志比瞎调参数快得多。