ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Context Mode实战:从上下文采集到状态机切换的完整方案

Context Mode实战:从上下文采集到状态机切换的完整方案

大概去年秋天,我们团队内部那套用了两年的运营工单系统,连续被几名核心用户吐槽,内容出奇一致:为什么每次新建工单,都要重新选项目、选环境、选时间范围?系统明明知道我刚在弄哪件事。这个场景,后来就成了我们内部一个叫 context-mode 的组件要解决的核心问题。我前后花了一个月把机制搭起来,又花了两个月在真实业务里打磨。它不是一个吹出来的概念,而是一套能采集上下文、推断当前场景、自动切换系统行为模式的可落地机制。这篇文章把从需求拆解到实测踩坑的整个过程写出来,尤其适合做 B 端工具、编辑器、助手类应用的朋友参考。

1. 为什么需要 Context Mode:从一次翻车现场说起

1.1 用户抱怨的本质不是“点击太多”

我们的工单系统操作路径确实不短:新建一张告警工单,选项目、选环境、选时间范围、选负责人,每一步都要手动来一遍。收到的反馈也集中在“操作太繁琐”上。我最初的解决办法很朴素,把用户上次的选择缓存下来,下次自动带出来。这个方案上线以后,一部分用户满意了,另一部分用户却骂得更凶。

原因很典型。一个运营同学上午在处理生产环境告警,下午在做版本发布复盘。系统记住了“上次你选了生产环境”,于是下午每次新建工单还是默认生产环境,他每建一张单子都要手动改成测试环境。这种感觉就像刚放下扳手去开会,回到工位系统还在问“要不要继续修机器”。用户真正烦的不是多点两次鼠标,而是系统完全不理解他当前的行为意图。

这个案例让我意识到,单纯的“记住上次选择”只是单向缓存,没有能力响应场景变化。Context Mode 要做的,是把“用户在什么场景下、正在做什么、接下来大概率要做什么”这些信息组织起来,让系统在当前场景下给出正确的默认值,而不是每次都从零开始。

1.2 Context Mode 和“记忆上一次选择”是两回事

为了避免这个词被滥用,先界定一下定义。Context Mode 包含两层含义:Context 指围绕当前任务的上下文信息,Mode 指系统在特定上下文下采用的一套稳定行为配置。

举一个生活化的类比:手机上的驾驶模式,就是根据“你在开车”这个上下文,自动把通知策略、界面布局切换成适合驾驶的那套配置。Context Mode 做的事情与之类似,只不过发生在业务系统内部——系统根据“你正在新建告警工单”“你刚刚批量选了二十台机器”这些线索,自动切到对应的操作模式。

我整理过一个对比表,能清楚看出它和普通记忆功能的差别:

能力维度普通缓存/记住上次选择Context Mode
保存用户上次的选择支持支持
感知当前行为与场景变化不支持支持
根据场景自动切换行为配置不支持支持
多场景并存时的冲突处理无有优先级与仲裁
冷启动(新用户)直接丢缓存默认模式+渐进推断

所以,“记住上次选择”只是 Context Mode 里一个很小的环节,真正的核心在识别场景并切换模式。这也是整个项目里最花心思、最值得写下来的部分。

2. 上下文怎么采集、分类与保鲜:三路输入与两级生命周期

2.1 三类上下文来源

设计初期,我把系统里可用的信号全部列出来,最终归成了三类:显式输入、行为信号、环境信号。每一类的可信度、时效性、采集成本都不一样,接入时必须有差别对待。

显式输入是用户主动告诉系统的信息,比如手动切换项目、勾选“跟随当前项目”、把某个模式固定住。这类信息可信度最高,应该拥有最高优先级。只要显式输入存在,行为信号和环境信号都无权覆盖它。

行为信号来自用户与系统的交互,包括页面停留、按钮点击、列表多选、输入框聚焦、操作频率等。这类信息需要积累到一定量才有意义,单次点击说明不了什么,连续打开五台机器的详情页,才能推断用户在做批量巡检。

环境信号包括当前时间、设备类型、登录位置、网络状态、系统负载等。它比较粗粒度,但有的时候很管用。比如晚上十点以后从远程登录进来,大概率是在处理紧急告警,这时候把默认环境切到生产环境反而是合理的。

