ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Context-Mode:让工具真正理解你的工作现场

Context-Mode:让工具真正理解你的工作现场

做工具开发这些年,我越来越意识到一件事:很多看起来毫不相干的工具,最后成败都卡在同一个点上——它怎么理解“你现在正在干什么”。AI 编程助手需要知道当前打开的文件、光标位置、项目语言,终端工具需要知道你在哪个目录、哪条分支下运行命令,编辑器插件需要知道光标所在的代码块属于哪个函数。这些零散信息如果能统一收进一个可管理的容器,再按场景动态切换,就是你今天看到的 context-mode,也就是上下文模式。

在 AI 工具和开发者工具越来越复杂的当下,context-mode 已经从“顺手封装一下”的小细节,变成了决定产品体验的核心设计。很多时候功能明明做出来了,但用起来总觉得“差一点”,差的就是上下文没喂对。这篇文章我会从原理讲到落地,给出一个可以直接抄作业的上下文模块设计思路,并带上我踩过的坑和排查方法。无论你是做编辑器插件、AI 辅助工具、CLI,还是自动化脚本,都能从中找到可复用的部分。

1. context-mode 到底是什么:先搞懂这个模式的设计定位

1.1 一个总在幕后发挥作用的核心设计

先说个现象:你打开 VS Code 切到一个项目,它记住了上次打开的文件;你在终端里按上箭头,它记住了你在这个目录下敲过的历史命令;你跟 AI 助手对话,它记得你刚才问过什么。这些看起来是“基本操作”,背后其实是同一套逻辑——工具需要维护一份“当前场景的状态”,并且这份状态要随着你的行为动态变化。

Context 这个词在编程里到处都是:Android 开发里 Activity 自带 Context,React 里通过 Context 跨组件传值,大模型对话里有 Context Window。但 context-mode 跟这些具体 API 不是一回事。我把它理解为一种主动接管上下文的设计模式:不是“碰巧有些数据可以传”,而是把“环境感知 + 状态收集 + 场景切换 + 生命周期管理”收拢成一个明确机制,让工具在每一个工作瞬间都清楚自己处在什么场景、应该携带哪些信息。

你可能觉得这有点抽象,我用一个例子描述。假设你在做笔记工具,打开一个 Markdown 文件时,工具需要知道:当前文件路径、文件标签、光标所在章节、最近编辑过的段落、以及这个笔记关联的项目。这些信息散落在不同模块里,如果没有 context-mode,每个功能都要自己去找一遍;有了 context-mode,它们会在你打开文件时被统一收进一个上下文对象,所有下游功能只需要读取这个对象。

1.2 context-mode 和普通状态管理的区别

很多人会把 context-mode 理解成“全局变量换个说法”,这个误解会直接导致设计跑偏。我习惯用一张对比表把两者的差异讲清楚:

对比维度普通状态管理(全局变量/单例)context-mode
作用范围全局可见,谁都能改按场景隔离,不同场景有独立副本
生命周期常驻,进程结束才销毁随场景创建和销毁,任务结束即清理
数据来源代码内部赋值为主环境自动采集 + 外部显式注入混合
切换方式手动改变量,靠约定显式切换作用域,切换时重建上下文
出错特征串台难排查,幽灵状态多结构清晰,可 dump 可回溯,定位快

普通状态管理解决的是“数据要保存”的问题,context-mode 解决的是“数据要在正确的场景里被正确的人看到”的问题。举个例子:同一套工具,你同时在处理 A 项目和 B 项目。普通全局变量存的是“当前项目”,一旦用户手滑触发了一个跨项目回调,这个变量就可能被改成 B,然后所有 A 项目的逻辑全部读到错误数据。context-mode 的做法是给每个项目建一个上下文作用域,切换项目时整体替换作用域,旧作用域的数据不允许继续影响新场景。

这个区别决定了调试体验的天差地别。用全局变量的团队,状态乱了只能满世界找哪个文件改了这个值;用 context-mode 的团队,直接看当前作用域的快照就能知道数据对不对。这也是我后续所有设计的第一原则:上下文必须是可以冻结、导出、对比的。

