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 回来之后上下文可能已经被别的逻辑改了。解决办法是用 AsyncLocalStorageNode.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 });这个模式的好处是异步调用链里不需要手动传 ctxgetCurrentContext会自动拿到当前作用域的上下文。注意 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 个字段的 Contextget 操作大约 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()方法返回当前上下文的可读描述。调试的时候打印这个描述比直接看原始数据直观得多。这个方法我每个项目都会加用过的同事都说好。