我最早意识到上下文管理是个不得不解决的问题,是在帮朋友排查一个AI聊天小工具的时候:多轮对话跑到第五六轮,模型开始一本正经地编事实。查了大半天,模型本身没毛病,问题出在送进模型的上下文越来越脏、越来越乱。也就是从这个项目开始,我逐步整理了一套叫 context-mode 的上下文模式管理方案。它不是一个大框架,而是一套针对 AI 应用上下文窗口、作用域隔离和记忆切换的状态管理规范。这篇文章把我从设计到落地过程中踩过的坑、试过的路、最后留下来的做法完整讲一遍,对正在做智能助手、Agent、文档问答或者任何带对话状态功能的人来说,应该能省不少摸索时间。
1. 先想清楚:context-mode 到底在解决什么问题
1.1 上下文的三个来源和一种错觉
很多第一次接触 AI 应用的人,会觉得上下文就是“把聊天记录原样传给模型”。实际上,真正送进模型的上下文至少有三个来源:第一是系统提示词里的固定角色约束和规则,第二是对话历史里的用户消息和助手回复,第三是外部检索回来的文档片段、代码片段或数据库记录。这三股信息被拼成一个长文本,再交给模型去生成输出。
context-mode 解决的第一个问题,就是这三股信息之间的边界。系统提示词如果和检索回来的文档内容打架,模型会跟着较新的文字走,哪怕它和最初的规则冲突。对话历史如果塞进了一段来自其他任务的旧消息,当前任务很容易被带偏。很多所谓“模型变笨了”“开始胡说了”的案例,排查到最后都是上下文边界没管好。
我见过不少团队,一觉得输出质量下降,就立刻换更大的模型、调温度参数,结果问题还在。原因很简单:你给模型的是一锅粥,模型怎么可能理得清哪句话是规则、哪句话是历史、哪句话是引用资料?先做好上下文作用域隔离,再谈模型能力提升,才是一条对的路。
1.2 没有上下文模式时,我见过的五种翻车现场
先说几个真实发生过的场景,你大概率也踩过类似的坑。
第一种,是最常见的“历史轰炸”。某个会话聊了几十轮,每次请求都把全部历史塞进去,Token 消耗越来越大,响应越来越慢,最后模型开始复读后面的内容,因为前面的信息熵已经低到没法用了。这不是模型的问题,是历史劣化。
第二种,是最隐蔽的“任务串场”。用户在同一个会话里先问了报销流程,又问了员工入职,接着又问报销的发票抬头怎么填。如果上下文不做任务作用域切换,模型很可能把入职相关的规则误用到报销问题上,而且错得理直气壮。
第三种,是检索造成的“过时信息污染”。检索系统返回了一份三个月前的旧文档,里面的流程早就改了,但它的内容和当前代码不一致。模型没有时间概念,它会认真拿旧文档回答你的新问题。上下文模式如果不在组装前对检索结果做筛选标记,这种错误还会反复出现。
第四种,是系统提示词太长导致的“输出挤占”。有团队把各种规则、历史案例、屏蔽词全塞进系统提示,一条提示词写了两千多字,可模型单次输出上限就那么多,提示词吃掉了大半,模型只能给出干巴巴的短回答,用户还以为是模型表达能力变差了。
第五种,是裁减历史时的“信息断层”。为了省 Token,有人简单粗暴地把前面的对话全部丢掉,结果用户突然问“你刚才说的那个方案价格是多少”,模型完全想不起来。用户觉得模型是人工智障,其实是上下文被砍成了没有索引的碎片。
这些坑单独看都能找到简单办法,但它们往往同时出现。context-mode 做的事情,就是把上下文看成和代码一样可以划分作用域、可以切换模式、可以精确回收的资源来管理。
1.3 选型思路:它不是一个框架,而是一套状态管理规范
我最早也给这个项目设计过完整的数据结构,想过用消息队列、想过用向量库存储全部历史,后来发现大部分场景根本不需要那么重。context-mode 的核心思路最终落在三个关键词上:作用域、优先级、生命周期。
所谓作用域,就是把上下文分成全局、会话、局部三层。全局是永远不变的角色设定和基础规则,会话是当前对话池内的记忆,局部是某一次具体任务或某一步操作相关的内容。这三层各有各的窗口大小和保留时间,不像以前那样把什么东西都丢进同一个桶里。
所谓优先级,是当三层信息出现冲突时,本地任务明确给出的指令优先于会话历史,会话历史优先于全局默认规则,而主动的检索结果需要经过置信度判断才能插入。这套优先级规则让模型在信息矛盾时有一个明确的处理路径,不至于自己发挥。
所谓生命周期,就是每一段上下文都要有过期时间、活跃状态和回收条件。一段历史完成了它的任务,就从上下文中退场;一个会话超过一定轮次,就触发压缩摘要;一段检索内容看完就标记为已消费。这样上下文窗口始终有新鲜的内容,模型不会一直背着沉重的历史包袱。
2. 方案设计与核心思路:三层上下文作用域怎么划分
2.1 全局、会话、局部,就像办公室的三层文件柜
我的一个同事听完作用域划分之后说,这不就是办公桌上的三层文件柜吗。仔细一想还真是。最底层的柜子放规章制度,任何人进来都要遵守,这是全局上下文;中间一层放这个月项目的往来邮件和会议记录,这是会话上下文;最上面一层是当前正在写的这份合同草稿,写完就收起来,这是局部上下文。
文件柜分层的好处是,你找东西时不需要把整栋楼的档案都搬到桌面上。context-mode 也这样:全局提示词只在会话开始或规则变更时重新加载,不需要每轮都重复传一模一样的角色设定;会话历史按照最近 N 轮或最近 M 个 Token 截取;局部信息只在当前步骤真正需要时注入,步骤结束立即抛弃。
实操中我建议给每层分配独立的 Token 预算。例如一个支持 4K 上下文的小模型,可以分配 500 给全局层、2500 给会话层、800 给局部层,剩下约 200 给当前用户输入。这个预算可以动态调整,但每层独立统计之后,你很快就能发现是哪一层吃掉了太多资源,而不是面对一个模糊的“上下文太大了”的报错无从下手。
2.2 公共前缀加局部覆盖,冲突不再是玄学
三层作用域划分清楚后,下一个问题就是冲突处理。我采用的做法可以总结成一句话:全局定义默认行为,会话积累共同记忆,局部覆盖即时需求。这句话翻译成规则就是,当三个层面对同一个问题给出了不同说法,局部模式中用户明确表达的当前需求,优先于会话历史中的旧说法,也优先于全局基础规则。
这里有一个细节容易被人忽略:全局规则不是永远不能推翻。比如一个客服助手的全局规则是“不要透露订单内部折扣策略”,但用户在本次会话中出示了合作伙伴身份,局部上下文里注入了一条新的授权说明,那么局部信息就应该覆盖全局规则。context-mode 在处理覆盖动作时,不是简单地把全局文字删掉,而是在全局规则后面追加一条“用户特权标记”,让模型知道是当前任务命中了更高的权限。
这个覆盖机制需要做两层记录,一层是冲突发生前的原始规则快照,另一层是当前的覆盖说明。否则等这次对话结束,全局规则被污染,下一段新对话会莫名其妙沿用上一次的授权状态。我在项目里用了一个很小的变更日志结构,每次覆盖都会写入一条记录,排查问题时可以直接翻看哪一轮发生了什么。
2.3 模式切换不是开关,而是一条组装流水线
context-mode 里的“模式”不是指一个布尔开关,而是指一串组装上下文的流水线配置。不同任务可以对应不同流水线:写代码任务需要代码片段、相关文件路径、最近变更记录;知识问答任务需要文档检索结果、引用来源、用户历史问题;日常闲聊任务只需要最近的对话历史,不需要检索步骤。
我最初把模式设计成静态配置,后来发现任务类型经常动态变化。比如用户在问文档的同时,额外问了一句“顺便看看这段代码哪里有问题”。如果还按文档问答模式组装上下文,模型就拿不到代码信息。后来我把模式改成基于当前输入的关键信号做自动切换,并允许用户通过指令直接指定上下文模式。切换的时候保留会话层的内容,只替换局部层的信息,这样既省 Token,又不容易丢失对话主线。
模式切换还有一个容易被忽视的点:切换后要选择性地保留一小段“切换原因”。例如从文档问答切到代码诊断,我会在下一次请求里附加一句“用户刚从文档问答切换到代码诊断,接下来优先处理代码相关问题”。这个过渡信息很薄,但能有效防止模型继续停留在上一个模式里绕圈子。
3. 实操落地:一个最小可用的 context-mode 是怎么搭起来的
3.1 目录结构和接口设计
理论讲多了容易飘,我直接放一份我实际用过的目录结构。它很小,不依赖任何重型中间件,跑在本地就能演示全流程。
context_mode/ __init__.py # 对外入口 scopes.py # 作用域定义与优先级解析 storage.py # 上下文存储与过期管理 packer.py # 上下文打包和 Token 预算分配 hooks.py # 模式切换钩子接口设计我尽量控制在三个:push 写入一段上下文,resolve 解析当前请求应该带上哪些上下文,pack 把解析结果拼成最终的 prompt。核心调用长这样:
from context_mode import ContextManager cm = ContextManager(max_tokens=4096) cm.push("global", "role", "你是智能助手,回答需要准确、简洁。") cm.push("session", "user_goal", "用户想了解项目部署流程") cm.push("local", "current_step", "正在查看 docker-compose.yml", ttl=60) prompt = cm.resolve( user_input="下一步该做什么?", mode="devops" )这三个接口看起来简单,但内部有几处细节决定了它好不好用。我会在下面逐步拆开讲。
3.2 关键实现一:作用域解析器怎么写
作用域解析器是 context-mode 的心脏,它负责回答一个问题:当前这一次请求,到底应该看到哪些上下文。我把它想成一个类似 CSS 选择器的过程,每一条上下文都带着 scope、key、match 三个属性,解析时按当前 mode 去匹配。
SCOPE_ORDER = { "global": 3, "session": 2, "local": 1, } def build_query(mode: str, scopes: list): matched = {} for item in scopes: if item.mode not in SCOPE_ORDER: continue if item.mode == "global" or item.mode == "session": matched.setdefault(item.key, []).append(item) elif item.mode == "local" and item.match == mode: matched.setdefault(item.key, []).append(item) return matched匹配顺序决定了冲突处理的方向。我让 global 和 session 先进入池子,local 再按当前模式进行精确匹配。只有 local 上下文标记了与当前模式相符的 match 字段时,它才有资格覆盖全局或会话层的同名 key。这样写的好处是直观,三行逻辑就能解释清楚为什么某些旧内容没有跑到新任务里。
3.3 关键实现二:上下文的过期和回收
如果只写存储不写过期的上下文管理器,运行几天后就会变成垃圾场。我给每条上下文记录都设了三种过期策略:绝对过期时间、空闲过期时间、消费次数上限。绝对过期时间适合临时检索结果,比如一条代码查询结果三分钟后就不再是可靠信息;空闲过期时间适合会话级记忆,比如用户五分钟不说话,上一轮的局部上下文自动回收;消费次数上限适合一次性信息,比如“正在打开文件 A”这个状态,被读完一次就不再重复注入。
在 storage.py 里,核心不是存数据,而是回收数据。我每天会跑一次 mock 对话测试,随机模拟用户连续切换多个任务,然后检查回收日志里有没有残留上下文。这个方法帮我找到过两个很隐蔽的 Bug,一个是局部上下文在模式切换后没有正确清零,另一个是全局规则被临时覆盖后没有恢复原始状态。如果没有回收日志,这类问题要过很久才会被用户反馈出来。
3.4 Token 预算要能被看见,否则一切都是黑盒
很多人管理上下文只看“长度”,却不算账。context-mode 里我专门加了一个 Token 预算模块,每次 resolve 之后会输出当前各组装的来源和预计消耗。
[context-mode] token budget for request 20240315_001 global_scope : 385 tokens session_scope : 1203 tokens local_scope : 267 tokens user_input : 58 tokens reserved : 183 tokens total : 2096 / 4096 tokens这行日志看起来不起眼,但在排障时价值极高。有一次用户反馈响应变慢,我一看日志,全局层从 385 涨到了 1100,明显是有人往全局配置里塞了一大段几乎没有变化的长文档。没有这套可观测行,你只能凭感觉猜。如果你自己在做类似功能,强烈建议把每一层上下文的来源、长度、过期时间都输出到一个可读的日志文件里,这比任何监控面板都直接。
4. 常见问题与排查技巧实录
4.1 模型输出越写越长,但有效信息越少
这个问题十有八九出在系统提示词和全局上下文。我在项目里遇到过团队往全局规则里塞了十几条“希望模型做到”的内容,结果每轮请求都背着五千多 Token 的固定包袱,留给新内容的窗口少得可怜。模型为了避坑,只能不断输出“关于这个问题,我们需要综合考虑以下几点”之类的废话。
我的排查动作是先看 Token 预算日志,确认全局层是否异常膨胀;然后把全局内容逐条标上启用日期,凡是超过一周没有被触发过的规则,先移到冷存储里观察。一个原则是:全局上下文里只放那些每一轮都必须生效、且用户绝对不会主动说出口的规则。其他规则交给局部模式按需加载。
4.2 上下文串场,用户总觉得你在答非所问
串场的根源是会话层没有做任务边界标记。比如用户聊完 A 项目又聊 B 项目,A 项目的历史如果完整保留,模型分不清当前问的哪个项目。我的解决办法是在会话层给每个任务设一个独立命名的 scope,例如task:project-a和task:project-b,当用户问题里出现项目 A 的关键词时,解析器自动把 B 的历史降权,只保留最近一轮的上下文避免完全失忆。
串场极难一次解决,因为关键词匹配总会有漏网。我建议在关键对话节点主动向用户确认当前任务,例如把用户上一轮的意图写成一行摘要挂在请求里:“根据已有对话,用户当前意图是修改部署配置,而非继续讲解部署流程。”这行字很小,但能显著减少模型在两种解释之间摇摆的情况。
4.3 检索结果和本地上下文冲突,模型选择了过时内容
这个问题在文档问答类产品里最容易触发。检索系统通常按相似度排序,不会判断文档的新旧。我做过一个粗粒度的修正:给每条检索结果打上时间戳和来源类型标记,在拼装 prompt 前比较与当前会话主题的发布时间。如果检索到的文档时间早于用户当前提到的版本信息,就降低它的插入优先级,并追加一行“以下资料可能需要与用户当前提供的信息交叉验证”。
这里还有一个坑,就是模型分不清“文档里说”和“实际情况”。只在 prompt 里写检索内容是远远不够的,还必须写明内容来源。我会把每条检索结果包成结构化引用,例如“来自内部知识库《XX手册》2023年修订版,其中提到……”,用户一旦发现细节过时,也能指出哪一份资料出了问题,而不是开始怀疑整个模型。
4.4 调试技巧:上下文快照和 A/B 对比
我一直觉得,做上下文管理最需要的不是技巧,而是一个能够回放的调试工具。为此 context-mode 里加入了快照功能,每次请求之前的完整上下文会被存下来,并按会话和请求编号索引。如果用户反馈某一轮回复不对,我可以直接把那一轮的上下文快照拉出来重放一次,看模型是不是因为缺少某个局部信息做出了错误判断。
A/B 对比是一个更高效的排查法。我准备两个配置版本,版本 A 是原来的上下文策略,版本 B 是新的策略,固定用一段包含多个任务切换的测试对话跑两边,比较输出质量的差异。重点不是看模型回答得好不好,而是看 B 是否能让 Token 消耗下降,并且不破坏核心问题的回答能力。实测中,一个只压缩系统提示词的改动,就能让单轮 Token 消耗降低 18%,而回答质量几乎不变,这个收益远比换大模型来得明显。
4.5 常见问题速查表
| 现象 | 常见原因 | 优先排查动作 |
|---|---|---|
| 响应越来越慢 | 全局层持续膨胀 | 查看 Token 预算日志中 global_scope 值 |
| 模型开始复读旧内容 | 会话历史过长且无压缩 | 检查最近 N 轮是否超过预算一半 |
| 任务之间互相串味 | 会话层没有任务边界 | 确认切换模式时是否标记了任务 scope |
| 回答引用了过时资料 | 检索结果未带时间校验 | 检查检索内容是否经过新旧排序 |
| 某一轮突然完全失忆 | 历史被粗暴裁剪 | 查看裁剪后是否保留了最后意图摘要 |
5. 这种设计怎么迁移到不同场景里
5.1 编程辅助领域的用法
代码生成和辅助诊断是 context-mode 收益最大的场景之一。写代码任务最怕旧代码上下文干扰新需求。我在实际项目里用了一个很简单的策略:把当前打开文件的路径、最近一次 git diff、光标所在行附近的源码作为局部上下文,把项目整体架构说明作为全局上下文,把用户对需求的阶段性描述作为会话上下文。这样一来,模型在回答“这个函数怎么改”时,看到的是相关的代码片段,而不是整个仓库的几千个文件。
编程场景还有一个特殊之处:代码片段往往有严格的格式和缩进,胡乱压缩会破坏语义。我建议设置“代码片段不做摘要,只做长度截断”的原则,保证喂给模型的一定是真实代码。如果需要更长的上下文,优先剪掉无关测试代码和第三方依赖说明,也不要对核心逻辑做概括性文字替换。
5.2 客服机器人和文档问答场景
客服机器人场景的上下文模式要比通用聊天更克制。用户的每一次提问可能涉及完全不同的产品线,全局规则只需要包含接待底线和转人工条件,产品细节全部放到局部检索里做精确注入。这里最该注意的,是不要在会话层保留用户上一个问题的完整历史,而是保留结构化字段,比如“用户咨询产品:A”、“用户会员等级:B”、“用户已确认的信息:C”。
文档问答场景则要关注引用来源的稀缺性。除了加入时间戳和来源标记外,我还会对检索片段做“摘要前置,引用后置”的排列:最前面放一段人话摘要,归纳这个片段回答了什么,后面再放具体引用原文。这样模型在生成时更容易前台调用摘要,而不会强背一整段原文导致信息混乱。
5.3 一个边界提醒:上下文模式不是万能的记忆
context-mode 无论怎么设计,本质上处理的是“当前能放进窗口的信息”,它处理不了跨会话的长期记忆。如果一个用户上次对话已经是三天前,你希望他今天继续上次的话题,就得靠真正的持久化存储,比如把关键事实抽成结构化记录写进数据库,而不仅是靠上下文缓存。
我在实际使用过程中踩过这样的坑:以为只要把历史摘要存在会话上下文里,用户隔两天回来还能无缝继续。但实际上,用户在几天内可能已经换了需求,强行延续旧摘要反而会误导模型。现在我的策略是,跨会话恢复时只向模型提供用户主动引用的旧关键信息,不做整段历史恢复。这套策略的准确率,比无脑恢复旧摘要要稳定得多。
最后再分享一个我自己的体会
做完 context-mode 之后,我对 AI 应用开发有个很直接的感受:模型能力是上限,上下文管理是下限。你换再强的模型,也架不住输入是一团乱麻。会分作用域、会设优先级、会做回收的上下文,才是真正让模型稳定发挥的基础。如果你也在做类似的东西,建议从最小可运行的三层作用域开始,先让每层 Token 可见,再逐步加上模式切换和过期策略。预算那行日志真的非常值得加,很多你看不见的问题,其实都藏在数字变化里。