三类信息的可信度排序是显式大于行为、行为大于环境。冲突的时候,低可信度信号让路。比如行为信号推断用户在批量操作,但用户显式切到了单条编辑模式,那么就应该以显式输入为准。

2.2 两级生命周期与保鲜机制

另一个容易翻车的地方是上下文的生命周期。我一开始只设计了“记住多久”的简单过期时间,后来发现根本不通用。有些上下文是操作级的,比如用户正在编辑的临时草稿,任务结束就该清掉;有些上下文是偏好级的,比如用户长期默认负责告警工单,能管好几个星期。

所以我们把上下文分成两级:会话级和持久级。会话级上下文绑定当前任务流,用户切换模式或者一段时间没有动作,就会自动清空。持久级上下文保存用户长期偏好,但也不是永久有效,每条都带过期时间,超过有效期自动失效。

保鲜很重要,因为过期上下文比没有上下文更危险。系统拿着昨天的场景推断今天的操作,用户会觉得这个产品“聪明过头”了。我给每条上下文都打上了 updatedAt 和 expiresAt 两个时间戳,信度会随时间衰减。

衰减函数我用了最简单的一种:有效期内信度按剩余时间比例线性下降,比如四十分钟前的行为信号衰减系数是 0.8,过期后权重归零。效果已经够用。写代码时别引入复杂的指数衰减,收益很低,反而增加排查难度。

2.3 从上下文到模式:加权打分

上下文本身不产生价值,只有转化为模式判断才有意义。我采用的是给模式打模板分数的方式:每种模式挂一组特征及权重,实时把当前上下文与特征模板匹配,算出候选模式的得分。

特征QUICK_EDIT 权重BATCH_OPERATION 权重REVIEW 权重
最近页面等于新建工单0.600.2
批量选择数量大于等于五00.80.2
上一个动作是批量应用00.50
最近一小时有异常关闭行为0.200.6

得分计算就是一个加权求和:score(mode) = Σ(特征权重 × 信度 × 时间衰减系数)。得到每个模式的分数后,再加两个约束再决定是否切换:一是最高分必须超过阈值 0.6;二是最高分和第二名的差距必须大于 0.15,否则保持现状。

这个“差距门槛”是实战里总结出来的。没有它,系统会在两个模式之间来回抖,用户会以为产品抽风了。宁可慢半拍再切换,也不要频繁跳来跳去。

3. 模式切换不能靠猜:状态机加规则优先级

3.1 推理引擎只提建议,状态机才做决定

打分推理的工作流其实很脆弱,如果直接让它控制界面,会出现严重的抖动问题。用户在批量操作和单条编辑之间反复切换时,分数在两个候选模式之间来回横跳,界面也跟着跳,体验非常差。

更麻烦的是,有些模式切换是不可逆的。比如用户正在批量执行更新,如果中途切走,批量任务的状态就悬空了。这种情况下,系统不能因为一道分数变化就自作主张。所以我把架构拆成了两层:推理引擎只负责产生“建议切到什么模式”,真正的模式管理器是独立的状态机,负责校验迁移合法性、执行守卫条件、记录切换日志。

这样做的收益很明显:界面展示的是状态机稳定后的结果,不会被推理引擎的实时波动带偏;任何模式下发生的问题,都可以通过切换日志回溯,定位是推理错了,还是守卫条件漏了。

3.2 状态定义与转移约束

我定义了四个核心模式,加上一个显式返回的兜底路径。NORMAL 是默认浏览模式;EDITING 是正在编辑单条工单;BATCH 是批量操作状态;REVIEW 是批量操作完成后的核对状态。

当前模式允许切换至切换条件
NORMALEDITING、BATCH有新建或编辑意图;多选数量达到阈值
EDITINGNORMAL、BATCH显式返回;多选数量超过阈值
BATCHREVIEW、NORMAL批量完成或取消
REVIEWNORMAL用户确认完成

状态机只允许表格里的转移路径,不在表格里的迁移直接被拒绝。守卫条件同样挂在这里。比如 EDITING 状态下有未保存的草稿,禁止切到 BATCH;BATCH 执行中禁止切到 NORMAL,避免批量任务中途丢状态。

3.3 一个可以直接抄的代码骨架

状态机的核心逻辑我抽出来也就一百多行,去掉业务细节后骨架长这样:

