☰
Context Mode:一套贯穿React与LLM应用的上下文管理实践
2026/10/29 5:56:58 网站建设 项目流程

前阵子我们内部起了一个项目代号,就叫context-mode。起因挺狼狈:凌晨两点,线上订单详情页被用户反馈"串号"——A 用户看到的是 B 用户的审批状态,第一反应是接口缓存问题,排查到最后才发现,根源是两组业务状态被塞进了同一个全局区域,页面切换时程序只更新了局部字段,"当前用户""当前订单"这些上下文被留在了原地。那次故障之后,我花了将近两周时间,把项目里的状态传递方式整体翻了一遍,最终沉淀出一套可以复用的设计思路。这篇文章就把这次实践完整拆开讲:从 Context 到底是什么,到 React 里怎么落地,再到后来做 AI 应用时发现同样一套思想可以迁移到 LLM 的上下文管理上。如果你正在被"状态散乱、上下文丢失、组件越改越耦合"这类问题困扰,这篇应该能帮你找到系统的解法。

1. 为什么要专门为"上下文"做一个模式

1.1 从一次凌晨故障说起

先还原一下当时出问题的现场。我们的中后台项目用的是 React + TypeScript,页面结构并不复杂:订单列表页、订单详情页、审批页,加上一个全局用户栏。早期为了赶进度,开发同学图省事,把"当前用户信息"和"当前正在查看的订单"都挂到了一个全局的 store 对象里,取数据的时候直接从里面拿。

平时单用户演示没问题,一旦有人快速切换账号、或者从详情页返回再进入另一条订单,问题就来了。因为这个全局 store 里存的是"上一次操作留下的状态",而新页面读数据时只覆盖了部分字段,两笔订单的字段就会混在一起。我们在修复时发现,最麻烦的不是改代码,而是找出"到底有哪些模块在隐式依赖这一份状态"。这就像办公室共用一个白板,A 组画了进度图,B 组来了没擦直接往上写日程,C 组路过看见一半,所有人都在猜白板现在代表什么。

这次事故让我明确了一个判断:系统里每个模块需要的"背景信息",必须有一种显式的、结构化的传递方式,而不是靠某一个全局变量来承担。这就是context-mode项目的起点——把"上下文"当作一个正式的设计概念,而不是顺手写的全局数据。

1.2 ContextMode 的两张面孔:组件上下文与 AI 上下文

做前端的人看到 Context,第一反应是 React Context API;但如果你最近接触过 LLM 应用开发,又会发现另一个高频词——上下文(Context)窗口、RAG 上下文注入、多轮对话上下文管理。我后来意识到,这两个场景底层是同一个问题:一个执行单元(组件、函数、大模型)在完成工作时,需要知道"当前处在什么环境、有哪些约束、之前发生了什么"。

组件需要上下文来避免把 props 一层层手动传下去,大模型需要上下文来避免"失忆"和答非所问。React 的方案是 Provider/Consumer,AI 应用的方案是 Prompt 组装和记忆压缩,但它们的目标一致——让执行单元用最少的显式参数,获得最完整的背景信息。

所以这篇文章不只是讲 React Context API 的用法,而是围绕context-mode展开一套完整的实践:怎么分层设计上下文、怎么管理上下文生命周期、怎么避免性能陷阱,以及同一套思想怎么平移到 LLM 应用的上下文工程里。

2. 上下文分层:把 Context 从"全局杂物间"变成"分级仓库"

2.1 为什么不能让一个 Context 包打天下

很多团队用 Context 的方式很粗暴:在入口组件包一个<AppContext.Provider>,把用户信息、主题色、接口地址、路由信息、甚至某个弹窗是否打开全部塞进去。代码写起来确实省事,但后果在项目中期集中爆发。

首先是更新收敛问题。任何一处调用setAppContext,整个 app 里所有消费这个 Context 的组件都会收到通知,哪怕它只关心主题色,也会因为"用户信息变了"而被连带重渲染。React 里 Context 的传播机制是"值变了就通知所有 consumer",它不像 Redux 那样有精细的 selector 能力,靠<Context.Provider value={...}>的一次性赋值,很难做到精准更新。

其次是职责混淆。用户信息是跨会话长期存在的,订单状态是页面级别的短期数据,弹窗开关是组件内部的瞬时状态。把它们混在一个 Context 里,让"生命周期"的管理无从下手——页面卸载时应该清理哪些?切换账号时要重置哪些?答案全都含糊。

