做过几年C端应用或AI产品的人,大概率都碰过同一个词:context-mode。翻译成大白话就是“上下文模式”,但这个词在很多团队里其实是被轻拿轻放的——产品经理把它当成一个“记住用户上次操作”的开关,后端同学把它当作一个“传参数”的结构体,前端同学则认为它是“全局状态管理”的代名词。同一个词,三种理解,最后做出来的东西常常四不像。
我这几年先后做过IM、编辑器和两个AI对话类产品,context-mode这个概念几乎每个项目都会以不同形态出现。它不只是一个“模式开关”,而是一套贯穿产品设计、数据结构、状态管理和线上问题排查的完整链路。这篇文章我想把链路里那些容易踩坑的点摊开讲一遍,包括什么时候该用、什么时候不该用、数据怎么设计、生命周期怎么管、线上泄漏和串号怎么排查。如果你正在设计或开发一个带“连续对话能力”“自动填充上下文”“跨页面状态保留”这类功能的东西,这篇内容应该能帮你少走不少弯路。
1. Context Mode 到底是什么:一个被低估的交互范式
1.1 从一次产品评审会说起:所有人都在说,但没人说清
先讲一个真实场景。去年我们做一个面向运营人员的内部工单系统,需求很简单:运营在处理一个客户投诉时,可能要从多个页面来回跳——客户画像页、订单列表、历史工单、聊天记录。当时产品经理提了一个需求:不管我在哪个页面,右侧栏都显示当前这个客户的基本信息和最近操作记录,方便我继续处理。这个需求看着不复杂,但评审会上吵翻了。前端说这是“全局状态需要共享”,后端说“每个页面的接口都带customerId就是上下文了”,PM说“我要的是系统记得我在处理谁”。
这个争吵背后,其实所有人都在说context-mode,只是没人意识到。所谓上下文模式,本质上就是让系统在多个交互步骤、多个页面、多次调用之间,完整记住“当前任务的身份信息和技术状态”,并在下一个环节自动延续。它是一种跨越单次交互的“状态连续性”设计。做得好,用户会觉得系统很懂我;做得差,用户会不断重复输入同样信息,体验直接打折。
1.2 三种典型形态:会话持久型、界面感知型、任务驱动型
在真实的系统里,context-mode很少是一个孤立的开关,我经手过的项目基本都落在三类形态里:
会话持久型:最常见。典型代表是AI对话产品里的“连续对话”。系统把用户在多轮对话中提供的关键信息存在一个会话上下文里,后续每一次请求都会带着这份上下文一起进入模型,这样用户说出“那刚才那个方案再细化一下”,系统也能知道“刚才那个方案”指的是哪个。
界面感知型:典型代表就是上面提到的工单系统。系统根据用户当前打开的页面、选中的列表项、停留的任务对象,动态推导出“用户正在处理什么”,并把相关信息和操作入口自动带出来。这种形态依赖前端路由状态与服务端会话状态的配合。
任务驱动型:多出现在审批流、订单流这类长链条流程里。系统为每一个具体任务维护一份独立的上下文,任务流转到哪个节点,上下文就跟到哪里,节点之间可以共享阶段产物。这种形态对数据隔离的要求最高。
这三种形态不是互斥的。一个复杂系统往往会同时存在多种类型,比如AI客服产品既要有会话持久型上下文来记住用户历史,又要在转人工后把任务驱动型上下文交接给客服坐席。设计时如果混成一种结构,后期一定会出大问题,这是我吃过亏后总结出来的:形态不同,存储位置、生命周期、隔离粒度都不一样,绝不能用一个Map全装了事。
2. 产品决策层:什么时候该上 Context Mode,什么时候该收着
2.1 该用的场景:连续操作、跨实体引用、状态恢复
我自己判断要不要上context-mode,有一套很朴素的判别标准:用户在这个场景里,是不是必须重复“向系统交代背景信息”,才能继续下一步。如果是,那就该上。
三个典型的正向信号:
- 连续操作同一对象。比如客服在处理同一个客户的多个工单,运营在配置同一个广告活动的多组策略,此时上下文能省去大量重复选择。
- 跨实体引用。用户在上一步提到的某个实体(客户、订单、文件),下一步要通过接口来操作或查询它。没有上下文,前端就得把实体ID当作路由参数一路手动传递,一旦中间漏传,系统就尬住了。
- 会话中断恢复。用户填了一半的表单,或者查到一半的结果,刷新页面或退出重进后需要恢复现场。上下文在这里承担了“记忆临时状态”的职责。
遇到这三类场景,别犹豫,直接把context-mode作为一等公民来设计,而不是事后打补丁。我见过太多团队先让前端写死一批全局变量,后面业务复杂了再回头重构,代价比一开始就规划至少高出一倍。
2.2 不该用的场景:敏感操作、高频切换、长会话
有该用的就该收着用的。以下场景如果盲目加上下文模式,反而会带来业务风险或性能灾难:
- 敏感操作确认。支付、删除、权限变更这类操作,恰恰要破坏“上下文自动延续”,强制用户重新确认当前操作对象与动作。很多支付系统在最终下单页都会展示“支付对象、金额”并让用户二次点击,就是刻意关闭上下文默认值,防止串客。
- 高频切换场景。例如在全局搜索里,用户可能每几秒就换一个搜索词。这时如果把上一次结果作为上下文强关联给每个搜索请求,用户输入新词时反而会得到一堆没用的“相关推荐”,感知很差。
- 长会话场景。AI会话超过几十轮之后,完整上下文会膨胀到超过模型窗口上限,这时候如果依然“全量喂给模型”,成本、延迟都会劣化。需要分层策略,而不是无脑累积。
一种更温和的做法是:给上下文分权。系统自动推导出的上下文只作为“建议”,是否真正生效由用户在当前操作里确认。这种“软上下文”方案在很多成熟产品里是默认形态,用户看到的是“系统好像知道我在干嘛”,而不是“系统擅自替我做了决定”。
2.3 用户控制权的分配:显式开关与隐式推导
这里有个反复被问的问题:上下文模式应该做成用户可见的开关,还是系统自动做的黑盒?
我的结论是:分层。基础层(比如记住用户ID、当前组织、语言偏好)走隐式推导,几乎不需要用户介入;业务层(比如当前处理对象、操作阶段、临时输入)必须允许用户显式查看和重置,否则用户一旦发现系统“自作主张”,信任度就会崩塌。
实际设计时我习惯在做右侧栏/浮动层上下文展示时,加一个清零入口。比如工单系统里放一个“结束处理”按钮,点击后清空当前customerId关联的上下文;AI对话里放一个“新对话”按钮,重置会话上下文。这些小入口看着不起眼,却是避免“系统自己迷糊”的最有效措施。我还见过一个不错的交互:在界面上展示当前上下文摘要的卡片,比如“正在处理客户:某某”,用户可以点叉号直接移除,这种可视化的显式控制比藏在设置里的开关管用得多。
3. 技术实现拆解:状态、作用域与生命周期
3.1 上下文的数据结构:从Map到作用域栈
初次实现context-mode的工程师,最喜欢用一个全局Map:
Map<String, Object> context = new ConcurrentHashMap<>();key是用户ID或会话ID,value是任意对象。它能跑通Demo,但上线一定会在某个时刻出事。原因很简单:value的类型随意、生命周期不清、作用域不明确。我见过线上出现ClassCastException,就是因为两个服务复用了同一个Map,把客户对象和订单对象塞进了同一个key下面。
更稳妥的做法是给上下文一个明确的类型定义和一个显式的作用域层级,例如:
public class UserContext { private String userId; private String tenantId; private UserProfile profile; private Map<String, Object> attributes = new HashMap<>(); private long createTime; private long lastAccessTime; }- 全局上下文(GlobalContext):跨请求、跨会话的稳定信息,比如用户身份、租户、权限等级。
- 会话上下文(SessionContext):随Session生命周期,保存对话中期状态。
- 请求上下文(RequestContext):仅当前请求内有效,用来挂在链路追踪ID、当前操作对象等。
在代码组织上,这三者最好拆成独立的类,不要复用同一个结构体。如果确实要放在一起,可以用作用域栈(Scope Stack)来表示嵌套关系——进入子任务时push一层,离开时pop一层,避免“一个Map装天下”的问题。这个栈的结构很直观:最底层是GlobalContext,上面是SessionContext,最顶层是RequestContext,每层维护自己的属性集合,查找时由顶向下逐层回溯,跟原型链的思路有点像。
3.2 三种传递方式:显式传参、依赖注入、ThreadLocal
上下文定义好了,怎么传递到真正干活的方法里?这可能是最容易被忽视、又是坑最多的环节。三种主流方式各有适用场景:
- 显式传参(Method Parameter)。最清晰、最易测试,但侵入性强。当一个方法链路超过五层时,每一层都要多传一个context参数,代码会很啰嗦。适合链路短、团队没有强约定时的起步方案。
- 依赖注入(DI)。把上下文对象注册到容器里,需要的地方直接注入。适合框架型项目,缺点是上下文生命周期与容器生命周期耦合,定时任务、异步线程里容易拿不到或拿到脏数据。
- ThreadLocal。Spring里常见的方案,在拦截器入口set,请求结束clear。优点是业务代码不用改签名,缺点是必须保证异步线程能传递(否则新线程里拿到null),而且忘记clear会引发内存泄漏。
我个人在正式项目里常用的是“中间层方案”:核心业务方法显式接收上下文对象,框架层用ThreadLocal做上下文透传,二者通过一个适配层桥接。直接写透传逻辑容易把异步底裤漏光,尤其是用了线程池之后,主线程的ThreadLocal根本进不了子线程。如果确实要跨线程传递,可以用TransmittableThreadLocal这类工具,或者干脆在任务提交时把上下文快照拷贝一份进去。
3.3 生命周期管理:创建、复用、销毁、清理
上下文不是有了就万事大吉,它跟数据库连接一样,有生命周期。
- 创建:通常在鉴权/登录成功后创建GlobalContext;第一个请求到达时创建RequestContext;用户发起一次连续对话时创建SessionContext。
- 复用:如果上下文代表的是用户稳定身份,可以长时间复用;如果代表的是某个任务的一次性状态,那么任务切换时必须创建新实例,而不是复用旧的。
- 销毁:Session超时、用户主动退出、任务完成,都是销毁信号。销毁时不能只删Map里的引用,还要通知下游(例如释放了连接、取消了订阅),否则会出现半死不活的状态。
- 清理:对于ThreadLocal,必须在finally里clear。我见过一个服务上线三天后老年代GC频率暴涨,最后定位就是ThreadLocal里塞了带大对象的context,在每次请求结束时没有remove,对象一直无法回收。
写一个标准的清理逻辑:
try { // 业务逻辑 } finally { contextHolder.clear(); }这条代码便宜到不能再便宜,但基本每个大项目都能抓到一两处漏写的。清理动作应该写在最外层拦截器或者过滤器的finally块里,不要散落在各个业务方法中,否则漏的概率会大得多。
3.4 并发隔离:为什么共享上下文会翻车
并发环境下,上下文最危险的问题叫“串号”——A用户的数据跑到B用户的请求里。经典事故是这样的:你用了一个static变量存当前登录用户ID,上线后高峰期发现A账号看到了B账号的订单。原因就是Tomcat默认是多线程处理请求,同一个static变量会被多个线程同时读写,后一个线程覆盖了前一个线程的值。
解决办法就是遵循“线程绑定”原则:要么用ThreadLocal让每个线程有独立副本,要么用显式的request-scoped作用域,由框架在每次请求到来时新建实例。无论哪种,都不允许用一个程序级全局变量直接存放用户级上下文。
另外,如果在上下文里放共享的可变对象(比如一个ArrayList),多线程同时对它做add操作,轻则数据丢失,重则ConcurrentModificationException。规则很简单:上下文里的对象要么是不可变的,要么各自持有自己的副本。这条规则看起来简单,实际翻车概率极高,因为业务同学往往为了让代码少写几行,就把上下文对象到处传递,顺手就共享了。
4. 实战落地:在AI问答产品中实现 Context Mode
4.1 需求定义与方案选型
用一个我最近落地的场景来把前面的理论串起来:做一个企业内部的AI知识问答助手,要求支持“连续对话”——用户在问完“第一季度营收是多少”之后,可以继续追问“那第二季度呢”,系统要能理解“那”指的是“营收”。
这里要的context-mode属于会话持久型,核心要解决两个问题:
- 哪些信息应该被放进上下文?全部历史消息都放不现实,模型窗口和成本都受不了。
- 上下文放在系统哪一层?是前端维护、网关维护,还是后端AI服务维护?
我最终选型:会话级上下文放在后端AI网关层,内存里用Redis做持久化,生成回答的同时把用户输入、系统输出、检索到的关键片段一起写入会话存储。前端只维护一个对话列表,不维护上下文细节。理由很简单:前端维护上下文,用户一刷新就丢;网关维护则可以灵活适配模型和检索器的选型,以后从GPT切换到其他模型,上下文格式的转换只需要改网关一层,不用动前端。
4.2 核心代码实现:支持连续对话的最小闭环
一个非常简化的网关层实现思路如下:
class SessionContext: def __init__(self, session_id): self.session_id = session_id self.history = [] # 历史消息,每条结构:{"role": ..., "content": ...} self.summary = None # 早期对话的压缩摘要 self.meta = {} # 用户偏好、业务约束等稳定信息 def add_message(self, role, content): self.history.append({"role": role, "content": content}) def get_prompt(self, max_history_len=10): recent = self.history[-max_history_len:] if self.summary: return [{"role": "system", "content": f"历史摘要:{self.summary}"}] + recent return recent真正的服务端处理流程:
def handle_chat(session_id, user_query): ctx = load_context(session_id) # 从Redis加载 ctx.add_message("user", user_query) prompt = ctx.get_prompt(max_history_len=10) answer = call_llm(prompt) ctx.add_message("assistant", answer) if len(ctx.history) > 20: # 历史过长,触发压缩 ctx.summary = summarize(ctx.history[:-10]) ctx.history = ctx.history[-10:] save_context(session_id, ctx) # 写回Redis return answer这套代码初看没问题,但有一个隐蔽的坑:并发。如果用户连续快速发两条消息,两个请求同时load同一个ctx,然后各自写入、各自保存,后保存的会把先保存的覆盖掉,导致有一条消息丢失、上下文不一致。解决办法是给会话加锁(Redis分布式锁)或者用版本号做乐观锁。我当年被这个问题坑过一次之后,在网关层统一给会话ID加了分段锁,才彻底解决。这里不做展开,但它真实发生在我负责的项目里,而且是非常典型的“看着没错,线上错乱”的案例。
4.3 记忆上限与压缩策略:token预算怎么省
AI问答产品的上下文有一个特有约束:token预算有限。全量历史都塞进prompt,一次对话没结束成本先爆了。
我的经验是分三段处理新消息和旧消息:
- 最近N轮(比如10轮)保留完整文本,因为它们和当前问题关联度最高。
- 更早的历史做摘要压缩,用一次LLM调用把早期对话提炼成100~300字的摘要。
- 稳定的背景信息(用户角色、业务规则、知识库切片)放在system区,每次请求都带上,不占用对话历史计数。
摘要压缩很重要,要设计专门的prompt,让模型输出“事件+结论+未完成事项”结构,而不是流水账。我自己踩过由于压缩prompt写得过松,模型把关键数字弄丢的坑。后来加了规则:摘要里凡是数字、价格、时间必须原样保留,效果立刻改善。类似地,检索到的知识片段也要做去重和裁剪,否则重复内容会挤占宝贵的上下文窗口。
4.4 可观测性与调试手段:让上下文问题现形
context-mode这种功能,最怕线上“看起来一切正常,实际上下文是坏的”。我落地时强制加了三类可观测点:
- 每个会话的上下文快照:在Redis里存一份最近一次完整上下文,排查问题时可回放。
- 上下文token用量指标:每次请求记录prompt总token、历史token、摘要token,建立基线,超过阈值时告警。上下文膨胀最直观的体现就是token用量曲线陡增。
- 用户侧反馈埋点:对话结束后让用户评价“回答是否记得之前的内容”,这个主观指标往往比任何日志都更早暴露上下文丢失问题。
调试时还有一个实用技巧:在网关层加一个debug参数,带上它会返回本次请求的上下文摘要和token明细。线上事故排查时,这东西比看一百行业务日志都管用。我建议所有context-mode功能都预留这样一个后门,只要注意鉴权,别让普通用户能调用就行。
5. 上线后的坑:泄漏、串号与性能退化
5.1 上下文泄漏:一个低级但致命的Bug
前面提过ThreadLocal忘记clear会引发泄漏,这里给一个实际排查过程。某个服务上线两周后,每日老年代GC时间从30毫秒涨到3秒,CPU占用居高不下。Heap dump发现一个带用户级上下文的ThreadLocal里挂着一个巨大的历史查询列表,因为请求线程被线程池复用,而finally里的clear被后来重构时误删了,导致这个列表在线程上存活了几万个请求。
修复只需要一行代码,但排查花了三个小时。这类泄漏的特点是:不会立刻炸,而是慢慢磨损系统,直到某个凌晨2点触发整机OOM。建议:
- 强制代码审查关注“所有ThreadLocal有没有配对clear”。
- 上线前用jmap做线程转储,确认线程上的上下文可被回收。
- 给GC时间和内存曲线配告警,异常波动的第一个小时就开始排查。
5.2 上下文串号:并发环境下的数据隔离陷阱
串号是context-mode最严重的事故,没有之一。发生串号时,用户看到的不是“功能退化”,而是赤裸裸的隐私安全事件。
我用一个真实case来说明。一个客服工作台,用了静态Map保存当前客服正在处理的客户ID。某天流量上来后,客服A在工单详情页点下一条,系统却显示客服B正在处理的客户。原因是代码里有一段:
public static Map<Long, String> currentCustomer = new HashMap<>();这个Map的确是按userId做key的,看起来没毛病。但它是一个static变量,多个线程同时往里put时,HashMap内部状态会混乱,轻则get到别的线程刚put的值,重则死循环。换成ConcurrentHashMap能缓解数据错乱,但根本的问题是用程序级变量存会话级状态,方向就错了。
诊断串号最有力的手段是链路追踪。给每个请求分配traceId,把traceId放到上下文的meta里一起透传。当用户投诉“看到了别人数据”时,直接把两个不同用户的traceId拉出来,对比上下文构造链路,10分钟就能定位是哪个环节没有做线程隔离。
5.3 性能退化:日志变慢与GC压力
context-mode对性能的影响往往不是单次请求的延迟,而是全局的资源消耗。最典型的三个坑:
- 日志里打印了全量上下文。线上查问题图省事,在info级别把整个context对象toString输出。结果上下文里有几十KB的临时数据,日志I/O直接成为瓶颈。
- 上下文序列化频繁。如果每次请求都从Redis读写完整上下文,序列化/反序列化的CPU开销和Redis网络开销会被放大成百倍。优化方向是只更新变更字段,或者把上下文拆成热数据和冷数据,热数据放本地内存,冷数据放Redis。
- 大对象存活。上下文对象很大且生命周期很长,会直接加剧GC压力。用弱引用持有可选数据,或者对大数据进裁剪,都能明显改善。
这几个坑都不难解决,但需要在设计阶段就形成一个清单,否则上线后指标一波动,再去定位“到底是谁打印了全量上下文”是非常痛苦的。
5.4 常见问题与排查速查表
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 用户A看到用户B数据 | 静态变量或单例中存放了会话级上下文 | 检查上下文载体,改用ThreadLocal或请求作用域 |
| 刷新后上下文丢失 | 上下文只存在前端内存,未做持久化 | 后端建立会话存储,例如Redis |
| 老年代GC时间陡增 | ThreadLocal未清理或大对象长期存活 | jmap堆转储,检查线程栈和对象引用链 |
| 多轮对话后回答开始胡说 | 上下文膨胀导致关键信息被淹没,或摘要压缩丢失细节 | 查看token用量曲线,检查summary生成规则 |
| 异步线程里拿不到上下文 | 未做异步透传,或线程池不继承ThreadLocal | 使用TransmittableThreadLocal或在任务创建时拷贝上下文 |
| 上下文被上次任务污染 | 任务结束后未重置上下文 | 任务级上下文在完成时显式pop和clear |
这个表里的每一条,我都亲眼见过或者听说过原型。共性的根子在于:把上下文当作普通业务对象随便放、随便传、随便复用,却没有给它一套独立的设计纪律。
最后说点个人体会。我做过好几个系统之后才真正想明白,context-mode最难的部分从来不是代码,而是确定“边界”:上下文信息的边界、作用域的边界、生命周期的边界、用户控制权的边界。边界划得清楚,实现只是一张数据表或一个类的事;边界划得模糊,后面就是无穷无尽的token浪费、GC告警和客服投诉。
如果你正在设计一个新的context-mode功能,我建议第一步不要打开IDE,先拿一张白纸回答三个问题:这个上下文要记住什么?它应该活多久?用户能不能自己重置它?三个问题都答清楚了,后面的路会顺很多。这个内容后续还可以这样扩展:把它从“单个系统的内部实现”上升到“多个服务之间的上下文协议”,那就是另一篇关于分布式状态传递的文章了,有机会再聊。