
最近跟几个做 AI 应用的朋友聊天发现大家都在折腾同一个东西context-mode。说白了就是大模型对话应用里的上下文模式——你往 prompt 里塞什么、保留什么、压缩什么直接决定了产品用起来像不像一个真正懂你的助手。这个词看起来简单但真落地的时候坑特别多。这篇文章我打算把一个基于 context-mode 的完整设计思路拆开讲从核心原理讲到代码实现再到线上踩过的坑给正在做智能客服、知识库问答或者 AI 编程助手的同学一个可以直接参考的框架。我先说结论context-mode 不是一个库、不是一个框架而是一套关于上下文怎么组织的策略集合。它的核心价值在于在模型上下文窗口有限、token 成本有限、信息噪声无限的现实约束下找到一条最优路径让模型在每一轮对话里都拿到它真正需要的信息。下面我会从问题场景出发逐步拆解三种主流模式然后用 Python 手写一个最小可用实现最后把我在线上踩过的典型坑和排查思路全部整理出来。1. context-mode 到底是什么先搞清楚它要解决什么问题1.1 从一个翻车场景说起我去年参与过一个电商客服机器人的项目早期版本特别简单每次用户提问就把整个历史对话一股脑全塞给模型。前几轮效果还不错用户问我家狗粮还剩 3 公斤能撑多久模型答得头头是道。结果对话到第 12 轮的时候用户随口问了一句那我上次说的那个退款什么时候到账模型直接懵了——它把 12 轮里所有信息都当成同等重要的内容结果在一堆发什么快递猫砂能不能混用的闲聊里把最关键的用户 ID、订单号、退款原因全给淹没了。这不是模型笨是我们的上下文组织方式错了。所有信息都塞进去等于所有信息都没被记住。这个场景让我彻底意识到context-mode 不是一个锦上添花的功能而是 AI 应用的底层基础设施。1.2 context-mode 要解决的三个核心矛盾第一个矛盾是窗口有限。主流模型的上下文窗口从 8K 到 200K 不等看起来很够用但真实业务场景里一个高频用户聊 50 轮很正常加上知识库片段、工具返回结果几十万 token 分分钟就爆了。第二个矛盾是信息相关性不同。用户昨天说的我喜欢蓝色外壳可能对今天的售后问题毫无帮助但模型不知道这一点它只会一视同仁地处理所有历史消息。第三个矛盾是成本陡增。每多塞 1000 个 token就多一次真实的金钱开销高频场景下这个数字会指数级膨胀。所以 context-mode 的本质就是在这三个矛盾之间找平衡点它不是尽可能多地记住而是尽可能精准地记住。做得好产品像真人助手做得差就是个一提问就胡言乱语的玩具。1.3 哪些业务场景最需要引入 context-mode不是所有 AI 应用都需要这套东西。单轮问答、一次性翻译、内容生成直接塞 prompt 就行完全没必要折腾。但下面这几类场景不做 context-mode 基本活不下去多轮对话型产品客服机器人、销售助手、心理陪伴类应用。用户的真实意图往往埋在多轮对话里漏一段就全跑偏。Agent 类工作流让 AI 自己决定调用哪些工具、按什么顺序执行。每一步的结果都是下一步的上下文串不起来就卡死。知识库问答这个问题 RAG 已经很成熟了但检索结果怎么和对话历史融合、怎么避免检索片段互相打架依然是 context-mode 的活。AI 编程助手一个项目里几十个文件模型需要同时记住当前文件、相关函数、用户最近改动的意图本质上就是多源上下文的组织问题。如果你正在做以上任意一类产品那这篇文章里的三种模式和一套实现代码应该能直接帮你省掉至少两周的试错时间。2. 三种主流的 context-mode 组织方式2.1 滑动窗口模式最简单的最近优先策略滑动窗口是所有方案里最直觉的。核心思想是只保留最近 N 轮对话更早的内容直接丢进记忆垃圾桶。比如窗口大小设为 10 轮那第 1 轮用户的初始需求到第 15 轮的时候就已经被自动遗忘了。那它为什么还能在很多场景下工作得很好因为大部分对话都存在局部性。用户聊退款的时候接着的三五轮大概率还是围绕退款用户改代码的时候最近的几次编辑就是他当前最关心的状态。滑动窗口的本质假设是最近的就是最相关的这在大多数短期任务里是成立的。实现上一定要注意按消息条数滑动还是按token 数量滑动。按消息条数简单但一条 2000 字的长消息和一条好的差 200 倍权重按 token 滑动更合理但需要实时计算长度。我一般用 token 作为窗口计量单位然后额外加一条硬性限制消息条数不超过 40 条防止单条超长消息把窗口全占满。2.2 摘要压缩模式对抗遗忘的高级解法滑动窗口的问题是遗忘太粗暴了。用户第 3 轮说我家的狗是只 4 岁的比格犬对鸡肉过敏到第 20 轮聊到出门五天怎么准备食物时这条信息早就滑出窗口了但模型恰恰需要它来判断不能喂含鸡肉的狗粮。摘要压缩模式的做法是不等信息滑出窗口而是在它快被丢掉之前用模型生成一段摘要。这些摘要像长期记忆笔记一样每次组装上下文时把摘要和历史对话一起塞给模型。相当于给模型配了个随时更新备忘录的助理。这里有个非常关键的细节摘要不要只存事实要把用户的偏好、状态、未完成事项单独总结出来。我见过太多人把摘要写成用户问了快递、用户问了退款这等于啥也没写。好的摘要是这个画风用户ID: 88231正在咨询订单 #A203 的退款进度该订单金额 329 元用户已发起退款申请目前状态为待商家处理。用户情绪略焦虑在意退款到账时间。用户在对话早期提到家中宠物狗对鸡肉过敏后续如推荐食品需避免。这种摘要才是真正能在后续对话里想起来的东西。摘要压缩模式相比滑动窗口信息保留率明显更高但代价是需要额外调用模型生成摘要有成本和延迟。2.3 检索增强模式按需捞取不靠记忆靠索引如果说摘要压缩是主动记笔记那检索增强就是用的时候去翻档案。你不需要在上下文里保留所有历史只需要把用户的历史消息、知识库文档提前切块、做成向量索引。每轮对话时根据当前用户的问题实时检索最相关的几个片段拼进 prompt。这个模式最适合知识库问答和 Agent 工具调用场景。比如用户问帮我看看上周那份合同里违约责任怎么写的系统全文可能有 10 万字但你只需要检索出违约责任相关的那 800 字塞给模型剩下的全部留在索引里。检索增强的核心在于切片策略和检索质量。切片切得太碎单个片段缺乏上下文模型理解不了切得太粗一个片段包含多个主题检索噪声大。我的经验是结构化文档按章节切非结构化文档按固定长度 500~800 字切重叠 50~100 字效果最稳。2.4 三种模式怎么选一张对照表很多初学者上来就问到底用哪个其实它们不互斥而是可以叠加的。我给你一个实操判断逻辑对比维度滑动窗口摘要压缩检索增强实现难度低中高信息保留能力弱只留最近中依赖摘要质量强按需捞取实时性高中摘要有延迟高成本低中频繁调模型中向量库 检索最适场景闲聊、短任务长对话、客服知识库、Agent我的建议是分三层叠加底层用滑动窗口保底保证任何情况下 prompt 不会无限膨胀中间层对窗口外的重要信息做摘要压缩顶层对知识库类内容用检索增强。这套三层金字塔结构是我在多个项目里验证过的最稳组合。后面我就按这个结构来实现一个完整的 context-mode。3. 手写一个最小可用的 context-mode 实现3.1 设计目标与数据结构定义在写代码之前先明确我们要做到什么程度。我打算实现一个ContextManager类支持滑动窗口、摘要压缩、检索增强三种策略并且可以自由组合。它对外暴露的接口很简单add_message(role, content)记录一条对话消息get_context(query)根据当前用户问题组装最终发给模型的上下文_maybe_summarize()内部触发摘要压缩_retrieve(query)内部触发检索增强数据结构我用一个字典表示一条消息字段包括role、content、timestamp。摘要单独记在一个列表里每条摘要也带时间戳和对应的原始消息范围。为了演示向量检索我用简单的余弦相似度而不是真接向量数据库方便你拷贝就能跑。from dataclasses import dataclass, field from typing import List, Dict import time, math dataclass class Message: role: str # user 或 assistant content: str timestamp: float field(default_factorytime.time) dataclass class SummaryBlock: content: str start_time: float end_time: float3.2 滑动窗口的实现滑动窗口的核心是维护一个消息队列超出预算就把最老的挤出去。我这里用 token 估算而非字符数。中文字符和 token 的比例大概 1 比 0.7 到 1 比 1.2不同模型有差异。为了通用性我写一个简单的估算函数英文字符数除以 4中文按一个算 1.2 个 token混排场景够用了。def estimate_tokens(text: str) - int: # 粗略估算 token 数实际项目建议用模型的分词器 chinese_chars sum(1 for c in text if \u4e00 c \u9fff) other_chars len(text) - chinese_chars return int(chinese_chars * 1.2 other_chars / 4)然后滑动窗口策略就是维护一个列表在新增消息后检查总 token 数超过max_tokens就从头部弹出。注意我保留了系统级摘要的位置——如果摘要已经存在它不会被滑动窗口挤掉。class ContextManager: def __init__(self, max_tokens4000, window_messages40): self.messages: List[Message] [] self.summaries: List[SummaryBlock] [] self.max_tokens max_tokens self.window_messages window_messages def add_message(self, role: str, content: str): msg Message(rolerole, contentcontent) self.messages.append(msg) self._prune_window() self._maybe_summarize() def _prune_window(self): # 双条件token 上限 消息条数上限 while (len(self.messages) self.window_messages or self._total_tokens() self.max_tokens): if not self.messages: break self.messages.pop(0) def _total_tokens(self) - int: summary_tokens sum(estimate_tokens(s.content) for s in self.summaries) msg_tokens sum(estimate_tokens(m.content) for m in self.messages) return summary_tokens msg_tokens这里有个很容易忽略的细节_total_tokens必须把摘要的 token 也算进去否则你会发现日志里系统提示上下文超限但自己的窗口明明设置得不大。我之前就在这个阴沟里翻过船。3.3 摘要压缩的实现摘要压缩的触发条件不能写死每 N 轮执行一次因为对话长短差异太大。我采用一个动态判断当前消息总 token 数超过 70% 窗口上限时把最早的一批消息压缩成摘要。这样既不会频繁触发浪费钱也不会等到快爆了才动手。class ContextManager: # 接上面的类继续 def _maybe_summarize(self): if self._total_tokens() self.max_tokens * 0.7: return # 找到最早的一半消息进行压缩 if len(self.messages) 6: return split_idx len(self.messages) // 2 old_msgs self.messages[:split_idx] self.messages self.messages[split_idx:] summary_text self._summarize_messages(old_msgs) self.summaries.append(SummaryBlock( contentsummary_text, start_timeold_msgs[0].timestamp, end_timeold_msgs[-1].timestamp )) def _summarize_messages(self, msgs: List[Message]) - str: # 这里用 LLM 生成摘要演示时用模板字符串代替 conversation \n.join(f{m.role}: {m.content} for m in msgs) prompt ( 请将以下对话压缩为一份结构化摘要必须包含 1) 用户的核心身份与偏好信息 2) 当前正在处理的未完成任务 3) 任何承诺、时间节点与关键事实。 不要保留寒暄和无关内容。\n\n对话内容\n conversation ) # 实际项目里result llm.chat(prompt) # 这里为了演示直接返回截断文本 return f[摘要] 对话中用户提到{conversation[:100]}...生成摘要的提示词极其重要。如果你只是说总结这段对话模型会给你输出一段废话。必须显式告诉它要提取哪几类信息而且要强调不要保留寒暄。我做客服项目的时候摘要 prompt 里还会加一条如果用户表达过情绪焦虑、生气、满意请标注情绪状态这对后续对话的语气调整帮助特别大。3.4 检索增强的实现检索部分我做一个轻量版把历史消息切成固定大小的 chunk用 TF-IDF 向量做余弦相似度检索。真实项目里当然会用 embedding 模型但原理是一样的——把文本变成向量算相似度取 Top-K。import re, math from collections import Counter class SimpleRetriever: def __init__(self, chunk_size200, top_k3): self.chunks [] self.chunk_size chunk_size self.top_k top_k self.tfidf_vectors [] def index(self, text: str): # 按 chunk_size 字符切块 for i in range(0, len(text), self.chunk_size): chunk text[i:iself.chunk_size] self.chunks.append(chunk) self.tfidf_vectors.append(self._tfidf(chunk)) def _tokenize(self, text: str): # 简单中文分词按字切 提取 2-gram演示够用 text re.sub(r\s, , text) return [text[i:i2] for i in range(len(text) - 1)] def _tfidf(self, text: str): tokens self._tokenize(text) counter Counter(tokens) total len(tokens) return {k: v / total for k, v in counter.items()} def _cosine(self, a: Dict[str, float], b: Dict[str, float]) - float: common set(a.keys()) set(b.keys()) dot sum(a[k] * b[k] for k in common) norm_a math.sqrt(sum(v*v for v in a.values())) norm_b math.sqrt(sum(v*v for v in b.values())) return dot / (norm_a * norm_b) if norm_a and norm_b else 0.0 def retrieve(self, query: str, top_kNone): k top_k or self.top_k q_vec self._tfidf(query) scores [(i, self._cosine(q_vec, vec)) for i, vec in enumerate(self.tfidf_vectors)] scores.sort(keylambda x: x[1], reverseTrue) return [self.chunks[i] for i, s in scores[:k] if s 0]把这个检索器挂进 ContextManager 之后get_context的时候先看用户的问题决定要不要触发检索。如果问题里包含合同订单之前说这类词说明用户需要翻旧账就捞取相关片段拼进 prompt。class ContextManager: def __init__(self, retrieverNone): # 与前面重复的属性合并在这里 self.retriever retriever # ... 其他属性 def get_context(self, query: str) - str: parts [] for s in self.summaries: parts.append(f[长期记忆] {s.content}) for m in self.messages: parts.append(f{m.role}: {m.content}) if self.retriever and self._need_retrieval(query): retrieved self.retriever.retrieve(query) for r in retrieved: parts.append(f[检索片段] {r}) return \n.join(parts) def _need_retrieval(self, query: str) - bool: keywords [之前, 刚才, 那个, 上次, 合同, 订单, 说过, 提到] return any(k in query for k in keywords)3.5 串联完整链路一个真实调用示例把上面的代码拼起来完整调用链路是这样的# 初始化 retriever SimpleRetriever() retriever.index(订单 #A203 退款流程说明用户提交申请后商家需在 48 小时内处理。) cm ContextManager(max_tokens4000, retrieverretriever) # 模拟多轮对话 cm.add_message(user, 我刚买的狗粮还没发货我想退款) cm.add_message(assistant, 请问您的订单号是多少) cm.add_message(user, 订单号是 A203能帮我查一下吗) cm.add_message(assistant, 好的我来帮您查询退款进度。) # 很晚之后用户又问 cm.add_message(user, 之前说的那个退款到账时间到底是多久) context cm.get_context(退款到账时间到底是多久) print(context)输出结果里会包含长期记忆摘要、最近几轮对话、以及从知识库里检索到的48 小时处理片段。模型拿到这个组装好的上下文之后才能给出预计 2 天内到账平台显示商家将在 48 小时内处理这样准确且连贯的回复。这个实现看起来简单但它覆盖了 context-mode 的完整骨架。你可以在上面加 embedding、加 Redis 存储、加多租户隔离方向都不会偏。4. 线上踩坑实录context-mode 的五个典型翻车现场4.1 上下文污染旧信息干扰新意图我踩过最诡异的一个坑是用户已经完成了退款开始问新品推荐但模型还在反复提退款的事。查了很久才发现摘要模块把退款中的状态一直保留在长期记忆里没有更新机制。问题不在于摘要本身而在于摘要没有随状态变化而失效。解决方案是在摘要块上加了一个active字段每轮对话后用规则或小型模型判断摘要里提到的任务是否已经闭环比如检测到退款相关消息完结用户说收到了客服说已退款就把对应的摘要块标记为失效组装上下文时直接跳过。如果你不做这个机制对话越长过期的摘要越多模型越糊涂。4.2 token 预算失控摘要自己也超了刚开始上摘要压缩时我犯过一个低级错误只限制消息窗口大小没限制摘要总量。结果用户聊到第 100 轮时光摘要就堆了 8000 token比消息本体还大。这就完全背离了 context-mode 的初衷。后来我加了一条硬性规则摘要总量超过窗口上限的 30% 时对最早的摘要再做一层摘要的摘要。相当于给记忆系统加了自动归档机制——早期记忆变成高层次概述只保留最近几轮的详细摘要。你可以理解为人的记忆也是这样三年前的细节早就模糊成那年我做了个项目效果不错这种级别了。4.3 摘要丢失关键细节摘要压缩最大的风险是模型漏掉细节。有一次用户在第 5 轮提到我对花生过敏但摘要只写了用户提及饮食偏好结果第 30 轮推荐菜单时模型推荐了含花生的菜品。回看日志摘要模型确实收到了那条消息但它认为过敏不如用户正在安排聚餐重要就漏了。我的补救策略是摘要 prompt 里增加安全类信息优先的硬约束并且在摘要末尾追加一行关键硬约束对食物过敏、资金数额、截止时间等必须原文保留。此外在消息被压缩之前先用规则扫一遍是否包含这类关键词有就直接把原句拼进摘要不让模型自由发挥。4.4 检索命中噪声捞回来的内容帮倒忙检索增强不是万能的捞回来的片段有时候反而会把模型带偏。最典型的情况是知识库里同时存在旧版和新版两套流程文档检索器按相似度把新版片段排在第一个但用户问的其实是旧版场景下的历史订单。模型如果不知道版本差异就会拿新流程解释旧订单得出一个完全错误的结论。针对这个问题我给每个检索片段加了元数据头[文档版本: v2.3 | 更新时间: 2024-06-01]。当同一个问题命中了多个版本的片段时在 prompt 里用两句话提示模型历史订单可能适用于旧版规则请根据时间判断。这个改动看起来很小但线上准确率直接提升了十几个百分点。4.5 并发场景下的上下文错乱最后一个坑是工程层面的。如果同一个用户同时开着两个对话框网页端和 App 端或者 Agent 并行调用多个工具回写消息上下文管理器就会遇到竞态问题——两条消息同时写入拿到的上下文串了。我用的是最朴素也最有效的方案按 session_id 加锁写入和组装上下文走同一个队列。单机用threading.Lock多实例用 Redis 分布式锁。另外每条消息必须带上session_id和seq序号组装 context 时严格按seq排序。不要相信消息到达的时间戳因为网络延迟会让时间戳乱序。5. 根据我的实操体会这几件事一定要做5.1 可观测性是 context-mode 的生命线第一个体会是没有日志系统的 context-mode 就是黑盒。你光看模型输出变差了根本不知道是滑动窗口挤掉了信息还是摘要写歪了还是检索捞错了。我在代码里给每次get_context都打了一条结构化日志记录最终的 token 组成明细、摘要条数、检索命中了哪些片段。每次线上效果波动第一时间翻日志定位而不是瞎调参数。具体来说日志至少要包含消息总数、摘要 token 占比、本次检索 Top-K 片段的 ID 和相似度分数、完整 prompt 长度。把这些指标每周拉一个趋势曲线你会发现很多玄学问题其实都有规律比如每天下午 4 点后效果下降这种很可能是某个定时任务把知识库新文档灌进来导致检索分布变了。5.2 参数别拍脑袋用回放数据来调滑动窗口设多大、摘要触发阈值设多少、检索 Top-K 取几个这些参数我从来不拍脑袋。我的做法是把线上真实对话记录下来做成回放集然后在回放集上跑 A/B 实验。每次只调一个参数对比模型回答准确率和单轮 token 成本这两个核心指标。以窗口大小为例实测下来 4000~8000 token 的窗口在大多数客服场景里性价比最高再大收益不明显成本却线性增长。摘要触发阈值放在 70% 是因为这时候压缩后的信息损失最小——太早触发会把还没聊透的细节压掉太晚触发又可能压不住突发长消息。5.3 再给一个所有场景通用的建议永远留一条逃生通道不管 context-mode 设计得多完美总会有模型答错的时候。所以在产品层一定要加一个澄清机制当模型发现自己拿到的上下文里信息不足时允许它反问用户而不是硬编一个答案。这个机制的实现只需要在系统提示词里加一句如果你觉得现有信息无法回答用户问题请直接向用户确认不要推测。就这么一句话能把幻觉率肉眼可见地压下去。context-mode 这个东西做到后面你会发现真正难的从来不是算法而是对业务的理解——你得知道哪些信息重要、哪些会过期、哪些需要跨对话保留。把这个想清楚了用什么框架都是顺手的事。