我的结论是:Context 不是不能用来做全局状态,而是不能只做一个"大杂烩" Context。正确思路是分层,把不同生命周期的数据放进不同的 Provider,让各自管理各自的范围。

2.2 三层上下文模型的划分

在context-mode项目里,我把上下文分为三层,分别命名为领域上下文、会话上下文、环境上下文。这三层的划分依据是"作用域 + 更新频率 + 生命周期"。

层级典型内容生命周期更新频率Provider 放置位置
环境上下文主题、语言、接口基地址、功能开关应用级,几乎不变极低应用根节点
会话上下文当前用户、登录态、权限标识登录会话期间低登录态校验之后
领域上下文当前订单、当前项目、当前正在编辑的文档页面或业务域内高页面路由或业务模块入口

环境上下文是最稳定的,它描述的是"这个应用运行在什么条件下"。会话上下文跟随用户登录状态,切换账号时必须整体重建。领域上下文是业务操作的核心背景,比如你在订单详情页看的是哪条订单、在审批页审的是哪一步——这类数据变化最频繁,也最容易串号。

这个分层带来的直接好处是:每一层 Context 都可以独立重建、独立测试。切换账号时只需要重建会话上下文,领域上下文跟着页面走;页面跳转时领域上下文重建,不会去动环境和会话层。故障排查时也能顺着层级快速定位——是环境配置错了,还是会话过期了,还是领域数据没刷新,一划分就清楚。

2.3 作用域与生命周期管理的对应关系

分层设计之后,Provider 的嵌套关系也要跟着层级走,而不是全部堆在根部。我们的做法是:

  • 环境上下文放在App.tsx的根节点,保证整个应用都能读到主题和开关配置。
  • 会话上下文放在登录校验路由内,只有已登录区域才需要它,登录页不消费用户信息。
  • 领域上下文放在各自业务域的入口组件里,比如订单模块的布局组件内部。

这样每个 Provider 的"管辖区"跟它的生命周期天然对齐。订单 Context 会随着订单路由卸载而被销毁,里面的"当前订单"数据自然清空,下次进入再重新加载,想串号都难。

这里有个操作细节值得强调:每一层 Provider 的 value 都要有明确的构建和销毁时机,不要在一个 Provider 内部去操作其他层的数据。比如订单 Provider 里可以读会话上下文里的用户 ID,但不要直接改写用户信息,跨层写数据会把生命周期搞混。

3. React 侧落地:从 createContext 到业务闭环

3.1 真实业务域:订单上下文的抽象过程

分层之后,最先改造的是当时出事的订单模块。当时业务需求是:列表页点进某条订单,详情页、审批页、操作日志页都要共享"当前订单 ID、订单状态、买家信息、卖家信息、当前审批人"这些数据,而且列表页操作后详情页要同步刷新。

如果不用 Context,最直接的方案是 props 一层层传。从列表页传到详情页,详情页再传给审批组件、日志组件,中间可能经过四五层无关组件,每传一层都要在类型上补充新字段,维护成本很高。如果用全局 store 又回到之前串号的老路。

我最后采取的方式是:为订单域建一个独立的OrderContext,粒度只覆盖订单模块,不掺入用户、主题这些全局数据。流程分成三步:定义值结构、实现 Provider、写自定义 Hook 供消费方使用。

3.2 Provider 的实现与稳定引用

先定义订单上下文的值结构。这里有一个经验:Context value 的每个字段都应该有明确的语义和来源,能不用 setter 就别直接暴露 setter。我们倾向于把"加载订单""刷新订单""清空订单"这些操作封装成方法,让业务组件语义化地调用,而不是把底层 state 的 setState 裸奔出去。

// order-context.ts import { createContext, useContext } from 'react'; export interface OrderBrief { orderId: string; status: 'pending' | 'approved' | 'rejected'; buyerName: string; sellerName: string; currentApprover?: string; } interface OrderContextValue { order: OrderBrief | null; loading: boolean; error: Error | null; loadOrder: (orderId: string) => Promise<void>; refreshOrder: () => Promise<void>; resetOrder: () => void; } export const OrderContext = createContext<OrderContextValue | null>(null);