type Mode = 'NORMAL' | 'EDITING' | 'BATCH' | 'REVIEW'; const transitions: Record<Mode, Mode[]> = { NORMAL: ['EDITING', 'BATCH'], EDITING: ['NORMAL', 'BATCH'], BATCH: ['REVIEW', 'NORMAL'], REVIEW: ['NORMAL'], }; interface SwitchResult { ok: boolean; changed: boolean; reason?: string; } function requestSwitch(nextMode: Mode): SwitchResult { const current = modeStore.getState().mode; if (current === nextMode) { return { ok: true, changed: false }; } if (!transitions[current].includes(nextMode)) { return { ok: false, reason: `forbidden:${current}->${nextMode}` }; } const guard = guards[nextMode]; if (guard && !guard(contextStore.getSnapshot())) { return { ok: false, reason: 'guard_blocked' }; } modeStore.setState({ mode: nextMode }); return { ok: true, changed: true }; }

守卫函数是一个简单的断言,比如 BATCH 模式的守卫会检查“当前没有正在执行的批量任务”“没有未保存草稿”。这样每个模式都有独立的准入逻辑,新增模式时不用改状态机主干,只需要扩表扩展,基本符合开闭原则。我建议把这个骨架沉淀成公共组件,所有需要模式切换的业务共用,避免每个团队各写一套自己的判断逻辑。

4. 数据模型与接口设计:别让 Context 变成垃圾场

4.1 为什么原始事件流不能直接当 Context

第一版实现时,我偷了个懒,把所有采集到的用户事件原样塞进一个全局对象里,想着反正数据都在,随时可以重新算。结果是灾难:对象越滚越大,无法区分哪些信号还有效,打分逻辑读起来一片浆糊,排查问题时根本不知道某条数据是谁、什么时候、依据什么写进来的。

后来我把数据结构彻底重构了,每条上下文都变成一个带元数据的结构化条目。核心接口如下:

