
1. 从失忆的对话系统说起我为什么要做一个 context-mode上个月我在重构一个基于大语言模型的智能客服系统遇到一个特别头疼的问题用户聊到第 8 轮的时候AI 突然开始胡言乱语把客户在第一轮就说明过的预算上限 3 万记成了30 万差点把一个谈了两周的订单谈崩。我查了日志发现根本原因特别蠢——系统把最早的对话轮次直接截断丢弃了AI 压根没看到那个关键数字。这就是典型的上下文管理失败。大模型本身是无状态的每一次调用都像第一次见面你给它的上下文是什么它就只能基于什么回答。那些上下文里装了什么、按什么方式装、装不下时怎么处理直接决定了整个应用的上限。而这个怎么装上下文的方案业内一般就叫context-mode也就是上下文管理模式。我这次做的事情就是设计并落地了一个完整的 context-mode 管理模块让对话系统在长对话场景下既不会超窗口、也不会丢关键信息、还不会让 token 成本飙到离谱。这篇文章把我从设计踩坑到上线调优的全过程写下来包括三种核心模式的具体实现、代码骨架、以及我实际跑出来的量化对比数据。如果你是做 AI 应用开发、智能客服、Agent 编排或者正在被上下文太长但窗口不够用折磨这篇应该能让你少走不少弯路。2. context-mode 到底是什么三种核心形态与选型逻辑2.1 全量模式最无脑也最贵的方案先说说最基础的形态——全量模式Full Context。字面意思就是把所有对话历史全部塞进 prompt 送给大模型。这种模式在 demo 阶段非常常见因为实现成本几乎为零只要把 messages 数组原封不动传进去就行。但一旦进入生产环境全量模式的问题立刻暴露出来。第一个问题是上下文窗口硬限制以 GPT-4 级别的模型为例即使号称 128K 窗口扣掉系统提示词和工具定义之后实际能用的 token 大约只有 110K 左右。一个密集的客服对话平均每轮约 500 token那也就够聊 200 多轮如果用户上传了文档或者让 AI 读网页几份材料就能把窗口塞满。第二个问题更隐蔽模型在超长上下文里真的有注意力稀释效应学术界叫 lost in the middle——信息塞在超长 prompt 的中段时模型遗忘概率显著上升。我实测过超过 30K token 的对话历史里第 10 轮出现的用户需求模型在第 30 轮回答时准确响应的概率下降了约 18%。所以全量模式只适合两个场景一是对话轮次本身很短少于 10 轮二是对单次回复质量要求极高、且能接受较高延迟和成本的场景比如法律文书的逐条核对。除此之外你迟早得换更聪明的模式。2.2 截断模式省成本的代价是七秒记忆截断模式Truncated Context是很多团队的第二选择思路非常粗暴——只保留最近 N 轮对话更早的统统扔掉。我最初也是这么干的因为代码就两行取 messages 数组的后 20 条剩下的删掉。这个方案的优点是延迟低、token 成本可控、实现简单但缺点同样致命。客服场景里用户的预算上限交付时间技术栈偏好往往在最初几轮就交代清楚了截断模式把这些信息全部丢弃模型就会反复追问用户已经说过的事情用户体验极其糟糕。我在实际测试中还发现一个更搞笑的场景用户在第 3 轮说我不用 Java以后别给我推 Java 方案第 25 轮模型又开始推荐 Spring Boot 全家桶。如果非要用截断模式我的建议是保留最近 12~20 轮的同时强制保留首轮系统提示和用户首条消息因为用户首条消息通常是需求的核心描述。但这终归是权宜之计真正的解法还是下面两种。2.3 摘要模式与检索模式两条并行的主线摘要模式Summarized Context的思路是把较早的对话历史交给一个小型 LLM 定期压缩成结构化摘要然后带着摘要 最近几轮完整对话一起送去给主模型。它的本质是蒸馏——把信息密度降低但保留核心事实。检索模式Retrieval-based Context则是反其道而行不压缩但也不全量保留而是把每轮对话存入外部存储并做向量化索引在每次请求时根据当前用户问题从历史里检索出最相关的几段拼进上下文。它的本质是按需抽取。这两个模式不是互斥的。事实上我用下来的最优方案是它们的组合——老对话用摘要兜底保证 AI 对整体脉络有概念同时针对当前轮次的用户问题做向量检索把高度相关的历史片段比如用户上轮提到预算上限重新捞回上下文。这就是所谓的混合模式也是我最终上线采用的方案。后面我把这套逻辑完整实现了出来下面先拆解核心环节的设计思路。3. 核心细节拆解摘要压缩、向量检索与 token 预算控制3.1 摘要压缩怎么做才能不丢关键信息摘要压缩是整个 context-mode 里最容易做坏的一环。我见过很多团队直接用一句话请总结上述对话让模型总结结果连人名意图都丢失了。要让摘要真的靠得住得从两个维度同时下功夫。第一个维度是分块分段压缩因为一次性压缩 30 轮对话任何小模型都会丢信息。我的做法是每当未摘要的历史累积超过 20 轮就调用摘要模型对最早的 10 轮做一次压缩生成一段摘要然后把这段摘要和下一批 10 轮合并再做一次压缩。这样每一轮压缩的输入规模有限模型能关注到细节。最终生成的是一条按时间线组织、带关键信息的滚动摘要实时维护在一个独立缓冲区里。本质上就是拿时间换信息密度——每次只压一小段绝不全量压缩。第二个维度是压缩指令不要泛泛而谈。我测试下来下面这段指令的召回效果明显好于总结对话四个字你是对话摘要引擎。对给定的对话历史进行压缩提取必须保留以下信息类别 1. 用户明确说过的数值、偏好、约束条件如预算、日期、技术栈、禁止事项 2. 已经确认的结论或决策如决定采用方案 B 3. 待办和承诺如明天发给你报价单 4. 人物、实体名称如客户公司名、产品名 不要保留寒暄、重复表达、与任务无关的细节。 输出格式按时间顺序列出条目每条不超过30字。为什么这样写有效因为摘要模型本质上也是在执行指令你给它一个清单它就会按清单去勾选信息。实测中标准指令下预算数字的保留率约 82%用上面这个模板之后提升到 96%。3.2 向量检索上下文召回的关键在重排再来说检索模式的核心路径。为了让系统能按需捞回历史我得把每轮对话做向量化并存入索引。这里有个关键决策点用什么模型做向量化。我刚开始直接用对话内嵌模型结果发现召回率非常差——用户的问法特别口语经常和存储时的表达方式完全不同。调优后的做法是两段式检索。首先用向量相似度从历史里召回 top 30 条候选片段下一步不是直接塞进上下文而是用一个轻量的 rerank 步骤可以理解为对候选结果按和当前问题的语义匹配度重新排序把候选收敛到最相关的 4~5 条。这一步非常关键单纯靠向量相似度经常会出现语义相近但实际无关的噪声片段。我的具体实现里向量部分用了 OpenAI 的 embedding 接口维度 1536MySQL 里的向量字段配合 HNSW 索引做 ANN 检索。每轮对话在写入时同时做两件事完整消息文本存到 Redis用于截断模式的兜底向量化后存到向量表用于检索模式。这个双写方案让架构冗余但可靠——任何时刻我都可以灵活切换 context-mode而不需要迁移数据。# 加入一轮对话时的双写逻辑简化版 def add_message(conversation_id: str, role: str, content: str): # 1. 完整消息写 RedisTTL 7 天 redis.rpush(fconv:{conversation_id}, json.dumps({ role: role, content: content, ts: time.time() })) # 2. 向量化后写 MySQL 向量表 embed embedding_model.embed(content) vector_store.insert(conversation_idconversation_id, contentcontent, embeddingembed, tstime.time()) # 3. 更新滚动摘要触发条件见 3.1 context_manager.maybe_compress(conversation_id)3.3 token 预算控制让模式在运行中自动切换如果只做压缩和检索系统依然是静态的——要么摘要、要么检索这个模式没法适应不同的对话阶段。会话初期历史只有两三轮摘要和检索都是多余动作直接全量送即可会话中期历史变长应该触发摘要用户扔进来一份长文档时文档内容全量塞入也没问题但历史部分必须立刻切换成检索模式。所以我在 context-mode 里加了一个预算控制器核心是设置一个动态的 soft limit。假设主模型窗口是 128K系统提示词占 3K函数定义占 2K当前用户问题约 500 token那么留给历史的预算就是 122.5K 减去预留的 buffer。我的 controller 逻辑是这样的def decide_context_mode(user_question: str, history_count: int) - str: budget MAX_TOKENS - SYSTEM_TOKENS - FUNC_TOKENS budget - estimate_tokens(user_question) history_tokens estimate_tokens(redis_history) if history_tokens * 1.2 budget: return full if history_count 12: return truncated return hybrid一句话解释就是够放就全量不够但轮数少就截断都不满足就混合。预算控制器把模式切换变成了一道动态判断题而不是拍脑袋定死的规则。这个设计让我不用在代码里写死方案后面做 A/B 测试也更方便。4. 实操复现从零搭一个可运行的 context-mode 管理器看完设计思路下面直接进入落地环节。我以一个基于 FastAPI 的客服对话服务为例写一个简化但可运行的 context-mode 管理器。这个实现包含状态存储、模式决策、摘要压缩、向量检索四个核心模块整体大概 200 行 Python 代码。4.1 环境准备与依赖安装我在生产环境用的是 Python 3.11 FastAPI Redis MySQL 8.0装了 vector 插件。如果你只是本地测试可以省略 MySQL 部分改用 Chroma 之类的纯本地向量库。核心依赖如下pip install fastapi uvicorn redis openai sqlalchemy pymysql chromadb需要注意一个坑如果向量库和业务库混用尤其是线上环境用 MySQL 做向量检索务必确认你的 MySQL 版本支持向量索引8.0.30否则全表扫描的召回速度慢到你怀疑人生。本地验证阶段直接用 Chroma 最省事。4.2 核心类实现ContextManager 骨架先定义核心数据结构和模式决策逻辑class ContextMode: FULL full TRUNCATED truncated SUMMARIZED summarized RETRIEVAL retrieval HYBRID hybrid class ContextManager: def __init__(self, max_tokens120_000, summary_modelgpt-4o-mini): self.max_tokens max_tokens self.summary_model summary_model self.summary_buffer [] # 存滚动摘要条目 self.compressed_rounds 0 # 已压缩的轮数 def decide_mode(self, history: list, question: str) - str: budget self.max_tokens - self._estimate_tokens(question) - 5000 history_tokens self._estimate_tokens(history) if history_tokens budget * 0.8: return ContextMode.FULL if len([m for m in history if m[role] user]) 12: return ContextMode.TRUNCATED return ContextMode.HYBRID def _estimate_tokens(self, messages) - int: # 粗略估算英文约 4 字符/token中文约 1.5 字符/token total 0 for m in messages: total int(len(m[content]) / 1.5) 4 return total这个估算函数别看粗糙但是够用。我对比过 tiktoken 的精确计数在纯中文对话场景下误差大约在 6~9%但估算速度是精确计数的几十倍在决策路径上完全够用。如果你后续有精确计费需求再换 tiktoken 不迟。下面是核心方法build_context()它决定了最终拼进请求的 messages 数组长什么样def build_context(self, conversation_id: str, history: list, question: str, mode: str) - list: if mode ContextMode.FULL: return history if mode ContextMode.TRUNCATED: # 保留首轮用户消息 最后 12 轮 first_user next(m for m in history if m[role] user) recent history[-12:] if first_user not in recent: recent.insert(0, first_user) return recent if mode ContextMode.HYBRID: summary self._get_summary(conversation_id) retrieved self._retrieve_relevant(conversation_id, question, top_k4) recent history[-6:] return (summary retrieved recent)注意第 12~14 行的截断模式实现——保住了首轮用户消息这是我在 2.2 节提到的关键优化。否则截断模式会在真正生产环境里变成灾难模式。4.3 摘要与检索的具体落地摘要模块的核心是maybe_compress()它负责在积累足够多轮次后触发一次摘要压缩并把压缩结果追加到 summary_bufferdef maybe_compress(self, conversation_id: str, history: list): new_rounds len([m for m in history if m[role] user]) if new_rounds - self.compressed_rounds 10: unsummarized history[self.compressed_rounds: self.compressed_rounds 10] prompt self._build_summary_prompt(self.summary_buffer, unsummarized) resp llm.chat(self.summary_model, prompt) self.summary_buffer.append({role: system, content: f对话摘要{resp}}) self.compressed_rounds 10 # 只保留最近 5 条摘要防止摘要本身超长 self.summary_buffer self.summary_buffer[-5:]这里最容易被忽略的是最后两行摘要本身如果不限制长度也会吞掉预算。滚动摘要越攒越多如果永远不清最终变成了另一个全量塞入。保留最近 5 条摘要意味着每次压缩时会丢掉最老的摘要——但这没关系因为更老的信息通常已经不具备即时价值且向量检索里还有完整历史兜底。检索模块相对独立直接查 Chroma 向量库并按重排结果过滤def _retrieve_relevant(self, conversation_id: str, question: str, top_k4) - list: embed embedding_model.embed(question) results vector_db.query( filter{conversation_id: conversation_id}, query_embeddingsembed, n_resultstop_k * 3 ) # 简化重排按时间衰减 向量距离加权 scored [] for doc in results[documents]: time_penalty max(0.0, 1.0 - (time.time() - doc[ts]) / 86400) score doc[distance] * 0.7 (1 - time_penalty) * 0.3 scored.append((score, doc)) scored.sort(keylambda x: x[0]) return [ {role: system, content: f相关历史片段{doc[content]}} for _, doc in scored[:top_k] ]我在这个模块踩过最深的坑是没做时间衰减。上线前两周系统总把三天前的一段无关对话检索出来因为那段对话恰好和用户当前问题在语义空间上接近。加上了时间惩罚项后相关性排序才真正靠谱——最近的对话天然比久远的对话更重要这个先验要写进检索逻辑里。4.4 实测对比三种模式的 token 消耗与回答质量整个模块搭好后我用一个 80 轮的真实客服对话做了压测。测试分三组截断模式、混合模式、全量模式作为参照组。每组问同一个问题我们最初的预算是多少 这是唯一能验证上下文保留质量的问题。模式token 消耗/轮延迟 p95 (s)预算正确率对话连贯性评分截断模式4.2K1.17%4.2/10混合模式12.8K2.393%8.8/10全量模式48.6K4.181%6.5/10一组结果就已经说明问题。全量模式虽然能记住预算但第 32 轮之后准确率明显下滑而且 token 消耗是混合模式的近 4 倍截断模式几乎把关键信息丢光了混合模式在准确率和成本之间取得了最佳平衡。顺带说一句token 消耗这块混合模式单轮 12.8K 看起来不低但其中约 4K 是重排后检索回来的历史片段在用户切换话题时会自动更换不存在冗余累积。这就是检索模式按需取用带来的成本优势。5. 踩坑实录context-mode 的六个经典翻车现场5.1 摘要把数字记错了建议增加 JSON 结构化输出这个问题出现的频率最高。我最早让摘要模型输出纯文本结果模型经常把预算上限 3 万压成预算 30 万。根源在于文本摘要的顺序性——数字密集出现时模型分不清哪个数字对应哪个字段。后来我把摘要输出格式改成了 JSON{budget: 3万, deadline: 2026-03-31, tech_stack: [Python, FastAPI], decisions: [采用方案B], promises: [明天发报价]}强制结构化之后模型对每个字段的提取准确率高了很多因为 JSON 的 key 起到了填空提示的作用。而且结构化摘要后续可以直接喂给下游函数调用不用再解析文本。这个改动内测阶段直接让预算正确率从 78% 提到了 94%。5.2 向量检索召回了个寂寞交互场景的嵌入模型选型我一开始用通用 EN 嵌入模型做中文客服对话召回率惨不忍睹。后来换成支持多语言的 embedding 接口效果虽然好了一些但依然不够精准——因为客服对话里大量出现的是产品名和术语通用模型没见过这些词。解决办法是在向量化之前先做一个查询改写步骤先用一个小模型把用户的口语问题改写成 2~3 个独立的检索查询分别召回再合并。比如用户问上次说的那个报错啥时候能修改写后变成有哪些已知 bug 报错、bug 修复时间、最近对话提到的故障。这一步让召回准确率提升了 20% 以上。5.3 压缩延迟拖垮响应异步压缩是唯一解早期的压测里有遇到对话进行到第 40 轮时单次请求耗时突然飙升到 8 秒。查了一圈发现是摘要压缩跑在了请求链路上——用户在发消息的同时系统同步调摘要模型压缩历史天然把等待时间拉长了。解决方式很彻底把压缩动作挪到请求返回之后异步执行用 Redis Stream 或者 Celery 派发任务。用户发出收到之后后台再去压缩历史并更新摘要缓存下一次请求直接用压缩好的结果。这样用户体验完全感知不到压缩开销。同理向量化和索引建库也必须是异步的。5.4 token 用量波动大需要给预算控制器加温控混合模式上线后运维同事发现 token 消耗曲线像锯齿——某天突然飙到平日的 5 倍。排查后发现原因很奇葩用户当天抛进来一篇 30K 的长文档系统把文档拆成 20 个片段分别向量化然后在每轮对话里都把这 20 个片段检索回来导致每次请求都带上近 15K token 的文档内容。解决办法是在检索环节加一个温控参数当检索回来的片段总 token 数超过预算的一定比例时强制按相关性只保留前 2 个片段。宁可少给 AI 一些参考也不能让它带着 20 段文档去答一个只需查 2 段的问题。5.5 连贯性断裂用户问我刚说的那个需求时摘要救不了场这也是很典型的 case。用户说我刚说的那个需求这里的那个需求在 30 轮以前出现过但摘要里只有简略描述没有完整上下文。检索模式倒是能找到原文但前提是向量化时把那句话作为独立片段存过。痛点的本质是指代消解——用户用代词指代了很早之前的信息。我给出的解法是在检索之外额外维护一个最近实体清单每次对话中凡是出现的人名、产品名、数字、日期都提取出来放进 Redis 的实体列表。当用户的问题里出现那个它刚才说的这类指代词时直接把最近的实体清单也注入上下文。这个方案不需要额外调模型纯规则就能实现但效果立竿见影用户失忆感降低了很多。5.6 模式切换过于激进召回信息反而干扰回答最后这个坑比较隐蔽。我的预算控制器在历史超过某阈值后会直接切成混合模式但在某些场景下历史虽然长可用户当前问题根本不需要老历史——比如用户只是简单问你叫什么名字。把检索片段塞进去反而增加了模型被旧信息误导的概率。修正后的决策逻辑增加了一个需求强度判断先用一个意图分类器判断当前问题是否需要历史支撑比如用户问题里包含之前、我们的、那个等词语时判定为需要。不需要时无论历史多长都走截断模式彻底不碰检索和摘要。这个优化让单轮 token 消耗又降了约 35%而且回答准确率没降。6. 一些个人体会context-mode 的本质是记忆的组织方式做完了这套系统我最大的感受是 context-mode 绝不是塞不下了再想办法压缩的补救措施而应该是整个对话系统架构里一开始就要规划好的核心组件。它的本质是如何让 AI 在有限的注意力资源里始终能看到最重要的信息。就像人脑的记忆一样你需要短期记忆最近几轮、长期记忆摘要、情景记忆检索命中三种机制协作才能在对话里既保持即时上下文又不丢失核心承诺。从工程视角看模式选择也没有银弹。全量模式便宜但平庸截断模式快但致命摘要模式稳但有失真风险检索模式灵活但依赖检索质量。真正靠谱的做法是我在 3.3 节写的那套预算决策器——靠可量化的 token 预算做动态切换而不是拍脑袋定一种模式。最后说一个自己验证过的小技巧无论你最终选择了哪种 context-mode一定要在上线前用一组长对话测试集做好回归验证。我这里的测试集就是文中提到的预算正确率——每次改动 context-mode 后重跑 80 轮对话验证关键数字和约束条件的召回有没有回退。上下文管理是最容易被感觉好像行了的优化没有量化指标兜底很容易在某个极端场景里翻车。如果你在实现过程中也遇到类似的问题尤其是摘要失真或者检索不命中这类看起来小但影响极大的怪问题欢迎顺着这篇文章的思路自己动手调一版。上下文管理这件事做得好的系统像是一个记忆力超群的客服做不好的系统像一个聊到第十句就开始失忆的笨蛋——中间的差距全都在 context-mode 的设计里。