Provider 部分的实现要特别注意 value 的稳定性。React 官方有个容易被忽略的细节:Context value 一旦换成新的对象引用,所有 consumer 都会重新渲染。如果 Provider 内部每次 render 都现场拼一个新对象,即使数据没变化,子组件也会全部跟着刷新。所以我们用useMemo把这几个操作函数和状态值打包成稳定引用。

// order-provider.tsx import { ReactNode, useCallback, useMemo, useState } from 'react'; import { OrderContext, OrderBrief } from './order-context'; export function OrderProvider({ children }: { children: ReactNode }) { const [order, setOrder] = useState<OrderBrief | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const loadOrder = useCallback(async (orderId: string) => { setLoading(true); setError(null); try { // 实际开发在这里调用接口,并做字段映射 const data = await fetchOrder(orderId); setOrder(data); } catch (e) { setError(e instanceof Error ? e : new Error('加载订单失败')); } finally { setLoading(false); } }, []); const refreshOrder = useCallback(async () => { if (order?.orderId) { await loadOrder(order.orderId); } }, [order?.orderId, loadOrder]); const resetOrder = useCallback(() => { setOrder(null); setError(null); setLoading(false); }, []); const value = useMemo<OrderContextValue>( () => ({ order, loading, error, loadOrder, refreshOrder, resetOrder }), [order, loading, error, loadOrder, refreshOrder, resetOrder] ); return <OrderContext.Provider value={value}>{children}</OrderContext.Provider>; }

注意useMemo的依赖数组里把order、loading、error都列进去了,也就是说数据真正变化时 value 才会更新。这个写法看起来繁琐,但能避免大量无意义重渲染。踩过几次坑之后我的体会是:写 Provider 时想清楚"什么变了才需要通知下游",比之后在 Profiler 里排查渲染树要省力得多。

3.3 自定义 Hook:把复杂性关在门内

Provider 写好了,消费端也不能直接用useContext(OrderContext)裸奔。因为在还没有任何 Provider 包裹的组件里调用它会拿到null,写ctx.order会直接抛错。我们封装了一个自定义 Hook,把空值保护和错误提示统一处理掉。

// use-order.ts import { useContext } from 'react'; import { OrderContext } from './order-context'; export function useOrder() { const ctx = useContext(OrderContext); if (!ctx) { throw new Error('useOrder 必须在 <OrderProvider> 内部使用'); } return ctx; }

这个 Hook 的意义不只是省一次判空。它相当于给订单上下文的消费者一个"接口约定":只要在 OrderProvider 范围内,组件不需要关心数据怎么来、怎么更新,直接拿到订单对象和操作函数即可。列表页选择订单时调用loadOrder(orderId),详情页挂载时读order,审批通过后调refreshOrder(),数据流转一目了然。

实际改造后的效果是:订单模块里删掉了大量手动传递的 props 和组件内部的"临时状态同步逻辑",组件树之间的依赖变成了一条清晰的 Provider 链路。链路内组件只管消费,链路外组件完全感知不到订单数据的存在,串号问题从设计上就被杜绝了。

4. LLM 应用中的 Context Mode:窗口、记忆与注入

4.1 上下文窗口不是无限内存

前端项目告一段落后,我刚好在帮一个团队做 AI 应用的技术方案,意外发现context-mode的思路在 LLM 场景下同样成立。

做 AI 应用的同学应该都遇到过这个矛盾:模型能记住的信息是有限的,但对话一长,早期内容要么被截断,要么被压缩得面目全非。调用商用的对话模型时,上下文越长,单次调用的成本也越高,响应速度还会变慢。于是"上下文管理"就成了 AI 工程里的核心问题——怎么在有限窗口里塞进最有价值的信息,而不是把所有历史原封不动倒进去。

这跟前面提到的"环境上下文、会话上下文、领域上下文"分层思想有很强的对应关系:系统提示(system prompt)描述的是模型运行的环境规则,属于环境级;当前对话的完整历史属于会话级;用户本次提问所涉及的具体资料片段属于领域级。把不同生命周期的上下文分开管理,分别决定"放进来、压缩了放进来、还是不放进来了",这才是 Context Mode 的本质。

4.2 四种上下文管理策略的取舍

我整理了一下目前工程上常见的上下文管理手段,大致可分成四类,各有各的适用场景:

