做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_factory=list) # system prompt + 约束 work_zone: List[Dict] = field(default_factory=list) # 最近原始消息 archive_zone: Dict = field(default_factory=dict) # 摘要 + 实体 token_budget: Dict = field(default_factory=dict) # 四区配额每个模式类只需要实现三个方法: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问题时,不要一开始就研究复杂的边界情况,先把用户走到这个状态之前的每一步完整记录下来,特别是模式切换的触发点。很多时候问题根本不在模式本身的逻辑,而是上游的事件检测把不该触发的信号传了进来。把信号源理清楚,上下文模式才会真正为你工作。