ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

context-mode 上下文模式管理:从设计到落地的通用架构思路

context-mode 上下文模式管理:从设计到落地的通用架构思路

1. 从“context-mode”这个词说起:它到底指什么

第一次看到“context-mode”这个标题,很多人会愣一下——它不像“XX管理系统”或“XX爬虫”那样一眼能看出用途。我最初接触这个词是在做对话系统上下文管理的时候,当时团队里有人提出“给模型加一个 context-mode 开关”,意思是让系统在不同场景下切换对上下文的理解方式。后来我发现,这个词在不同圈子里含义差别挺大:做前端路由的人会想到“上下文模式匹配”,做状态管理的人会想到“上下文作用域”,做AI应用的人则会想到“对话上下文的组织策略”。

所以这篇内容我打算把“context-mode”当作一个上下文模式管理的通用设计思路来拆解。它不是一个具体的库或框架,而是一类问题的统称:当你的系统需要同时处理多种上下文来源、多种上下文生命周期、多种上下文优先级时,怎么用一种可切换、可配置、可观测的方式把它们管起来。适合谁看?如果你正在做聊天机器人、IDE插件、低代码平台、或者任何需要“记住当前状态并据此决策”的系统,这篇内容应该能给你一些可直接抄作业的思路。

我先把结论放在前面:context-mode 的核心价值不在于“多了一个模式”,而在于把隐式的上下文假设显式化。大多数系统在早期都是“全局一个上下文”,所有逻辑都默认读同一份状态;等到场景变多,代码里就会到处出现if (isChat) {...} else if (isEdit) {...}这种分支。context-mode 要解决的就是把这种分支收敛成可配置的模式定义,让上下文的选择变成数据而不是代码。

提示:如果你现在的系统里已经有超过三处“根据当前场景读不同状态”的判断,那基本就是 context-mode 该出场的时候了。

2. 为什么需要上下文模式:三个真实场景的痛点

2.1 场景一:同一个后端服务要同时服务多种前端

我之前参与过一个内部工具平台,后端是一套 API,前端却有三种形态:Web 管理台、IDE 插件、命令行工具。最开始后端只按“用户ID”来组织上下文,结果 IDE 插件需要“当前打开的文件 + 光标位置”,命令行需要“当前工作目录 + 最近命令”,Web 管理台需要“当前选中的项目 + 分页状态”。这三套上下文混在一起后,缓存键冲突、权限判断错乱、日志里根本看不出请求来自哪种形态。

当时的做法是在请求头里加一个X-Client-Type,然后在业务代码里到处写if (clientType === 'ide')。代码能跑,但每次加一种前端就要改十几处。后来我们把它重构成 context-mode:每种前端对应一个模式定义,模式里声明“这个模式需要哪些上下文字段”“这些字段从哪来”“默认值是什么”“优先级如何”。业务代码只读统一的上下文对象,不再关心来源。改动量从“十几处”降到“一个模式配置文件”。

2.2 场景二:对话系统里“短期记忆”和“长期记忆”的冲突

做对话系统的人一定遇到过这个问题:用户上一句说“帮我查一下北京的天气”,这一句说“那上海呢”。系统需要把“北京”替换成“上海”,同时保留“查天气”这个意图。但如果用户中间隔了十轮对话再问“那上海呢”,你就不能简单替换了,因为“那”指代的东西可能已经变了。

这就是典型的上下文模式问题:短期上下文(最近几轮)和长期上下文(用户画像、历史偏好)需要不同的读取策略。有的系统用滑动窗口,有的用摘要压缩,有的用向量检索。context-mode 的思路是:把这些策略定义成模式,比如short-term模式只读最近 N 轮,long-term模式读用户画像加检索结果,hybrid模式两者都读但给不同权重。运行时根据对话阶段切换模式,而不是把所有策略硬编码在一个函数里。

2.3 场景三:低代码平台里“设计态”和“运行态”的上下文隔离

低代码平台有个经典难题:同一个组件,在设计器里需要显示“可拖拽、可配置”的上下文,在运行时需要显示“真实数据、真实交互”的上下文。如果两套上下文混用,就会出现“设计器里点了按钮真的触发了业务逻辑”这种事故。

我们当时的解法是给每个组件实例挂一个context-mode标记:design模式下,事件处理器被替换成空操作,数据源被替换成 mock 数据;runtime模式下才走真实逻辑。这个标记贯穿渲染、事件、数据三层,任何一层读到design就短路。听起来简单,但如果没有统一的模式定义,每个组件各自实现一套判断,维护成本会指数级上升。

这三个场景的共同点是:上下文不是单一的,而是多维的;不是静态的,而是随场景切换的。context-mode 就是把这种“多维 + 可切换”抽象出来的设计模式。

3. 拆解 context-mode 的核心结构:四个必须定义的要素