策略做法成本风险
直接截断超出窗口的部分丢弃,只保留最近几轮最低丢关键信息,模型可能忘记重要前提
摘要压缩把早期对话用模型压缩成摘要,用摘要代替原文中等,要额外调用模型摘要会丢失细节,且自身也是 token
结构化提炼边聊边抽取"用户偏好、关键实体、待办事项"等字段较高,需要设计抽取规则抽取质量影响后续理解
RAG 检索注入在外部知识库中检索与当前问题相关的片段,拼进上下文中高,需要搭建向量检索检索不准会引入噪音,反而干扰模型

这四类策略对应的是不同的上下文生命周期。对话进行中,每一轮的原始消息属于高更新频率的短期上下文;从对话里提炼出的用户偏好属于跨会话的长期上下文;外部知识库片段则是随问题变化的领域上下文。把它们混在一个数组里暴力拼接,前面提到"大杂烩 Context"的毛病就全部出现了。

4.3 组装 Prompt 时如何编排上下文

实际写代码的时候,我们会封装一个"上下文组装器",专门负责决定哪些内容进 Prompt、哪些内容不进。它的逻辑可以理解成一个环形缓冲加分级压缩:短期对话历史保留最近几轮原文,更早的合并成摘要;系统指令作为环境上下文固定在最前面;用户当前问题触发外部知识检索,把相关片段插进对话历史之前。

// build-context.ts interface ContextBundle { system: string; compressedHistory: string; relevantDocs: string[]; recentTurns: string[]; } export function buildPrompt(userInput: string, bundle: ContextBundle): string { return [ bundle.system, '=====相关资料=====', bundle.relevantDocs.join('\n'), '=====历史摘要=====', bundle.compressedHistory, '=====近期对话=====', bundle.recentTurns.join('\n'), `用户:${userInput}`, ].filter(Boolean).join('\n'); }

这个架构的价值在于:每一段上下文的变化都有独立触发条件。用户只问了新问题,就只更新recentTurns和relevantDocs,不用重算历史摘要;只有历史积累到一定轮数才触发压缩流程。这就好比 React 里分层后的多个 Context,各自在自己的生命周期内更新,不会牵一发动全身。

做完 AI 项目再回头看,两个领域解决的是同一个问题,只是表现载体不同:前端的 Context 是组件树上的 Provider,AI 的 Context 是 Prompt 里的结构化段落。掌握底层的分层和生命周期思想,技术栈迁移时就能快速把经验带过去。

5. 性能与调试:Context 重构最容易翻车的地方

5.1 重渲染风暴:问题的定位过程

Context 的模式写起来清爽,但落地后第一波问题往往出现在性能上。我们做订单模块改造时,上线第二天就收到反馈:订单详情页每次加载都要卡顿小半秒,比改造前还慢。

打开 React DevTools 的 Profiler 录制操作,一眼就看到了问题——详情页里几乎所有组件都被标成了红色,重渲染范围远超预期。进一步看 props 变化,发现原因很典型:我们在订单 Provider 内部把"加载中的 loading 状态"和"订单数据"放在同一个 Context value 里,接口请求一开始setLoading(true),整个 value 对象就变成新的引用,所有消费方集体重渲染;然后接口返回setOrder(data),又是一次全量重渲染。一次加载触发两轮风暴。

5.2 拆分、缓存与按序消费

根因清楚了,解决方向很明确:让 Context value 的粒度匹配消费方的关注点。我拆成了两个思路来改。

第一是拆分粒度。把"订单数据本身"和"加载状态"拆成两个 Context,或者用单独的状态管理来承载高频变化的 loading 态。消费方里只有真正需要展示 loading 的按钮和骨架屏才订阅加载态,页面主体只关心订单数据。这样一来,setLoading(true)不再是全局广播,只有少数组件响应。

第二是稳定引用。排查时发现一个隐藏 bug:我们有个详情组件用了memo,但仍然跟着无关 Context 一起重渲染。原因是 Provider 的 value 里塞了一个每次 render 都会新建的临时对象——{ data: order, actions },结构相同但引用不同。改成useMemo之后,memo 组件的性能优化才真正生效。

我整理了一张排查表,后续再遇到类似问题直接按表排查:

症状常见根因解法
一次更新触发大量渲染Context value 引用总在变useMemo包裹 value,依赖项尽量少
只关心 loading 也重渲染数据loading 和数据在同一个 Context拆分 Context,按消费频率分层
所有子组件全部重渲染Provider 包在组件树过高的位置下沉 Provider,缩小作用范围
memo 组件失效传入了不稳定函数或对象用useCallback稳定函数引用

