
做LLM应用做到第N个月我终于忍不住把“上下文”这三个字焊进了一个独立模块里。就是这个叫 context-mode 的东西——一个专门解决大模型上下文管理问题的运行时组件。先说清楚它是什么不是某个框架的插件不是RAG也不是记忆系统专用的SDK而是一套可插拔的上下文组织策略负责决定“每一轮对话模型到底该看到哪些内容、看不到哪些内容、以什么顺序看到”。它能直接解决的问题做过AI应用的人应该都懂token无限膨胀烧钱、长对话聊到后面模型智商掉线、不同用户或多个会话之间上下文互相污染。如果你正在做基于大语言模型的对话产品、Agent应用、或者需要连续多轮理解的工具这篇文章涉及的方案和踩坑记录大概率对你有用。说实话“context-mode”一开始只是我工程目录里的一个文件夹名字后来演进成了一套独立的上下文管理模式。我希望用这篇文章把这套方案的完整思考过程、代码实现和排障经验一次性梳理清楚。不用你用什么重量级框架普通Python后端就能上手。1. 整体设计与思路拆解1.1 从三个真实痛点反推需求我最早做的是一个偏向客服场景的对话机器人。上线一周后用户反馈“聊到第八轮开始胡说八道”我查了日志发现问题的根源不在于模型而在于我们喂给模型的内容太乱了。每一轮我们都把完整历史消息一股脑塞给模型早期对话还短效果不错一旦对话长度超过某个阈值token成本飙升模型反而被淹没在无关信息中。这就是第一个痛点历史消息无限累积导致的上下文爆炸。第二个痛点来自多会话部署同一个用户开多个窗口时A窗口的上下文跑到了B窗口里模型回话张冠李戴。第三个痛点最隐蔽即便上下文没有被污染早期对话中的关键信息反而被大量闲聊稀释模型能“看到”但已经“不记得”了。我当时把这些痛点摆在桌面上问了自己一个问题是模型的上下文窗口容量不够吗不完全是。本质问题是——我们给模型的信息组织方式太粗糙了。于是核心思路确定了与其让原始消息一股脑进上下文不如把上下文当成有生命周期的资源来管理要有模式、有策略、有淘汰机制。1.2 为什么选择多模式组合而不是一把梭很多开源项目会在上下文管理上做一个统一方案要么全量保存、要么只留最近N条、要么全部做摘要。但我在实践里发现业务场景差异太大单一策略没法覆盖所有需求。客服场景需要保留完整的“事实链”用户上次报修的设备编号、诉求细节、承诺时限这些在后续对话里必须反复引用。情感陪伴类场景恰恰相反细节不重要情绪状态才是重点。文档问答场景则要求精准定位答完问题之后旧内容可以迅速滚出上下文窗口。如果我硬把某一种策略套到所有场景要么让陪伴型机器人记住一堆无关日志要么让客服机器人在第五轮就丢掉关键信息。最终我决定把上下文管理拆成几种独立模式让使用者按场景选择并在需要时叠加使用。context-mode 因此形成了三个核心模式会话模式session mode、全局模式global mode和摘要压缩模式summary mode后面会逐个拆解。1.3 整体架构一条消息从进入到淘汰的完整路径在设计架构时我画了一条消息在 context-mode 里的完整流转路径。刚进来的时候消息同时写入原始消息存储和索引存储。原始存储保留全量数据索引存储负责快速召回。在这之后进入调度阶段系统根据当前启用的模式决定哪些内容进入本轮真实上下文。每个模式内部维护一个独立的消息列表列表有容量上限超过上限的旧消息不是简单删除而是根据模式策略选择丢弃或压缩。最后生成上下文时各模式按优先级拼接生成一个带排序的上下文对象交给模型。这个架构里最核心的原则是原始消息永远不丢模型看到的永远是快照。这样即使某轮压缩策略出错也能从原始存储里恢复。2. 核心模式解析与实操要点2.1 会话模式最常用的滑动窗口策略会话模式负责处理单一会话内部的消息管理核心机制是“滑动窗口分区保留”。我给每个会话配置了窗口大小和分区规则。窗口大小决定单次最多携带多少条消息分区规则决定哪些消息不能被挤出窗口。这里的难点在于如何定义“不能挤出的消息”。我的做法是给消息加了一个重要性标记。用户主动提供的结构化信息报修单号、地址、时间点自动标记为高重要度模型生成的寒暄、过渡语自动标记为低重要度。淘汰时优先丢弃低重要度消息高重要度消息则被保留即使它们来自最早的轮次。好消息是这个机制在客服场景里直接解决了“早期关键信息被遗忘”的问题。坏消息是消息标记不能全靠规则否则会产生大量漏网之鱼。后来我加了一个轻量级实体识别模型来辅助打标准确率可接受推理成本也不算高。2.2 全局模式跨会话携带用户画像和业务偏好全局模式解决的是多会话之间共享信息的问题。它不直接存储对话消息而是从所有历史会话中提取用户级和业务级的持久化信息比如用户偏好、身份特征、购物习惯。实现上全局模式维护了一个“键-值-时间戳”结构。每次新消息进来时经过一个抽取器把关键特征变成结构化条目写进全局存储。每次生成上下文时全局存储中的高置信度条目会被注入到系统提示词后面确保模型在回答前就了解用户是谁。我在这里踩过一个坑全局模式一旦控制不住会把大量过时信息永远塞给模型。用户上个月喜欢露营这个月可能对露营毫无兴趣。所以我对全局条目加上了时间衰减权重超过一定时间未命中的条目直接降权或删除。这样既保留了长期记忆的连续性又不会变成呆板的永久印象。2.3 摘要压缩模式告别Token失控的终极手段摘要压缩模式是成本控制的最后一道防线。当某个会话的消息数量超过窗口上限或单次上下文的估算token超过预算阈值系统会自动触发压缩流程。压缩不是简单地调接口总结而是分层处理。先抽取对话中的结构化事实实体、时间、目标再对剩余内容生成一段紧凑摘要。合并后的摘要带着“时间戳来源消息ID”存起来方便溯源。我实测下来摘要压缩能把相同信息量的上下文体积压掉70%到80%但代价是信息精度下降。有些细节在压缩中会丢失尤其那些一次性的、口语化的表达。所以我把摘要压缩默认设置为“只在超出预算时触发”而不是每一轮都压缩。预算充足的时候让模型看原始消息永远是最稳妥的。2.4 三种模式怎么选一份直接可抄的对照表模式核心数据结构适用场景优点缺点会话模式滑动窗口列表客服、多轮任务的单会话管理实现简单实时性好长对话仍会超长需要搭配压缩全局模式键值存储时间衰减用户画像、跨会话偏好信息持久个性化强提取不准会形成错误认知摘要压缩模式分层摘要列表长文档对话、成本敏感的规模化应用成本可控上下文精简细节丢失需要溯源机制实际落地时别试图只选一种。以客服系统为例主流程用会话模式管理多轮叠加全局模式记住用户历史诉求当轮次长到超出预算再触发摘要压缩。三种模式并行工作各管一段效果最稳。3. 实操过程与核心环节实现3.1 数据结构用什么容器来管理消息设计数据结构时我优先考虑了读写性能和顺序保持。第一版用普通数组存消息每轮对话都做全量拷贝会话一长就卡顿。后来换成双向链表结构每个节点存储单条消息的元信息和内容引用。dataclass class MessageNode: msg_id: str session_id: str role: str content: str importance: int 0 # 0-5越关键数字越大 timestamp: int 0 prev: MessageNode | None None next: MessageNode | None None meta: dict field(default_factorydict)节点只持有消息数据维护顺序用指针完成。插入新消息时直接在尾部追加淘汰旧消息时从头部或低重要度节点开始摘除。这样避免了反复数组拷贝长会话下性能明显提升。每个会话维护一个SessionWindow对象持有链表的头尾指针、当前长度和窗口上限。更新操作都收敛到几个方法里面维护起来很干净。class SessionWindow: def __init__(self, session_id: str, max_size: int 20): self.session_id session_id self.max_size max_size self.head None self.tail None self.length 0 def append(self, node: MessageNode): if self.tail is None: self.head node self.tail node else: self.tail.next node node.prev self.tail self.tail node self.length 1 self._evict_if_needed() def _evict_if_needed(self): while self.length self.max_size: current self.head while current and current.importance 3: current current.next if current is None: current self.head self._remove(current)3.2 Token预算计算别让上下文超过模型窗口的一半在设计上下文注入策略时我给自己定了一条硬规矩单次请求的上下文token总量不得超过模型窗口的50%。原因很简单留出足够空间给模型生成回复否则对话长度稍微一长就会截断或报错。预算分配是我手工调出来的以常见的8K上下文窗口模型为例系统提示词占800到1000 token全局模式注入占800到1500 token会话模式占3000到4000 token摘要模式占500到1500 token兜底预留1000 token给模型回复。表格看起来简单但背后有一个核心分配逻辑历史越久远的内容单位token的信息价值越低权重就越低。每次请求进入时我按这个顺序做减法先扣系统提示词再扣全局模式再填会话模式最后用摘要模式补齐不足部分。如果仍然溢出宁可截断最老的摘要也不截断会话窗口里的最近消息因为最近几轮模型必须完全理解否则回答必然断裂。3.3 上下文注入流程拼接顺序决定模型注意力模型对上下文不同位置的信息敏感度不同开头和结尾是注意力强区中间是弱区。所以我设计拼接顺序时把全局模式的关键画像数据放在系统提示词之后把最近几轮用户消息放在最末尾中间穿插摘要和历史关键事实。[系统提示词] [全局模式用户画像标签] - 强区让模型识别用户身份 [摘要模式历史事实摘要] - 弱区提供背景信息 [会话模式最近历史消息] - 强区保证上下文连贯 [当前用户最新输入]实测对比过不同的拼接顺序把最近的用户消息放在中间回复质量下降明显逻辑连贯性也变差。调整顺序之后同样的对话数据人工评分直接上一个台阶。所以这个顺序看似简单能不要动就不要动。3.4 缓存淘汰策略LRU还是时间衰减上下文管理里最容易忽略的是缓存淘汰。我最早对所有会话的消息一视同仁结果发现不活跃会话占用了大量存储活跃会话反而频繁触发压缩。后来改为双层淘汰策略LRU负责处理访问热度时间衰减负责处理信息时效。LRU只在会话被访问时更新引用时间衰减则按周给所有消息权重乘一个衰减因子低于阈值的消息进入待回收队列。这样既不冤枉低频但重要的长尾信息又能避免记忆陈旧内容。后端存储上我直接用Redis做LRU缓存缓存键是context:{session_id}值为序列化后的窗口结构。过期时间设置成3天每次访问续期。原始全量消息则存到MongoDB方便任何时候回放重建。4. 工具选型与参数调优4.1 Redis还是内存存储按并发规模和持久化需求选我一开始图省事直接用Python进程里的全局字典做存储。单机单进程跑测试没问题一旦上了多进程部署就乱了每个进程各持一份上下文会话在负载均衡下跳来跳去模型根本认不出同一个用户。换成Redis之后所有worker共享同一份上下文数据才真正解决了多实例一致性问题。加上Redis的过期机制和持久化RDB备份即使服务重启也不会把用户的对话记忆全弄丢。但Redis也不是万能的。访问频率极高的小规模服务Redis反而成了瓶颈。后面我改成了两级缓存热会话的上下文在进程内直接维护一个LRU缓存只有冷会话和跨实例共享数据才走Redis。进程内缓存未命中时再从Redis拉取并重建热缓存。4.2 关键参数配置表一次调稳的参考值参数名建议初始值调优说明max_context_tokens4096根据模型窗口按50%预留计算session_window_size20条短对话可减到10条复杂任务可增到30条summary_trigger_tokens3072上下文估算值超过此值触发压缩global_profile_ttl7天按业务周期调活动季需要缩短importance_threshold3低于该值的消息在淘汰时优先丢弃cache_ttl3天值过小导致频繁重建上下文过大导致脏数据残留参数初始值只解决“能跑起来”的问题真正的调优要结合线上数据。比如summary_trigger_tokens设太低会导致频繁压缩摘要越积越多最后还是要处理摘要列表本身设太高则失去压缩意义。我最后是按业务平均对话长度做了统计分布取P80值作为触发阈值勉强算是动态调优的平替方案。4.3 摘要模式接口模型调用要控制频率摘要压缩需要调用生成模型成本高且延迟大不能每条消息都触发。我加了一个防抖设计距离上次摘要生成未超过30秒或者新增消息不超过5条都直接跳过压缩流程。def maybe_trigger_summary(session_id: str, new_count: int) - bool: last get_last_summary_time(session_id) if time.time() - last 30: return False if new_count 5: return False summary_tokens estimate_tokens(session_id) return summary_tokens 3072这样设计之后高峰期摘要调用量下降了很多整体响应延迟稳定住了。有一点需要提醒摘要压缩也有失败率。模型偶发超时或返回空内容时必须保留上一次摘要兜底而不是把原文丢弃。我在存储层给摘要加了快照机制每次生成成功后覆盖更新失败则沿用旧摘要。5. 常见问题与排查技巧实录5.1 会话串话问题出在缓存Key设计上有一次线上反馈用户B回答出了用户A的信息。查了半天发现上下文缓存的key只用了session_id但前端把不同窗口的session_id都生成了同一个值。接入层拿到用户请求后没有做窗口维度隔离。修复方案是在缓存key上加了多级命名空间context:{user_id}:{session_id}:{session_type}。这样同一个用户的不同窗口、同一窗口的不同场景都不会互相污染。这个教训告诉我上下文隔离这件事从接入层就要开始管不能等到了context-mode再做判断。5.2 上下文超长截断为什么总是截到关键位置日志报错显示单次请求提示词超长模型直接拒绝调用。刚开始我只是简单地把列表尾部砍掉结果把最后一条用户消息给删了模型完全不知道用户刚才问了什么。后来我意识到截断必须按优先级反向操作先删摘要模式中的最老条目再删会话模式中的低重要度消息最后才允许缩减会话模式的最近窗口。因为这些内容的保留价值是从低到高的截断也应该从最不重要的开始。我还在截断逻辑里加了一个保护哨兵当前用户的最后一条输入永远不可截断。5.3 缓存击穿冷启动阶段上下文重建太慢服务重启后大量会话同时从Redis重建上下文数据库压力陡增接口耗时飙到几秒。排查后发现原因是冷启动时进程内缓存为空所有请求都去查原始存储。解决思路不复杂加了一个重建队列窗口重建任务进队列串行处理同一会话的重建请求合并成一个。同时降低了初次请求对历史消息的要求冷启动时先只加载最近5条消息给模型做首轮回复后台再异步补齐历史上下文下一轮自动生效。用户无感知接口压力也降下来了。5.4 问题排查速查表症状可能原因检查命令 / 排查方式模型答非所问拼接顺序不正确最近消息被放到弱区打印最终的上下文字符串检查末尾内容不同会话信息互相串缓存key命名空间设计缺失检查session_id生成逻辑确认user级隔离请求报超长错误预算计算未包含模型回复预留核对max_context_tokens是否超过窗口50%压缩摘要丢失关键信息摘要触发太频繁或摘要模型温度过高降低压缩频率温度调到0.3以下冷启动响应慢原始存储重放到会话窗口阻塞请求启用异步重建队列首轮只加载最近5条内存持续增长Redis未设置TTL或LRU淘汰失效检查ttl配置确认过期策略为volatile-lru5.5 一个被绝大多数人忽略的小技巧每一轮请求响应之后我习惯把context-mode的实际“命中率”打印到日志里。所谓命中率就是最终进入模型上下文的内容条数除以候选内容总条数。如果命中率长期低于20%说明候选内容筛选策略太弱大部分历史消息都在白占存储如果长期高于90%说明窗口设计太保守关键信息可能被挤掉了。这个指标不需要任何额外基础设施几行日志就能统计。观察两个星期上下文策略的调整方向就非常清晰了。6. 一些基于实战的体会与扩展建议这个项目做了这么久我最深的感受是上下文管理没有银弹多模式组合才是现实世界的最优解。session mode负责短程连贯global mode负责长程画像summary mode负责成本控制三者协作才覆盖了绝大多数业务诉求。如果你也想在自己的项目里落地类似方案我建议从小切口开始把现有系统的全量消息注入先改成“最近N条结构化摘要”的简单组合观察效果变化。别一上来就引入复杂的实体抽取、多层缓存和摘要调度那样会把问题本身搞复杂。如果后续要做扩展我建议往两个方向发力一是接入可观测链路把每一轮上下文的构建耗时、token消耗、模式命中率全部上报形成时间序列数据二是把语义检索合并进来当会话窗口内找不到相关信息时自动从历史全量数据里按向量相似度召回。到这一步context-mode就不再是简单的上下文容器而是一套真正具备理解能力的记忆中枢了。