1. 从“上下文模式”说起:一个被低估的工程概念
第一次看到“context-mode”这个词,很多人会以为是某个新出的框架或者库。其实不是。它更像是一种设计思路,一种在系统里管理“上下文”这件事的工程模式。你可以在前端状态管理里见到它,可以在后端请求链路里见到它,也可以在大模型应用的会话管理里见到它。名字听起来抽象,但落到代码里,它就是一套关于“当前这一刻,系统该知道什么、该忽略什么、该传递什么”的规则集合。
我最早接触这个概念,是在做一个多轮对话系统的时候。当时遇到一个很典型的问题:用户上一句说的是“帮我查一下北京的天气”,下一句说“那上海呢”。如果系统只把第二句单独丢给后端,它根本不知道“那上海呢”指的是天气。这就是上下文丢失。后来我们引入了一套上下文管理机制,把最近几轮对话、当前意图、用户身份、甚至设备信息都打包成一个“上下文对象”,跟着请求一起走。这套机制的核心,就是所谓的 context-mode。
所以,context-mode 本质上解决的是信息在时间维度和调用链路中的传递与隔离问题。它要回答三个问题:第一,哪些信息需要被记住并传递下去;第二,这些信息在传递过程中以什么结构存在;第三,什么时候该清理或重置这些信息。这三个问题,在任何有状态交互的系统里都会遇到,只是很多人没有把它抽象成一个独立的模式来对待。
这篇文章适合谁看?如果你正在做聊天机器人、智能客服、多轮问答、工作流引擎,或者任何需要“记住上一刻发生了什么”的系统,那 context-mode 就是你绕不开的东西。如果你只是写简单的 CRUD 接口,那可能暂时用不上,但了解一下也没坏处,因为迟早会遇到状态管理的需求。接下来我会从设计思路、核心细节、实操落地、问题排查几个方面,把 context-mode 拆开讲清楚。
2. 上下文模式的整体设计与选型逻辑
2.1 为什么需要独立的上下文管理层
很多团队一开始会把上下文信息直接塞在请求参数里。比如前端每次请求都带上session_id、last_query、user_intent这些字段。刚开始能跑,但很快就会出现问题。第一个问题是字段膨胀。随着业务迭代,上下文里需要带的东西越来越多,从最初的几个字段变成几十个,每个接口都要重复定义这些参数,改一处就要改十处。第二个问题是生命周期混乱。有些上下文只在单次请求内有效,有些要跨请求保持,有些要跨用户共享,混在一起之后很难判断什么时候该清、什么时候该留。第三个问题是调试困难。当上下文散落在各个参数里,出了问题你根本不知道是哪个环节把哪个字段改错了。
独立的上下文管理层就是来解决这些问题的。它的核心思路是:把“当前交互状态”从业务数据里剥离出来,用一个统一的结构来承载,由专门的模块负责它的创建、更新、传递和销毁。业务代码只关心“我要读上下文里的哪个字段”和“我要写上下文里的哪个字段”,不需要关心它存在哪里、怎么传过来的。这样做的好处很明显:字段定义集中了,生命周期可控了,调试的时候也能一眼看到上下文的完整快照。
我个人的经验是,只要你的系统满足以下任意一条,就应该考虑引入 context-mode:交互是多轮的、状态需要跨请求保持的、多个模块需要共享同一份运行时信息的、上下文结构可能频繁变化的。如果只是单次无状态请求,那确实没必要上这套东西,反而增加复杂度。
2.2 上下文模式与其他状态管理方案的对比
有人会问,这和 session、cache、全局变量有什么区别?区别在于关注点和边界。Session 通常偏向用户身份和登录态,生命周期以用户会话为单位,存储位置一般在服务端。Cache 偏向性能优化,存的是可重建的数据,丢了可以重新算。全局变量则是进程内的共享内存,生命周期跟进程走,多实例部署时会出问题。Context-mode 的关注点更窄也更具体:它只管“当前这次交互链路中需要携带的信息”,生命周期通常跟一次完整的交互流程绑定,可以跨请求,但不一定跨用户会话。
举个例子。用户登录之后,session 里存了用户 ID 和权限。用户发起一次多轮对话,第一轮问天气,第二轮追问城市。这里的“上一轮问的是天气”这个信息,就不适合放 session,因为它跟登录态无关;也不适合放 cache,因为它不是可重建的性能数据;更不适合放全局变量,因为不同用户的对话会串。它最适合放在 context 里,由 context-mode 来管理。这就是边界划分的价值。
选型的时候还有一个关键决策:上下文存在客户端还是服务端。客户端存储的优点是服务端无状态,容易水平扩展,缺点是上下文可能被篡改,且容量受限于客户端。服务端存储的优点是安全可控,容量大,缺点是服务端需要维护状态,扩展时要考虑上下文的路由问题。我的建议是,敏感信息放服务端,非敏感的展示态信息可以放客户端,两者结合使用。比如用户 ID 和权限放服务端,当前页面滚动位置和临时筛选条件放客户端。
2.3 上下文的数据结构设计原则
上下文的数据结构设计,直接决定了后续的可维护性。我踩过的坑是:一开始用扁平结构,所有字段平铺,后来字段多了之后完全分不清哪些属于哪个模块。后来改成嵌套结构,按模块分组,清晰了很多。比如:
{ "session": { "user_id": "u123", "locale": "zh-CN" }, "dialog": { "turn": 3, "last_intent": "query_weather", "slots": { "city": "北京" } }, "runtime": { "timestamp": 1700000000, "trace_id": "abc-123" } }这个结构里,session放会话级信息,dialog放对话级信息,runtime放运行时元信息。每个模块只读写自己关心的那部分,互不干扰。设计的时候有几个原则:第一,按生命周期分组,同一组字段的存活时间应该一致;第二,避免深层嵌套,一般不超过三层,否则读写都很麻烦;第三,给每个字段明确类型和默认值,不要留 undefined 的坑;第四,预留扩展字段,但不要滥用,扩展字段也要有命名规范。
还有一个容易被忽略的点:上下文的序列化格式。如果要在网络上传输,JSON 是最常见的选择,但它不支持日期类型和二进制数据。如果上下文里有时间戳,建议统一用数字存储,展示的时候再转换。如果有二进制数据,比如图片缩略图,建议存引用而不是内容本身。这些细节看起来小,但在实际项目里经常引发 bug。
3. 核心细节解析与实操要点
3.1 上下文的创建时机与初始化策略
上下文什么时候创建?这个问题看似简单,但不同的选择会影响整个系统的行为。常见的创建时机有三种:第一种是在用户会话建立时创建,比如登录成功之后;第二种是在一次交互流程开始时创建,比如用户发起一次新的对话;第三种是在每个请求进入时创建,请求结束就销毁。这三种方式对应不同的生命周期,适用于不同的场景。
我做的多轮对话系统用的是第二种:用户打开对话窗口时创建一个 context,之后每一轮对话都在这个 context 上更新,直到用户关闭窗口或超时。这样做的好处是上下文能跨多轮保持,又不会像会话级那样长期占用资源。初始化的时候,我会填入一些默认值,比如当前时间、默认语言、初始意图等。这些默认值很重要,因为后续代码读取上下文时,如果某个字段不存在,就会出错。有了默认值,至少能保证读取不会崩。
初始化策略里还有一个关键决策:上下文是懒加载还是预加载。懒加载是指用到某个字段时才去查,预加载是指创建时就把所有可能用到的字段都填好。懒加载省资源,但代码里会到处是“如果不存在就查一下”的逻辑;预加载逻辑简单,但可能查了很多用不到的数据。我的建议是折中:核心字段预加载,扩展字段懒加载。核心字段就是每次交互都需要的,比如用户 ID、当前意图;扩展字段就是特定场景才用的,比如某个业务模块的临时配置。
注意:上下文的初始化一定要幂等。也就是说,同一个交互流程里重复调用初始化,不应该产生副作用。我见过因为重复初始化导致上下文被重置、丢失中间状态的案例,排查了很久才发现是初始化逻辑没有做幂等判断。
3.2 上下文的读写隔离与并发安全
上下文被多个模块读写时,并发问题就来了。最典型的是:模块 A 读取了上下文,准备修改,还没写回去,模块 B 也读取了同一份上下文并修改了,然后 A 写回去,把 B 的修改覆盖了。这就是典型的丢失更新问题。解决方式有几种:第一种是加锁,读写都串行化,简单但性能差;第二种是版本号,每次写入时检查版本号是否变化,变了就重试;第三种是合并写入,只更新自己关心的字段,不整体覆盖。
我一般用第三种,因为上下文的结构是分组的,每个模块只改自己那组字段,天然就避免了冲突。比如 dialog 模块只改dialog下面的字段,session 模块只改session下面的字段,两者互不影响。但这里有个前提:分组要分得干净,不能有跨组的字段依赖。如果确实需要跨组更新,那就用版本号机制兜底。
还有一个并发场景是分布式部署。如果上下文存在服务端,而服务端有多个实例,那么同一个用户的请求可能被路由到不同实例上。这时候上下文要么存在共享存储里(比如 Redis),要么用一致性哈希保证同一用户总是路由到同一实例。前者更灵活,后者更简单。我倾向于用共享存储,因为扩展和运维都更方便,代价是每次读写都要走网络,延迟会高一些。如果对延迟极其敏感,那就用本地缓存加定期同步的方式,但要接受短暂的不一致。
3.3 上下文的传递方式与链路追踪
上下文在调用链路中怎么传递?如果是单进程内的函数调用,直接作为参数传就行。但如果是跨服务调用,就要考虑序列化和传输方式。常见的有三种:放在请求头里、放在请求体里、放在独立的上下文服务里。请求头的方式最轻量,适合小体积的上下文,但受限于头部大小限制,一般不超过 8KB。请求体的方式容量大,但会污染业务参数,需要约定一个独立的字段来放上下文。独立上下文服务的方式最灵活,但增加了一次网络调用。
我实际项目里用的是请求头加请求体结合的方式:轻量的元信息(比如 trace_id、user_id)放请求头,方便日志和监控系统直接读取;重量级的业务上下文放请求体的独立字段里,由业务代码解析。这样既保证了可观测性,又保证了灵活性。传递的时候要注意,上下文里的敏感信息要脱敏,比如用户手机号、身份证号,不能明文传输。我一般会在序列化之前做一层过滤,把敏感字段替换成哈希值或掩码。
链路追踪是上下文传递的一个附加价值。如果上下文里带了 trace_id,那么整个调用链路上的日志都可以用这个 ID 串起来,排查问题的时候非常方便。我建议在上下文初始化时就生成一个全局唯一的 trace_id,之后所有跨服务调用都带上它。这样即使上下文的其他字段丢了,至少还能通过 trace_id 把链路还原出来。
3.4 上下文的清理与超时机制
上下文不能无限增长,必须有清理机制。清理的触发条件通常有三种:交互流程结束、超时、手动清除。交互流程结束时的清理最自然,比如用户关闭对话窗口,就销毁对应的上下文。超时清理是兜底,防止因为异常导致上下文一直不释放。手动清除是给运维和调试用的,比如发现某个上下文有问题,可以强制清掉。
超时时间的设置需要权衡。太短了,用户还没操作完上下文就过期了,体验很差;太长了,资源占用高,而且可能积累大量僵尸上下文。我的经验值是:对话类场景 30 分钟到 2 小时,表单类场景 15 分钟到 30 分钟,后台任务类场景可以到 24 小时。这个值不是固定的,要根据实际用户行为调整。我一般会先设一个保守值,然后观察上下文平均存活时间和超时率,再逐步优化。
清理的时候要注意,不能只删存储里的数据,还要清理相关的缓存、锁、订阅关系。我踩过的坑是:上下文删了,但订阅该上下文的回调没取消,导致后续事件触发时回调报错。后来我在清理逻辑里加了一个“取消所有订阅”的步骤,才解决这个问题。所以清理不是简单的 delete,而是一个完整的销毁流程。
4. 实操过程与核心环节实现
4.1 从零搭建一个上下文管理模块
假设我们要在一个 Node.js 服务里实现 context-mode,核心模块包括:上下文定义、上下文存储、上下文中间件、上下文工具函数。先定义上下文的结构:
// context-schema.js const defaultContext = { session: { userId: null, locale: 'zh-CN' }, dialog: { turn: 0, lastIntent: null, slots: {} }, runtime: { traceId: null, createdAt: null, updatedAt: null } }; function createContext(overrides = {}) { const now = Date.now(); return { ...defaultContext, ...overrides, runtime: { ...defaultContext.runtime, traceId: generateTraceId(), createdAt: now, updatedAt: now } }; }这里用了一个工厂函数createContext,每次创建都生成新的 traceId 和时间戳。注意我用了浅拷贝加手动合并 runtime 的方式,而不是深拷贝,因为深拷贝性能差,而且上下文结构是已知的,手动合并更可控。
接下来是存储层。如果单机部署,可以用内存 Map;如果分布式部署,用 Redis。我抽象一个接口,方便切换:
// context-store.js class ContextStore { async get(contextId) { throw new Error('not implemented'); } async set(contextId, context, ttl) { throw new Error('not implemented'); } async delete(contextId) { throw new Error('not implemented'); } } class MemoryStore extends ContextStore { constructor() { super(); this.map = new Map(); } async get(contextId) { const item = this.map.get(contextId); if (!item) return null; if (item.expireAt && item.expireAt < Date.now()) { this.map.delete(contextId); return null; } return item.context; } async set(contextId, context, ttl = 3600) { this.map.set(contextId, { context, expireAt: Date.now() + ttl * 1000 }); } async delete(contextId) { this.map.delete(contextId); } }内存存储适合开发和单机测试,生产环境换成 Redis 实现即可。注意 TTL 的处理,内存存储需要自己判断过期,Redis 可以直接用 EXPIRE 命令。
4.2 上下文中间件的编写与接入
中间件的作用是在请求进入时加载上下文,请求结束时保存上下文。以 Express 为例:
// context-middleware.js function contextMiddleware(store) { return async (req, res, next) => { const contextId = req.headers['x-context-id'] || req.query.contextId; let context; if (contextId) { context = await store.get(contextId); } if (!context) { context = createContext(); contextId = generateContextId(); } req.context = context; req.contextId = contextId; res.setHeader('x-context-id', contextId); const originalEnd = res.end; res.end = async function(...args) { context.runtime.updatedAt = Date.now(); await store.set(contextId, context, 3600); originalEnd.apply(res, args); }; next(); }; }这段代码有几个关键点。第一,上下文 ID 优先从请求头取,取不到再从查询参数取,都没有就新建。第二,新建的上下文 ID 通过响应头返回给客户端,客户端下次请求带上它。第三,在响应结束时保存上下文,而不是在请求处理过程中保存,这样可以减少写入次数。第四,保存之前更新updatedAt,方便后续判断上下文的新鲜度。
接入的时候,把这个中间件挂在所有需要上下文的 routes 前面。不需要上下文的 routes 可以不挂,避免不必要的开销。我一般会区分公开接口和业务接口,公开接口不挂上下文中间件,业务接口才挂。
4.3 上下文在业务代码中的读写示例
业务代码里怎么用上下文?我封装了一组工具函数,避免直接操作原始对象:
// context-utils.js function getSlot(context, key) { return context.dialog.slots[key]; } function setSlot(context, key, value) { context.dialog.slots[key] = value; context.runtime.updatedAt = Date.now(); } function incrementTurn(context) { context.dialog.turn += 1; context.runtime.updatedAt = Date.now(); } function setIntent(context, intent) { context.dialog.lastIntent = intent; context.runtime.updatedAt = Date.now(); }这些工具函数看起来简单,但价值在于统一了更新逻辑。每次修改都更新updatedAt,这样超时判断才准确。另外,如果以后要在修改时加日志或埋点,只需要改工具函数,不用改所有业务代码。
在对话处理逻辑里,典型的使用方式是这样的:
async function handleUserMessage(req, res) { const context = req.context; const message = req.body.message; incrementTurn(context); const intent = await classifyIntent(message, context); setIntent(context, intent); if (intent === 'query_weather') { const city = extractCity(message) || getSlot(context, 'city'); if (city) { setSlot(context, 'city', city); const weather = await fetchWeather(city); res.json({ reply: ` ${city}的天气是${weather}` }); } else { res.json({ reply: '请问您想查哪个城市的天气?' }); } } }这段逻辑里,getSlot(context, 'city')就是上下文模式的价值体现:用户上一轮说了城市,这一轮没说,系统能从上下文里取到。如果没有上下文,这里就要额外维护一个状态,代码会乱很多。
4.4 上下文快照与回滚的实现
有些场景下,我们需要回滚上下文。比如用户说“算了,重新开始”,或者某个操作失败需要恢复到之前的状态。这时候就需要上下文快照。实现方式很简单:在关键节点把上下文深拷贝一份存起来,需要回滚时替换回去。
function snapshot(context) { return JSON.parse(JSON.stringify(context)); } function restore(context, snapshot) { Object.keys(context).forEach(key => delete context[key]); Object.assign(context, snapshot); }注意这里用了 JSON 序列化来做深拷贝,前提是上下文里没有函数、Date、undefined 这些 JSON 不支持的类型。如果有,就要用更完善的深拷贝库,或者手动处理。我一般会在上下文设计时就避免这些类型,全部用基本类型和普通对象,这样快照和恢复都很简单。
快照的存储位置也有讲究。如果快照很多,放内存会占用大量空间,建议放独立的存储,并设置较短的 TTL。如果快照很少,放内存里跟着上下文一起走也行。我一般会在上下文里预留一个snapshots字段,存最近几个快照,超过数量就淘汰最旧的。
5. 常见问题与排查技巧实录
5.1 上下文丢失的典型原因与定位方法
上下文丢失是最常见的问题,表现是用户明明上一轮说了某个信息,下一轮系统却像失忆了一样。原因通常有几种:第一,上下文 ID 没有正确传递,客户端没带上或服务端没解析到;第二,上下文存储过期了,TTL 设置太短;第三,上下文被意外覆盖,比如新建了上下文但没复用旧的;第四,多实例部署时路由到了不同实例,本地内存里没有对应的上下文。
定位的时候,我一般按这个顺序排查:先看日志里上下文 ID 是否一致,如果不一致,就是传递问题;如果一致但取不到,就是存储问题;如果存储里有但内容不对,就是覆盖问题;如果单机正常多机异常,就是路由问题。为了快速定位,我会在上下文中间件里打日志,记录上下文 ID、操作类型、关键字段的值。这样出问题时直接搜日志就能还原整个过程。
提示:上下文 ID 建议用有意义的格式,比如
ctx_{userId}_{timestamp}_{random},这样从 ID 就能看出属于哪个用户、什么时候创建的,排查时非常方便。不要用纯随机字符串,虽然更安全,但排查时很痛苦。
5.2 上下文膨胀的性能问题与优化
上下文用久了会膨胀,字段越来越多,序列化和传输的开销越来越大。我遇到过上下文体积超过 100KB 的情况,每次请求都要传输这么多数据,延迟明显增加。优化方式有几种:第一,定期清理不再使用的字段,比如过期的 slots;第二,把大字段拆出去,上下文里只存引用;第三,压缩上下文,比如用短字段名、去掉冗余的 null 值;第四,分片存储,把上下文按模块拆成多个 key,按需加载。
我一般会先做字段审计,统计每个字段的大小和使用频率,把低频大字段拆出去。然后做命名优化,把lastIntent改成li这种短名,虽然可读性差一点,但传输时能省不少空间。如果还不行,就上压缩,用 gzip 或 msgpack 序列化。实测下来,msgpack 比 JSON 能省 30% 到 50% 的体积,而且序列化速度更快。
还有一个隐藏的膨胀源是日志。如果每次上下文更新都打完整日志,日志文件会爆炸。我建议只打关键字段的变化,或者用采样日志,比如每 100 次更新打一次完整快照。这样既能排查问题,又不会拖垮日志系统。
5.3 上下文并发冲突的排查与解决
并发冲突的表现是:两个操作同时修改上下文,后写的覆盖了先写的,导致某个修改丢失。排查的时候,我会在上下文更新时加版本号,每次更新版本号加一,如果发现版本号跳跃,就说明有并发写入。然后根据时间戳和 trace_id 定位是哪些请求在并发。
解决方式前面提过,主要是分组隔离和版本号重试。分组隔离适用于字段之间没有依赖的情况,各改各的,互不影响。版本号重试适用于有依赖的情况,写入时检查版本号,不一致就重新读取、重新计算、重新写入。重试次数一般设 3 次,超过就报错,避免无限重试。
还有一种并发是读写并发。一个请求在读上下文,另一个请求在写上下文,读到的可能是写了一半的数据。解决方式是加读锁或使用原子操作。如果存储是 Redis,可以用 WATCH/MULTI 实现乐观锁;如果是内存,可以用读写锁。我一般倾向于用乐观锁,因为冲突概率通常不高,悲观锁的性能损失不值得。
5.4 上下文模式常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 上下文丢失 | ID 未传递 | 检查请求头/参数 | 确保客户端带上 ID |
| 上下文丢失 | 存储过期 | 检查 TTL 和创建时间 | 调整 TTL 或续期 |
| 上下文丢失 | 多实例路由 | 检查实例日志 | 用共享存储或一致性哈希 |
| 上下文内容错误 | 并发覆盖 | 检查版本号 | 分组隔离或乐观锁 |
| 上下文膨胀 | 字段过多 | 统计字段大小 | 清理、拆分、压缩 |
| 上下文更新不生效 | 缓存未刷新 | 检查读写路径 | 统一读写入口 |
| 上下文清理不彻底 | 订阅未取消 | 检查清理逻辑 | 完善销毁流程 |
| 上下文序列化失败 | 不支持的类型 | 检查字段类型 | 统一用基本类型 |
这张表是我从多次踩坑中总结出来的,基本上覆盖了 80% 的上下文相关问题。遇到问题时先查表,能快速缩小范围。如果表里没有,再去查日志和代码。
6. 上下文模式的扩展与个人经验
6.1 上下文模式在 AI 应用中的特殊考量
现在大模型应用很火,context-mode 在这个领域有了新的含义。大模型的上下文窗口是有限的,怎么在有限的窗口里塞进最有用的信息,就是一个上下文管理问题。我做过一个基于大模型的客服系统,核心挑战就是:对话历史越来越长,但模型只能处理固定长度的输入。解决方案是分层管理:最近几轮对话完整保留,较早的对话做摘要,更早的只保留关键实体。这样既保留了核心信息,又控制了长度。
具体实现的时候,我会维护两个上下文:完整上下文和压缩上下文。完整上下文存在存储里,用于审计和回溯;压缩上下文发给模型,用于生成回复。压缩策略是:最近 3 轮完整保留,第 4 到第 10 轮提取关键信息(意图、实体、结论),第 10 轮之前只保留摘要。这样实测下来,模型回复的准确率比直接截断历史高很多。
还有一个特殊点是上下文的动态性。传统应用的上下文结构是固定的,但 AI 应用里,上下文的内容和结构可能随对话变化。比如用户突然切换话题,上下文里就要新增一个话题分支。这时候上下文模式要支持动态扩展,不能写死结构。我的做法是用一个extensions字段放动态内容,结构固定,内容灵活。
6.2 我踩过的三个印象最深的坑
第一个坑是上下文 ID 冲突。早期我用时间戳做上下文 ID,结果同一毫秒内创建的两个上下文 ID 一样,导致串号。后来改成时间戳加随机数加用户 ID,才解决。这个坑的教训是:ID 生成一定要考虑并发,不能只用时间戳。
第二个坑是上下文更新丢失。有一次用户反馈说设置的城市没生效,排查发现是两个模块同时更新上下文,一个更新了城市,一个更新了意图,后写的把先写的覆盖了。后来改成按模块分组更新,才彻底解决。这个坑的教训是:上下文更新要尽量细粒度,不要整体覆盖。
第三个坑是上下文清理不彻底。有一次内存泄漏,排查发现是上下文删了,但定时任务还在引用它,导致无法回收。后来在清理逻辑里加了取消定时任务的步骤,才解决。这个坑的教训是:清理是一个流程,不是一次删除,要清理所有相关引用。
6.3 上下文模式的适用边界与替代方案
Context-mode 不是万能的。如果你的系统是无状态的,每次请求都独立,那就不需要上下文。如果状态很简单,只有一两个字段,那直接放请求参数里也行,没必要上完整的上下文管理。如果状态需要长期保存,那可能更适合用数据库或专门的会话服务,而不是上下文模式。
替代方案主要有几种:一是用工作流引擎,把状态管理交给引擎;二是用事件溯源,通过重放事件来重建状态;三是用状态机,把状态转换显式定义出来。这些方案各有优劣,选择的时候要看具体需求。我的经验是:交互简单用参数,交互中等用上下文,交互复杂用工作流或状态机。不要为了用而用,适合的才是最好的。
最后分享一个小技巧:上下文模式落地的时候,先写测试。因为上下文的行为是跨请求的,手动测试很难覆盖所有场景。我会写一组集成测试,模拟多轮交互,验证上下文在每个环节的状态。这样重构的时候才有信心,不会改出问题。这个习惯帮我避免了很多线上事故,推荐你也试试。