1. 从“context-mode”这个词说起:它到底在解决什么问题
第一次看到“context-mode”这个标题,很多人会一头雾水——它不像某个具体框架的名字,也不像某个库的API,更像是一个概念、一种状态、或者一个开关。我最初接触这个词是在做对话系统上下文管理的时候,当时团队里有人提出“我们是不是该给系统加一个context-mode”,意思是让系统能够根据当前所处的上下文环境,自动切换行为模式。这个说法一下子点醒了我:context-mode本质上是一种“让系统感知自身所处上下文,并据此调整行为”的设计思路。
那它到底解决什么问题?举个最直观的例子。你在用一个智能助手,当你问“今天天气怎么样”的时候,它需要知道你在哪个城市;当你接着问“那明天呢”的时候,它需要知道“明天”是相对于“今天”的,而且城市还是刚才那个。如果系统没有context-mode的概念,它每次都会把你当成一个全新的用户,你每问一句都得把背景重新说一遍。这就是上下文丢失带来的体验灾难。而context-mode要做的,就是让系统能够识别“我现在处于多轮对话的上下文中”,并据此保留、传递、更新相关的状态信息。
再往深一层说,context-mode不仅仅是对话系统的事。任何需要根据运行环境、用户状态、历史行为来动态调整逻辑的系统,都会涉及context-mode的设计。比如一个推荐系统,用户刚注册时和用了三年的老用户,推荐策略应该完全不同;比如一个游戏AI,在玩家血量充足和濒死时,行为模式应该切换;比如一个工作流引擎,在调试模式和正式运行模式下,日志级别和错误处理策略应该不一样。这些场景背后都是同一个问题:系统如何知道自己“现在处于什么上下文”,以及“在这个上下文下应该怎么做”。
所以,当你看到“context-mode”这个标题时,不要把它当成某个具体的技术名词去搜索,而应该把它理解为一个设计维度。它关心的是:上下文如何定义、如何传递、如何切换、如何隔离、如何持久化。这几个问题想清楚了,无论你用的是哪种语言、哪个框架,都能落地出一套可用的方案。这篇文章就是把我自己在多个项目中踩过的坑、总结出的模式,系统地梳理一遍,适合正在做对话系统、状态机、工作流引擎或者任何需要“感知上下文”的开发者参考。
2. 上下文模式的核心设计维度:五个必须想清楚的问题
2.1 上下文的边界在哪里:什么该进,什么该出
设计context-mode的第一个问题,也是最容易被忽略的问题,就是上下文的边界。很多人一上来就想着“我要把所有的历史信息都存下来”,结果系统越跑越慢,内存越吃越多,最后发现大部分数据根本用不上。我见过一个团队做客服机器人,把用户过去三个月的所有对话记录都塞进上下文,每次请求都要序列化几MB的数据,响应时间从200ms飙到3秒。这就是典型的边界没划清楚。
那怎么划?我的经验是遵循**“最小必要原则”:只保留对当前决策有影响的信息。具体来说,可以按三个维度来筛选。第一是时间维度**,多久之前的信息还有参考价值?对于大多数对话场景,最近5到10轮足够了,再早的可以摘要化或者直接丢弃。第二是相关性维度,当前问题涉及哪些实体、哪些意图?只保留与这些实体相关的上下文。第三是状态维度,哪些是持久状态(比如用户偏好),哪些是临时状态(比如当前正在填的表单)?持久状态可以长期保留,临时状态在任务完成后就应该清理。
这里有个实操技巧:给上下文中的每个字段打上TTL(生存时间)标签。比如“用户当前所在城市”这个字段,TTL可以设长一点,因为用户不会频繁换城市;“用户当前正在查询的订单号”这个字段,TTL就设短一点,订单查询完成后就可以移除。这样系统会自动清理过期数据,不需要你手动管理。我在一个电商客服项目里用了这个方案,上下文平均大小从2MB降到了200KB,响应时间回到了300ms以内。
注意:边界划分不是一次性的工作。随着业务变化,什么该进什么该出会变。建议每季度回顾一次上下文字段的使用频率,把那些“存了但从来没用过”的字段果断删掉。
2.2 上下文如何传递:从请求头到状态机的多种方案
边界划清楚之后,下一个问题就是上下文怎么在系统各层之间传递。这个问题在不同架构下答案完全不同。如果是单体应用,上下文可能就是一个全局变量或者ThreadLocal;如果是微服务架构,上下文就需要通过请求头、消息队列或者专门的上下文服务来传递;如果是前端和后端分离的架构,上下文还需要考虑客户端和服务端如何同步。
我经历过的最简单也最有效的方案,是在请求头里放一个context-id,服务端根据这个id去Redis或者本地缓存里取完整的上下文。这样做的好处是请求本身很轻量,上下文的变化不会影响请求的格式。但缺点是每次都要多一次网络查询,如果上下文服务挂了,整个系统就瘫了。所以后来我们改进了一下,把热上下文(最近几分钟内活跃的)放在本地内存,冷上下文放在远程存储,请求头里同时带上context-id和一个版本号,本地有就直接用,没有再去远程取。这个方案在QPS上万的情况下,上下文读取的P99延迟控制在5ms以内。
另一种方案是把上下文直接编码在请求里,比如用JWT或者自定义的token。这样做的好处是无状态,服务端不需要存储任何东西,水平扩展非常容易。但缺点是上下文大小受限于token长度,而且每次请求都要传输完整上下文,带宽消耗大。我一般只在上下文很小(比如只有用户ID和会话ID)的时候用这种方案。
还有一种更激进的方案,是把上下文做成一个独立的状态机服务。每个会话对应一个状态机实例,状态机负责管理上下文的所有变更,其他服务通过事件或者RPC来读写上下文。这种方案适合上下文逻辑非常复杂的场景,比如多轮填表、多步骤审批。但引入的复杂度也最高,需要额外考虑状态机的持久化、故障恢复、并发控制等问题。我的建议是:除非上下文逻辑复杂到用if-else写不清楚,否则不要轻易上状态机。
2.3 上下文如何切换:模式识别与路由策略
context-mode之所以叫“mode”,是因为它隐含了模式切换的概念。系统需要能够识别“现在进入了什么模式”,然后切换到对应的处理逻辑。这个识别过程可以很简单,也可以很复杂。
最简单的模式识别是基于显式指令。比如用户在对话中输入“切换到英文模式”,系统就切换语言上下文。这种方案实现简单,但用户体验不好,用户需要记住指令。稍微好一点的是基于意图识别,系统通过NLU模块判断用户当前意图,如果意图是“查询订单”,就进入订单查询模式。这种方案对用户透明,但依赖NLU的准确率,误判会导致模式切换错误。
更高级的是基于上下文推断。系统不只看当前这一句话,还结合历史上下文、用户画像、时间地点等信息,综合判断应该处于什么模式。比如同样是“帮我订一张票”,如果用户之前一直在聊电影,系统应该进入电影票模式;如果之前一直在聊火车,就应该进入火车票模式。这种方案体验最好,但实现也最复杂,需要维护一个模式推断模型。
我在实际项目中的做法是分层路由:第一层用规则做快速匹配,覆盖80%的常见情况;第二层用模型做兜底,处理规则覆盖不到的边缘情况;第三层留一个人工干预的入口,当模型置信度低于阈值时,转人工确认。这样既保证了效率,又保证了准确率。具体来说,规则层可以用正则或者关键词匹配,模型层可以用一个轻量级的文本分类器,人工层就是一个简单的“请确认您是要办理XX业务吗”的交互。
提示:模式切换一定要有回退机制。如果系统判断错了模式,用户应该能够方便地纠正。比如在对话中加一个“不是这个”的按钮,点击后回到上一个模式。没有回退机制的模式切换,一旦出错就是死胡同。
2.4 上下文如何隔离:多用户、多会话、多租户的并发挑战
只要系统不是单用户单会话的玩具,就一定会遇到上下文隔离的问题。A用户的上下文不能泄露给B用户,同一个用户的多个会话之间也不能互相干扰,如果是SaaS系统,不同租户之间的上下文更是要严格隔离。
最基础的隔离是按会话ID隔离。每个会话有一个唯一的ID,上下文存储时以这个ID为key。这样不同会话天然隔离。但这里有个坑:如果用户开了多个标签页,每个标签页都发起一个会话,用户期望的是这些会话共享同一个上下文(比如登录状态),但按会话ID隔离的话它们就是独立的。所以还需要一个用户级别的上下文,存储跨会话的共享信息,比如用户ID、偏好设置、权限等。会话级别的上下文和用户级别的上下文叠加使用,才能既隔离又共享。
多租户场景下,隔离要求更高。除了数据层面的隔离(不同租户的上下文存在不同的数据库或者不同的schema),还需要考虑资源隔离。一个租户的上下文如果特别大,不能影响其他租户的性能。我们的做法是给每个租户设置上下文大小配额和QPS配额,超过配额就限流或者降级。这样即使某个租户出了问题,也不会拖垮整个系统。
还有一个容易被忽略的隔离维度是环境隔离。开发环境、测试环境、生产环境的上下文绝对不能混在一起。我见过一个事故,测试环境的定时任务误连了生产环境的Redis,把生产环境的上下文全部覆盖了,导致线上用户全部掉线。所以上下文存储的key一定要带上环境前缀,比如prod:ctx:123和test:ctx:123,从命名上就杜绝混用的可能。
2.5 上下文如何持久化:从内存到数据库的选型逻辑
上下文要不要持久化?这个问题没有标准答案,取决于你的业务需求。如果系统重启后用户可以重新开始,那上下文可以只放在内存里,性能最好。如果系统重启后用户期望继续之前的进度,那就必须持久化。
持久化的选型我一般按访问频率和生命周期来分。热上下文(正在活跃使用的)放在内存或者Redis里,读写延迟在毫秒级。温上下文(最近几天可能还会用的)放在Redis或者本地SSD上,读写延迟在十毫秒级。冷上下文(归档备查的)放在对象存储或者数据库里,读写延迟在百毫秒级,但成本最低。这样分层存储,既保证了性能,又控制了成本。
具体到Redis的使用,有几个参数需要特别注意。maxmemory-policy建议设为allkeys-lru,这样当内存满了会自动淘汰最久未使用的上下文,而不是直接报错。save策略要根据业务容忍度来设,如果上下文丢失几分钟可以接受,可以设成save 900 1(900秒内至少1个key变化才保存);如果完全不能丢,就要开AOF并且设appendfsync always,但这样性能会下降很多。我的经验是大多数场景用appendfsync everysec就够了,最多丢1秒的数据,性能也还能接受。
数据库选型方面,如果上下文是结构化的(字段固定),用MySQL或者PostgreSQL就行;如果上下文是半结构化的(字段经常变),用MongoDB或者Elasticsearch更合适;如果上下文主要是用来做分析的,可以考虑ClickHouse或者Doris。我个人的偏好是能用Redis解决的就不要上数据库,因为上下文的读写模式通常是“读多写多但单条数据小”,Redis在这种场景下性价比最高。
3. 落地一套context-mode:从零到可运行的完整步骤
3.1 定义上下文的数据结构:用JSON Schema约束字段
动手写代码之前,先把上下文的数据结构定义清楚。我强烈建议用JSON Schema来定义,因为它既能当文档,又能当校验规则,还能自动生成各语言的模型代码。一个典型的对话上下文Schema大概长这样:
{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "ConversationContext", "type": "object", "properties": { "context_id": { "type": "string", "format": "uuid" }, "user_id": { "type": "string" }, "session_id": { "type": "string" }, "mode": { "type": "string", "enum": ["idle", "ordering", "querying", "complaining"] }, "entities": { "type": "object", "properties": { "city": { "type": "string" }, "order_id": { "type": "string" }, "product_id": { "type": "string" } } }, "history": { "type": "array", "items": { "type": "object", "properties": { "role": { "type": "string", "enum": ["user", "assistant"] }, "content": { "type": "string" }, "timestamp": { "type": "integer" } } }, "maxItems": 20 }, "ttl": { "type": "integer", "default": 3600 } }, "required": ["context_id", "user_id", "mode"] }这个Schema里几个关键设计点值得说明。mode字段用枚举,而不是自由字符串,这样可以在代码里做穷举检查,避免出现未知模式导致系统行为不可预测。history数组设了maxItems,强制限制历史轮数,防止上下文无限膨胀。ttl字段让每个上下文自带过期时间,存储层可以根据这个字段自动清理。entities对象用来存放从对话中提取的实体,这些实体是模式切换和业务逻辑的主要依据。
定义好Schema之后,用代码生成工具(比如quicktype或者datamodel-code-generator)自动生成Python、Java、TypeScript等语言的模型类。这样前后端用的模型定义完全一致,不会出现“后端改了字段前端不知道”的情况。每次Schema变更都要走版本管理,新字段加进去要设默认值,删字段要先标记为deprecated,等所有调用方都迁移完再真正删除。
3.2 上下文存储层的实现:Redis Hash与TTL的配合
存储层我推荐用Redis Hash来存上下文,而不是String。原因有三:第一,Hash可以单独更新某个字段,不需要把整个上下文读出来再写回去,减少了网络传输和并发冲突;第二,Hash可以单独给字段设TTL(Redis 7.4+支持HEXPIRE),这样不同字段可以有不同的生命周期;第三,Hash在Redis内部是用ziplist或者listpack编码的,小Hash非常省内存。
具体操作上,key的设计用ctx:{env}:{user_id}:{session_id},这样既能按用户查,也能按会话查。写入的时候用HSET批量写多个字段,读取的时候用HMGET批量读需要的字段。如果上下文很大,可以拆成多个Hash,比如ctx:meta存元信息,ctx:entities存实体,ctx:history存历史。这样读取的时候可以按需加载,不需要的字段不读。
TTL的设置要分两层。key级别的TTL用EXPIRE设置,比如设成24小时,保证即使没有主动清理,上下文也不会永久占用内存。字段级别的TTL用HEXPIRE设置,比如history字段设1小时,entities字段设6小时,user_id字段不设过期。这样系统会自动淘汰旧数据,不需要写额外的清理任务。
import redis import json import uuid r = redis.Redis(host='localhost', port=6379, decode_responses=True) def create_context(user_id, session_id): context_id = str(uuid.uuid4()) key = f"ctx:prod:{user_id}:{session_id}" r.hset(key, mapping={ "context_id": context_id, "user_id": user_id, "session_id": session_id, "mode": "idle", "entities": json.dumps({}), "history": json.dumps([]) }) r.expire(key, 86400) r.hexpire(key, 3600, "history") return context_id def update_mode(user_id, session_id, new_mode): key = f"ctx:prod:{user_id}:{session_id}" r.hset(key, "mode", new_mode) def get_context(user_id, session_id): key = f"ctx:prod:{user_id}:{session_id}" data = r.hgetall(key) if not data: return None data["entities"] = json.loads(data.get("entities", "{}")) data["history"] = json.loads(data.get("history", "[]")) return data这段代码里有个细节:history字段用JSON字符串存,而不是用Redis的List。为什么?因为List只能追加和弹出,不能方便地做“只保留最近N条”的操作。用JSON字符串的话,在应用层做切片更灵活。当然,如果history特别大(比如上千条),JSON序列化的开销就不能忽略了,那时候可以考虑用Redis Stream或者专门的时序数据库。
3.3 模式识别引擎的搭建:规则+模型的双层架构
模式识别引擎是context-mode的核心。我的建议是规则层和模型层分开实现,用责任链模式串联。规则层负责快速匹配,模型层负责兜底,两层的结果合并后输出最终模式。
规则层的实现很简单,就是一个模式-关键词映射表。比如:
MODE_RULES = [ {"mode": "ordering", "keywords": ["下单", "购买", "我要买", "帮我订"]}, {"mode": "querying", "keywords": ["查询", "查一下", "订单状态", "物流"]}, {"mode": "complaining", "keywords": ["投诉", "差评", "太差了", "退款"]}, ]匹配的时候遍历规则,如果用户输入包含某个模式的关键词,就返回该模式。规则层的好处是可解释性强,运营人员可以直接修改关键词表,不需要重新训练模型。缺点是覆盖不全,用户换个说法就匹配不到了。
模型层我一般用一个轻量级的文本分类器,比如FastText或者TextCNN。训练数据从历史对话中标注,每个对话标注一个模式标签。模型不需要很大,几万条标注数据就能达到90%以上的准确率。推理的时候输出每个模式的概率,取最高分且超过阈值的作为结果。如果所有模式的概率都低于阈值,就返回idle模式,表示“不确定,保持当前模式”。
两层合并的策略是:规则层命中则直接采用,规则层未命中则用模型层结果。这样既保证了常见情况的快速响应,又保证了边缘情况的覆盖。实测下来,规则层能覆盖约70%的请求,模型层覆盖剩下的30%,整体准确率在95%以上。
注意:模型层一定要有置信度阈值。我见过一个团队直接把模型输出的最高分模式作为结果,结果模型在不确定的时候乱猜,把用户带到了完全错误的模式。设一个阈值(比如0.7),低于阈值就不切换模式,保持当前模式不变,这样至少不会出错。
3.4 上下文更新与清理的时机控制
上下文不是创建之后就一成不变的,它需要随着对话的进行不断更新。更新的时机很关键:太频繁会导致性能问题,太稀疏会导致上下文过期。我的经验是在每次对话轮次结束时更新,而不是每收到一个字就更新。具体来说,当系统生成完回复并发送给用户后,触发一次上下文更新,把这一轮的输入、输出、提取的实体、模式变化都写进去。
更新的内容要分增量更新和全量更新。增量更新只写变化的字段,比如HSET key mode ordering;全量更新写所有字段,比如HSET key mapping={...}。大部分情况下用增量更新就够了,只有在上下文结构发生重大变化(比如Schema升级)时才用全量更新。增量更新的好处是并发冲突小,两个请求同时更新不同字段不会互相覆盖。
清理的时机有两个:被动清理和主动清理。被动清理靠Redis的TTL,到期自动删除。主动清理是在业务逻辑中判断上下文已经无用,主动调用DEL删除。比如用户明确说“结束对话”或者“退出”,就可以主动清理。还有一种情况是模式切换时清理,比如从“下单模式”切换到“查询模式”,下单相关的临时实体(比如正在填的收货地址)就应该清理掉,避免污染查询模式。
def on_turn_end(user_id, session_id, user_input, assistant_output, extracted_entities): key = f"ctx:prod:{user_id}:{session_id}" ctx = get_context(user_id, session_id) if not ctx: return history = ctx["history"] history.append({"role": "user", "content": user_input, "timestamp": int(time.time())}) history.append({"role": "assistant", "content": assistant_output, "timestamp": int(time.time())}) history = history[-20:] entities = ctx["entities"] entities.update(extracted_entities) new_mode = detect_mode(user_input, ctx) r.hset(key, mapping={ "history": json.dumps(history), "entities": json.dumps(entities), "mode": new_mode }) if new_mode != ctx["mode"]: cleanup_mode_specific_entities(key, ctx["mode"])这段代码里cleanup_mode_specific_entities是关键。不同模式下的临时实体要定义清楚,切换时清理掉。比如下单模式下的shipping_address、payment_method,在切换到查询模式时就应该删除。这样下次再进入下单模式时,用户需要重新填写,而不是看到上次的残留数据。
4. 踩坑实录:context-mode落地过程中最容易翻车的五个地方
4.1 上下文膨胀导致的内存泄漏:从一次线上事故说起
那是一个周五的晚上,客服系统的告警突然响了:Redis内存使用率超过90%。我登上服务器一看,ctx:*开头的key占了80%的内存,而且数量还在持续增长。排查后发现,问题出在历史记录没有正确截断。代码里写了history = history[-20:],但有个分支逻辑在追加历史后直接return了,没有执行截断。结果某些特定路径下的对话,历史记录无限增长,一个会话就占了十几MB。
这个坑的教训是:上下文的截断逻辑必须放在统一的出口处,不能散落在各个分支里。后来我们改成了装饰器模式,所有更新上下文的函数都被一个@enforce_context_limits装饰器包裹,装饰器里统一做截断、TTL刷新、大小检查。这样无论从哪个分支进来,都会经过同样的限制逻辑。
另外,监控一定要跟上。我们后来加了几个关键指标:上下文平均大小、上下文最大大小、上下文数量增长率。当平均大小超过100KB或者数量增长率超过10%/小时,就触发告警。这样在问题还小的时候就能发现,不至于等到内存爆了才处理。
4.2 模式误判引发的死循环:用户被困在错误模式里
另一个印象深刻的坑是模式误判导致的死循环。用户本来是想查询订单,但系统误判成了投诉模式,回复了一套投诉处理话术。用户说“不是投诉,我是要查订单”,系统又误判成了投诉模式(因为“投诉”这个词出现了),继续回复投诉话术。用户越说越急,系统越判越错,最后用户直接关掉了页面。
这个问题的根因是模式识别只看当前输入,不看历史模式。如果系统知道当前已经是投诉模式,而用户说“不是投诉”,就应该倾向于切换到其他模式,而不是继续留在投诉模式。修复方案是在模式识别时加入历史模式作为特征:如果当前模式和历史模式相同,且用户输入中包含否定词,就降低当前模式的置信度,提高其他模式的置信度。
具体实现上,我们在模型输入里加了一个current_mode的one-hot特征,让模型学习“在当前模式下,什么样的输入应该切换模式”。同时加了一个否定词检测规则:如果用户输入包含“不是”“不对”“错了”等词,且当前模式不是idle,就强制触发一次模式重评估,把当前模式的概率打七折。这样用户说“不是投诉”时,投诉模式的概率从0.8降到0.56,其他模式的概率相对上升,更容易切换成功。
提示:模式切换一定要有用户显式纠正的入口。我们在界面上加了一个“切换业务”的按钮,用户点击后直接回到
idle模式,重新开始。这个按钮看似简单,但能解决大部分模式误判的问题。
4.3 并发更新导致的数据覆盖:两个请求同时写同一个上下文
并发问题在单用户场景下不明显,但在多设备场景下就会暴露。用户可能在手机和电脑上同时操作,两个设备发起的请求同时更新同一个上下文,后写的覆盖先写的,导致部分更新丢失。我们遇到过一次:用户在手机上填了收货地址,同时在电脑上选了支付方式,结果两个请求并发执行,收货地址被覆盖成了空,支付方式倒是保存了。
解决方案是用Redis的Hash字段级原子操作,而不是读出来改完再写回去。HSET和HDEL都是原子操作,两个请求同时HSET不同字段不会互相覆盖。但如果两个请求同时HSET同一个字段,还是会有覆盖问题。这时候就需要乐观锁:读取上下文时带一个版本号,写入时用Lua脚本检查版本号是否变化,如果变了就重试。
-- 乐观锁更新脚本 local key = KEYS[1] local expected_version = ARGV[1] local new_version = ARGV[2] local field = ARGV[3] local value = ARGV[4] local current_version = redis.call('HGET', key, '_version') if current_version ~= expected_version then return 0 end redis.call('HSET', key, field, value) redis.call('HSET', key, '_version', new_version) return 1这个脚本在Redis里执行是原子的,保证了“检查版本号-更新字段-更新版本号”三个操作不会被其他请求打断。如果返回0表示版本号不匹配,应用层重试整个“读取-修改-写入”流程。重试次数一般设3次,超过3次就放弃并返回冲突错误,让用户手动刷新。
4.4 上下文跨环境串用的惨痛教训
前面提过环境隔离,这里展开说一下我们踩过的具体坑。有一次我们在测试环境做压测,压测脚本里写死了Redis的连接地址,结果连到了生产环境的Redis。压测产生的上下文把生产环境的上下文全部覆盖了,线上用户全部掉线,恢复了半小时才恢复。事后复盘,根本原因是配置管理不规范:测试环境的配置文件里不应该出现生产环境的地址,但有人为了图方便直接复制了生产配置。
修复方案是三层防护。第一层是配置分离:不同环境的配置放在不同的文件里,通过环境变量ENV=prod来加载,测试环境的配置文件里根本不存在生产地址。第二层是key前缀强制:所有上下文key必须带环境前缀,代码里硬编码ctx:{env}:,env从环境变量读取,测试环境不可能写出ctx:prod:的key。第三层是网络隔离:测试环境的机器没有生产Redis的网络权限,即使配置错了也连不上。
这三层防护里,key前缀强制是最有效也最容易实现的。我们在Redis的配置里加了一个rename-command,把FLUSHALL和FLUSHDB重命名成了随机字符串,只有运维知道。这样即使有人误连了生产Redis,也没法执行清库操作。这个措施后来救了我们一次:有个开发同学在测试环境执行了FLUSHDB,因为连的是测试Redis,只清掉了测试数据,生产数据安然无恙。
4.5 上下文序列化格式变更引发的兼容性问题
最后一个坑是序列化格式变更。早期我们用Pickle序列化上下文,后来为了跨语言兼容改成了JSON。改完之后,旧的Pickle格式上下文读出来全是乱码,导致所有在线用户的上下文丢失。这个问题在灰度发布时没发现,因为灰度期间新老代码共存,新代码读旧数据会报错但被catch住了,返回了空上下文。全量发布后,所有用户都变成了空上下文,体验断崖式下降。
解决方案是双格式兼容+渐进迁移。读取时先尝试新格式(JSON),如果失败再尝试旧格式(Pickle),如果都失败就返回空上下文。写入时统一用新格式。同时加了一个迁移任务,后台扫描所有旧格式的上下文,逐个转换成新格式。迁移期间新旧格式共存,迁移完成后删除旧格式的读取逻辑。
这个教训告诉我们:序列化格式的变更一定要有兼容期。不能今天改明天就全量,要给旧数据留出迁移时间。另外,上下文读取失败时不要静默返回空,至少要打日志告警。我们后来加了context_deserialization_failure指标,一旦有失败就告警,这样能在问题扩大前发现。
5. 进阶玩法:让context-mode从能用变成好用
5.1 上下文压缩:用摘要代替原始历史
当历史记录很长时,直接截断会丢失信息,全部保留又会膨胀。折中方案是对历史做摘要。具体做法是:保留最近N轮原始对话,更早的对话用一个大模型或者规则引擎生成摘要,摘要的长度控制在原始长度的10%以内。这样既保留了关键信息,又控制了上下文大小。
摘要的生成时机可以放在历史记录超过阈值时触发。比如设阈值为20轮,当历史达到20轮时,把前10轮压缩成一段摘要,保留后10轮原始记录。摘要的格式可以是一段自然语言,也可以是一个结构化的JSON,包含“用户意图”“关键实体”“处理结果”等字段。结构化摘要的好处是后续可以直接用代码解析,不需要再过一次NLU。
我实测下来,摘要方案能把上下文平均大小降低60%到70%,而关键信息的保留率在90%以上。代价是每次摘要生成需要调用一次模型,增加了几百毫秒的延迟。所以摘要生成要异步做,不要阻塞主流程。可以在对话轮次结束后触发一个后台任务,慢慢生成摘要,下次读取上下文时如果摘要已经生成好就用新的,没生成好就用旧的。
5.2 上下文预热:提前加载可能用到的数据
有些上下文数据不是对话中产生的,而是从其他系统预加载的。比如用户的订单列表、用户的会员等级、用户的偏好设置。这些数据如果在对话中实时查询,会增加响应延迟。更好的做法是在会话开始时预热,提前把这些数据加载到上下文里。
预热的触发时机可以是用户登录时或者会话创建时。预热的内容根据业务场景来定:电商场景预热订单和优惠券,客服场景预热工单和历史对话,推荐场景预热用户画像和浏览历史。预热的数据量要控制,不能把用户的所有数据都加载进来,只加载最近最相关的。比如订单只加载最近5个,历史对话只加载最近3次。
预热的实现可以用异步任务队列。会话创建后,往队列里发一个预热任务,后台worker从各个数据源拉取数据,写入上下文。用户的第一轮对话可能来不及用上预热数据,但从第二轮开始就能用上了。实测下来,预热能把首轮之后的平均响应时间降低30%到40%。
5.3 上下文版本化:支持回滚和A/B测试
上下文结构不是一成不变的,随着业务迭代,字段会增删改。如果没有版本管理,新代码读旧上下文就会出错。解决方案是给上下文加版本号,每个版本对应一个Schema定义。读取时根据版本号选择对应的解析逻辑,写入时统一用最新版本。
版本化的另一个好处是支持A/B测试。你可以让一部分用户用v1版本的上下文逻辑,另一部分用v2版本,对比两组的业务指标。如果v2效果不好,可以快速回滚到v1。具体实现上,可以在上下文里加一个_version字段,路由层根据用户ID的哈希值决定用哪个版本的处理逻辑。
版本迁移的策略我一般用懒迁移:不主动批量迁移旧版本上下文,而是在读取时如果发现是旧版本,就顺便转换成新版本并写回去。这样迁移是渐进的,不会对存储层造成瞬间压力。同时保留旧版本的读取逻辑至少一个发布周期,确保所有活跃上下文都迁移完了再删除。
5.4 上下文可观测性:监控、日志与追踪
上下文系统出问题时,最难的是排查。因为上下文是动态变化的,出了问题往往已经时过境迁。所以可观测性必须从第一天就做好。我一般会加三类观测数据。
第一类是指标:上下文读写延迟、上下文大小分布、模式切换频率、上下文创建和销毁速率。这些指标用Prometheus采集,Grafana展示。关键指标要设告警阈值,比如模式切换频率突然下降可能意味着模式识别挂了。
第二类是日志:每次上下文变更都打一条结构化日志,包含context_id、user_id、field、old_value、new_value、timestamp。日志量会很大,所以只保留最近7天,更早的归档到冷存储。排查问题时,根据context_id过滤日志,就能还原出上下文的完整变更历史。
第三类是追踪:在分布式追踪系统里,把上下文ID作为trace的一个tag,这样从一次请求的trace里就能看到它读了哪些上下文、写了哪些上下文。我们用的是Jaeger,效果很好。有一次线上问题,用户说“系统不记得我说过的话”,我们通过trace发现上下文读取超时了,返回了空上下文。顺着trace查下去,发现是Redis集群的一个分片负载过高,扩容后问题解决。
6. 我个人的一些经验体会
做了这么多上下文相关的项目,最大的体会是:context-mode的难点不在技术,而在边界定义。技术方案再复杂,只要边界清晰,都能实现;边界不清晰,再简单的技术方案也会漏洞百出。所以每次开始一个新项目,我都会花大量时间和产品、运营对齐:什么信息算上下文?保留多久?什么情况下清理?这些问题想清楚了,代码写起来就是水到渠成。
另一个体会是不要过度设计。我见过一些团队,一上来就搞状态机、搞事件溯源、搞CQRS,结果上下文逻辑本身很简单,大部分复杂度都花在了框架上。我的建议是:从最简单的方案开始,遇到问题再演进。先用Redis Hash存,不够再加版本号,再不够再加状态机。每一步演进都要有明确的触发条件,而不是为了“以后可能用得上”而提前设计。
最后分享一个小技巧:给上下文加一个“调试模式”。在调试模式下,每次上下文读写都会在响应里附带完整的上下文快照,方便开发和测试排查问题。生产环境关闭这个模式,但保留一个白名单,允许特定用户ID开启。这个功能在排查线上问题时非常有用,比翻日志快多了。实现上就是在上下文里加一个_debug字段,读取时如果为true,就在响应头里加一个X-Context-Snapshot,内容是Base64编码的上下文JSON。注意这个头的大小要限制,超过1KB就截断,避免影响正常响应。