
去年我做了一个内部AI客服项目上线第一周就翻车了——用户多问几个来回聊天机器人就开始胡言乱语要么把前面聊过的车架号忘了要么把之前改好的订单地址又改回去。我翻了半天代码发现原因很朴素上下文窗口就这么大历史消息一长前面的内容就被暴力截断了。后来我专门抽了两周时间把一个叫 context-mode 的上下文管理模块搭了起来才算把这个问题按住。这套东西说白了就是一套给大模型做“记忆调度”的中间层核心是解决一件事在上下文窗口有限的前提下怎么让模型同时记得住、找得到、用得上。下面我把我当时的思路、代码和踩坑记录整理出来适合正在做聊天机器人、RAG知识库或者AI Agent的开发者参考。1. 为什么需要一套“上下文模式”1.1 一个让我重构的翻车现场先说翻车现场。当时我们接的是32K上下文窗口的模型最开始的做法就是所有对话历史一把梭地拼进Prompt里逻辑简单代码也简单。但实际跑起来问题一个接一个一是成本肉眼可见地涨用户聊满十分钟每次请求都要把全部历史重发一遍token开销成倍翻二是模型表现极其不稳定当历史超过十个来回它开始“选择性失聪”偶尔把最新需求忽略偶尔又拿两个月前的一句玩笑当指令执行。这个现象其实不奇怪业界叫它 lost in the middle模型对长文本中间部分的关注度明显低于开头和结尾。你把用户最新的一句话放在Prompt最后模型当然看得到但中间夹着几十轮历史它很可能抓错重点。我当时的第一个念头不是优化代码而是重新想清楚我们要的到底是一个“能记住所有话的聊天窗口”还是一个“知道该记什么该忘什么”的记忆系统后一个就是我说的 context-mode。1.2 上下文模式的定位不是单次请求而是链路想清楚定位之后整个设计就变了。context-mode 不是一个Prompt模板也不只是一段截断逻辑而是一条完整的数据链路。用大白话描述就是用户的话进来之后先做意图判断和敏感信息过滤然后去记忆系统里检索相关的历史知识再把检索结果、最近对话摘要、系统指令和当前问题按优先级拼装成一个精简但信息完整的Prompt等模型输出完之后异步地把新对话沉淀回记忆库该更新的更新该压缩的压缩。它分三层第一层是即时对话上下文也就是最近几轮原始消息保证模型对当前话题不跑偏第二层是工作记忆就是自动生成的摘要和关键信息条目负责承接中短期记忆第三层是长期知识库用向量检索承载负责把用户以前问过的相似问题、项目背景资料捞回来。三层各干各的活互不干扰这是整套方案能稳定工作的基础。2. 关键设计如何分配有限的上下文窗口2.1 先给上下文算一笔账设计 context-mode 之前我干了一件很笨但很有效的事把token预算当成钱一样记账。以32K窗口为例输出必须要留出空间一般按8K预留不然模型生成到一半被截断用户体验极其糟糕系统指令和套话固定占掉3-4K这部分雷打不动剩下大概20K才是真正能塞动态内容的地方。20K看着不小但中文token消耗本来就快一段2000字的纪要可能就吃掉3000 token。如果不做管理聊十分钟就爆。所以我在代码里写死了比例动态内容里最近对话最多占40%摘要占20%检索结果占15%剩余留白给容错。这个比例不是拍脑袋拍出来的而是跑了大量离线测试之后收敛出来的后面会有参数说明。你完全可以根据自己的模型和场景去调但建议先按这个框架把每一块的上限钉死而不是让消息一路堆到Prompt末尾。2.2 三种上下文管理模式调到最实用的层面我把 context-mode 拆成了三种运行模式对应不同使用场景。第一种是全量模式适合代码评审、长文档分析这类一次性任务要求模型看全所有材料不设摘要和截断但只在明确触发时启用第二种是滑动窗口模式适合普通客服和闲聊只保留最近N轮原始消息更早的内容被自动摘要替代窗口始终保持收支平衡第三种是摘要加检索模式适合需要长期记忆的个人助理或Agent滑动窗口保留近期向量库负责捞中长期信息两条线并行。三种模式各有各的代价我用一个表格对比过模式适合场景内存与费用实现难度典型上下文占用全量模式长文档、法律文本、代码库分析高简单20K-28K滑动窗口客服、闲聊、任务型对话中中等6K-10K摘要检索Agent、个人助理、知识库问答中高较高8K-14K2.3 选择策略背后的“为什么”为什么我不推荐一直使用全量模式这里面有三个很现实的原因。一是成本重复发送全量历史等于每次调用都按最贵的价格付费聊天类场景一天几万次请求费用差异是量级性的。二是效果我刚才提到过 lost in the middle当内容一多模型抓重点的能力显著下降你费力把历史塞满结果它反而漏掉关键指令这属于花钱买差评。三是速度Prompt越长首字延迟越高用户等不起。那滑动窗口是不是最优解也不是。它牺牲了长期记忆用户三天前交代过的事情如果没人提模型就真的忘了。所以对于真正需要记忆的产品我建议直接上摘要加检索模式虽然实现复杂度最高但它是唯一能同时兼顾短期连贯性和长期记忆的方案。3. 实操从零写一个上下文管理器3.1 项目结构与基础模型封装下面这部分是代码实操。我用Python写了一个最小可用的 ContextManager结构非常简单但骨架是完整的你可以直接抄回去扩展。先看项目结构context_mode/ ├── __init__.py ├── manager.py # 核心上下文管理器 ├── bucket.py # token预算控制 ├── memory.py # 向量记忆检索 ├── summarizer.py # 摘要生成 └── config.py # 参数配置核心思路是manager对外只暴露 add_user_message 和 build_prompt 两个方法其余逻辑全部内聚在内部。这样UI层不用关心上下文细节只管把用户的话丢进来拿组装好的Prompt去调用模型。这里我建议所有模型调用都走统一的OpenAI兼容接口方便以后换模型厂商。基础封装的复杂度不在于接口而在于token计算必须准确所以memory和bucket都依赖 tiktoken 来做tokenize。3.2 实现滑动窗口与自动摘要滑动窗口是 context-mode 里最基础也最关键的一环。我实现的方式是维护一个双端队列保存最近消息每次新增消息后检查总token数超过阈值就触发摘要任务把窗口内最旧的一段消息压成摘要塞进memory模块的短时区然后从队列里清除。这个设计不需要临时去截字符串也不会把用户一句话从中间切断因为摘要的粒度是按整条消息来的。import tiktoken class SlidingWindow: def __init__(self, max_tokens8000, summary_ratio0.2): self.encoder tiktoken.get_encoding(cl100k_base) self.max_tokens max_tokens self.summary_ratio summary_ratio self.history [] # [(role, content)] self.summary # 自动摘要 def _count(self, text: str) - int: return len(self.encoder.encode(text)) def _total(self) - int: return sum(self._count(msg[1]) for msg in self.history) self._count(self.summary) def add(self, role: str, content: str): self.history.append((role, content)) self._maybe_compact() def _maybe_compact(self): if self._total() self.max_tokens: return budget int(self.max_tokens * self.summary_ratio) removed [] used 0 while self.history and used budget: msg self.history.pop(0) removed.append(msg) used self._count(msg[1]) if removed: text \n.join(f{r}: {c} for r, c in removed) self.summary summarize(text)注意几个细节。第一个是摘要触发时机我建议在新增消息后检查而不是在请求前检查这样可以避免模型调用前临时压缩造成的延迟。第二个是budget的计算不是简单地把超出的部分全砍掉而是按固定比例留出压缩空间剩下的交由摘要器决定保留什么。第三个是 summarize 函数你可以接任意LLM我会在后面单独讲摘要提示词的写法。3.3 加入向量检索让“模式”有长期记忆滑动窗口解决的是短时记忆向量检索解决的是长期记忆。这里我用最轻量级的方案演示embedding用OpenAI接口向量存储先用一个简单的numpy数组加json元数据够了生产环境再升级到FAISS或pgvector。检索的流程是用户消息进来后先生成query向量然后在记忆库里做相似度搜索返回top-k相关记录这些记录会拼进Prompt的历史区。import numpy as np class VectorMemory: def __init__(self, embed_fn): self.embed_fn embed_fn self.items [] # [{text, vec}] def add(self, text: str): vec self.embed_fn(text) self.items.append({text: text, vec: np.array(vec)}) def search(self, query: str, top_k: int 5): q np.array(self.embed_fn(query)) scored [] for item in self.items: score self._cosine(q, item[vec]) scored.append((score, item[text])) scored.sort(reverseTrue) return [text for _, text in scored[:top_k]] staticmethod def _cosine(a, b): return float(a b / (np.linalg.norm(a) * np.linalg.norm(b)))这个Demo看起来简单但要注意两点一是embedding的文本需要和检索时的query保持同一粒度用户问题短记忆条目也应该短我通常在写入时会把长对话切成小块再embed而不是把一大段丢进去二是向量记忆不是越多越好成千上万条不相关的记忆会让检索噪声变大必须配合定期清理或按时间衰减。我实际项目里会把超过一定时限且从未被命中的记忆标记为可清理。3.4 把上下文模式接进Prompt模板最后一步是把所有模块组装成Prompt。模板我用的是经典的四个区块系统指令区、长期记忆区、近期对话区、当前问题区。顺序很重要不可乱排。系统指令固定在最前用来设定角色和输出规则长期记忆区放检索结果和摘要让模型拿到背景近期对话区放最近的原始消息让模型保持语气和话题连贯当前问题放在最后也是注意力最强的地方保证它优先响应你要它做的事。def build_prompt(self, user_input: str): memory_hits self.memory.search(user_input, top_kself.top_k) blocks [] blocks.append(system: self.system_prompt) blocks.append(memory:) blocks.extend([- item for item in memory_hits]) blocks.append(recent:) blocks.extend([f{r}: {c} for r, c in self.history]) blocks.append(question:) blocks.append(fuser: {user_input}) return \n.join(blocks)组装出来的效果大概长这样system: 你是XX业务的AI助手请基于提供的历史和业务规则回答用户问题。 memory: - 用户偏好习惯用简体中文喜欢结论先行的回答方式 - 3天前用户咨询过退款流程结果已标记为处理中 recent: user: 之前说的退款现在到哪一步了 assistant: 我帮你查一下请稍等。 question: user: 好的麻烦快点。这里要提醒一个很多人会踩的坑检索出的记忆如果和最近对话自相矛盾以哪一个为准我的策略是在memory区块加一行生成时间标签比如3天前、刚刚并在提示词里明确写“如果记忆信息与近期对话冲突以近期对话为准”。否则模型可能会拿着过期记忆一本正经地瞎解释。3.5 摘要提示词与记忆写入策略摘要提示词是整个 context-mode 里最容易偷懒也最不能偷懒的部分。我一开始用的是通用提示词比如“请总结这段对话”结果就是模型把对话变成了一段枯燥的流水账丢失了大量有用的实体信息。后来我改成结构化模板明确要求输出三个部分人物和关键实体、用户诉求、当前状态。这相当于让摘要器按信息类型分类归档后续检索的时候也非常容易命中。summarize_prompt 请对以下对话进行结构化摘要输出三部分 1. 关键实体包括用户ID、订单号、手机号、地址等没有的写无 2. 用户诉求用户想要解决的问题或目标 3. 当前状态该问题目前处理到哪一步有什么结果 对话内容 {text} 在写回记忆库之前我会把这三部分拼接成一段自然语言同时把原始的对话引用ID带上。这样即使摘要写错也能顺藤摸瓜找回原文。另外要提醒的是摘要生成时的模型温度一定要调低我固定在0.1避免模型在压缩事实类信息时自己脑补。4. 性能、成本与调优记录4.1 延迟和费用的平衡点模块写完以后我用同一组测试数据把三种模式跑了一遍结果还挺有意思。模式平均上下文token单次调用费用(相对值)p50首字延迟全量模式21000100%580ms滑动窗口650042%340ms摘要检索820050%380ms滑动窗口模式直接省了一半多的token延迟也掉了40%。摘要加检索模式只比滑动窗口略贵但保留了长期的记忆能力。如果你的产品没有强记忆需求直接上滑动窗口就够了如果有摘要加检索带来的额外成本是值得花的。4.2 三个值得调的参数第一个参数是窗口大小它决定模式的个性。窗口越大短期记忆越强但成本和延迟也跟着涨我建议以3到5轮对话为下限低于这个值聊天气氛会显得很笨。第二个是摘要触发的比例之前代码里是0.2意思是压缩后留下的摘要最多占窗口的20%。这个值太大会丢细节太小又会频繁压缩导致性能下降。第三个是向量检索的top_k我测试下来5到8是甜点区超过10之后模型容易被多条相似但无关的记忆带偏。4.3 实测对比同样的问题三种模式的效果差异用同一个场景举例用户周一问过退货政策周五又问“我之前那个退货申请现在卡在哪”。全量模式如果历史没超限表现稳定但成本高滑动窗口模式到了周五一早就把周一的对话挤出窗口只能回答“我没有找到相关记录”体验翻车摘要加检索模式在检索区捞回了周一的对话摘要能正确识别用户说的是哪件事并且续上状态。效果差异一目了然。性能调优不是玄学核心就一句话在满足业务效果的前提下尽量少给模型塞无关信息。5. 常见问题与排查技巧实录5.1 问题速查表下面是我在开发和上线期间遇到过得比较多的五类问题整理成速查表。现象问题原因解决办法模型漏掉最新指令历史塞太满或模板顺序问题调整模板顺序把当前问题放最后收紧窗口记忆里出现自相矛盾没有给记忆加时间标签写入时记录时间提示词里声明近期优先摘要之后重要信息丢失摘要温度太高或摘要范围过大降低摘要LLM温度限制单次摘要条数向量检索捞回一堆无关内容存储粒度太粗切块存储清理过期记忆调低top_k多用户共用同一记忆库导致串话没有按会话隔离记忆条目标记用户ID查询时强制过滤5.2 排查思路先看日志再看数据context-mode 这类模块最难查的问题是“偶发性失忆”因为它不像报错那样有明确堆栈。我自己的排查习惯是两步走。第一步看日志每次调用模型的Prompt全文必须落盘光记录token数没用要把组装后的完整Prompt存下来出问题当场复盘就能定位是哪一块没给到位。第二步看记忆库把当次检索命中的记忆条目dump出来对比实际输入大概率能发现到底是没有写入、没有检索到还是检索到了但被截断了。这套思路救了我很多次。另外强烈建议在管理后台做一个Prompt预览功能哪怕是自己内部用能看到每次请求实际喂给模型的是什么排查效率直接翻倍。很多时候你以为模型“变笨了”其实是上一轮摘要把关键字段丢了或者检索时把别的用户的订单信息拉了过来。5.3 我踩过的三个坑第一个坑是没区分全局记忆和局部记忆。一开始我把用户ID和订单号都塞进向量库结果不同用户的信息在检索时互相干扰追问了一圈才发现是embedding时把用户标识一起嵌进去了。解决办法是加一层元数据过滤检索之前先按用户ID过滤向量集合。第二个坑是摘要生成时温度设太高摘要结果开始“创作”把订单状态从处理中写成了已退款差点造成客诉。后来摘要生成直接固定 temperature0.1并且把摘要历史也纳入核对。第三个坑是流式输出时的token估算错误前端拿到一半内容就断流排查半天是因为我用的是 encode 的长度估算但有些特殊字符在流式拼接时被重复计数。流式场景下我建议用模型的 usage 回传数据校准而不是自己估。6. 后续可以怎么扩展这个模块做到能稳定上线之后我还有几个方向上想继续做先说给同样在搞 context-mode 的同行参考。一是把记忆库从JSON文件换成真正的向量数据库比如 pgvector 或者 Milvus这样能在数据量变大之后保持检索性能。二是加一个“重要信息自动标注”模块让系统在对话中主动识别需要长期记住的信息比如用户新换的手机号、常去的地点而不是只靠全文embedding。三是给摘要加版本管理和回滚每次摘要都记录原始消息的引用ID一旦发现问题能定位到源头。这些扩展有没有价值取决于你的业务场景但 context-mode 最核心的价值不是某一项技术而是让你在设计对话系统时从一开始就把记忆当成一等公民去考虑。最后分享一个小技巧不管用哪种模式都要在Prompt里用自然语言给模型说明这套记忆规则而不是只靠字段顺序硬压。比如在系统指令里加一句“你可以参考记忆区的内容但必须以用户最新的问题为核心”模型的配合度会高很多。我做 context-mode 最大的体会是给大模型做上下文管理本质上是在做信息的取舍和重构而不是一味地往回拼文字。先算清预算再设计分层最后把细节抠清楚这个系统就能稳定陪你上线很久。