5.3 调试 Context 的实用技巧

调试 Context 比调试 props 要难,因为数据流是隐式的,不像 props 直接写在组件调用处。这里分享三个我用着顺手的小技巧。

第一个是给 Context 加日志装饰。在自定义 Hook 里临时打印ctx的值变化,开发环境能看到每一次触发更新的来源,定位"谁改了上下文"非常方便。上生产前把这段日志删掉就好。

第二个是在 Provider 边界设断点。怀疑某个区域上下文异常时,直接在useMemo计算 value 的那行打断点,看依赖数组里的每一项是什么时候变的。很多"玄学 bug"都是依赖项里混入了每次 render 都重新创建的数组或函数导致的。

第三个技巧是"上下文越矩检测"。我们在开发环境写过一个 HOC 包装器,在 Context Provider 外套一层代理,如果发现某个组件订阅的 Context 数量和它的实际位置不匹配(比如很底层的小组件竟然订阅了领域级 Context),就打印警告。这能逼着团队把上下文消费控制在一个合理的层级范围内,避免上下文用成新的全局变量。

6. 什么场景才值得启用 Context Mode

6.1 Context 与状态管理库的分工

经常有人问:既然有 Zustand、Redux 这些状态管理库,为什么还要用 Context?我的看法是,这两者解决的问题有重叠,但侧重点不同。

Context 的本质是依赖注入,它擅长的是"把背景信息从高处传给深层的组件",解决 props 层层透传的问题。状态管理库则擅长"跨组件共享可更新的状态,并精细控制响应范围"。实践中可以组合使用:稳定的、低频更新的背景信息用 Context 传递;高频的、需要精细 selector 的共享数据交给状态管理库。

对比维度Context状态管理库
主要作用依赖注入、背景传递共享状态、跨模块通信
学习成本低,原生 API中高,需理解数据流范式
细粒度更新差,值变则全通知好,支持 selector 精确订阅
异步逻辑需自己封装多数内置或生态成熟
适用场景传递主题、用户、业务域背景复杂跨模块交互、高性能列表

context-mode项目最终没有推翻状态管理库,而是把"业务域上下文"这类生命周期明确、更新频率适中的数据用 Context 承接了下来,让状态管理库专心处理它擅长的部分。方案之间不是替代关系,而是分工关系。

6.2 适用的信号与不适用的场景

经过这一轮实践,我总结出几个"该启用 Context Mode"的信号:

  • 同一个业务数据被三层以上组件传递,中间隔着无关组件。
  • 页面或业务域有明确的生命周期,进入时需要加载背景数据,退出时需要重置。
  • 多个模块共享同一份业务状态,且希望状态变化对所有消费方同步可见。
  • 团队希望减少 props 透传带来的类型维护成本。

反过来,有些场景强上 Context 反而是麻烦。比如简单的父子级联组件,两三层 props 就能传完,硬加 Context 只是增加阅读负担;再比如高频实时数据(每秒推送的行情、游标位置),用 Context 触发全量通知会很吃力,外部 store 或状态管理库更合适。

6.3 我踩过之后沉淀的判断标准

做完整套改造,我给自己定了一条判断标准,遇到"要不要引入 Context"这个问题时先想三件事:粒度是否够小、作用范围是否够窄、生命周期是否清楚。粒度小,就不会牵出一堆无关组件重渲染;范围窄,就不会变成新的全局变量;生命周期清楚,就不会出现上下文残留和串号。

当初凌晨那次故障,本质上就是这三个标准全都没满足。后来把订单上下文收进独立 Provider,它只服务订单域,数据随路由卸载而销毁,周期结束就清理干净,问题自然消失。回头看,context-mode不是一个复杂的架构,它更像一组朴素的原则:显式传递背景信息,分层管理生命周期,让每个模块都知道自己处在什么状态下、该按什么规则办事。

我个人现在再做类似设计时,会把这套思路也带进新项目里,包括 AI 应用中 Prompt 的组装,也会下意识问一句:这段上下文该放在哪个层级,它的生命周期有多久,更新时会影响哪些下游。想清楚这三个问题,大部分上下文混乱的坑就都能提前绕开了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询