interface ContextEntry { key: string; // 唯一标识,如 task.currentProject value: unknown; // 业务值 source: 'explicit' | 'behavior' | 'environment' | 'inferred'; confidence: number; // 0~1,显式输入固定为 1 updatedAt: number; // 最后更新时间戳 expiresAt: number; // 过期时间戳 scope: 'session' | 'persistent'; // 会话级还是持久级 version: number; // 冲突仲裁用 namespace: string; // 任务/业务命名空间 }

引入 namespace 是关键,后面讲上下文串扰时会重点提。version 字段用于同 key 写入时的版本仲裁,避免旧数据覆盖新数据。每个字段都有明确用途,排查问题时可以从一条记录看出它的来源、浓度和寿命,这比盯着一个黑盒对象强太多了。

4.2 对外只暴露五个操作

对外接口我控制得很克制,只保留五个操作,避免让业务层过度理解内部机制。所有业务方只需要知道这几个方法。

方法用途关键行为
writeContext(entry)写入一条上下文同 key 按版本合并
readContext(key, namespace)读取上下文自动过滤过期条目
watchContext(handler)订阅上下文变化用于 UI 响应
suggestMode()返回推理建议只给建议不落状态
switchMode(mode)请求切换模式走状态机校验

写入时的合并规则要严格。行为信号写入时,如果同 key 已经存在显式输入且版本号更高,行为信号直接丢弃;显式输入写入时可以强制覆盖任何低版本条目。这个规则解决了“用户明明手动选了,行为推断还想改回去”的问题。

4.3 接入旧系统:中间件和事件总线

很多团队在引入这类机制时,喜欢把旧代码全面改造一遍,结果带来的回归问题比收益还大。我用的方案是不动旧业务代码,加一层适配中间件。旧系统里已有的路由跳转、按钮点击、表单提交,通过事件总线统一转发给上下文组件,由上下文组件决定哪些信号值得写入。

适配器模式的好处是,业务代码完全不知道 context-mode 的存在,后续把机制摘掉也不会影响主流程。我给每个页面接入时只做两件事:在这个页面的生命周期回调里上报页面类型,在关键操作按钮上挂一个统一的数据属性。上报逻辑集中在一个公共函数里,新页面接入时成本很低。

另一个重要的兜底手段是给用户一个“重置上下文”的入口。不管机制多聪明,总会遇到推断错误的时候,与其让用户忍受错误的默认值,不如让他一键清空当前会话上下文,回到最基础的默认模式。

5. 实测中的坑:上下文串扰、冷启动与性能黑洞

5.1 上下文串扰:最常见也最隐蔽

上线两周后,我们遇到一个奇怪的问题。一个用户同时处理两个任务,一边准备版本发布,一边响应线上告警。系统经常把发布任务的上下文带到告警工单里,导致默认项目、默认时间范围全是错的。

排查发现,根因是上下文没有做命名空间隔离。持久级的“当前项目”被存成一个全局 key,发布任务写了它,告警任务也读了它,互相污染。修复分三步。第一,所有 key 必须带 namespace,不同任务不同前缀;第二,模式切换时,把上一模式的持久级条目降级为会话级,降低影响半径;第三,保留手动重置入口。

我把常见现象和对应处理整理成了表,排查效率高了很多:

现象可能原因处理方式
新工单带出了上个任务的默认值持久上下文无命名空间隔离按任务类型划分 namespace
两个模式来回跳缺少得分差距门槛或状态机增加 gap 阈值和转移约束
切到批量模式后界面回不来切换前未做守卫检查增加 guard:有未保存内容禁止切换

5.2 冷启动:没有上下文时怎么办

冷启动是另一个坑。新用户没有任何历史行为数据,如果系统强行推断场景,十次有九次是错的。我处理的原则是:没有证据就不推断。新用户进入系统后,默认使用最保守的 NORMAL 模式,所有敏感默认值都用全局配置兜底。

同时做一点渐进式推断。用户操作数量不足五条时,suspend 所有自动模式切换;达到阈值后,才开始产生建议。而且第一次自动切换前,我会在界面上弹一个非阻塞提示,用户可以选择接受、忽略或永久关闭。用户对自动切换的反馈都会被记下来,作为后续调整权重和阈值的参考。

事实证明,用户不怕系统给建议,怕的是系统在他没同意的情况下做出不可逆变更。所以所有自动切换都只影响默认值和界面布局,绝不触碰用户已经提交的数据。

5.3 性能与存储优化

上下文机制如果实现得很笨,会变成性能黑洞。第一版我是每个事件都实时写入全局状态,导致高频操作时写放大严重,页面卡顿明显。后来做了三个关键优化。

第一,写入防抖。行为信号统一走一个 800 毫秒的防抖队列,短时间内多次点击只合并成一次写入。显式输入不走队列,实时写入保证及时性。第二,持久级上下文懒加载。只在会话开始时读一次,后续所有更新都先落在内存缓存里,等防抖队列触发才落盘。第三,给上下文条目加上 LRU 上限,默认最多保留一千条,超出后移除最久未访问的条目;过期条目由定时器每小时清扫一次。

还有一条原则要守住:路由跳转和点击拦截的路径上,绝对不能出现同步的磁盘或网络读写。这个路径上的任何阻塞都会直接体现在用户操作延迟上,所有 IO 都必须异步化。

6. 后续可以怎么扩展:从模式切换到更多场景

机制稳定之后,我把同一套上下文模型复用到通知聚合上。以前用户会同时收到好几类推送,系统无法分辨他此刻在忙什么。现在通知模块读取当前模式和会话级上下文,用户在批量操作时只显示进度类提醒,在编辑状态时只显示拦截类错误,其余消息推迟到模式切回 NORMAL 再弹出。这个改动很小,但用户感知提升非常明显。

我还有一个持续在做的方向:把用户的纠错行为当作训练数据。用户拒绝了某个自动切换,说明当前特征权重与他的真实意图不符。虽然短期内还没办法自动调参,但每条纠错记录都会保存下来,等到积累了足够数据再统一分析,调整模板权重。

最后说一点个人体会。上下文不是越多越好,我把几十种信号接入后又删掉了一大半,留下的都是真正预测价值的信号。判断标准只有一个:这条上下文如果删掉,用户行为的结果会不会明显变差。如果不会,它就是噪音。另外,永远给用户留一个手动控制和重置的出口,再聪明的推断算法也有边界,让用户随时可以回到最确定的操作方式,比追求百分之百的自动准确率更重要。

RELATED READING

延伸阅读

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