2. 为什么现在绕不开 context-mode:三大驱动因素

2.1 AI 工具必须“看懂”你的工作现场

这两年的 AI 编程助手把 context-mode 的重要性推到了顶点。原因很简单:大模型本身没有记忆,它每次响应只依赖你塞进请求里的那些文本。一个 AI 助手如果不知道当前文件的语言、不知道光标在第几行、不知道项目的依赖管理方式,它就只能给你一个泛泛而谈的答案——这跟没做这个功能几乎没有区别。

我在给一个内部 AI 代码审查工具做上下文设计时,第一次意识到这个问题的真实成本。当时我们第一版只传了“当前文件内容”,结果模型经常给出跟项目规范相冲突的建议,比如在 Python 项目里推荐 JavaScript 风格的命名,或者不知道当前分支特性,提出一些已经废弃的接口。后来我们把上下文扩展成四层:仓库信息、文件信息、代码结构、用户意图,准确率立刻上了一个台阶。这里的核心不是模型参数调得多好,而是 context-mode 保证了模型每次拿到的都是最有用的现场信息。

同样的逻辑适用于所有 AI 工具。你用 AI 写周报,需要上传本周的提交记录;你用 AI 做客服,需要传入用户历史订单。这些都属于上下文接入的问题。甚至可以说,在大模型时代,上下文的质量决定了产品体验的上限。

2.2 多场景切换时最怕状态串台

第二个驱动因素是真实工程里的场景切换。我以前维护过一个 CI 构建脚本,它负责在多个项目之间轮流执行打包任务。最初脚本里用一个全局变量记录“当前项目”,然后各个模块都去读这个变量。结果遇到并行任务时,项目 A 的构建日志偶尔会打印出项目 B 的路径,排查了很久,最后发现是回调顺序不可控,某个异步函数在切到 B 之后才执行 A 的清理逻辑,把已经卸掉的上下文又用了一遍。

这种“状态串台”是工程里最隐蔽的 bug 之一:它不报错,只是偶尔出现脏数据。context-mode 的解决思路不是让大家小心点,而是从结构上消灭这种可能性——每个场景有自己独立的作用域,场景切换是整体替换,不允许部分混杂。就像每个手术室有自己的无菌包,不会因为两台手术交替进行就把器械混在一起。

2.3 可测、可回滚、可排查的必要性

第三个因素是工程质量的进化。现在好的系统不仅要“能用”,还要方便排查和复现。context-mode 天生适合做这件事,因为它把状态收束成了明确的单元:你可以把某个用户、某个任务在某一时刻的上下文序列化成 JSON,保存为快照;下次遇到问题,把快照加载回来,就能在本地重现现场。

我参与过的项目中,凡是采用 context-mode 的模块,故障定位时间基本都能缩短一半以上。原因不复杂——传统排查要问“用户在哪个页面”“点了什么”“当时环境怎么样”,用 context-mode 之后,这些问题都变成了“拿用户的上下文快照来看”。数据说话,比回忆可靠得多。

3. 核心设计要点:从数据模型到生命周期管理

3.1 上下文的数据结构怎么设计才合理

设计上下文数据结构是 context-mode 的起点,也是最容易做砸的地方。新手常见的问题是“一坨全塞”:把用户 ID、当前目录、文件内容、选中的文本、天气、主题色全部塞进一个 Map。这样做的后果是上下文对象迅速膨胀,下游不知道该读哪个字段,依赖关系混乱。

我的经验是强制分层。第一层是全局上下文,包含用户 ID、语言偏好、工具版本这类在绝大多数场景都需要的稳定信息;第二层是会话上下文,包含当前工作目录、打开的项目、分支名称,它随用户的切换行动变化;第三层是场景上下文,最小也最关键,比如编辑文件时的文件路径和光标位置、执行命令时的参数和标志位。这三层各管各的,下游只关心自己需要的那层。

我用 TypeScript 写过一份可以直接参考的类型结构:

interface GlobalContext { userId: string; locale: string; toolVersion: string; featureFlags: Record<string, boolean>; } interface SessionContext { projectRoot: string; gitBranch: string; openFiles: string[]; recentCommands: string[]; } interface SceneContext { editor: { filePath: string; language: string; cursorLine: number; selectedText?: string; }; task?: { taskId: string; goal: string; relatedEntities: string[]; }; }

三层分离的好处很直接:全局上下文几乎不变,可以常驻内存并加缓存;会话上下文在切换项目时整体替换,不用关心小字段的增量更新;场景上下文跟随用户的每个动作高频变化,必须轻量且及时。我要特别提醒一点:不要把会话信息放进全局层。比如有人觉得“反正同一个用户大概率在同一个项目”,就把 projectRoot 放全局层,结果用户一开第二个窗口就出问题,这种教训我见过太多次。

3.2 上下文收集的三条渠道

数据结构定好之后,接下来解决“数据从哪来”。我总结为三个渠道,实际设计时三条腿走路:

显式注入是第一优先渠道,也是最可靠的。用户通过命令行参数、配置文件、API 调用传入的信息,可信度最高,直接写入上下文。比如运行一个构建工具时用户指定的--config=production,这类信息不需要猜测。

环境采集是第二渠道,也是最容易被低估的。读取当前工作目录、环境变量、Git 分支、最近修改的文件,甚至读取操作系统的时区和语言,都是环境信息。环境采集的关键是“只收需要的”,很多工具把这个过程做成“全量收集”,然后发现上下文里塞满了根本不用的变量,这会给后续的传递和缓存带来压力。我建议维护一张环境采集白名单,上面写明每个字段的用途,加一个字段就要理直气壮地说出理由。

行为推断是第三渠道,也是最有技巧的。通过用户最近的操作序列推测意图,比如用户在某个接口定义文件里停留了很久,刚刚又搜索过相关函数,那上下文里可以推断“当前任务与该接口改造有关”。这个渠道风险较高,因为推测可能出错,所以推断出的字段必须打上confidence标记,并且允许下游不采纳。

给出一个收集器的接口设计示例:

