1. 从“上下文模式”说起:一个被低估的工程概念
第一次看到“context-mode”这个词,很多人会以为是某个新出的框架或者库。其实不是。它更像是一种设计思路,一种在系统里管理“上下文”这件事的工程模式。你可以在前端状态管理里见到它,可以在后端请求链路里见到它,也可以在大模型应用的会话管理里见到它。名字听起来抽象,但落到代码里,它解决的是一个非常具体的问题:当一段逻辑需要依赖外部环境信息才能正确执行时,这些信息该怎么组织、传递、隔离和回收。
我最早接触这个概念是在做一个多轮对话系统的时候。当时系统里到处都在传一个叫session的对象,从入口传到业务层,再从业务层传到工具层,最后连日志模块都要带上它。代码越写越乱,改一个字段要动十几个文件。后来我把所有跟“当前对话环境”相关的东西抽出来,统一用一个 context 对象管理,并且定义了一套明确的读写规则,整个系统才清爽下来。那套规则,本质上就是 context-mode 的雏形。
所以这篇文章不是要讲某个具体的库,而是想把这个模式拆开,讲清楚它为什么存在、怎么设计、怎么落地,以及在实际项目里踩过哪些坑。适合谁看?如果你写过稍微复杂一点的状态管理、请求链路、插件系统或者 AI 应用,并且觉得“上下文传递”这件事很烦,那这篇内容应该对你有用。如果你只是写简单的 CRUD,可能暂时用不上,但了解一下也没坏处,因为系统一旦复杂起来,这个问题迟早会找上你。
2. 核心思路拆解:上下文到底该怎么管
2.1 为什么需要单独的 context-mode
先想一个问题:为什么不能直接把需要的数据当参数传?答案很简单,参数会爆炸。一个函数如果需要知道“当前用户是谁、当前请求来自哪个入口、当前处于哪个业务阶段、当前的语言环境是什么”,你不可能把这些全部塞进函数签名。就算塞进去了,中间经过的每一层都要透传,任何一层漏了,链路就断了。
另一种做法是用全局变量。全局变量确实方便,但问题是它没有隔离。两个请求同时进来,全局变量会被互相覆盖。在单线程的脚本里可能没事,但在并发环境里就是灾难。而且全局变量很难测试,你没法在测试用例里轻松模拟不同的上下文。
context-mode 要解决的就是这两个极端之间的平衡:既要像全局变量一样随处可访问,又要像参数传递一样有明确的边界和生命周期。它的核心思想是,把上下文当作一个显式的、有作用域的对象来管理,而不是散落在各处的隐式状态。
2.2 上下文的三个核心属性
我在设计 context 的时候,通常会关注三个属性:作用域、生命周期、可见性。
作用域决定了这个上下文在多大的范围内有效。比如一个 HTTP 请求的上下文,只在这个请求处理期间有效;一个用户会话的上下文,可能跨越多个请求。作用域划不清楚,就会出现“上一个请求的数据泄漏到下一个请求”这种经典 bug。
生命周期决定了上下文什么时候创建、什么时候销毁。创建太早会浪费资源,创建太晚又拿不到数据。销毁不及时会导致内存泄漏,销毁太早会导致后续逻辑拿不到数据。这个时间点需要根据业务场景仔细设计。
可见性决定了哪些代码可以读写上下文。理想情况下,只有真正需要上下文的模块才能访问它,其他模块应该通过参数或者接口来获取数据。如果所有代码都能随便改上下文,那它就退化成了全局变量。
2.3 常见的实现方式对比
实现 context-mode 有几种常见方式,各有优劣。我整理了一个对比表,方便你根据场景选择。
| 实现方式 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|
| 显式参数传递 | 简单函数调用 | 边界清晰,易测试 | 层级深时参数爆炸 |
| 线程局部存储 | 多线程服务端 | 无需透传,隔离性好 | 异步场景容易丢上下文 |
| 依赖注入容器 | 大型应用 | 解耦彻底,可替换 | 配置复杂,学习成本高 |
| 上下文对象显式传递 | 中等复杂度系统 | 平衡性好,可控 | 需要团队约定规范 |
| 隐式全局单例 | 脚本工具 | 写起来快 | 并发不安全,难测试 |
我个人的经验是,大多数业务系统用“上下文对象显式传递”就够了。它不像依赖注入那么重,也不像全局变量那么危险。关键是要把上下文的读写接口设计好,让调用方不需要关心内部结构。
3. 核心细节解析:上下文对象该怎么设计
3.1 上下文的存储结构选择
上下文对象内部用什么结构存数据,这个选择会影响后续的扩展性和性能。常见的有三种:普通对象、Map、以及分层结构。
普通对象最简单,{ userId, locale, traceId }这样直接写。优点是访问快,类型提示好。缺点是字段固定,动态扩展不方便。如果你的上下文字段是稳定的,用对象就够了。
Map 的优点是动态性强,可以随时增删字段,而且键可以是任意类型。缺点是类型不安全,访问时要做空值判断。适合字段不固定的场景,比如插件系统里每个插件可能往上下文里塞不同的数据。
分层结构是我比较推荐的一种方式。它把上下文分成几层,比如“请求层”“会话层”“用户层”,每层有自己的存储,访问时按层级查找。这样做的好处是隔离性好,不同层的数据不会互相污染,而且可以针对每层设置不同的生命周期。实现上可以用一个数组存多个 Map,查找时从最内层往外找。
3.2 读写接口的设计原则
上下文的读写接口设计有几个原则,我踩过坑之后总结出来的。
第一,读接口要宽容,写接口要严格。读的时候如果字段不存在,返回默认值或者 undefined 都行,调用方自己处理。但写的时候要严格校验,比如不允许写入保留字段,不允许覆盖已有字段(除非显式声明)。这样可以防止上下文被意外污染。
第二,提供批量操作。实际使用中经常需要一次性读取多个字段,或者一次性写入一组相关字段。如果只有单个读写接口,调用方要写很多行代码。提供getMany和setMany可以显著提升开发效率。
第三,支持快照和恢复。这个在调试和测试时特别有用。你可以随时把当前上下文的状态保存下来,之后恢复到某个快照点。实现上就是深拷贝一份数据存起来。注意深拷贝的性能开销,如果上下文很大,要考虑用结构共享或者不可变数据结构。
第四,变更要可观测。上下文被修改时,最好能触发一个事件或者记录一条日志。这样在排查“为什么这个字段的值不对”的时候,可以追溯变更历史。我一般会在开发环境开启变更日志,生产环境关闭以避免性能影响。
3.3 上下文的隔离与传播
隔离和传播是 context-mode 里最容易出问题的两个点。
隔离指的是不同上下文之间不能互相干扰。在并发场景下,每个请求应该有自己独立的上下文。如果用了线程局部存储,要注意异步任务切换时上下文会不会丢失。如果用了显式传递,要注意不要不小心把上下文存到了某个长期存活的对象里。
传播指的是上下文在调用链路中怎么传递。同步调用比较简单,一层层传下去就行。异步调用就麻烦了,比如你发起一个异步任务,任务执行时原来的上下文可能已经销毁了。解决办法是在发起异步任务时把当前上下文快照传进去,任务内部用这个快照创建新的上下文。
还有一个常见问题是跨服务传播。如果你的系统拆成了多个服务,上下文怎么从服务 A 传到服务 B?通常的做法是把上下文序列化后放在请求头或者消息体里。注意不要放太多数据,请求头有大小限制。敏感信息也不要放,因为请求头可能被日志记录。
4. 实操过程:从零实现一个上下文管理器
4.1 基础版本:同步场景下的上下文
先从一个最简单的版本开始,适合同步调用的场景。核心就是一个类,内部用一个 Map 存数据,提供 get、set、delete 方法。
class Context { constructor(initial = {}) { this.store = new Map(Object.entries(initial)); this.snapshots = []; } get(key, defaultValue = undefined) { return this.store.has(key) ? this.store.get(key) : defaultValue; } set(key, value) { if (typeof key !== 'string') { throw new Error('Context key must be a string'); } this.store.set(key, value); return this; } has(key) { return this.store.has(key); } delete(key) { return this.store.delete(key); } snapshot() { const copy = new Map(this.store); this.snapshots.push(copy); return copy; } restore(index = -1) { const target = this.snapshots.at(index); if (!target) { throw new Error('Snapshot not found'); } this.store = new Map(target); } toObject() { return Object.fromEntries(this.store); } }这个版本够用,但有几个细节要注意。snapshot做的是浅拷贝,如果存的是对象,修改对象内部属性会影响快照。如果需要深拷贝,要自己实现或者用库。restore用at(-1)取最后一个快照,这是 ES2022 的语法,老环境要改成this.snapshots[this.snapshots.length - 1]。
4.2 进阶版本:支持异步和分层
同步版本在异步场景下会出问题。比如你在一个异步函数里 set 了一个值,等 await 回来之后,上下文可能已经被别的逻辑改了。解决办法是用 AsyncLocalStorage(Node.js)或者类似的机制。
const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); function createContext(initial = {}) { const ctx = new Context(initial); return ctx; } function runWithContext(ctx, fn) { return asyncLocalStorage.run(ctx, fn); } function getCurrentContext() { const ctx = asyncLocalStorage.getStore(); if (!ctx) { throw new Error('No context available in current async scope'); } return ctx; }用的时候这样写:
const ctx = createContext({ traceId: 'abc123' }); runWithContext(ctx, async () => { const current = getCurrentContext(); current.set('userId', 'u001'); await someAsyncOperation(); // 这里拿到的还是同一个 ctx console.log(getCurrentContext().get('userId')); // u001 });这个模式的好处是,异步调用链里不需要手动传 ctx,getCurrentContext会自动拿到当前作用域的上下文。注意 AsyncLocalStorage 在 Node.js 的某些版本里有性能开销,如果 QPS 很高,要做压测确认。
4.3 上下文的分层实现
分层上下文适合复杂系统。我一般会定义一个 ContextStack,内部维护多个 Context 实例,按层级排列。
class ContextStack { constructor() { this.layers = []; } push(layer) { this.layers.push(layer); return this; } pop() { return this.layers.pop(); } get(key, defaultValue = undefined) { for (let i = this.layers.length - 1; i >= 0; i--) { const layer = this.layers[i]; if (layer.has(key)) { return layer.get(key); } } return defaultValue; } set(key, value) { if (this.layers.length === 0) { throw new Error('No layer available'); } this.layers[this.layers.length - 1].set(key, value); return this; } setGlobal(key, value) { if (this.layers.length === 0) { throw new Error('No layer available'); } this.layers[0].set(key, value); return this; } }用的时候,最外层放全局配置,中间层放会话数据,最内层放请求数据。读取时从内往外找,写入时默认写最内层,需要写全局时用setGlobal。这样设计的好处是,不同生命周期的数据自然分离,不会互相覆盖。
4.4 参数选择与性能考量
上下文对象的大小会影响性能。我做过一个简单的测试,在 Node.js 18 下,一个包含 10 个字段的 Context,get 操作大约 0.1 微秒,set 大约 0.2 微秒。如果字段增加到 100 个,get 和 set 的时间基本不变,因为 Map 的查找是 O(1)。但如果用对象存储并且频繁增删字段,性能会下降,因为 V8 会不断调整隐藏类。
所以我的建议是,如果字段数量稳定,用对象;如果字段动态变化,用 Map。另外,不要在上下文里存大对象,比如完整的请求体或者文件内容。上下文应该只存“标识性”和“配置性”的数据,大对象应该通过其他方式传递。
还有一个容易忽略的点是上下文的序列化。如果你需要把上下文写日志或者跨服务传输,序列化的开销要考虑。JSON.stringify 一个包含 20 个字段的上下文大约需要 5 微秒,如果每个请求都做,QPS 一万的话就是 50 毫秒的额外开销。解决办法是只序列化必要的字段,或者用更高效的序列化格式。
5. 常见问题与排查技巧实录
5.1 上下文丢失的几种典型情况
上下文丢失是最常见的问题,表现是“明明之前 set 了,后面 get 不到”。我遇到过几种原因,整理成表格方便排查。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 异步后丢失 | 没有用 AsyncLocalStorage | 在异步回调里打印上下文 | 改用 runWithContext 包裹 |
| 跨模块丢失 | 模块用了不同的上下文实例 | 检查模块导入路径 | 统一从入口注入 |
| 定时任务丢失 | 任务创建时没捕获上下文 | 在任务里打印上下文 | 创建任务时快照上下文 |
| 跨服务丢失 | 请求头没带上下文 | 抓包看请求头 | 序列化后放入请求头 |
| 测试环境丢失 | 测试用例没初始化上下文 | 看测试日志 | 在 beforeEach 里初始化 |
我印象最深的一次是,一个定时任务里需要用到请求上下文里的 traceId,但任务执行时请求早就结束了,上下文已经销毁。后来改成在创建任务时把 traceId 作为参数传进去,任务内部自己创建一个新的上下文。这个问题本质上是生命周期没对齐,上下文的存活时间短于使用它的代码。
5.2 上下文污染与数据泄漏
上下文污染是指一个请求的数据被另一个请求读到了。这个问题在并发环境下特别隐蔽,因为单次测试可能复现不出来。
我遇到过一次,两个用户同时请求,用户 A 看到了用户 B 的数据。排查了半天,发现是上下文对象被缓存在了一个模块级变量里。第一个请求设置了这个变量,第二个请求直接复用了。解决办法是每次请求都创建新的上下文,不要复用。
还有一种污染是“字段覆盖”。比如两个模块都往上下文里写userId,一个写的是操作者 ID,一个写的是被操作者 ID。后写的覆盖了先写的,导致逻辑出错。解决办法是给字段加命名空间,比如auth.userId和target.userId,或者用分层上下文隔离。
提示:在开发环境开启上下文的写保护,如果检测到同一个 key 被不同模块写入,打印警告。这个机制帮我提前发现了好几次潜在的污染问题。
5.3 性能问题的排查思路
上下文相关的性能问题通常表现为“系统越跑越慢”或者“内存持续增长”。排查思路如下。
先看内存。如果上下文对象被长期持有,会导致内存泄漏。用 heap snapshot 对比不同时间点的对象数量,看 Context 实例是不是只增不减。如果是,检查哪里把上下文存到了长期存活的对象里。
再看 CPU。如果 get 和 set 调用非常频繁,可能成为瓶颈。用 profiler 看 Context 相关方法的耗时占比。如果超过 5%,考虑优化。优化的方向包括:减少不必要的读写、用更轻量的数据结构、把频繁读取的值缓存到局部变量。
还有一个隐蔽的问题是快照。如果每次请求都做快照,而且快照是深拷贝,性能开销会很大。我一般只在调试和测试时开启快照,生产环境关闭。如果确实需要,用结构共享代替深拷贝。
5.4 测试策略与调试技巧
上下文相关的代码不好测试,因为依赖隐式的环境。我的做法是,所有依赖上下文的函数都接受一个可选的 context 参数,测试时显式传入,生产环境从 AsyncLocalStorage 取。这样测试用例不需要模拟异步环境,直接传一个 mock context 就行。
调试的时候,我会在上下文里加一个__debug字段,记录创建时间、创建位置、变更历史。出问题的时候打印这个字段,能快速定位。注意这个字段只在开发环境启用,生产环境要过滤掉,避免泄漏内部信息。
还有一个技巧是给上下文加一个唯一的 ID,在每个日志里都带上。这样排查问题时,可以按 ID 过滤出同一个请求的所有日志,非常方便。ID 的生成用 UUID 或者雪花算法都行,注意不要用自增 ID,因为跨服务可能冲突。
6. 实际项目中的应用场景
6.1 在 Web 服务中的请求上下文
Web 服务是 context-mode 最典型的应用场景。每个 HTTP 请求进来,创建一个上下文,存放请求 ID、用户信息、租户信息、语言偏好等。这个上下文在整个请求处理链路中传递,从路由到中间件到业务逻辑到数据访问层。
我一般会在框架的入口处创建上下文,然后通过中间件注入到请求对象里。业务代码通过req.context访问。对于异步操作,用 AsyncLocalStorage 保证上下文不丢失。响应返回前,把上下文里的关键信息写日志,然后销毁上下文。
这样做的好处是,日志里自动带上请求 ID 和用户 ID,排查问题时非常方便。而且不同请求的上下文完全隔离,不会互相干扰。实测下来,引入上下文管理后,排查线上问题的平均时间从 30 分钟降到了 10 分钟以内。
6.2 在 AI 应用中的会话上下文
AI 应用里的上下文管理又是另一回事。这里说的上下文不是请求级别的,而是会话级别的。一个用户可能跟 AI 聊很多轮,每一轮都需要知道之前聊了什么。这个“之前聊了什么”就是会话上下文。
会话上下文的特点是数据量大(可能几十轮对话)、生命周期长(可能几小时甚至几天)、需要持久化。我的做法是,会话上下文只存最近 N 轮的摘要,完整的对话历史存数据库。每轮对话开始时,从数据库加载摘要到上下文,对话结束后更新摘要并写回数据库。
摘要的生成可以用规则(比如只保留最近 10 轮)或者用模型(比如让模型总结之前的内容)。规则简单但可能丢失重要信息,模型效果好但成本高。我一般用混合策略:最近 5 轮保留原文,更早的用模型总结。
还有一个问题是上下文的长度限制。模型的输入有 token 限制,上下文不能无限增长。解决办法是设置一个阈值,超过就触发压缩。压缩策略要小心设计,不要把关键信息压没了。我试过几种策略,最后发现“保留最近对话 + 关键实体提取 + 历史摘要”的组合效果最好。
6.3 在插件系统中的扩展上下文
插件系统里,上下文是插件和宿主之间通信的桥梁。宿主创建上下文,插件通过上下文获取宿主提供的能力,也通过上下文向宿主暴露自己的功能。
这种场景下,上下文的设计要考虑扩展性。因为插件的数量和功能是不确定的,上下文不能是固定字段的对象。我一般用分层上下文,宿主层放核心能力,插件层放插件自己的数据。插件可以读宿主层的数据,但不能写。插件之间的数据默认隔离,需要共享时显式声明。
还有一个问题是版本兼容。插件可能依赖上下文的某个字段,如果宿主升级后删了这个字段,插件就挂了。解决办法是给上下文加版本号,插件声明自己需要的版本,宿主做兼容处理。这个机制在插件生态里很重要,但很多系统一开始没考虑,后面改起来很痛苦。
7. 一些踩坑之后的经验之谈
上下文管理这件事,说简单也简单,说复杂也复杂。我做了几个项目之后,最大的体会是:不要试图用一个方案解决所有问题。请求上下文、会话上下文、插件上下文,它们的生命周期、数据量、隔离要求都不一样,用同一套实现往往顾此失彼。
另一个体会是,上下文的设计要趁早。如果项目初期没设计好,后面代码里到处散落着隐式状态,再想重构成本就很高了。我见过一个项目,上下文信息散落在全局变量、闭包、参数里,后来想统一管理,改了两个月还没改完。
还有一点,上下文不是越多越好。有些团队把什么东西都往上下文里塞,最后上下文变成了一个巨大的垃圾场,谁也不知道里面有什么。我的原则是,只有“跨模块共享”且“生命周期明确”的数据才放上下文,其他数据该传参传参,该查库查库。
最后分享一个小技巧:给上下文加一个describe()方法,返回当前上下文的可读描述。调试的时候打印这个描述,比直接看原始数据直观得多。这个方法我每个项目都会加,用过的同事都说好。