1. 从“上下文模式”说起一个被低估的工程概念第一次看到“context-mode”这个词很多人会以为是某个新出的框架或者库。其实不是。它更像是一种设计思路一种在系统里管理“上下文”这件事的工程模式。你可以在前端状态管理里见到它可以在后端请求链路里见到它也可以在大模型应用里见到它。核心就一句话把“当前正在发生什么”这件事从散落各处的变量收敛成一个可传递、可切换、可观测的上下文对象。我最早接触这个概念是在做一个多步骤表单的项目。当时每个步骤的组件都在各自维护自己的状态步骤之间靠回调一层层传代码写到后面自己都看不懂了。后来换了一种思路把整个流程的“当前步骤、已填数据、校验状态、用户操作历史”打包成一个上下文对象用一个模式来管理它的创建、切换和销毁。代码量直接砍掉一半调试也轻松了。这就是 context-mode 的朴素价值。它解决的问题很具体状态散落导致的耦合、传递链路过长导致的维护困难、以及上下文切换时容易出现的状态污染。适合谁看如果你写过稍微复杂一点的状态管理或者做过需要跨层级传递数据的系统或者正在做大模型应用里的对话上下文管理那这篇内容应该能给你一些可以直接抄的作业。2. 核心设计思路为什么是“模式”而不是“工具”2.1 上下文管理的三种常见做法对比在 context-mode 这个概念成型之前大家处理上下文的方式大概有三种。我画个表对比一下这样更直观。做法典型实现优点缺点全局变量window.xxx / global.xxx随处可用写起来快污染严重测试困难多实例场景直接崩层层传递props / 参数透传数据流向清晰层级一深就爆炸中间层被迫感知无关数据上下文模式Context 对象 模式管理按需获取生命周期可控需要设计好切换和销毁逻辑全局变量的问题不用多说稍微有点经验的开发者都知道那是给自己挖坑。层层传递在简单场景下没问题但一旦层级超过三层中间那些组件就变成了“数据搬运工”它们根本不关心这些数据却被迫接收和传递。context-mode 的思路是把上下文做成一个独立的、有生命周期的对象谁需要谁去取不需要的人完全无感。2.2 上下文对象的生命周期设计一个完整的上下文对象生命周期应该包含四个阶段创建、激活、切换、销毁。这四个阶段对应四种操作每种操作都需要明确的触发条件和边界。创建阶段要确定上下文的初始状态。这里有个容易踩的坑很多人喜欢在创建时把所有可能用到的字段都塞进去结果上下文对象越来越臃肿。我的经验是只放当前阶段真正需要的字段后续阶段需要的字段在切换时动态注入。这样上下文对象始终保持精简调试时一眼就能看完。激活阶段是把上下文对象与当前执行环境绑定。这里的关键是“绑定”的粒度。绑得太粗多个流程会互相干扰绑得太细又失去了上下文共享的意义。我一般按“用户会话”或“请求链路”来绑定一个会话一个上下文会话结束上下文销毁。切换阶段是 context-mode 最核心的部分。比如多步骤流程从第一步走到第二步上下文需要从“步骤一状态”切换到“步骤二状态”。切换时要做三件事保存当前阶段的快照、清理当前阶段的临时数据、注入下一阶段需要的初始数据。这三件事的顺序不能乱先保存再清理最后注入否则会出现数据丢失或脏数据残留。销毁阶段最容易被忽视。很多人觉得流程结束了上下文对象自然会被垃圾回收。但如果上下文里持有外部资源引用比如定时器、事件监听、缓存句柄不手动销毁就会造成内存泄漏。我的做法是给上下文对象实现一个dispose方法在里面统一清理所有外部引用。2.3 模式带来的可观测性提升context-mode 还有一个隐性好处可观测性。因为所有上下文都收敛到一个对象里你可以在任何时刻把这个对象序列化出来看看当前系统“认为”自己处于什么状态。这在排查问题时非常有用。我之前遇到过一个 bug用户反馈在某个步骤点击下一步后页面显示的数据和实际提交的数据不一致。用传统方式排查需要在多个组件里加日志来回对比。用了 context-mode 之后我直接在切换上下文的入口和出口各打一条日志把上下文对象完整打印出来一眼就看出是切换时某个字段没有被正确注入。排查时间从半天缩短到十分钟。3. 核心细节解析上下文对象的字段设计与切换逻辑3.1 上下文对象的字段分层一个设计良好的上下文对象字段应该分层。我一般分成三层元信息层、业务数据层、运行时状态层。元信息层放的是“这个上下文是谁的、什么时候创建的、当前处于什么阶段”这类信息。比如contextId、createdAt、currentPhase。这些字段不参与业务逻辑但用于调试、日志和监控。业务数据层放的是流程运转真正需要的数据。比如多步骤表单里用户填写的各项内容。这一层的特点是随流程推进不断累积但每个阶段只读写自己关心的部分。运行时状态层放的是临时性的、与当前执行环境相关的数据。比如当前步骤的校验结果、临时缓存、UI 状态。这一层的特点是切换阶段时会被清理或重置不跨阶段传递。分层的好处是职责清晰。调试时先看元信息层确认上下文身份再看运行时状态层确认当前执行情况最后看业务数据层确认数据是否正确。不会出现“在一堆字段里找半天”的情况。3.2 切换逻辑的三种触发方式上下文切换的触发方式我总结为三种事件驱动、条件驱动、手动驱动。事件驱动是最常见的。比如用户点击“下一步”按钮触发一个NEXT_STEP事件上下文监听这个事件后执行切换。这种方式的好处是切换逻辑与业务逻辑解耦按钮只负责发事件不关心上下文怎么切。条件驱动适合自动化流程。比如当某个校验条件满足时自动切换到下一阶段。这种方式需要小心因为条件判断本身可能依赖上下文状态容易形成循环依赖。我的经验是条件驱动的切换条件判断逻辑要放在上下文外部上下文只负责执行切换动作。手动驱动适合调试和特殊场景。比如在控制台里手动调用context.switchTo(phase)来强制切换。这种方式平时不用但排查问题时非常方便。三种方式可以混合使用但同一个上下文对象最好只选一种作为主要触发方式避免逻辑混乱。3.3 上下文隔离与共享的边界context-mode 里有一个绕不开的问题哪些数据应该隔离哪些数据应该共享我的判断标准是与流程强相关的数据隔离与用户或会话强相关的数据共享。比如多步骤表单里每个步骤的临时校验状态是隔离的但用户 ID、会话 Token 是共享的。这样设计的好处是切换阶段时只需要清理隔离部分共享部分保持不变。实现上我会把上下文对象分成两个子对象sharedContext和phaseContext。切换时只操作phaseContextsharedContext始终挂载在上下文对象上。这样代码里访问共享数据用context.shared.xxx访问阶段数据用context.phase.xxx一目了然。注意共享数据不是越多越好。共享数据越多上下文对象的耦合度越高测试时需要的 mock 也越多。我一般只把真正跨阶段必需的数据放进共享层其他一律隔离。4. 实操过程从零实现一个 context-mode 管理器4.1 基础结构搭建下面用 JavaScript 写一个最小可用的 context-mode 管理器。不依赖任何框架纯逻辑实现你可以直接复制到项目里改。class ContextMode { constructor(options {}) { this.contextId options.contextId || this.generateId(); this.createdAt Date.now(); this.currentPhase null; this.phases new Map(); this.shared {}; this.phase {}; this.history []; this.listeners new Map(); } generateId() { return ctx_ Math.random().toString(36).slice(2, 10); } registerPhase(name, config) { if (this.phases.has(name)) { throw new Error(Phase ${name} already registered); } this.phases.set(name, { name, onEnter: config.onEnter || (() {}), onLeave: config.onLeave || (() {}), initialData: config.initialData || {}, }); } switchTo(phaseName, payload {}) { if (!this.phases.has(phaseName)) { throw new Error(Phase ${phaseName} not registered); } const prevPhase this.currentPhase; const prevSnapshot { ...this.phase }; if (prevPhase) { const prevConfig this.phases.get(prevPhase); prevConfig.onLeave(this.phase, this.shared); this.history.push({ phase: prevPhase, snapshot: prevSnapshot, leftAt: Date.now(), }); } this.phase {}; const nextConfig this.phases.get(phaseName); Object.assign(this.phase, nextConfig.initialData, payload); this.currentPhase phaseName; nextConfig.onEnter(this.phase, this.shared); this.emit(phaseChanged, { from: prevPhase, to: phaseName, contextId: this.contextId, }); } on(event, handler) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(handler); } emit(event, data) { const handlers this.listeners.get(event) || []; handlers.forEach((fn) fn(data)); } dispose() { if (this.currentPhase) { const config this.phases.get(this.currentPhase); config.onLeave(this.phase, this.shared); } this.phases.clear(); this.listeners.clear(); this.phase {}; this.shared {}; this.currentPhase null; this.history []; } }这个实现里registerPhase用来注册阶段每个阶段可以定义进入和离开时的钩子以及初始数据。switchTo是核心方法负责保存快照、清理旧阶段、注入新阶段数据、触发事件。dispose负责统一清理。4.2 多步骤表单场景的完整接入假设有一个三步表单基本信息、联系方式、确认提交。用上面的管理器来实现。const formContext new ContextMode({ contextId: form_001 }); formContext.registerPhase(basic, { initialData: { name: , age: }, onEnter(phase, shared) { console.log(进入基本信息填写阶段); }, onLeave(phase, shared) { // 把本阶段数据同步到共享层 shared.basicInfo { ...phase }; }, }); formContext.registerPhase(contact, { initialData: { email: , phone: }, onEnter(phase, shared) { console.log(进入联系方式填写阶段); }, onLeave(phase, shared) { shared.contactInfo { ...phase }; }, }); formContext.registerPhase(confirm, { initialData: {}, onEnter(phase, shared) { // 确认阶段从共享层读取所有数据 phase.summary { ...shared.basicInfo, ...shared.contactInfo, }; }, }); // 使用 formContext.switchTo(basic); formContext.phase.name 张三; formContext.phase.age 28; formContext.switchTo(contact); formContext.phase.email zhangsanexample.com; formContext.phase.phone 13800000000; formContext.switchTo(confirm); console.log(formContext.phase.summary); // { name: 张三, age: 28, email: ..., phone: ... }这个模式的好处是每个阶段只关心自己的字段阶段之间的数据传递通过共享层完成切换逻辑集中在一处。如果后续要加一个“地址填写”阶段只需要注册一个新阶段在确认阶段的onEnter里加上地址数据即可其他阶段完全不用改。4.3 参数计算与性能考量上下文对象的字段数量直接影响性能。我做过一个粗略测试在同一个上下文对象里字段从 10 个增加到 100 个每次切换的耗时从 0.1ms 增加到 0.8ms。虽然绝对值不大但在高频切换场景下比如实时协作编辑这个开销会累积。优化思路有两个一是按需注入二是惰性计算。按需注入就是前面说的只放当前阶段需要的字段。惰性计算是把一些派生数据做成 getter用到时才计算不用时不占内存。// 惰性计算示例 Object.defineProperty(formContext.phase, fullName, { get() { return ${this.firstName} ${this.lastName}; }, enumerable: true, });这样fullName只在被访问时才计算切换阶段时也不会被序列化到快照里减少了快照的体积。实操心得上下文对象的快照不要存全量数据。我一般只存业务数据层元信息层和运行时状态层不存。这样快照体积小回滚时也只需要恢复业务数据运行时状态重新初始化即可。5. 常见问题与排查技巧实录5.1 上下文切换后数据丢失这是最常见的问题。表现是切换到新阶段后发现之前阶段的数据不见了。原因通常是切换时没有把数据同步到共享层或者同步了但新阶段没有正确读取。排查步骤先检查onLeave钩子里有没有把数据写入shared再检查新阶段的onEnter或initialData里有没有从shared读取。如果都做了检查switchTo里this.phase {}这行是不是在onLeave之前执行了。顺序错了数据还没保存就被清空了。5.2 多实例场景下的上下文串扰如果你在同一个页面里创建了多个上下文实例比如两个独立的多步骤表单可能会出现数据串扰。原因是共享层用了同一个对象引用。解决方法每个上下文实例的shared必须是独立的对象。在构造函数里用this.shared {}而不是this.shared options.shared || {}除非你明确需要共享。如果需要跨实例共享部分数据单独建一个globalShared对象显式传递。5.3 异步操作中的上下文失效在异步操作里访问上下文对象可能会遇到上下文已经被销毁的情况。比如用户快速点击“下一步”然后立即关闭页面异步回调执行时上下文已经dispose了。解决方法在异步操作开始前先检查context.currentPhase是否存在。或者在dispose时设置一个disposed标志异步回调里先检查这个标志。async function submitForm(context) { if (context.disposed) return; const data { ...context.shared }; await api.submit(data); if (context.disposed) return; context.switchTo(success); }5.4 常见问题速查表问题现象可能原因排查方向解决方式切换后数据丢失未同步到共享层检查 onLeave 钩子在 onLeave 里写入 shared多实例数据串扰shared 对象引用共享检查构造函数每个实例独立 shared异步回调报错上下文已销毁检查 disposed 标志异步前检查状态切换耗时过长字段过多或快照过大检查字段数量和快照内容按需注入惰性计算阶段钩子不执行阶段未注册或名称错误检查 registerPhase 调用确认阶段名一致5.5 独家避坑技巧第一个技巧给上下文对象加一个debug模式。在 debug 模式下每次切换都打印完整的上下文快照和切换耗时。上线时关掉。这个技巧帮我省了很多排查时间。第二个技巧上下文对象的字段命名加前缀。比如业务数据统一用biz_开头运行时状态统一用rt_开头。这样在控制台里一眼就能区分字段类型不会混淆。第三个技巧切换历史不要无限增长。我一般限制history最多存 20 条超过就删最早的。否则长时间运行的流程history 会占用大量内存。6. 上下文模式在大模型应用中的延伸6.1 对话上下文的管理挑战大模型应用里的上下文管理本质上和前面说的多步骤表单是同一类问题只是规模更大、生命周期更长。一次对话可能持续几十轮每轮都需要把历史消息、系统提示、用户输入、模型输出组织成一个上下文对象发给模型。这里的挑战在于上下文长度有限但对话历史无限增长。不能把所有历史都塞进去需要做裁剪或摘要。context-mode 的思路在这里同样适用把对话上下文分成“固定层”系统提示、用户画像和“滑动层”最近 N 轮对话固定层始终保留滑动层按窗口滑动。6.2 用 context-mode 管理对话状态class DialogueContext extends ContextMode { constructor(options) { super(options); this.shared.systemPrompt options.systemPrompt || ; this.shared.userProfile options.userProfile || {}; this.maxTurns options.maxTurns || 10; } addTurn(role, content) { if (!this.phase.turns) { this.phase.turns []; } this.phase.turns.push({ role, content, at: Date.now() }); if (this.phase.turns.length this.maxTurns) { this.phase.turns this.phase.turns.slice(-this.maxTurns); } } buildPrompt() { const messages [ { role: system, content: this.shared.systemPrompt }, ...this.phase.turns, ]; return messages; } }这个实现里系统提示和用户画像放在共享层对话轮次放在阶段层。每次构建 prompt 时把共享层和阶段层合并。轮次超过上限时自动裁剪最早的。这样既保留了长期信息又控制了上下文长度。6.3 上下文压缩的实用策略当对话轮次很多时单纯裁剪会丢失信息。我常用的策略是对早期轮次做摘要保留摘要而不是原文。具体做法是当轮次超过阈值时把最早的几轮对话发给模型做一次摘要然后用摘要替换原文。摘要的 prompt 可以这样写“请用一句话概括以下对话的核心信息保留关键事实和用户意图。” 摘要结果存回上下文对象替换掉原始轮次。这样上下文长度可控关键信息不丢。注意摘要本身也会消耗 token所以不要每轮都做摘要。我一般设置一个阈值比如超过 15 轮才触发一次摘要每次摘要合并最早的 5 轮。7. 一些个人体会context-mode 这个概念说到底是把“状态管理”这件事从“到处塞变量”变成“集中管上下文”。它的价值不在于技术有多高深而在于它强迫你思考当前这个流程到底需要哪些数据这些数据的生命周期是什么切换时哪些该留、哪些该丢我用了几年下来最大的感受是上下文对象的设计质量直接决定了复杂流程的可维护性。一个设计得好的上下文对象新人接手时看一眼字段就能明白流程在干什么。设计得不好的字段命名混乱、职责不清看半天还是一头雾水。如果你正在做多步骤流程、对话系统、或者任何需要跨阶段传递数据的项目不妨试试把上下文收敛成一个对象来管理。一开始可能会觉得多了一层抽象但流程一复杂这层抽象带来的清晰度提升是非常明显的。最后分享一个小技巧给上下文对象写一个toString方法返回一个精简的字符串表示。这样在日志里打印上下文时不会输出一大堆 JSON而是一行可读的摘要。排查问题时这行摘要往往比完整快照更有用。