interface ContextCollector<T> { collect(raw: RawEnvironment): Promise<Partial<T>>; priority: number; // 数字越低越先执行 source: "explicit" | "environment" | "inferred"; }

3.3 上下文的作用域与合并规则

多个来源把上下文凑齐之后,需要一套合入规则。这里我直接参考 CSS 的层叠思路:全局上下文是基底,会话上下文覆盖全局里的同名字段,场景上下文优先级最高,覆盖前面两层。任何一层缺失时自动回退到低一级的值。

补充一个关键细节:不需要层层合并所有字段。真正的实现只需要在切换作用域时计算一次“合并后的扁平视图”并缓存,用户每次读取走缓存即可,能省掉大量重复计算。合并是只读的,任何一层数据的改动都要通过对应的 setter 完成,不允许在下游直接改汇总对象。

优先级表如下:

层级优先级生命周期典型字段
全局层低进程生命周期userId, locale, version
会话层中项目/任务生命周期projectRoot, gitBranch, openFiles
场景层高单次操作生命周期cursorLine, selectedText, taskGoal

3.4 清理、过期与默认值:防止上下文幽灵

上下文管理不能只管“写入和读取”,还得管“清理和过期”。我常用的触发策略有三种:任务完成时主动清场;场景切换时整体重建;超过预设时间窗口自动失效。其中“场景切换时整体重建”是最重要的一道防线,它能防止旧场景的数据污染新场景。

默认值兜底同样不能省。每个可空字段都要有一个合理的默认值:文件路径默认为空字符串,语言默认为 plaintext,分支默认为当前 HEAD 的短哈希。这样即使上下文收集缺失,下游功能也能继续运行而不是直接抛异常。

清理时还要考虑关联资源释放。比如上下文里存了一个文件监听器的引用,退出场景时要把监听器解绑,否则会出现“上下文已经销毁,监听器还在收数据”的泄漏场景,时间一长内存和回调都会堆积。

4. 实操:用 TypeScript 实现一个最小可用的 context-mode 模块

4.1 最小版本:ContextManager 的核心结构

理论说再多,不如直接上一份能跑的代码。这里我给出一个最小可用的 context-mode 实现,设计目标不是覆盖所有功能,而是把刚才讲的核心机制落到实处:作用域栈、层级合并、按场景切换。

type ScopeId = string; interface ScopeOptions { parentId?: ScopeId; ttlMs?: number; // 过期时间窗口 } class ContextManager { private scopes = new Map<ScopeId, Map<string, unknown>>(); private cache = new Map<ScopeId, Record<string, unknown>>(); private activeScopeId: ScopeId | null = null; createScope(id: ScopeId, options: ScopeOptions = {}) { const parent = options.parentId ? this.scopes.get(options.parentId) : undefined; const scope = new Map(parent ? Array.from(parent.entries()) : []); this.scopes.set(id, scope); if (options.ttlMs) { setTimeout(() => this.destroyScope(id), options.ttlMs); } return id; } setActiveScope(id: ScopeId) { if (!this.scopes.has(id)) { throw new Error(`Unknown scope: ${id}`); } this.activeScopeId = id; this.buildCache(id); } get<K extends keyof any>(key: K): unknown { if (!this.activeScopeId) throw new Error("No active scope"); return this.cache.get(this.activeScopeId)?.[key]; } set(key: string, value: unknown) { if (!this.activeScopeId) throw new Error("No active scope"); this.scopes.get(this.activeScopeId)!.set(key, value); this.buildCache(this.activeScopeId); } destroyScope(id: ScopeId) { this.scopes.delete(id); this.cache.delete(id); if (this.activeScopeId === id) { this.activeScopeId = null; } } dump(): Record<string, unknown> { if (!this.activeScopeId) return {}; return { ...this.cache.get(this.activeScopeId) }; } private buildCache(id: ScopeId) { const scope = this.scopes.get(id); if (!scope) return; const parent = this.scopes.get(this.parentOf(id)); const merged = { ...(parent ? this.cache.get(this.parentOf(id)) : {}) }; for (const [k, v] of scope.entries()) { merged[k] = v; } this.cache.set(id, merged); } private parentOf(id: ScopeId): ScopeId | null { // 实际项目中 Parent 关系需要显式存下来 // 这里简化处理:取创建时记录 return null; } }

这段代码用作用域栈实现了“子作用域可以读到父作用域的值,但写操作只落在本层”的效果。核心逻辑在buildCache:创建缓存时先拷贝父级缓存,再用当前作用域的键值对覆盖,读操作从缓存拿,写操作用 set 进当前层。因为缓存是自动重建的,所以不需要手动同步各层数据。

4.2 给一个“伪 AI 工具”接入上下文

上面 ContextManager 只是骨架,下面我用一个伪 AI 补全工具演示实际效果。假设工具根据当前文件路径、语言和光标所在行生成提示词:

const cm = new ContextManager(); const userScope = cm.createScope("user-001"); const projectScope = cm.createScope("project-042", { parentId: userScope }); const editScope = cm.createScope("edit-scene-01", { parentId: projectScope }); cm.setActiveScope(editScope); cm.set("cwd", "/repo/frontend"); cm.set("openFile", "src/index.ts"); cm.set("language", "typescript"); cm.set("cursorLine", 42); function buildPrompt(ctx: ContextManager) { const language = ctx.get("language") as string; const line = ctx.get("cursorLine") as number; const file = ctx.get("openFile") as string; return `In ${file}, line ${line}, writing ${language} code. Suggest completion:`; } console.log(buildPrompt(cm)); // "In src/index.ts, line 42, writing typescript code. Suggest completion:"

切换场景时,只需要重新创建 scope 并 setActiveScope。旧场景的数据因为作用域已经切走,不再会污染新场景。这个模式在真实项目中非常好用,因为所有下游功能不需要知道上下文从哪来,只需要ctx.get("key")。

4.3 上下文切换与释放的单测写法

写完实现,必须搭配单测,否则后面维护很容易退化。我习惯给 ContextManager 设计三组用例:创建后读到默认值、切换场景后旧值不可见、销毁后触发兜底逻辑。下面是一组最小的 Jest 风格测试:

test("scope switch should isolate old values", () => { const cm = new ContextManager(); const a = cm.createScope("scope-a"); const b = cm.createScope("scope-b"); cm.setActiveScope(a); cm.set("env", "prod"); cm.setActiveScope(b); cm.set("env", "dev"); expect(cm.get("env")).toBe("dev"); }); test("after destroy, reading defaults instead of throwing", () => { const cm = new ContextManager(); const s = cm.createScope("scope-c"); cm.setActiveScope(s); cm.set("projectRoot", "/tmp/demo"); cm.destroyScope(s); // 这里可以改成读取默认值,而不是直接抛错 expect(() => cm.get("projectRoot")).not.toThrow(); });

4.4 在编辑器插件中的真实接入经验

除了通用模块,我在编辑器插件场景里也实际用过类似设计。以我维护的一个内部 Vim 插件为例,插件需要在用户触发快捷键时弹出一个上下文面板,面板内容要随光标移动实时更新。如果不用 context-mode,面板逻辑就要自己监听光标事件、自己读取文件信息、自己维护状态;用了之后,光标事件只负责更新场景层里的cursorLine和selectedText,面板渲染逻辑只关心场景层数据,两者解耦非常彻底。

这种解耦带来的维护收益在插件功能变多之后尤其明显。今天面板要增加一个“显示当前 git 分支”的模块,不需要去翻面板渲染逻辑该怎么改,只需要在会话层里补一个gitBranch字段,面板模块自己读取即可。新增功能的成本从“到处找上下文”降为“新增一个字段”。

5. 常见问题与排查技巧实录

5.1 上下文串台:旧数据污染新场景

症状是切换场景后,工具仍然拿着上一个场景的信息做决策。比如用户从 A 项目切到 B 项目,AI 助手还是按 A 项目的依赖关系给建议。这通常发生在“手动修改全局状态”和“没有在切换场景时重建上下文”两种情形下。

排查思路:先看ContextManager.dump()输出的快照,对比切换前后的上下文字段。如果旧场景的字段还在,说明创建新场景时继承错了父作用域,或者忘了清缓存。我自己的教训是:任何时候都不要复用同一个作用域对象,切换场景必须createScope一个新的实例,即使内容完全相同。

5.2 敏感信息意外进入上下文

这个问题在 AI 场景里特别严重。用户选中了一段含有 API Key 的代码,AI 助手的上下文就带着密钥发给了模型服务;或者环境采集时把包含密码的 env 变量一股脑收进上下文,然后被日志打印出来。

解决思路分三层:第一层是采集白名单,只在明确必要的字段上收集;第二层是脱敏处理,所有疑似密钥的长字符串统一替换成[REDACTED],保住具体值之前做判断;第三层是禁止递归收集,特别是不要从文件内容里自动抽取看起来像密钥的值。这三个机制同时跑,才能把敏感信息进上下文的概率降到最低。

5.3 上下文过大导致性能降级

上下文太大会造成两个问题:内存膨胀导致操作卡顿;AI 请求时上下文塞满 Token,导致模型无法聚焦关键信息。很多人误以为 context-mode 应该尽量多存信息,恰恰相反,上下文的设计目标应该是“精准最小集”。

我常用的策略是分级缓存:全局层常驻内存,会话层只在切换时更新,场景层严格限制字段数。对于文件内容这类体积大的数据,不直接进上下文,而是存一个文件引用再加一段最近修改的 diff,下游需要完整文件时按引用读取。这个策略把上下文体积缩小了一个数量级。

5.4 上下文更新不及时

症状是工具显示的信息滞后,比如 git 分支已经切换了,面板还显示旧分支。原因多半是通过轮询获取状态,而不是事件驱动。轮询的间隔设短了浪费资源,设长了丢失实时性。

解决方案是让数据源主动通知:文件系统监听目录变化、Git 钩子触发分支更新、编辑器插件通过事件回调刷新cursorLine。ContextManager 提供一个统一的notify(key, newValue)入口,所有外部事件最终都走这个入口更新场景层字段,并触发对应的监听回调。这样上下文永远跟真实环境保持同步,而不是在读取的时候临时去现场猜。

5.5 问题排查速查表

症状可能原因排查要点修复建议
切换项目后仍显示旧项目数据场景未重建 / 父作用域错误查看 dump 快照,核对 scopeIdswitch 时强制 createScope
日志里出现密钥环境采集范围过大检查采集白名单和脱敏器命中情况收紧白名单,增加脱敏规则
AI 回答内容泛泛、不贴题上下文关键字段缺失查看发给模型的最终提示词补全文件路径、光标、代码结构等字段
面板信息延迟轮询间隔不合理查看更新时间间隔改事件驱动 notify
内存只增不减作用域未销毁 / 监听器泄漏查看活跃作用域数量任务完成强制 destroyScope
场景数据被异常覆盖下游直接改汇总对象检查是否有 setActiveScope 之外的写入路径只允许 setter 写入

这张表基本覆盖了我在多个项目里遇到的高频问题。每次上线新功能之前,我都会按这张表过一遍,能提前消掉大部分隐患。

6. context-mode 的更多落地场景:从工具扩展到业务

6.1 浏览器插件:感知当前网页并智能响应

浏览器插件的核心难点也在于上下文。一个阅读助手插件,点击按钮之后要提取当前网页正文、获取用户选中的文字、记住用户对类似页面的偏好设置。这些数据如果散落在各自的模块里,插件扩展新功能时就会束手束脚。用 context-mode 包装后,每次页面切换就是一个场景切换,插件可以在onTabActivated钩子里重建场景上下文,所有下游逻辑自动拿到新页面的信息。

6.2 自动化脚本:在正确目录用正确参数执行命令

写自动化脚本时,最容易踩的坑就是“目录不对导致命令跑错地方”。用 context-mode 的思路,脚本可以在启动时把命令行参数、当前目录、目标环境一次收集进上下文,所有后续步骤都从上下文读取路径和相关变量,而不是在脚本里到处cd和读取环境变量。这样脚本本身变成纯逻辑,环境适配问题全部交给上下文层处理。

6.3 CI/CD 构建任务:为每个构建隔离上下文

在 CI 流水线里,多个并行任务共用同一个执行器时,上下文污染风险尤其突出。我曾经维护过一个流水线系统,构建日志偶尔会把另一个分支的版本号打进来。后来给每个构建任务创建一个独立的作用域,把仓库、分支、提交哈希、依赖缓存路径全部收进作用域,并行任务之间的状态彻底隔离,这个偶发问题就再没出现过。对流水线工具来说,context-mode 不只是优化体验,更是正确性的保障。

6.4 个人知识库工具:基于场景召回相关信息

再往远处想一步,context-mode 的概念也能用在知识库场景里。当你打开某个项目的文档时,如果笔记工具能感知当前项目名、当前任务阶段、最近的阅读记录,它就能自动把相关的条目召回并展示出来。这种体验本质上也是上下文驱动的。知识库能不能“聪明”,往往不取决于检索算法有多高级,而取决于你是否把用户的真实场景收齐了。

7. 我的实操体会:先列上下文清单,再写业务代码

根据我个人的经验,做任何带状态的工具,都要先花小半天列一份“上下文清单”,再动手写业务逻辑。这份清单就三列:字段名、来源渠道、生命周期。字段想清楚了,代码的结构自然就清晰了。很多项目做到一半觉得状态混乱,回溯就会发现源头是开工时没想清楚到底要维护哪些上下文。

最后再分享一个小技巧:给每条上下文记录打上时间戳和来源标记,也就是记下这个值是从环境采集来的,还是用户显式注入的,还是行为推断出来的。调试时这件事能省一半的时间,因为你可以直接判断是某条数据来源出错,还是合入规则出错,还是下游用错了字段。这个习惯我保留了好几年,每次排查上下文问题都非常有用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进