
做LLM应用开发的人应该都遇到过这种场景对话轮数一多模型就开始“犯糊涂”忘了用户最开始的要求甚至把前面某轮的错误信息当成事实。你以为是模型不够聪明其实多半是上下文管理出了问题。我最近整理的这个context-mode项目就是专门来解决这个问题的。它的核心思路是给LLM的上下文引入“模式”概念按不同任务场景显式管理prompt的组成、窗口大小、压缩策略和切换时机而不是让所有对话都挤在一个无差别的长上下文里。这篇文章我把整个设计思路、核心模块、以及一个可以拿过去直接改的轻量实现都梳理出来做AI应用、智能体、RAG相关项目的朋友应该都能用得上。1. 项目整体设计思路context-mode到底在解决什么问题1.1 从“工作记忆”类比理解上下文模式先聊一个比较直觉的问题为什么对话越长模型越容易说不靠谱的话用人的工作记忆来类比就很好懂。一个人同时只能记住大概7个信息块如果非要让他一边记住前面聊天记录里的每一个细节一边处理你刚抛过来的新问题他很快就会混乱。大语言模型也一样它的注意力窗口虽然能承载很多token但窗口越大、内容越杂模型在生成时就越难聚焦在真正重要的信息上。更麻烦的是如果旧对话里有错误前提、过时指令模型还会把这些噪声带进当前决策这就是所谓的“上下文污染”。context-mode这个项目解决的就是这个问题与其让模型自己在一个混乱的长上下文里找关键信息不如我们主动把上下文分成几种明确的模式每种模式有自己固定的结构、预算和更新策略。比如短对话模式就干净利落地只保留最近几轮长文档问答模式就把全局摘要和当前片段分开管理工具调用模式则额外保留完整的工具定义和调用历史。1.2 三个典型使用场景我在实际项目里主要遇到三类场景正好对应了三种最基础的context mode。第一类是单轮或极短对话的“工具调用型”场景比如让模型从一段输入里提取结构化信息、判断用户意图然后触发某个API。这类任务的上下文只需要system prompt加当前输入任何历史信息都是噪声。第二类是多轮对话型场景比如客服机器人和用户连续聊十几个来回。此时需要保留足够的对话历史让模型理解指代关系但又不能无限堆积所以得有滑动窗口和摘要机制。第三类是长文档分析型场景比如让模型读一份几十页的PDF并回答细节问题。这类场景的上下文不再按“轮次”组织而是按“内容块”组织需要把文档切片、检索、再拼装成当前上下文。三种场景的token预算结构完全不同如果都用同一个上下文管理逻辑必然有一方被浪费或拖累。context-mode的思路就是为每一种场景定义一套独立的上下文构建策略。1.3 方案选型为什么需要mode而不是简单截断有人可能会说直接用滑动窗口截断历史不就行了搞这么多模式是不是过度设计我之前也这么试过结果踩了不少坑。最简单的“只保留最近N轮”方案确实能控制token开销但会丢掉非常关键的信息。举一个实际例子用户在第一轮里说“我只想看近三个月的数据”聊到十几轮时如果这个初始约束被截断了模型可能就会给出全量数据的统计结果。这种问题的根源在于不同信息的“有效期”不同——有些约束需要全程保留有些对话内容只需要短暂记忆而滑动窗口对它们一视同仁。另一种常见做法是把所有历史都塞进去寄希望于模型自己分辨主次。这个方案在上下文长度充足的早期还行一旦会话变长模型不仅注意力被稀释推理延迟也会明显上升token费用更是肉眼可见地涨。所以context-mode本质上是一种折中和秩序化的方案把上下文划分成“固定区”“工作区”“归档区”固定区放必须全程保留的指令和约束工作区放当前正在处理的对话内容归档区放已经压缩过的历史信息。不同mode下这三个区域的比例和更新规则不同这样才能既保住关键信息又控制成本和延迟。2. 核心实现要点context-mode的四个关键模块2.1 上下文窗口管理如何规划token配额一个模式要落地第一步是算清token预算。我一般会把模型的上下文上限分成四块固定区system prompt 工具定义 不可变约束、工作区最近N轮对话或当前检索片段、归档区历史摘要 关键实体、预留区给模型生成输出的空间。以常见的8K上下文窗口为例我会建议这样分固定区1500 token工作区4000 token归档区1500 token预留1000 token。如果模型换成32K窗口工作区和归档区的配额可以放大但固定区一般不需要动因为system prompt的复杂度跟模型能力有关跟窗口大小关系不大。这里有一个很容易被忽略的点预留区一定要留够。如果上下文塞得太满模型被迫在极小的生成空间里输出结果回答质量会明显下降甚至出现截断。我通常至少保留模型最大输出token数的一倍作为安全余量。2.2 上下文压缩与遗忘机制工作区的内容迟早要往归档区迁移这就是压缩机制要做的事情。我最常用的压缩方式是摘要式压缩把一段对话历史交给模型让它生成一段包含“用户核心诉求、关键事实、未完成事项”的摘要。比如用户连续问了三轮某个功能的配置细节最后确定了一个方案那归档摘要里就应该明确写出“用户选择了方案B原因是更便宜”这样后续对话即使看不到原始讨论也能正确接续。除了摘要关键实体抽取也很重要。比如用户提到“我们公司在上海有3个仓库北京有2个”这些具体事实应当单独提取出来存成一个结构化字段因为它们比普通对话内容更可能被后续问题引用也更适合原样保留而不是被摘要模糊掉。遗忘机制则解决另一类问题有些信息不仅不需要保留还要主动清除。比如用户前面聊了一句“我是从朋友推荐来的”这个信息在后面完全用不上保留它只会增加噪声。我一般会设定一个轮数阈值默认超过10轮未引用的非关键实体自动丢弃必要时再针对特定业务配置“必须保留”的白名单字段。2.3 模式状态机与切换逻辑有了不同模式和各自的上下文结构之后下一个核心问题是什么时候用哪个模式我实现的方式是做一个轻量级状态机。每一轮用户输入进来后先做一次模式判定判定依据包括当前会话已持续的轮数、最近几轮的意图分类、以及是否有新的文档上传或工具调用事件。比如用户突然上传了一个PDF状态机就会从普通多轮对话模式切换到文档问答模式如果用户连续触发了5次工具调用且每个调用之间没有太多闲聊就应当切换到短上下文工具调用模式以降低token开销。这里有一个非常关键的细节模式切换不只是切换“当前模式”这个标签还要做上下文的迁移和重建。从多轮对话模式切到文档问答模式时原来工作区里的普通对话历史应当被压缩进归档区然后把新上传文档的检索结果填充进工作区。如果不做这个迁移两个模式的内容会混在一起反而比不切模式更乱。2.4 上下文持久化与多会话管理最后一个模块是存储。每次会话的上下文状态不能只存在于内存里因为应用进程随时可能重启用户可能隔几天回来继续聊如果上下文丢了前面的模式设计全部白搭。我通常以session_id为维度把每个会话的固定区内容、工作区原始消息、归档区摘要和实体库统一序列化后存入数据库或Redis。存储结构大概是会话ID、模式类型、固定区版本号、归档区摘要内容、关键实体JSON、工作区最近的原始消息列表、最后更新时间。有一点要特别提醒固定区的system prompt和业务约束是会升级的比如你调整了产品的功能逻辑改了prompt模板这时老会话的固定区是继续用旧版本还是强制更新到新版本我实践下来的经验是对于未完结的会话保留旧版本更好否则模型会突然收到一套和之前不一致的指令导致行为漂移产线升级时尽量让新会话使用新模板老会话自然结束即可。3. 实操手写一个轻量级context-mode管理器3.1 项目结构与核心类设计理论和模块聊完了接下来上一份可以直接参考的轻量实现。我用的语言是Python核心抽象是BaseMode类和ModeManager类。from dataclasses import dataclass, field from enum import Enum from typing import List, Dict, Optional, Callable class ModeType(str, Enum): CHAT chat # 多轮对话 TOOL tool # 工具调用/单轮短任务 DOC document # 长文档问答 dataclass class ContextState: session_id: str mode: ModeType ModeType.CHAT fixed_zone: List[Dict] field(default_factorylist) # system prompt 约束 work_zone: List[Dict] field(default_factorylist) # 最近原始消息 archive_zone: Dict field(default_factorydict) # 摘要 实体 token_budget: Dict field(default_factorydict) # 四区配额每个模式类只需要实现三个方法build_context把三个区域拼成最终发给模型的prompt、update新消息进来后如何更新工作区、archive工作区溢出时如何压缩进归档区。class BaseMode: mode_type: ModeType None def __init__(self, manager: ModeManager): self.manager manager def build_context(self, state: ContextState, current_input: str) - List[Dict]: raise NotImplementedError def update(self, state: ContextState, message: Dict): raise NotImplementedError def archive(self, state: ContextState): raise NotImplementedErrorModeManager负责模式切换和上下文迁移。每次收到用户消息时先让当前模式执行update再判断是否需要切换模式如果需要则调用一个migrate方法把旧模式的工作区内容压缩并交给新模式。3.2 关键配置与参数配置文件我一般用YAML核心是三个参数每个区的token限额、触发归档的轮数阈值、以及摘要生成时使用的模型。context_mode: default_mode: chat token_budgets: chat: fixed: 1500 work: 4000 archive: 1500 reserve: 1000 tool: fixed: 1200 work: 1000 archive: 300 reserve: 800 document: fixed: 1500 work: 5000 archive: 2000 reserve: 1000 archive: chat: trigger_rounds: 8 # 工作区超过8轮就开始压缩 compress_batch: 6 # 每次压缩最近6轮 summary_model: gpt-4o-mini document: trigger_chunks: 6 # 检索片段超过6个就清理 compress_batch: 4 switch: tool_call_streak: 3 # 连续3轮工具调用则切到tool模式 doc_upload: true # 检测到文档上传则切到document模式 idle_to_chat_rounds: 2 # 工具/文档模式下连续2轮无特殊事件则回到chat这里我特别说一下trigger_rounds这个参数。它决定了工作区最多容纳多少轮原始对话超过之后就要把最旧的一批压成摘要。阈值设太小摘要生成频繁token费用和延迟都会涨设太大工作区的长上下文又开始稀释注意力。8轮是我在多数业务场景里测下来比较平衡的值但如果你用的是上下文窗口很大的模型比如128K档位工作区上限可以放宽到15轮以上充分利用模型的记忆能力。3.3 运行效果与测试用例实现完后我用一组模拟对话做了测试。首先是一个纯多轮对话场景用户先是咨询价格中间聊了几个无关问题在第6轮时问“那这个方案的最终报价是多少”。传统滑动窗口方案在第6轮时已经丢掉了第一轮的价格信息导致模型反问“您指的是哪个方案”。而在context-mode下第一轮的关键事实“A方案报价5000元”已经在第3轮被抽取进归档实体库模型直接给出了正确报价。第二个测试场景是工具调用连续触发。我模拟了用户连续查询5个城市的天气每次查询都是同样的意图、不同的参数。如果每次都在一个越来越长的上下文里做意图解析不仅延迟高偶尔还会把上一个城市名误当成当前查询目标。切成tool模式后工作区只保留最新的查询语句配合固定区里的工具定义识别准确率明显提升单轮生成时间也降了30%以上。第三个场景是文档切换。用户先聊了几轮普通问题然后上传一份技术文档开始追问细节。状态机检测到文档事件后切换模式把前面的聊天摘要归档文档检索结果取代了原来工作区里的日常对话内容。测试的结果是模型能够准确回答文档中的具体参数并且没有出现把前面闲聊背景混进回答里的情况。4. 常见问题与排查实录4.1 上下文污染旧指令干扰新对话症状某个会话聊到中后期模型开始遵守一些已经不适用、甚至自相矛盾的早期指令比如用户已经明确说“换个方案吧”模型还是按旧方案继续回答。排查思路先dump出当前实际发送给模型的prompt检查固定区里是否存在过期的用户约束。最常见的原因是固定区里存了用户第一轮提出的临时性要求但后续对话已经改变了前提固定区没有同步更新。解决方法把固定区分成两层——系统级指令层和会话级约束层。系统级指令保持稳定会话级约束每次update时都做一次“是否仍然有效”的校验。如果检测到用户表达了变更意愿例如“不用这个了”“换个思路”就把旧约束从固定区移除必要时生成一条新的替代约束。4.2 摘要压缩后的信息失真症状压缩后模型忘了用户的具体偏好细节比如“用户明确说过不要推荐某品牌的设备”。排查思路摘要生成的prompt模板可能没有要求保留“否定性约束”和“具体数值”。我在早期版本里就发现模型生成的摘要在概括事实时表现不错但经常丢掉“不要”“禁止”“最小”“最大”这类限定性表达。解决方法在摘要模板里增加两个强制字段user_constraints用户明确提出的限制条件原样保留和unresolved_items尚未完成的事项。这两个字段要求模型尽可能原文引用而不是转述能显著降低信息失真率。常见问题直接原因排查手段上下文污染固定区指令未随会话更新dump最终prompt检查固定区内容摘要丢失关键字段摘要模板未强制保留约束和数值检查归档区摘要看是否包含否定性表达模式切换频繁判定阈值设置过低或事件检测有误查看状态机日志统计切换频次token超限配额设置错误或单轮输入过大记录每轮实际token消耗对比预算延迟升高归档摘要生成过于频繁调高trigger_rounds合并压缩批次4.3 模式切换“抖動”症状会话在chat模式和tool模式之间反复横跳导致上下文频繁重建模型回答风格不一致用户体验很割裂。排查思路我遇到过的最典型原因是工具调用在业务上并不是连续的但判定逻辑只看“连续3轮工具调用”这一条结果用户偶尔连续点了两次工具按钮就触发了切换然后下一轮回到正常对话又切回来。解决方法给状态机加“稳定期”逻辑切换后至少要停留2轮才能再次切换同时增加一个“业务意图连续性”判断——只有当工具调用意图在最近几轮中占比超过80%时才考虑长期停留在tool模式。阈值设好之后再配合日志观察基本能消除抖动问题。4.4 调试技巧给上下文加“检视窗口”对于上下文管理类的项目最痛苦的是排查时看不到模型到底看到了什么。我建议在开发阶段给ModeManager加一个debug模式每一轮请求结束后把最终发送给模型的完整prompt按区域打印到日志里并标注各区域的token占用和来源。看起来多此一举但实际排查时比任何花哨的可视化工具都管用。你可以很直观地看到固定区是不是带了过期内容、工作区是不是有重复信息、归档摘要是不是丢字段。我甚至会在测试环境里对每轮prompt做一份snapshot存档这样即使线上出问题也能回溯到具体某轮模型看到的原始输入定位效率高非常多。4.5 性能开销控制最后提醒一个容易被忽视的问题压缩摘要本身也是在调模型会带来额外的延迟和费用。如果你用的是自建模型高并发场景下摘要生成可能会堵住核心推理链路。我的建议是所有压缩、实体抽取任务都放到异步队列里执行不要在用户的请求线程里同步等待。另外如果对话轮数增加很快可以考虑让归档操作批量执行——比如每凑满10轮才压缩一次而不是每轮都压缩。把压缩频率降下来之后整体系统的p95延迟有明显改善。5. context-mode的未来扩展方向我个人的体会是context-mode这套思路最大的价值不在于某一个具体实现而在于它把“上下文”从被动的历史堆积变成了主动的工程变量。从应用层的角度你能精确控制模型看到什么、看不到什么从成本角度你能按场景对token消耗做精细化预算从质量角度你能减少因为上下文混乱导致的低级错误。后续如果想继续扩展我觉得有两个方向值得尝试。一个是把模式判定从规则换成模型用一个轻量分类器去预测下一轮最合适的模式这样可以处理更多边界情况。另一个是把归档区做成可检索的长期记忆库而不是简单的摘要文本这样模型在回答深层问题时可以主动“回忆”更早的信息。前者是提升模式切换的智能度后者是把上下文模式向记忆系统延伸。最后再分享一个小技巧当你调试一个具体的context-mode问题时不要一开始就研究复杂的边界情况先把用户走到这个状态之前的每一步完整记录下来特别是模式切换的触发点。很多时候问题根本不在模式本身的逻辑而是上游的事件检测把不该触发的信号传了进来。把信号源理清楚上下文模式才会真正为你工作。