上下文模式(Context-Mode)工程落地指南:告别对话断片
context-mode 这个词我第一次认真琢磨它是因为我的本地资料问答助手连续翻车。用户先让我读一份十几页的合同然后问“违约金那条怎么写的”我能答上来过了十分钟再问“我刚让你读的那份合同版本还会更新吗”我突然完全断片了。同期同事的客服机器人在多轮对话里把指代当成乱码IDE 插件在我写 Python 时拼命推荐 CSS 变量。社区里聊了一圈所有人不约而同提到一个词——context-mode。没人能给出一致定义但大家都很清楚我需要这个能力我缺的就是这个能力。我后来花了两周在自己项目里完整落地了一遍“上下文模式”陆陆续续把聊天、编辑器、自动化脚本、会话存储都串了进来。这篇文章不打算给它强行造一个教科书定义而是把我从建数据模型到踩坑修复的全过程写出来。如果你正被连续对话断片、智能助手记性差、工具建议不贴上下文这类问题困扰这篇文章大概率能帮你少折腾一个月。1. context-mode 热起来不是出现了一个新按钮是用户对“断片”的集体不满1.1 三个让人抓狂的“断片”时刻先说我真实遇到的三个场景。第一个。同事在做一个客服机器人用户先问“你们有没有 12 寸的立式风扇”机器人回复了型号、价格、购买链接。用户紧接着问“那它静音吗”机器人直接卡住因为它不知道该把“它”映射到哪个商品。机器人把这句话当成一个全新问题来处理前面那个风扇信息完全没有被当成有效上下文。第二个。我在某 IDE 里写一个 Python 后端接口正在给一个函数补参数。旁边的补全插件突然给我推了一层 CSS 变量名和组件类名。原因很简单插件在判断“当前上下文”时没有看活跃编辑器标签、当前打开文件类型、光标所在的函数签名而是简单粗暴地把工作区里“最近出现过”的内容一股脑当成了补全依据。第三个。就是我开头说的那个本地问答工具。它有一个会话 ID能读文件能聊天。但它没有把“这个会话正在处理的合同”作为一个强上下文对象保存下来。会话一间断它就回到一片空白。三个产品完全不同的形态但病根是同一个系统在执行当前动作之前没有正确加载、筛选和传递与该动作真正相关的上文信息。客服缺的是对话级指代上下文IDE 缺的是工作区活动文件上下文我的本地工具缺的是会话级任务上下文。1.2 从三个场景里我抽象出了一个三动作模型翻了大量产品文档和社区讨论之后我发现大家嘴上说的 context-mode其实都指向同一件事在执行动作之前先把当前环境里和这个动作有关的信息收集起来、挑出该用的部分、按执行体能消费的形式组装好。我把这件事拆成三个动作采集Collect从环境里收集一切可能影响判断的信号。用户上一句说了什么、当前打开的文件是什么、光标选中了哪段代码、当前 git 分支是什么、用户是否显式声明过偏好。筛选Filter决定哪些信息真的值得进入执行体。上下文不是流水账更不是把整个历史无脑粘贴过去必须按照重要性和时效性做一个优先级排序。装配Assemble把筛选出来的信息组织成具体执行体可以消费的格式。大模型场景下是 system prompt 加多轮历史自动化脚本场景下则是一个结构化的 InputContext 对象。后面我踩的坑、写的代码、做的修复基本都能映射回这三个动作。你可以把“采集—筛选—装配”这条链路当成 context-mode 的最小骨架。1.3 两分钟判断一个产品有没有真正的 context-mode在深入研究之前我一直以为 context-mode 是某个开关打开就有、关闭就没。实际上不是。通过这三个场景的观察我总结出一个非常初级的“上下文模式检测法”不需要看源码只要跟产品对话就能判断连续多轮里使用“它”“这个”“上面那个”这类指代词看系统能否正确引用之前的实体。切换项目或文件之后问一个只跟当前项目有关的问题看旧项目的信息是否还在干扰回答。新建一个空白会话问“我们之前约定的约束还在不在”看系统是否还私自带着历史记忆或者完全不记得。这三条基本覆盖了上下文模式的三个能力维度引用链、隔离性、持久性。我做后期验收时用的就是这三条扩展出来的五条用例。2. 动手前先把“上下文”对象化一个最小数据模型2.1 用列表存文本为什么会越做越乱刚开始我特别天真。工具要记忆上下文那我开一个全局数组把每轮用户消息、助手回复、文件内容都推进去不就行了结果一周以后就完蛋了。我不知道某条消息到底属于哪个会话不知道“打开合同.pdf”这个动作是来自用户手动操作还是程序内部自动读取的更没法回答“如果这次回答只保留最重要的 2000 个 token该优先丢哪条”。一个裸文本列表根本承载不了这些判断。更现实的问题是身份缺失。你没法给一段上下文贴标签也就没法追踪它是怎么进来的、应该活多久、能不能被清除。等系统出了错误答案你连它是被哪条残留信息带偏的都不知道。2.2 ContextItem 与 InputContext把上下文当对象而不是当字符串我后来做的第一件事就是建了一个最小的上下文数据模型。这个模型在后面所有功能里反复用已经打磨得比较稳定from dataclasses import dataclass, field dataclass class ContextItem: source: str # 来源user_message / file_action / system_event / mode_preference scope: str # 作用域conversation / session / project / global timestamp: float # 产生时间 importance: float # 基础重要性供裁剪排序 payload: dict # 具体内容 mode_id: str # 归属于哪个上下文模式 pinned: bool False # 是否被用户钉住钉住后不可裁剪 dataclass class InputContext: mode_id: str items: list[ContextItem] field(default_factorylist) schema_version: int 1 project_id: str branch_id: str fingerprint: str 每个字段都不是白加的source 解决“这段上下文是哪来的”。同样是文本内容用户直接输入的可能需要原文保留系统内部读取的文件内容则更适合走摘要和按需引用。scope 决定它的生命周期conversation 级的跟着会话走project 级的跟着项目走。importance 给裁剪排序用。mode_id 是整个 context-mode 的核心身份——不同模式拥有不同的上下文包互相不串场。schema_version 则是给未来升级用的防止老上下文被新代码以新格式解析导致字段错位。2.3 一次完整的“采集—筛选—装配”代码落地我把前面说的三个动作分别做成了函数先看采集def collect(request: UserRequest, current_mode: str) - list[ContextItem]: items [] # 会话历史里最近的 N 条 items.extend(history_tail(request.session_id, n10)) # 当前动作产生的信息 items.append(ContextItem( sourceuser_request, scopesession, timestampnow(), importance0.9, payload{ text: request.text, active_file: detect_active_file(request), selection: detect_selection(request), }, mode_idcurrent_mode, )) # 当前模式下用户显式的约束 items.extend(mode_preferences(current_mode)) return items筛选的核心是“预算有限按优先级分配”def filter_items(items: list[ContextItem], max_tokens: int) - list[ContextItem]: ranked sorted( items, keylambda x: (x.importance, x.timestamp), reverseTrue ) budget 0 result [] for item in ranked: cost estimate_tokens(item) if budget cost max_tokens: continue result.append(item) budget cost return result装配是把筛选后的信息格式化成大模型或其他执行体需要的字符串def assemble(items: list[ContextItem]) - str: parts [] for item in items: if item.source user_message: parts.append(f[用户消息] {item.payload[text]}) elif item.source file_action: parts.append( f[当前文件] {item.payload[path]} f最近改动{item.payload[change_summary]} ) elif item.source mode_preference: parts.append(f[模式约束] {item.payload[constraint]}) return \n.join(parts)这三个函数串起来之后上下文包长这样{ mode_id: qa_local_docs, schema_version: 1, project_id: proj_a, fingerprint: a3f9e1c2..., items: [ { source: user_message, scope: session, timestamp: 1721956789, importance: 0.9, payload: {text: 这份合同里违约金条款怎么写的} }, { source: file_action, scope: session, timestamp: 1721956780, importance: 0.8, payload: {path: /docs/contract_0725.pdf, change_summary: 关键条款见第3条} } ] }做完这一步你就能回答很多之前无法回答的问题“这个上下文包是谁的它包含了几条信息每条信息来源是什么重要性多高占了多少 token 预算”2.4 数据模型是给未来排错用的不是摆设很多人觉得搞个 dataclass 很容易没啥稀罕。但我要强调这个模型最大的价值不是运行时性能而是排错时的可观测性。我后面遇到跨项目串场的时候就是因为 fingerprint 字段缺失遇到模式污染的时候就是因为 mode_id 没有参与上下文替换。如果没有这些显式字段排查难度会直接翻倍。上下文这东西一旦看不见摸不着你就只能靠猜。3. 上下文的关键不是“多”而是“裁剪、衰减、摘要”3.1 全量塞入为什么反而会更蠢我第一次完整跑通 context-mode 时的实现很粗暴把会话历史、打开文件全文、工作区里所有最近改动全部塞进大模型的上下文窗口心想“信息越多越准”。结果恰恰相反。回答变慢了而且经常被很前面一条无关消息带偏。用户问 6 月的销售数据模型因为上下文里 7 月报告太详细硬是按照 7 月的思路答了一遍。这里有两个硬约束。第一个是 token 预算任何模型都有最大上下文长度比如某模型支持 128K token系统提示词占掉 3K你希望它输出留出 4K那么历史上下文实际可用约 121K看起来很多但如果你把整本 PDF、整份代码库变更记录都塞进去几分钟就爆。第二个更隐蔽是“中间信息迷失”。实验和实测都表明大模型对长文本开头和结尾的内容注意力更强中间大段内容容易被忽略。你把所有历史做成一个超大文本等价于让模型在一堆没什么用的噪音里捞针结果就是没捞着还被噪音带偏。我后来打了一个比方你让一个同事在全部部门的邮件堆里帮你找昨天那封报价邮件如果他真的把几百封邮件从头读到尾反而最容易忘掉你真正要的那封。上下文管理做的事是直接把那封邮件和相关回复摆到他面前。3.2 三层候选记忆瞬时、会话、项目为了解决“什么该进上下文”的问题我把候选信息分成了三层每层采用不同的保留策略。瞬时层当前这次请求里携带的信息。用户刚输入的文字、当前选中的代码、此次命令带上的参数。这些永远最高优先级直接原样保留。会话层当前会话最近 N 条消息。不是全量保留而是“最近几条原文 更早内容的摘要”。比如最近 5 条原文再往前每 10 轮做一个摘要。项目层跨会话的长期事实和用户偏好比如“这个项目里我只看销售合同不看开发文档”“问答时默认用中文回复”。按 mode_id 隔离存贮。会话层的摘要压缩是一个很典型的需求。我的实现大概是def summarize_history(items: list[ContextItem], max_chars: int) - str: merged [] current [] for item in items: text item.payload.get(text, ) if len(.join(current)) len(text) max_chars: merged.append(ai_summarize(current)) current [] current.append(text) if current: merged.append(ai_summarize(current)) return \n.join(merged)ai_summarize 可以用任意一个摘要模型实现关键是摘要之后上下文包体积能下降一个数量级而关键信息还能留在里面。要注意的是摘要生成的过程本身也要保留“来源”的概念——摘要文本来自哪些原始 item需要记录否则将来无法追溯。3.3 用“钉住”和“衰减”控制信息的重要性除了裁剪我还加了两个控制项重要性衰减和用户钉住。默认情况下一条上下文距离当前时间越久重要性就越低这就是衰减。但用户如果明确说“记住这个价格上限后面的讨论都别超过它”那你就应该把这条信息钉住不让衰减和裁剪把它清掉。具体实现非常简单给 effective_importance 加一个逻辑def effective_importance(item: ContextItem, now: float) - float: if item.pinned: return 999.0 age_hours (now - item.timestamp) / 3600 # 每 24 小时重要性减半 return item.importance * (0.5 ** (age_hours / 24))这个衰减曲线不是唯一解但它有一个很好的性质一天内的重要信息基本保得住三天以上的低重要性信息自动沉底。加上 pinned 之后用户主动声明过的约束在任何窗口裁剪中都不会被挤掉。这是我很早引入但收益最明显的一个机制。3.4 一张预算分配表把上下文用好我后来把每一轮进入模型的上下文都做一个预算分配效果非常稳定。以某模型 128K token 上下文为例组成部分预估占用说明系统提示词3K角色、工具说明、输出格式当前用户请求1K本次输入原文输出预留4K给模型生成留空间会话层摘要5K早于最近 5 轮的压缩内容最近 5 轮原文4K高保真的近期细节当前文件/相关文档片段其余按需加载非全文我很少真的把 121K 全部用完因为中间信息太多反而帮倒忙。宁可宁可留白也不要硬塞。这个观念和很多人“窗口够大就随便放”的做法正好相反。4. 挂到实机上的三种接法内存式、会话式、代理式context-mode 不是一个空泛理念你得决定它挂在哪种体量的系统上。我在项目里总结出三种接法内存式、会话式、代理式。4.1 内存式一次请求内装配用完即扔内存式是最轻量的 context-mode。典型产品是浏览器插件里的“总结当前页面”“翻译选中文字”或者命令行工具里“解释一下这段日志”。它的生命周期只有一次请求。点击按钮的瞬间系统采集当前页面、用户指令、选中区域组装成一个上下文包发给执行体拿到结果结束。没有持久化没有用户身份没有跨会话记忆。优点是极简几乎没有状态管理的成本。缺点是解决不了连续对话问题用户下一次点击又是一张白纸。如果你的产品只需要处理“当下这一刻”选内存式就够了别为了 context-mode 硬加一张历史表。伪代码大概长这样def handle_once(page, user_command): ctx InputContext( mode_idtool_once, items[ ContextItem(sourcefile_action, scopeglobal, timestampnow(), importance1.0, payload{page_content: page.text}, mode_idtool_once) ] ) prompt assemble(ctx.items) return call_model(system你是总结助手, userprompt)4.2 会话式带 ID、带摘要的持久上下文会话式是客服机器人、聊天助手最常用的接法。上下文包不再是一次性的而是绑定 session_id 存在数据库或内存里。需要记录的核心字段大概是session_id: str mode_id: str created_at: float updated_at: float recent_messages: list history_summary: str pinned_items: list[ContextItem] project_fingerprint: str每一轮新请求到达时先按 session_id 把当前会话的上下文包取出来追加当前用户消息再做一次裁剪和摘要更新然后交给模型。会话式能解决多轮对话断片问题代价是要处理会话存储、并发更新和摘要的一致性问题。我当时选型的时候本地资料问答助手自然就走到了这一步。它的核心场景是“用户和助手就一批文档连续讨论很久”断片不可接受。4.3 代理式后台监听事件的持续上下文代理式是当前最接近热词宣传片里那种“工具像贴身助理一样了解你在干嘛”的模式常见于 IDE 插件、自动化助手、跨应用工作流。它不只是在你提问时采集上下文而是在后台持续监听系统事件不断更新上下文状态。监听的事件可能包括文件打开或切换文件保存与内容变化光标选择区域变化Git 分支切换终端输出新增剪贴板内容复制每收到一个事件就把对应 ContextItem 的 importance 抬高并写入一个持续存在的上下文状态。当你最终发起请求时系统直接从这个状态里取数而不是临时扫描一遍文件系统。代理式最贵的地方在于状态管理和隐私边界。什么时候该遗忘旧事件哪些事件属于当前项目、哪些属于其他项目如果监听的事件里混入密码、密钥、个人信息上下文包里就全是地雷。我自己的项目最后没有做完整的代理式只做了“有限代理”也就是只监听当前 IDE 标签切换事件不做系统级跨应用采集因为投入产出比已经很清楚了。4.4 三类接法的选型对比接法生命周期典型场景主要成本什么时候用内存式单次请求页面总结、翻译、临时工具实现简单无持久化只处理当下一次动作会话式一个会话客服、聊天机器人、文档问答需要会话存储、摘要更新用户需要连续对话代理式长期后台IDE 插件、自动化助手需要事件监听、状态管理、隐私审查需要跨操作持续感知总结成三个问题每次选型先问自己我需要记住多长的历史一次请求内存式、一次会话会话式、还是跨会话代理式。在执行动作之前我需要感知多少个外界信号一个页面内容还是一个复杂的文件系统状态。用户想怎么管理系统的记忆是希望清空之后彻底重来还是希望助手永远记得自己是谁。我的经验是很多人一上来就冲着“代理式”去想让软件无所不知结果被事件监听、状态同步和隐私问题拖垮。先从会话式起步把连续对话打磨好再加一层有限监听性价比要高得多。5. 我在亲测中踩过的四个坑以及完整排查思路这一部分是我觉得对社区最有价值的。理论说得再漂亮不踩几个坑都算不上真做过 context-mode。5.1 坑一把全量历史喂给模型答案被“上一轮”带偏现象会话式问答里用户先问“请总结 7 月销售报告”助手回答完。用户接着问“那 6 月呢”助手又复述了一遍 7 月的内容只是日期改成了 6 月。排查过程我先打开日志看模型实际收到的上下文。发现除了对话历史之外里面还塞着上一轮系统读入的完整 7 月销售报告全文而且被标记成高重要性。模型在生成 6 月内容时被这份全文反复强化最后判定“用户多半还是在问 7 月”。根因我的 ContextItem 把“用户问题产生的文本”和“为回答而加载的附件内容”混在同一个优先级层里加载进来的文档全文没有做生命周期管理。它本应只在“当前这轮被主动引用”时存在却被当成永久历史带进了后续每一轮。修复给附件类内容建立单独的“按需加载”规则。上轮读取的文档下一轮默认不保留全文只保留“文档路径 摘要 与上轮回答的关系”。如果用户后续再次问到这个文档系统通过路径和摘要重新加载相关内容而不是继续背着一整份 PDF。这个修复让我的 token 消耗降了接近一半回答准确率反而高了。我得到的教训就是上下文包里历史永远是摘要优先全文永远按需出现。5.2 坑二文件路径当上下文身份跨项目串场现象同一个 IDE 里用户先在 A 项目写代码再切到 B 项目。B 项目里的补全和问答竟然还在引用 A 项目的历史上下文。仔细一查两个项目里都有一个叫utils.ts的文件而我的 system 把“当前上下文身份”只定义成了当前文件相对路径。于是 A 项目utils.ts的历史被错误挂到了 B 项目utils.ts上。排查链路我先确认上下文身份是怎么生成的。当时的代码是fingerprint sha256(file_path)只含相对路径。这个设计问题很大因为 file_path 在“同一个工作区不同分支”“不同项目同名文件”“临时文件被移动后”都会发生碰撞。修复方案是给 fingerprint 加入多层标识def make_fingerprint(project_id, branch_id, file_path, mode_id, session_idNone): raw |.join([ project_id or , branch_id or , file_path or , mode_id or , session_id or , ]) return sha256(raw.encode()).hexdigest()[:16]加了 project_id、branch_id、session_id 之后两个项目即便有同名文件上下文身份也不同同项目不同分支之间上下文也互不污染。从那之后“打开文件之后建议内容怎么还在跟着上一个项目跑”这类问题彻底消失了。排查思路值得记下来出问题时先问“系统认为当前上下文是哪条身份”再看这个身份是否真的能区分当前场景。5.3 坑三模式切换后旧上下文污染新任务现象用户先让助手进入“翻译模式”翻译了几段产品文案。随后切到“代码审查模式”问“帮我审查这段 Python 代码”。模型在回答里出现了翻译腔甚至把代码里的字符串当英文文案直接翻译。根因我的 InputContext 在模式切换时只更新了 mode_id但 items 并没有按模式做隔离。翻译模式下的 mode_preference“源语言为英文目标语言为中文”残留在新的上下文包里对新模式产生了不可见的干扰。修复模式切换时要执行“上下文替换”而不是单纯追加。我给每个 mode 定义了一张生命周期表会话历史可以跨模式保留因为同一个会话里的主题有时会延续。mode_preference必须立即丢弃切换模式前显式画句号。采集到的系统事件文件开关、剪贴板按来源判断是否与新模式相关。具体代码就是在 switch_mode 时对旧 items 做一轮过滤def switch_mode(old_ctx, new_mode): keep_scopes {conversation, project} new_items [ item for item in old_ctx.items if item.mode_id new_mode or (item.scope in keep_scopes and item.source in {user_message, system_event}) ] return InputContext(mode_idnew_mode, itemsnew_items, schema_versionold_ctx.schema_version)这个修复做完我问了自己一个很有用的问题如果上下文的文字被完整打印出来让一位同事单独看一遍他能否猜到我当前处于什么模式如果猜不到那就是模式隔离没做好。5.4 坑四敏感字段和普通信息同层浮动安全边界模糊现象有一版上下文包我把用户手机号、API Key、内部文档路径都标成了高重要性理由是“这些都是用户明确提供的重要信息”。结果它们全被优先保留并送进大模型。这很危险一旦模型被对话引入歧途或者上下文被泄露这些敏感数据就会流出。修复重要性不应该只由“对当前任务的价值”决定还要叠加“访问安全级别”。当时我加了一个清洗函数在装配之前扫描 payload对包含手机号、身份证号、密钥等模式敏感字段直接不进入上下文除非用户在当前请求里显式引用并确认脱敏。对内部文档路径等标识在进入上下文时做假名化替换成随机 token。过滤规则必须写在上下文装配链路的最后一层确保任何上游采集函数都不可能绕过。这个坑提醒我context-mode 越强大采集能力越强就越需要一道出口处的水闸。上下文里“能做到”和“应该做”是两件事。6. 把 context-mode 做好的四条原则以及一套可手测的验收清单6.1 怎么判断“这一条信息该不该进上下文”在我把整套系统修完第三轮之后慢慢沉淀出了四条原则。现在我在任何场景设计 context-mode都用这四条先自我检查每一条被带进上下文的信号都必须能说清楚“它在帮系统更准确地处理哪件事”。如果说不清楚就删掉。上下文必须有身份身份由 mode_id 加 fingerprint 组成。没有身份就无法隔离、更新、作废。上下文永远从“用户当前意图”倒推不要做全量周边信息采集。判断一条信息值不值得进上下文的唯一标准是缺了它当前结果会不会变。用户必须能一键清空上下文并且系统绝不能悄悄恢复。这是信任底线。清空之后又来了一句“根据之前的上下文…”产品基本就失去信任了。这四条看着像常识但很多产品翻车就是因为违背了其中某一条。尤其是第四条清空上下文不彻底、之后又悄悄把历史塞回来非常容易被用户识破。6.2 五条验收用例全部手测即可项目跑起来之后我留了一套很小的验收清单每次改完 context-mode 相关代码都会手动跑一遍。不需要写自动化测试框架人工操作即可。用例操作预期对应能力1连续聊 10 轮第 10 轮问第 1 轮提到的事实能正确引用会话持久性2当前轮说“上面那份文档里”问具体字段能映射到具体文档内容指代与引用链3切换项目/分支后问只属于新项目的问题不出现旧项目干扰上下文隔离4点击清空上下文再问“你记得刚才聊过什么吗”完全不记得也没有悄悄恢复清空彻底性5历史里先后说“用蓝色”“改主意了用绿色”再问用什么颜色按最新的绿色回答优先级与时效性大多数 context-mode 翻车问题最后都能归到这五条里的某一条。每次排查的时候先从这五条里找对应项能少走很多弯路。6.3 上下文摘要质量的粗评法会话式接法离不开摘要。摘要做得差上下文包体积省了但关键信息丢了。我的粗评方法非常朴素把摘要文本和原始文本分别交给同一个评估提示词各问三个关键问题比较回答的覆盖度。比如原始文本是一份销售合同摘要文本是模型生成的压缩版。关键问题可以设计为“违约金比例是多少”“付款期限是几天”“有没有排他条款”。如果摘要上找不到三个问题中的任何一个就说明摘要压缩过度。这个方法不能完全替代人工评估但能用一个脚本快速筛出版本差异。我一般每个版本跑十组问题通过率达到九成才会让摘要进入正式上下文。最后一次读代码时的一点感受做了这一整轮我最大的体会是context-mode 的强大不在于“记住多少”而在于“敢忘多少”。同样一个会话全量历史塞不进去塞进去也未必准确真正稳的做法是采集充分、筛选果断、装配清晰然后再靠衰减和钉住去微调。最后分享一个小技巧给每一个 ContextItem 的 source 字段命名时务必直白。用user_message、file_action、system_event、mode_preference这种一眼能看懂的名字不要用什么internal_1、context_unit_a。调试上下文问题的时候你才能快速看到究竟是哪类信息把回答带偏了。这一步看起来不起眼但排查效率差好几倍。