
过去两个月我一直在做一个小工具的二次重构。工具本身功能不算少快捷键、面板、自动脚本都齐了可身边的人用起来总觉得别扭我一开始以为是交互不够炫后来反复扒日志才意识到问题出在一个特别基础的地方——工具并不知道用户此刻在哪个场景里。我按一次快捷键它永远执行同一套逻辑我切到另一个项目、换了一种文件类型它依然用上一步的默认行为。就好比你跟一个人说话他每句话都答非所问因为他根本不知道你在什么处境里。后来我把系统的核心机制改成了context-mode上下文模式让每个操作在触发时先做一次环境感知再根据感知结果决定执行哪个动作。改完之后同一个快捷键在不同场景下能自动给出不同反应项目里的人也终于不再频繁抱怨这个工具怎么这么笨。这篇文章就把我这次重构的思路、上下文信号设计、打分路由机制、遇到的四个典型翻车现场以及最后沉淀下来的一些边界原则完整记录下来给正在做类似自适应工具的朋友当个参考。1. context-mode是什么一次操作三种信息一个决策1.1 从右键菜单说起理解上下文为什么重要很多人第一次接触上下文驱动这个概念其实早就在用了——右键菜单就是最经典的例子。你在桌面上点右键菜单里有新建文件夹显示设置你在代码编辑器里点右键菜单变成转到定义重构运行测试。同一个动作点右键系统会根据你点击的位置、当前打开的窗口、选中的对象给出完全不同的候选操作。这就是上下文模式最朴素的形态行为不是固定的而是随着当前环境动态生成。但为什么右键菜单这种几十年前的设计放到今天反而成了热词因为过去大部分工具只是把上下文用在了表层——比如菜单项不同。现在的工具已经走到了更深的层级同一个动作连执行目标、执行参数、执行顺序都会因为上下文而改变。举个我实际遇到的例子我工具里有一个快速打开的快捷键重构前它只打开一个固定面板重构后当光标在一个报错日志上时它直接打开对应文件的对应行并把报错关键字带入搜索框省掉了中间的查文件、翻行号、复制关键字三步操作。信息没变变的只是它知道你正在看什么。1.2 上下文模式的三个要素动作、对象、环境我想了很久把 context-mode 的运行逻辑拆成了三个部分任何上下文驱动的系统都逃不开这三个问题动作Action工具现在能做哪些事。这是能力清单对应代码里的函数、脚本、插件。对象Target动作要作用在什么东西上。用户在当前界面选中的文件、文字、按钮、数据项。环境Environment动作发生的场景。当前项目、当前分支、当前文件类型、最近的操作序列、时间窗口。传统工具把这三者写死成固定组合比如快捷键A 对当前文件执行格式化。context-mode 则是把三者拆开让系统在运行时自己去组装动作还是那个动作但对象从当前文件变成了当前上下文中最匹配的那个文件执行参数也从固定值变成了根据环境推算出来的动态值。这种拆法最直接的好处是用户需要记忆的快捷键与行为的对应关系急剧减少——你不用为每个场景学一套按键系统会替你选。1.3 对比传统模式切换省掉的是切换这个动作本身过去大部分软件解决多场景需求的方式是模式切换Photoshop 里切画笔/钢笔/移动工具Vim 里切插入/普通/可视模式很多编辑器里切调试模式发布模式。模式切换的优点是行为完全确定缺点是用户必须记得自己现在身处哪个模式并且要手动做切换动作。一旦忘记切换行为就会出错——在 Vim 里按了 i 才发现自己其实想要移动光标这种错几乎每个人都犯过。context-mode 的野心在于彻底抹掉切换这一步。它不是不区分模式而是让系统通过环境信号自动判断当前应该处于哪种模式。我把这两种设计的区别做了个简单对照维度传统模式切换上下文模式用户记忆负担高得记住当前在哪个模式低系统自动推断行为确定性完全确定不会猜错依赖信号质量可能猜错学习成本需要学习模式语法零学习按直觉操作主要风险忘记切换导致误操作误判导致自动行为偏离预期适合场景操作对象单一、行为差异明显操作对象多变、行为需要精细匹配结论很清晰context-mode 不是万能的但它特别适合操作种类多、对象频繁变化、用户不愿意花成本学习的场景。这恰恰是现代效率工具的普遍痛点也解释了为什么这个概念最近在开发者社区里被反复提及。2. 设计一个可落地的上下文模型从信号采集到动作打分2.1 场景设定给动作调度层加上感知能力我这次重构的对象是一套统一动作调度层代码里的核心叫ActionDispatcher。重构前它就是个简单的注册表加分发器根据快捷键去查表查到了就执行。重构的思路是给它加一个感知环节所有功能先注册成动作Action每个动作声明自己需要什么样的上下文系统在快捷键触发时先收集当前上下文信号再计算每个动作的匹配度最后执行最匹配的那个。这个场景有一个很明显的特点动作数量在不断增加而且很多动作的目标对象高度相似。比如我有打开终端在项目根目录打开终端在当前文件目录打开终端三个动作传统做法是分配三个快捷键实际用下来大多数用户只记得第一个而在 context-mode 里这三个可以合并成一个打开终端系统根据当前有没有选中文件光标在不在项目管理器上自动决定终端落在哪个目录。动作还是那几个但用户感知到的是这个工具终于知道我要在哪开终端了。2.2 上下文信号的设计清单可落地的 context-mode 第一步是把上下文信号明确定义出来。我最后收敛成了五类信号每一类都对应一组获取方式信号类型具体内容获取来源刷新时机项目身份当前所属项目 ID、Git 分支编辑器工作区、git 命令项目切换时文件对象当前文件路径、扩展名、选中文本长度编辑器事件光标移动/选中变化时操作历史最近 10 个动作类型调度层内部日志每次动作执行后视觉焦点当前焦点在哪个面板/区域UI 框架焦点事件焦点切换时时间上下文距上次类似操作的时间间隔时间戳实时这里有一个很容易被忽略的细节信号不必全量收集只需要收集能改变决策结果的那部分。我一开始把屏幕分辨率、窗口大小、系统主题都塞进了上下文结构里自以为很全面后来发现这些信号对动作选择没有任何影响反而让缓存命中率大幅下降。设计信号清单时一个很好的检验方法是问自己如果这个信号变了用户期望的行为会变吗不会变的信号就不要进上下文。2.3 用打分函数做路由为什么我不用机器学习拿到信号之后关键问题是怎么决定执行哪个动作。这里我只做了一件事给每个动作写一个评分函数Score Function让系统根据信号对候选动作打分分数最高的胜出。核心逻辑简化成代码如下// 动作的上下文评分器返回该动作与当前上下文的匹配程度 function scoreAction(action: Action, ctx: Context): number { let score 0; // 1. 项目级匹配动作限定的项目与当前项目一致权重最高 if (action.scopeProject action.scopeProject ctx.projectId) { score 50; } // 2. 文件类型匹配动作适用的扩展名包含当前文件类型 if ( action.fileTypes action.fileTypes.length 0 ctx.fileExt action.fileTypes.includes(ctx.fileExt) ) { score 30; } // 3. 历史动作衔接动作声明了上一个动作是我最自然的后续 if (action.expectedPrevAction action.expectedPrevAction ctx.lastAction) { score 20; } // 4. 焦点区域匹配动作关心的 UI 区域恰好是当前焦点所在 if (action.focusArea action.focusArea ctx.focusArea) { score 15; } return score; } // 调度层的路由决策大于阈值才自动执行否则交给用户手动选择 function dispatch(actionList: Action[], ctx: Context): Action | null { const best actionList .map(action ({ action, score: scoreAction(action, ctx) })) .sort((a, b) b.score - a.score)[0]; if (best best.score 50) { return best.action; } return null; // 低于阈值进入手动候选列表 }有人会问为什么不训练一个模型来做分类我的回答是在这个场景里可解释性和可控性远比聪明重要。打分函数每一分加在哪里都是透明的用户问为什么它执行了 A 而不是 B时我可以把分值逐条列出来当某个动作频繁被误选时我可以直接调它的权重而不是重新训练模型然后面对一个无法解释的黑盒。打分函数看似简单但它给人保留了完整的干预入口这对工具类软件来说是压倒性的优势。2.4 阈值与兜底策略分数低的时候怎么办再完美的打分也一定会出现没有动作匹配的情况。这时候很多初版实现会犯一个错误强行选一个分数相对最高的动作执行结果自然离谱。我踩了一次之后就定了规矩——匹配分低于设定阈值时系统不自动执行而是进入候选列表让用户手动选择。这样做的代价是偶尔多点一下鼠标收益是避免了一次可能造成破坏的自动行为。阈值设多少没有绝对标准我的经验是先设 50然后跑一周日志把那些用户执行完又立刻撤销/修正的案例捞出来看是不是阈值太低导致误选再微调。3. 实现中的四个真实翻车现场每一个都值得引以为戒3.1 刷新时机不对执行动作打在了旧上下文上第一个大坑出现在上下文刷新上。我最初的设计是每次动作执行前刷新全部信号听起来没问题实际操作中却发现编辑器弹出一个异步事件比如保存文件我监听到事件后立刻刷新filePath和fileExt但编辑器内部还没有完成索引更新这时候调度层读取到的文件内容还是旧的动作执行时拿到的对象已经过期。具体表现是我保存一个文件后立刻按格式化快捷键格式化工具偶发报文件不存在因为那一刻文件的内部状态还没就绪。排查到最后问题出在刷新和就绪之间有一段肉眼不可见的时间差。我的修复方案是给上下文刷新加了一个短延迟加幂等校验监听事件后不立刻刷新而是 50ms 后再读一次同时把这个延迟过程中收到的相同事件合并掉。50ms 对用户来说完全无感但恰好避开了编辑器内部状态同步的窗口期。3.2 上下文栈只增不减历史信号全是旧账第二个坑是我的最近操作历史信号设计成了只追加的数组每次动作执行就 push 一条记录。跑了几天之后这个数组越来越长而我的打分函数里有个上一个动作衔接的匹配规则本意是识别连续操作流结果发现最后匹配成功的永远是几个星期前的老动作——因为那些老动作在数组里占了绝大多数新动作反而被稀释了。这个案例很能说明 context-mode 的一个核心难点上下文不是越多越好而是要时新。修复方式是给历史记录加了一个固定容量 10 的环形缓冲并且每条记录带上时间戳匹配时只取最近 60 秒内的动作参与打分。改了之后衔接上一个动作的匹配准确率从不到 40% 提升到了 87%。3.3 阈值过低引发的误触发自动执行了用户完全不想要的动作第三个坑来自对聪明的过度追求。当时我心里挂着让用户少点一次鼠标的目标把阈值从 50 降到了 25。结果某个文件类型比较冷门、项目匹配又没命中的情况下系统靠着焦点区域匹配 15 分 历史动作衔接 20 分凑够了 35 分自动执行了一个跟用户当前意图八竿子打不着的动作直接把用户正在编辑的内容替换成了模板文本。这个事故的直接原因是阈值设置过于激进根本原因是我忘了 context-mode 的首要原则不是多做事而是不做错事。事后我把阈值恢复了 50并且加了一条硬性约束凡是修改类动作写文件、改配置、发消息打分必须达到 80 以上才允许自动执行60 到 80 之间只做轻量提示。这等于把动作按照危险程度分成了几个等级不同等级设不同的自动执行门槛。这个思想后来成了我整个调度层的安全基石。3.4 上下文完全丢失找不到任何信号时的降级策略最后一个坑发生在极端情况下系统刚启动、还没打开任何文件、焦点在系统级窗口上这时候上下文信号几乎全为空。我最初的处理是让调度层抛一个未找到匹配动作的提示结果用户反馈工具是不是崩了。后来我专门为这种状态设计了一个全局默认上下文当信号不足时系统自动回退到一套预设的默认动作列表——比如打开设置、打开最近项目、显示动作帮助。这样既不会误自动执行危险操作又保证工具在任何状态下都可用。4. 让上下文飞轮转起来缓存与异步刷新的性能优化4.1 上下文缓存的关键是 TTL不是无脑全量信号采集听起来简单真正做起来才发现性能是硬伤。像git branch这种命令每次触发快捷键都执行一遍延迟从几十毫秒到几百毫秒不等如果做全量信号采集一次快捷键操作没个一两百毫秒根本响应不完。我的方案是给每类信号设置独立的TTL过期时间项目 ID 这种变化极慢的信号TTL 设 5 分钟文件类型这种每次切换都会变的信号TTL 设 5 秒焦点区域这种经常变化的信号TTL 设 1 秒。每次取信号时先查缓存没过期就直接用过期才去重新获取。4.2 异步刷新与事件合并别让 UI 卡顿另一个性能优化是异步刷新。我早期版本里的信号刷新是同步的导致快捷键触发时会短暂阻塞 UI 线程。改成异步之后又遇到了新问题快速连续切换文件时事件一个接一个刷新任务堆积有些旧任务的回调还会覆盖掉新状态。解决办法是给刷新任务加上合并标志同一个信号来源的刷新任务如果在队列中已存在就把它替换成最新的请求而不是排队执行旧任务。这招在实际使用中效果非常明显我测过一次连续快速切换十几个文件全部上下文都能在最新状态下就绪没有一次被旧数据覆盖。4.3 重构前后的实测对比为了让你更有体感我把重构前全量同步刷新和重构后TTL 缓存加异步合并的关键指标做了个对比同一台机器上、同样的操作序列指标重构前重构后快捷键平均响应延迟约 140ms约 18ms写文件类动作误触发次数一周6 次0 次上下文信号缓存命中率0%约 89%上下文刷新任务积压数经常超过 20最大 2从 140ms 降到 18ms 的收益不是体感上的快了一点而是让工具从能感觉到卡顿变成了条件反射级响应。很难说清楚这种改变对一个高频快捷键工具的意义有多大——当工具响应足够快用户的使用频率会指数级上升很多原本觉得麻烦的操作也开始愿意高频尝试了。5. context-mode 的边界与原则别把它做成玄学5.1 可解释性优先于智能我曾经给动作打分加过一个模糊匹配逻辑想让系统在用户没说清楚想要什么时也能猜个八九不离十。后来发现这个逻辑害人不浅——用户完全搞不懂为什么触发了一个动作因为分数来源里的模糊相似度无法被直观理解。最后我把这个模块删了替换成一个简洁的规则每个动作在候选列表里都显示一行为什么推荐直接把加分项列出来。比如推荐打开终端因为当前焦点在项目管理器15且项目配置了终端工具30。只有用户能看懂为什么他才愿意在出错时帮你调否则他只会觉得这个工具疯了。5.2 永远保留手动覆盖通道context-mode 最容易犯的错是把自动判断变成唯一入口。我在加自动路由的同时给每一个动作保留了三种手动触发方式快捷键直调跳过打分、命令面板手动选择、候选列表里的取消按钮。手动覆盖通道存在的意义不只是兜底它还有一个心理作用——用户知道这个工具最后还是我说了算才会愿意放开手让系统去自动执行。如果不保留手动覆盖用户会本能地对所有自动行为保持警惕context-mode 的体验反而会变差。5.3 最小采集别让上下文变成隐私收集器最后一条原则也是我在设计之初差点忽略的上下文信号的采集目标必须服务于动作决策本身而不是越全越好。我一度想采集用户的完整操作日志理由是这样匹配会更精准后来做了个简单的隐私评估发现很多信号跟动作决策毫无关系纯粹是好奇。现在我的准则是——采集的每一个信号都必须能回答它会影响哪个动作的选择否则就不采集。这既是对用户负责也是对自己的工具负责数据面越小越容易保证正确性、越容易排查问题。context-mode 的意义是让工具更懂人不是让人在工具面前裸奔。我在写这套系统的时候身边有个同事问过我一句你是不是想把工具做得跟人一样善解人意我当时的回答是不我没想让它善解人意我只想让它在合适的时刻说出合适的话。工具不是人它永远不可能也不需要完全理解用户的复杂意图它要做的只是在有限的信号里把最可能正确的动作推出来并且在不确定时老老实实承认我不确定。这个边界想清楚后context-mode 从玄学变成了一门可以调试、可以优化的工程学。如果你也在做一个功能不少但用起来总差点意思的工具不妨回头看看它是不是少了上下文感知这一步。核心思路其实就三句话把动作和场景拆开用可解释的规则做匹配永远给用户留着手动干预的手柄。这套方法不需要什么高深技术花一个周末就能在现有框架里铺起来但带来的体验提升可能比你再添十个新功能都明显。