
说实话第一次看到context-mode这个词的时候我愣了一下。它不是一个新框架也不是某个开源库的名字而更像是一种设计思路的浓缩。我做完这个项目之后才意识到越是这种看似简单的概念背后牵扯的问题越深。如果你正在做AI应用、聊天机器人或者任何依赖大模型进行多轮交互的系统这篇文章应该能帮你少走几个月的弯路。我做的这个context-mode简单来说就是一套让AI应用能合理管理上下文的机制。它解决的是大模型对话中一个非常要命的问题模型能记住的东西是有限的而用户的对话是无限的。怎么在有限的窗口里装下最重要的信息怎么让模型在长对话中不失忆怎么控制每次请求的token成本——这些都是context-mode要回答的问题。这篇文章适合正在搭建AI应用后端、做Prompt工程、或者研究大模型落地的开发者参考我会把完整的设计思路、核心原理、代码结构和踩坑记录都放出来。1. 项目概述与整体设计思路1.1 最初的问题会话一长就断片事情起源于某团队接到的一个需求给企业内部做一个知识问答助手文档库大概有几万篇用户会围绕某个主题连续提问单轮对话可能持续半小时以上。刚开始实现的时候很简单就是把用户所有历史消息一股脑塞给大模型让模型基于完整对话历史来回答。这个方案在测试初期还能跑但问题很快就暴露了。第一是成本每轮请求的token数量和对话轮数成正比一个聊了20轮的用户每问一个问题就要把之前所有内容重新发送一遍费用肉眼可见地涨。第二是质量模型输入太长之后注意力会被拉散用户最早提到的关键信息经常被忽略回答开始答非所问。第三是硬限制模型有固定的上下文窗口上限一旦对话超过这个长度系统直接报错用户聊到一半就断掉了。有个比较极端的场景我记得很清楚用户问了一个关于合同条款的问题前面十几轮一直在聊公司内部的报销流程结果模型把合同条款的细节全忘了一本正经地开始解释报销标准。这不是模型能力不行而是我们把上下文管理这件事做砸了。我当时的判断是与其优化Prompt不如从根本上解决什么该记、什么该忘、什么该重点记的问题。1.2 选型背后的取舍逻辑在研究了一圈方案之后我们发现业界的做法基本可以归为四类滑动窗口、摘要压缩、检索增强、混合路由。每一类都有非常明确的适用场景也都有各自的代价。我最初的想法是只做一个滑动窗口因为实现起来最简单。但实际测试后发现如果只保留最近几轮对话用户早期提到的关键背景信息比如我们公司是制造业、这个项目的预算上限是50万会被无情地挤出去。这些信息一旦丢失后续回答的用户体验是灾难性的。让我最终决定做一套混合模式的是一次真实的用户反馈。某位测试用户连续问了三个小时的问题中间穿插了大量闲聊今天食堂的菜不错滑动窗口模式下模型把闲聊也当成重要上下文保留了下来反而把真正的业务约束挤掉了。这让我意识到上下文管理最核心的问题不是窗口开多大而是如何区分什么是值得记住的信息。所以最终的设计是这样的系统有一个默认的滑动窗口作为底仓负责保留最近几轮对话保证连贯性在这个基础上叠加一个摘要模块定期把早期对话压缩成结构化的摘要另外再加一层检索逻辑从外部知识库中拉取与当前问题最相关的文档片段。这三者之间通过一个路由策略来协调根据当前对话长度、问题类型和用户的活跃状态动态切换。这个结构就是context-mode的核心。2. 核心技术原理上下文模式的四种策略2.1 滑动窗口最朴素的保底方案滑动窗口的原理一句话就能讲清楚只保留最近N轮对话更早的全部丢弃。但真做起来有两个参数需要仔细斟酌窗口大小和裁剪粒度。窗口大小主要受限于模型的上下文长度。以我当时使用的模型为例它在8K token的输入范围内表现比较稳定超过之后回答质量会逐步下降。我最初把窗口设成6K token预留2K给检索到的知识文档和系统指令。后来调整到5K因为实测发现留出更多余量可以避免接近上限时的性能波动。裁剪粒度上有一个很多新手会忽略的细节到底按轮裁剪还是按token裁剪。按轮裁剪逻辑简单但每轮的token数量差异可能非常大——用户发一句好的和发一段500字的长文都是一轮。按token裁剪更精准但实现起来要小心不能把一个完整的消息从中间截断否则模型看到的输入是残缺的反而会产生幻觉。我的做法是按token裁剪为主但要保证被裁剪时以消息为单位进行对齐宁可保留一条稍长的消息也不能把一个消息拆成两半。滑动窗口看起来简单但它回答了一个根本性的问题当资源有限时最近的对话信息优先级永远高于以往的对话信息。这个假设在大多数场景下都是成立的因为用户当前的问题通常与最近的内容关联最强。2.2 摘要压缩把历史浓缩而不是丢弃对于那些退出窗口但仍有价值的历史信息直接丢掉太可惜。摘要压缩的思路是定期把旧对话交给模型让它生成一段结构化摘要然后这段摘要作为新的记忆参与后续的对话。这里有一个关键的工程决定摘要生成的触发条件。不能每轮对话都做摘要那会让token消耗翻倍。我的做法是设置一个摘要水位线——当滑动窗口中被裁剪掉的消息总token量累计超过2000时触发一次摘要。摘要的内容也不是让模型自由发挥而是要求它按照固定的三段结构输出用户的核心诉求、已经确认的约束条件、尚未解决的问题。这样后续模型读取摘要时可以快速定位到需要的信息。在生产环境中我还发现摘要不应该只生成一次。用户聊到第50轮时回头看第20轮生成的摘要可能已经过时了但它仍然占据着上下文空间。所以更合理的方式是树状摘要——每5次摘要之后把之前的摘要再压缩成更上层的概要。这个层级不建议超过三层否则信息丢失太严重模型读到的将会是一堆空洞的概括。摘要压缩的代价是信息失真。模型生成的摘要不可避免会丢细节特别是数字、名称、精确的时间点这些信息非常容易被模型概括掉。我踩过的坑是摘要压缩之后用户之前明确说过的预算不能超过80万变成了预算有限模型后续给的方案完全偏离了用户的硬性约束。这让我后来在摘要模板里专门加了一个硬约束字段凡是检测到数字、单位、否定词等关键token必须原样保留而不是概括。实测这个改动让信息保留率提高了不少。2.3 检索增强让上下文从记忆变成查证如果要回答的知识点不在对话历史里而在一个外部知识库中靠上下文管理是解决不了的必须引入检索。这就是RAG检索增强生成的模式。context-mode里的检索增强部分核心解决的是哪部分知识应该进上下文的问题。很多人的理解是把用户的问题拿去全文搜索然后把搜索结果拼接进Prompt。这样做的效果其实很差。用户的问题通常很简洁那这个怎么处理单独拿去搜索召回的内容往往完全不对。正确的做法是结合当前的对话摘要和历史消息构造一个查询上下文再从这个上下文里抽取关键词和隐含实体去做检索。比如用户问那这个怎么处理需要先解析出这个指代的是对话中提到的合同续签流程然后拿合同续签去做向量检索。检索结果的调度同样需要策略。一次检索可能召回10条文档但这些文档不一定都与当前问题相关。我的做法是先按相关度排序然后逐条计算与当前问题的语义重叠度只取重叠度最高的Top-3进入上下文。同时每条检索结果要在前面标注来源编号比如[文档1] 来自合同管理制度.pdf这样模型在回答时可以引用来源也方便用户追溯。2.4 混合路由不同模式如何协同工作单看每一种模式都不是特别新奇的东西难点在于怎么让它们协同。context-mode的核心就是把路由决策做成一个显式的模块。我给它设计了一个决策函数输入是当前状态对话轮数、窗口占用率、检索结果的评分输出是当前这一轮应该采用哪种策略组合。具体来说我设计了三档路由低水位窗口占用率低于60%直接全量拼接把历史消息检索结果都给模型。这时候空间充裕没必要做任何压缩。中水位窗口占用率在60%到85%之间启用摘要压缩用旧对话的摘要替换掉部分原始消息保留最近5轮原始对话全量摘要检索结果。高水位窗口占用率超过85%这已经是危险区了。此时要主动切断循环把过于久远的对话摘要进一步压缩只保留最高层级的核心概要最近2轮原始对话检索结果。这个路由逻辑在工程上不复杂就是一个if-else链加几个评分函数但实际效果非常显著。在压力测试中它让长对话的有效率模型回答被用户认可的比例从52%提升到了79%同时把平均单轮token消耗降低了约30%。后面我会把这些数字背后的实现细节展开。3. 实操实现从零搭建 context-mode3.1 数据结构设计与核心接口context-mode的代码结构并不复杂核心就两个角色上下文管理器ContextManager和策略路由ContextRouter。我用的编程语言是Python因为团队的技术栈以Python为主而且项目后续要接入几个不同的大模型SDKPython的兼容性最好。先定义消息结构。这里的消息不只是简单的用户说了什么、模型回了什么而是要带上时间戳、token数量、消息类型、是否触发过摘要等元信息。from dataclasses import dataclass from typing import Optional, Literal dataclass class ContextMessage: role: Literal[user, assistant, system, summary] content: str timestamp: float token_count: int msg_id: str source: Optional[str] None # 例如 retrieval, chat hard_constraints: list[str] None # 摘要时保留的硬约束这个数据结构里有几个字段是后来加上的。source字段用来标记消息来源调试时非常有用可以一眼看出当前上下文里有哪些内容来自知识库、哪些来自用户对话。hard_constraints字段服务于第2节提到的硬约束保留策略在生成摘要时如果检测到数字、单位、否定词等关键信息会单独提取出来存入这个字段。下一步是定义ContextManager的外部接口。无论内部模式怎么切换对外暴露的方法应该保持稳定这是为了让上层业务代码不需要关心上下文管理的细节。class ContextManager: def add_message(self, role: str, content: str) - None: ... def build_prompt(self, query: str, retrieval_results: list) - str: ... def get_usage_stats(self) - dict: ...add_message负责把新消息写入缓冲队列build_prompt是核心方法它接收用户当前的问题和检索模块返回的候选文档经过策略路由处理后拼装出最终发给模型的prompt。get_usage_stats用于监控返回当前窗口占用率、摘要层级、token消耗等指标方便对接告警系统。3.2 上下文窗口管理器实现窗口管理器做的事情可以抽象为三步记账、裁剪、压缩。记账是指实时统计当前缓冲区内所有消息的token总数裁剪是指当总数超过阈值时按策略丢弃或归档早期消息压缩是指对被裁剪的消息触发摘要生成。token计算这一块要特别注意。不同模型的tokenizer不一样同一个字符串在不同模型下的token数量差异可能达到20%。如果你用的是某个模型的SDK尽量直接调用该模型的tokenize接口而不是用通用的字符数估算。我和团队一开始图省事用字符数除以2估算token数结果频繁出现实际请求超限的情况后来换成了模型自带的tokenizer才算稳定。裁剪逻辑的伪代码如下def trim_to_fit(self, max_tokens: int): current self._total_tokens() if current max_tokens: return # 按时间从早到晚遍历消息逐条移出主窗口 overflow current - max_tokens evicted [] for msg in self._messages_oldest_first(): if overflow 0: break evicted.append(msg) overflow - msg.token_count self._handle_evicted(evicted) # 被移除的消息进入压缩流程这里遇到一个实际问题如果一条消息特别长比如用户粘了一大段日志进来逐条移除时可能直接把整条消息移除了导致主窗口里剩下的消息很少但是token还是超限。我们的解决办法是在移除前先检查该消息是否可以被截断如果它的token数超过剩余可释放空间的50%就先做一次内部截断——保留消息的开头部分和结尾部分中间用省略标记替代。因为用户粘日志的场景下往往开头是背景结尾是错误信息中间是无关紧要的输出。摘要压缩的具体实现我把它设计成一个独立的摘要器对象。触发条件是每次裁剪之后检查被移除消息的总token量是否超过设定阈值。摘要器接收被移除的消息列表加上当前已有的摘要作为上下文让模型生成新的摘要。为了防止摘要越滚越长每次生成时还带了一个约束输出必须控制在一定token范围内超出的话模型会被要求再次精简。class Summarizer: def __init__(self, llm): self._llm llm def summarize(self, evicted_messages, existing_summary) - str: prompt self._build_summary_prompt( evicted_messages, existing_summary ) result self._llm.generate(prompt, max_tokens800) return result在摘要生成的Prompt里我强制要求输出使用三段式结构并且专门强调保留所有硬约束原值。我在测试中反复调整过这段Prompt的措辞最终的版本把信息保留率从73%提到了91%可见Prompt细节对结果的影响非常大。3.3 路由策略与动态参数调优路由模块是这整个项目里最有意思的部分。它的输入是一组状态指标输出是一个决策当前这轮使用哪种上下文组合方式。状态指标包括当前窗口占用率、对话轮数、最近一次摘要的token量、检索结果的数量和评分分布。我先定义了几组经验阈值然后通过测试不断修正。最初的阈值如下指标低水位中水位高水位窗口占用率 60%60% - 85% 85%对话轮数 88 - 20 20摘要层级01 - 23但是实际运行了一段时间之后发现单纯靠阈值判断还是太死板。比如用户连续快速提问时虽然是第15轮但每轮内容都很短窗口占用率可能只有40%这时候根本没必要启用摘要。后来我把路由逻辑改成了评分制给每个候选策略分配一组权重根据当前状态的评分动态选择。从工程实现上看这就是一个小的决策函数并不需要引入复杂的机器学习模型。路由决策函数的核心逻辑如下def decide_mode(self, state: ContextState) - str: score_full 0.0 score_summary 0.0 score_retrieval 0.0 # 窗口占用率越高越不应该使用全量拼接 score_full - state.window_usage * 0.8 score_summary (state.window_usage - 0.5) * 0.6 score_retrieval state.retrieval_confidence * 0.5 # 对话轮数越多摘要的收益越大 score_summary min(state.turn_count / 30.0, 1.0) * 0.4 # 检索结果评分高时优先让检索内容进入上下文 if state.retrieval_confidence 0.7: score_retrieval 0.6 scores {full: score_full, summary: score_summary 0.2, retrieval: score_retrieval} return max(scores, keyscores.get)这段代码里可能有些数字看起来像魔法数字它们确实是实验调出来的。例如在summary的分数里加0.2的常数值是因为实测中摘要策略的稳定性最好给它一个轻微的默认偏好可以避免频繁切换策略策略切换本身会让模型表现变得不连贯。路由确定之后build_prompt就开始组装最终的输入。组装顺序也有讲究系统指令在最前然后是检索到的外部文档再接摘要和历史消息最后是当前问题。原因是模型对开头和结尾的内容注意力最强开头放系统指令可以约束整体行为结尾放当前问题可以让模型优先聚焦在回答上中间放参考资料和历史信息作为被调用的上下文。3.4 检索模块的接入细节检索模块本身不是我这次项目的重点但和context-mode的衔接有几个坑必须说。检索发生在路由决策之前因为要不要把检索结果放进来本身就是路由的依据之一。检索的query不能直接用用户的原始输入这一点我在第2节提过这里给出具体的实现方式。我的做法是先从当前状态中提取检索意图——用摘要器的一个简化版本把最近5轮对话压缩成一句查询描述。这个简化版摘要器只输出一句话token消耗很小。然后在这句话里提取名词性关键词结合最近消息中的实体人名、日期、单据编号等组成最终的检索query。实测这种query构造方式比直接用用户原话做检索召回率大概提升了25%。检索结果进入上下文之前会经过一个评分过滤。我用的是一个轻量级的相似度计算器计算每条结果与当前问题的余弦相似度低于0.5的直接丢弃。这个阈值看着不高但我在测试中发现设定太高容易把一些间接相关的背景文档过滤掉反而会影响回答的完整性。4. 常见问题与排查技巧实录4.1 上下文溢出问题最常遇到的硬故障上下文溢出是最直接、最容易复现的故障。现象就是某个对话进行到一半调用模型接口直接抛出超限错误。排查时我第一反应是看窗口管理器的裁剪逻辑但经常发现裁剪逻辑明明有触发为什么还是超限后来定位到原因裁剪逻辑统计的是会发给模型的token总预估值但模型SDK在做请求时还会额外加上一些内部格式化的token比如聊天模板的前缀后缀、函数定义的说明等。这部分我一开始没算进去导致实际请求量总是比预估高出几百到一千个token。解决办法是给窗口阈值留出15%的安全余量——假设模型上限是8K那么上下文管理器把硬阈值设为6.8K剩下的1.2K留给SDK的格式开销。另一个容易忽略的情况是检索模块召回大量文档时虽然单条文档经过过滤但总量可能仍然很大。我在调试中发现当检索结果给到5条以上时即使单条都相关度达标模型输出质量反而下降。后来在路由里加了一条硬规则检索结果最多保留3条按评分从高到低截取。这条规则极大缓解了溢出问题也提升了回答的准确度。4.2 摘要信息失真问题软故障中最难排查的上下文溢出是硬故障报错立刻就能发现。摘要失真则完全不同——系统运行完全正常模型回答也不会报错就是内容细节对不上。我记得最典型的一次用户在前20轮里提到项目截止日期是3月15日到了第60轮模型完全忘记了截止日期给出的排期安排定在了4月。查遍日志发现裁剪逻辑正常、摘要生成也正常问题出在摘要生成的Prompt本身——模型在概括时把3月15日简化成了三月中旬。这一次事故让我做了一个很关键的决定在摘要器里加入硬约束检测环节在调用模型生成摘要之前先用规则从原文中提取包含数字、日期、金额、明确的否定表述、专有名词的片段把它们单独存入hard_constraints字段。生成摘要时这些硬约束直接以以下内容必须原样保留的指令拼进Prompt。同时在摘要合并时检查hard_constraints中的每一项是否出现在新的摘要文本里如果缺失就自动补回原文。这个机制上线之后摘要失真类问题基本绝迹。4.3 检索命中率过低问题问题出在query构造上有段时间团队反馈知识问答的准确率很低经常答非所问。我第一反应是知识库的向量索引有问题于是去检查了文档切分和向量化流程发现都没问题。后来打开了路由日志发现一个诡异现象很多请求被路由到了full模式检索结果一个都没进上下文。继续深挖发现检索模块收到的query是经过摘要器压缩后的一句话描述但这个描述存在严重的指代消解问题。比如用户问那个方案能审批通过吗摘要器生成的是询问方案审批可能性完全丢失了那个方案具体指代的方案名称。或者说摘要器并没有真正理解对话它只是把文本压缩成了一句话实质性的指代关系一点没解决。最后我调整了策略不再使用摘要器生成检索query而是直接取最近5轮对话的原始文本用规则提取其中的名词短语和命名实体然后拼接成检索query。同时保留原文中的后3轮作为候选参考文本参与重排序。这样检索模块拿到的query更贴近用户的真实用例命中率立刻回升。这次故障给我的教训是不要为了省token把检索的输入也压缩了检索阶段的信息完整性比成本更重要。4.4 资源开销与性能调优context-mode本身是纯逻辑模块但摘要压缩需要调用大模型这带来了额外的延迟和费用。初期上线时一次摘要生成要花2到5秒用户体验明显变差。我做了两件事来优化。第一是异步化摘要压缩不放在用户请求的关键路径上而是在对话消息写入后通过一个后台任务队列触发。用户发完消息系统先快速返回正常回答摘要压缩在返回之后慢慢做下一次对话时再使用新摘要。这个改动把用户感知延迟降到了零。第二是降频不是每次裁剪都生成完整摘要而是累积到一定量才触发。我设置了两个阈值——累计被裁剪消息数超过10条并且累计token超过2000才触发摘要。这两个条件同时满足避免了一两句话就被触发摘要的浪费。这里有必要提醒一句后台异步摘要有一个副作用就是记忆不是实时更新的。用户连续快速提问时历史摘要可能还没生成完毕导致模型在短时间内忘记了刚被裁剪的内容。解决方案是让上下文管理器支持临时性恢复——如果检测到用户当前的问题与某个尚在摘要队列中的历史消息高度相关就跳过摘要直接把原始历史消息重新放入窗口。这个机制比较绕但实际效果非常好强烈建议做异步摘要的同行考虑加入。4.5 模式切换的连贯性问题context-mode本质是动态切换策略的这本身会带来一个让用户很困惑的问题模型在不同模式下回答风格不一致。在full模式下模型能看到大量原始对话回答充满细节切换到summary模式后它只能看到摘要回答突然变得笼统。排查时发现模型感觉不到策略发生了变化它只知道输入不同了。用户体感上的突然变笨本质是输入信息密度的跳变。解决的思路是让切换过程变得平滑在路由切换时不要从全量直接跳到摘要而是先启用一个过渡策略——保留最近10轮原始消息旧摘要再逐步压缩原始消息的保留轮数。我在路由决策里加入了一个梯度切换逻辑把切换过程拉长到3到5轮对话用户基本感知不到变化。配合这个逻辑我还给系统加了会话状态标记在拼装Prompt时明确告知模型当前处于何种模式。例如在中水位模式下Prompt里会加一句以下是本会话的历史摘要若用户问题涉及具体细节请优先参考原始记录如已提供。这个小改动让模型更清楚哪些信息是概括性的哪些是原文回答的细节度稳定了不少。5. 经验总结与后续扩展方向我自己做下来最大的体会是上下文管理不是一个实现一次就完事的功能而是需要持续根据真实数据调优的系统。项目上线后的前两周我几乎每周都在调整路由阈值和摘要器的Prompt。今天写的这些参数和经验可能过几个月换一个更强的大模型就要重新调一遍。但底层的设计思想——区分信息类型、分层压缩、动态路由、显式状态标记——是相对稳定的也是我认为这个项目最有价值的部分。如果后续要扩展我建议在三个方向上加力。其一是引入更细粒度的信息重要性评分比如通过用户反馈点赞、点踩来动态调整哪些话题的上下文保留优先级更高这会实现真正的个性化记忆。其二是把摘要压缩升级成结构化知识蒸馏不只是生成一段文本摘要而是把对话里的实体、关系、约束提取成结构化的记忆条目这样做交叉对话的长期记忆会更容易。其三是和端侧存储结合把用户的长期偏好持久化到本地数据库context-mode只负责短期工作记忆长期记忆交给更稳定的存储层。这个方向我已经在启动原型验证了等有结果再来分享。最后分享一个小技巧如果你也在做类似的项目从第一天起就给所有消息加上来源标记和可追溯ID。我最初没在意这个遇到问题要查是哪条历史消息导致模型产生错误回答时全靠手工翻日志痛苦不堪。加上来源标记之后每次模型输出异常可以直接回溯到对应的上下文片段排查效率提升是数量级的。这个看起来不起眼的细节比任何花哨的算法都顶用。