3.1 模式标识与模式注册表

任何 context-mode 实现的第一步都是给模式起名字并注册。名字要稳定、可枚举,不要用魔法字符串散落在代码里。我习惯用一个常量对象或枚举来管理:

// 模式标识定义 const ContextMode = { DESIGN: 'design', RUNTIME: 'runtime', PREVIEW: 'preview', DEBUG: 'debug', } as const; type ContextModeType = typeof ContextMode[keyof typeof ContextMode];

然后有一个注册表,把模式和它的上下文提供者绑定起来:

// 模式注册表:每个模式声明自己的上下文来源 const modeRegistry: Record<ContextModeType, ContextProvider[]> = { [ContextMode.DESIGN]: [designTimeProvider, mockDataProvider], [ContextMode.RUNTIME]: [runtimeDataProvider, userSessionProvider], [ContextMode.PREVIEW]: [runtimeDataProvider, mockDataProvider], [ContextMode.DEBUG]: [runtimeDataProvider, debugLogProvider], };

这样做的好处是:新增模式只需要加一行注册,不需要改业务逻辑。模式之间的差异全部体现在“用哪些 provider”上。

注意:模式标识不要用数字(0/1/2),因为数字在日志和序列化时不可读,排查问题时会很痛苦。用字符串,哪怕长一点。

3.2 上下文提供者的优先级与合并策略

多个 provider 同时存在时,必然有字段冲突。比如runtimeDataProvider提供了userId,mockDataProvider也提供了userId,以谁为准?这就需要定义优先级和合并策略。

我的经验是分三层:覆盖(override)、合并(merge)、回退(fallback)。覆盖用于“这个模式必须用这个值”,合并用于“对象字段互补”,回退用于“没有就取默认”。在注册表里可以这样声明:

interface ContextProvider { name: string; priority: number; // 数字越大优先级越高 strategy: 'override' | 'merge' | 'fallback'; provide: () => Record<string, unknown>; }

合并时的顺序按 priority 从低到高执行,高优先级的 provider 后执行,自然覆盖低优先级的值。如果是merge策略,则做浅合并或深合并(取决于你的上下文复杂度)。我一般建议默认用 override,只有明确知道字段不冲突时才用 merge,因为 merge 在嵌套对象上容易产生意料之外的结果。

3.3 上下文的生命周期与失效时机

上下文不是永久有效的。用户切换了项目、关闭了文件、退出了登录,对应的上下文就应该失效。context-mode 需要定义每个模式的失效条件。

常见做法是给上下文加一个scope字段,声明它的作用域:

作用域失效时机典型用途
request请求结束单次 API 调用的临时状态
session会话结束用户登录态、对话历史
component组件卸载组件内部状态
global应用重启全局配置、主题

在模式定义里声明 scope 后,框架层可以在对应时机自动清理,避免内存泄漏和脏读。我踩过的一个坑是:把对话历史放在globalscope 里,结果多个用户会话互相污染。后来改成sessionscope,并在会话结束时显式清理,问题才解决。

3.4 模式切换的触发机制

模式不会自己切换,需要有触发点。触发点分三类:

  • 显式触发:调用方主动设置,比如setContextMode('debug')。
  • 条件触发:满足某个条件自动切换,比如“检测到开发环境变量则切到 debug”。
  • 嵌套触发:在某个模式内部临时切换到另一个模式,执行完再切回来。

嵌套触发最容易出问题。我建议用一个栈来管理模式切换:

class ContextModeManager { private stack: ContextModeType[] = ['runtime']; push(mode: ContextModeType) { this.stack.push(mode); } pop() { if (this.stack.length > 1) { this.stack.pop(); } } current(): ContextModeType { return this.stack[this.stack.length - 1]; } }

这样即使嵌套多层,也能正确回到上一层模式。如果没有栈,用单个变量记录当前模式,嵌套切换后就会丢失原始模式。

4. 落地实现:从零搭一个可用的 context-mode 层

4.1 目录结构与模块划分

我习惯把 context-mode 相关代码单独放一个目录,不要和业务逻辑混在一起:

src/ context-mode/ modes.ts // 模式标识定义 registry.ts // 模式注册表 providers/ // 各上下文提供者 design.ts runtime.ts mock.ts manager.ts // 模式管理器(切换、栈、当前模式) resolver.ts // 上下文解析与合并 hooks.ts // 对外暴露的读取接口

这样划分的好处是:modes.ts和registry.ts是配置层,providers/是数据层,manager.ts和resolver.ts是逻辑层,hooks.ts是接口层。任何一层要改,影响范围可控。

4.2 上下文解析器的实现细节

解析器的职责是:根据当前模式,找到对应的 provider 列表,按优先级执行,合并结果。核心逻辑大概是这样:

function resolveContext(mode: ContextModeType): Record<string, unknown> { const providers = modeRegistry[mode] || []; const sorted = [...providers].sort((a, b) => a.priority - b.priority); let result: Record<string, unknown> = {}; for (const provider of sorted) { const value = provider.provide(); if (provider.strategy === 'override') { result = { ...result, ...value }; } else if (provider.strategy === 'merge') { result = deepMerge(result, value); } else if (provider.strategy === 'fallback') { result = { ...value, ...result }; // 已有值优先 } } return result; }

这里有个性能细节:如果 provider 的provide()是异步的(比如要读数据库),就不能用同步循环。我的做法是先把所有 provider 的 provide 调用收集成 Promise 数组,用Promise.all并发执行,再按优先级合并。并发执行能把解析耗时从“累加”降到“取最大值”。

提示:如果某个 provider 的 provide 很慢,考虑加缓存。缓存键可以用“模式 + 作用域 + 关键参数”组合,避免不同模式之间互相污染。

4.3 对外接口设计:让业务代码无感

业务代码不应该关心模式是怎么解析的,它只需要读上下文。所以对外暴露的接口要极简:

// 读取当前上下文中的某个字段 function useContext<T>(key: string): T | undefined { const mode = manager.current(); const ctx = resolveContext(mode); return ctx[key] as T; } // 在指定模式下执行一段逻辑 function withContextMode<T>(mode: ContextModeType, fn: () => T): T { manager.push(mode); try { return fn(); } finally { manager.pop(); } }

withContextMode这个接口特别有用。比如在设计器里渲染一个组件时,可以这样写:

withContextMode('design', () => { renderComponent(component); });

渲染过程中任何useContext调用都会读到 design 模式的上下文,渲染结束后自动恢复。业务代码里看不到任何if (mode === 'design')的判断。

4.4 调试与可观测性:模式切换的日志埋点

context-mode 最大的风险是“模式切换了但没人知道”。所以我强烈建议在 manager 的 push/pop 里加日志:

push(mode: ContextModeType) { const prev = this.current(); this.stack.push(mode); logger.debug(`[context-mode] ${prev} -> ${mode}`, { stack: [...this.stack], trigger: new Error().stack, // 记录调用栈,方便定位谁切换的 }); }

new Error().stack这招在排查“谁把模式切错了”时非常管用。我遇到过一个问题:某个组件在渲染时意外把模式切到了 debug,导致线上用户看到了调试信息。靠日志里的调用栈,五分钟就定位到了那个组件的生命周期钩子。

另外,可以在开发环境加一个浮层,实时显示当前模式和上下文快照。这个浮层不用很精致,一个固定定位的 div 就行,但对调试效率的提升是巨大的。

5. 踩坑记录:我在 context-mode 上翻过的三次车

5.1 第一次翻车:模式切换没有恢复,导致上下文泄漏

早期实现里我用的是单个变量记录当前模式,没有用栈。结果在一个嵌套场景里:外层是 runtime,内层临时切到 preview,内层结束后把模式设回了 runtime——看起来没问题。但后来加了一个“preview 里再嵌套 debug”的场景,debug 结束后设回 preview,preview 结束后设回 runtime,逻辑上是对的。问题出在异常路径:如果内层抛异常,没有 finally 恢复,模式就永远停在 preview 了。

修复方案就是前面说的栈 + try/finally。这个坑让我明白:任何“临时切换”都必须用栈,并且必须在 finally 里恢复。不要相信“这段代码不会抛异常”。

5.2 第二次翻车:provider 之间的循环依赖

有一次我让runtimeProvider依赖sessionProvider提供的userId,而sessionProvider又依赖runtimeProvider提供的tenantId。结果解析时死循环了。当时没有加依赖检测,直接栈溢出。

后来我在注册表里加了依赖声明,解析前先做拓扑排序,检测到环就报错:

interface ContextProvider { name: string; dependsOn?: string[]; // ... }

拓扑排序的代码不复杂,但能避免很多隐蔽问题。如果你的 provider 之间有依赖关系,一定要显式声明,不要靠“执行顺序碰巧对”来工作。

5.3 第三次翻车:模式粒度过细,配置爆炸

有一段时间我追求“每个场景一个模式”,结果模式数量膨胀到二十多个。每个模式都要维护 provider 列表,改一个公共字段要改十几个模式。后来我做了合并:把差异小的模式合并成一个模式加参数。

比如design-web、design-mobile、design-desktop三个模式,其实只有viewport字段不同,完全可以合并成design模式加一个viewport参数。模式应该是“上下文来源的组合”,而不是“所有差异的枚举”。判断标准很简单:如果两个模式的 provider 列表完全相同,只是某个字段值不同,那它们应该是同一个模式加参数。

6. 进阶玩法:把 context-mode 和状态管理、依赖注入结合

6.1 与状态管理库的边界划分

很多人会问:context-mode 和 Redux、Zustand 这些状态管理库有什么区别?我的理解是:状态管理管的是“数据怎么存、怎么改”,context-mode 管的是“当前该读哪份数据”。两者是互补的。

实际项目中,我通常让状态管理库持有所有数据,context-mode 只负责“选择器”——根据当前模式决定从 store 的哪个 slice 读数据。这样状态管理库不需要知道模式的存在,context-mode 也不需要关心数据怎么更新。

// context-mode 的 provider 从 store 读数据 const runtimeDataProvider: ContextProvider = { name: 'runtime-data', priority: 10, strategy: 'override', provide: () => ({ userId: store.getState().session.userId, projectId: store.getState().project.currentId, }), };

6.2 与依赖注入容器的配合

如果你的项目用了依赖注入(DI),可以把 context-mode 的 manager 注册成单例,provider 注册成多实例。DI 容器负责生命周期,context-mode 负责选择逻辑。这样测试时可以轻松替换 provider,不需要改模式定义。

我试过在 NestJS 项目里这样做:把ContextModeManager注册为Scope.REQUEST,每个请求一个实例,请求内的模式切换互不影响。配合拦截器在请求开始时根据路由设置初始模式,整个链路非常干净。

6.3 模式继承:减少重复配置

当模式数量增多时,可以用继承来减少重复。比如debug模式继承runtime模式,只额外加一个debugLogProvider:

const modeRegistry = { runtime: [runtimeDataProvider, sessionProvider], debug: [...modeRegistry.runtime, debugLogProvider], };

这样runtime的 provider 变更会自动同步到debug。但要注意继承层级不要超过两层,否则“这个模式到底有哪些 provider”会变得难以追踪。

7. 一些实测有效的经验与检查清单

7.1 模式命名的三条规则

第一,用场景名而不是技术名。design比no-event好,因为前者表达意图,后者表达实现。第二,用正向命名而不是否定命名。runtime比non-design好,因为否定命名在模式增多时会混乱。第三,模式名要能独立理解,不要用mode1、mode2这种。

7.2 上线前的自检清单

  • 所有模式是否都有明确的 provider 列表,没有“空模式”?
  • 每个 provider 的 priority 是否唯一?重复的 priority 会导致合并顺序不确定。
  • 模式切换是否都在 try/finally 里?有没有可能异常导致模式不恢复?
  • 上下文里是否有可能为 undefined 的字段?业务代码是否做了空值处理?
  • 日志里是否能看出“当前模式是什么、谁切换的、切换前是什么”?
  • 生产环境是否关闭了 debug 模式的入口?有没有可能被外部触发?

7.3 性能上的一个实测数据

在一个中等规模的前端项目里(约 200 个组件),引入 context-mode 后,首屏渲染耗时增加了约 3%——主要来自上下文解析。后来加了缓存(按模式缓存解析结果,模式不变就不重新解析),耗时回落到 1% 以内。所以缓存是必须的,尤其是 provider 的 provide 有计算成本时。

缓存失效的时机要跟模式切换绑定:push/pop 时清空缓存。不要用时间过期,因为模式切换是事件驱动的,时间过期会导致读到旧数据。

7.4 一个容易被忽略的细节:服务端渲染

如果你的应用要做服务端渲染(SSR),context-mode 的 manager 不能是模块级单例,因为服务端会同时处理多个请求。必须每个请求创建一个 manager 实例,请求结束后销毁。这个坑我在一个 Next.js 项目里踩过:开发环境正常,一上生产就出现“A 用户的上下文泄漏到 B 用户”。排查了半天才发现是 manager 被模块级缓存了。

修复方式是用 React 的 Context 或请求作用域的 DI 来持有 manager。在 Next.js 里可以用AsyncLocalStorage来隔离请求上下文,效果很好。

8. 最后分享几个小技巧

第一个技巧:给模式加一个description字段,写清楚“这个模式什么时候用”。半年后回来看代码,你会感谢自己。第二个技巧:在模式注册表旁边放一个测试文件,每个模式至少测一次“解析出的上下文包含预期字段”。模式配置是数据,数据最容易在重构时被改错。第三个技巧:如果团队里有人不熟悉 context-mode,不要一上来就讲架构,先给他看一个“切换模式后行为不同”的最小 demo,让他直观感受到价值,再讲实现。

这个方向后续还可以扩展的地方:把模式定义从代码里抽出来放到配置文件或数据库,让非开发人员也能调整;加一个模式的可视化编辑器,拖拽 provider 就能生成模式;把模式切换和埋点结合,分析用户实际在哪些模式下停留最久。这些都是我在实际项目里想过但还没完全落地的方向,有兴趣的可以试试。

RELATED READING

延伸阅读

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