1. 为什么需要 context-mode:从一次线上事故说起
先讲一个我实际经历过的场景。之前给一家企业做智能客服系统,业务方提了个需求:用户咨询时,如果能知道“他刚才在浏览哪个页面”“当前是售前还是售后阶段”“是否已经确认过订单信息”,回答会准很多。听起来不难对吧?结果做的时候才发现,真正的难点不在于“多轮对话”,而在于“上下文是怎么混进来的”。
系统对接了三个数据源:用户的实时浏览行为、CRM里的历史工单、以及大模型的多轮会话记录。一开始我们图省事,把所有信息一股脑拼进提示词里,结果问题立刻暴露:用户明明在问退货流程,系统却因为历史工单里有“退款金额争议”的记录,把回答带偏成了“请您联系财务核对”。更离谱的是,有一次会话里同时出现了“新品推荐”和“物流异常”两个话题,模型直接宕机式输出了一篇两不像的回复。
这就是典型的上下文污染。我当时的第一反应是:得给这个系统加一个“context-mode”,也就是上下文模式,让研发人员能显式定义当前会话到底该用哪些上下文、按什么优先级组合、到什么时候应该丢弃。后来我花了差不多三周时间把整套方案落地,效果立竿见影:回答准确率从68%提到了91%,上下文Token消耗降了40%左右。
这篇文章就把整个过程拆开来讲。适合谁看?如果你正在做AI对话类应用、智能客服、知识库问答,或者你在用LangChain、Semantic Kernel这类框架、但发现“上下文”越来越难管,这篇应该能给你一些可直接拿来用的思路。不涉及晦涩的算法推导,偏工程实践,但会把原理讲透。
2. context-mode 的核心设计思路
2.1 先搞清楚“上下文”到底包含几层
很多刚接触的人会把“上下文”简单理解成“历史聊天记录”,这是最大的误区。我在实际设计context-mode时,把上下文拆成了四个独立维度:
- 会话上下文(Session):当前用户与系统之间多轮对话的内容,包括问题、回答、追问、修正等,是最基础的一层。
- 场景上下文(Scene):用户当前所处的位置或阶段,比如“在商品详情页”“正在提交订单”“已进入售后流程”,这决定了对话的目标与策略。
- 业务上下文(Business):从外部系统(CRM、ERP、订单系统等)拉取的结构化数据,比如用户积分、订单状态、历史工单。
- 知识上下文(Knowledge):从知识库/文档中检索到的、与当前问题相关的片段,通常经过向量召回或关键词匹配。
context-mode 最核心的思想,就是把这四层拆开、并且允许每一层单独配置开关、权重和生命周期。而不是像之前那样,一股脑全塞进去。
2.2 三种基础模式,对应不同的交互场景
在设计时我没有一开始就做很复杂的规则引擎,而是先定义了三种基础模式,覆盖了绝大多数业务场景:
严格模式(Strict Mode)
只使用会话上下文,忽略场景和业务上下文。适合那种“就事论事”的问答场景,比如FAQ解答、政策咨询。严格模式的好处是输出稳定、Token消耗低、几乎不会跑偏。
智能模式(Smart Mode)
默认模式,同时启用会话+场景+业务上下文,但设置了优先级,场景上下文优先于业务上下文。适合智能客服、售前导购这类复杂场景,既能理解用户意图,又能结合用户画像给出个性化回答。
知识增强模式(Knowledge Mode)
在智能模式的基础上,额外启用知识上下文,也就是把向量检索的结果拼入提示词。适合知识库问答、内部文档检索。这里要注意,知识上下文一旦引入,检索质量和上下文拼接顺序会显著影响最终效果。
这三者不是三选一,而是支持按轮次动态切换的。比如用户一开始问“这个手机多少钱”,可以用严格模式;当识别到用户意图是“比较两款手机”时,自动切到智能模式并拉取业务上下文;如果用户问“保修政策是什么”,则临时切到知识增强模式。
2.3 为什么要有独立的上下文模式层
有人说,那我直接在代码里写if判断,根据用户输入切换不同的提示词不就完了?理论上可以,但工程上完全不可行。原因有三:
第一,提示词会爆炸。每个业务场景都要精心构造不同的提示词模板,功能多了以后,维护成本指数级上升,改一个词可能影响几十个场景。
第二,上下文来源不一致。会话上下文存在Redis里,业务上下文存在数据库里,知识上下文需要实时检索向量库。如果没有一个统一的管理层,每次拼接上下文都得写一遍获取逻辑,重复代码极多,还容易漏。
第三,排查极其困难。上线后如果模型回答出了问题,你怎么定位?到底是提示词写错了,还是历史消息取多了,还是检索结果相关度太低?没有上下文模式这一层抽象,你面对的就是一坨无法观测的“输入拼接”。
所以context-mode本质上不是“一个功能”,而是一种上下文编排层(Context Orchestration Layer)。它把“获取上下文”、“裁剪上下文”、“拼接上下文”、“生命周期管理”这四件事统一收口,向上对业务方暴露简洁的配置接口,向下屏蔽各数据源的差异。这样做之后,任何一次Prompt构造都可以被记录、被复现、被审计,这比“能跑通”重要得多。
3. context-mode 的工程实现细节
3.1 配置驱动的上下文策略
我最终采用的方式是“配置驱动”,没有把逻辑写死在代码里。每一类对话流程对应一份YAML或JSON配置,研发人员只需要修改配置,就能调整上下文的使用策略。
mode: smart context_sources: session: enabled: true max_turns: 6 max_tokens: 1200 scene: enabled: true resolver: from_url_and_session priority: high business: enabled: true resolver: from_crm_api required_fields: - user_id - order_status - membership_level cache_ttl: 300 knowledge: enabled: false fallback: on_business_failed: degrade_to_session_only on_scene_unknown: use_default_scene这份配置的核心在于每个上下文源都有独立的开关、获取方式和容量上限。max_turns控制会话历史取多少轮,max_tokens控制这块上下文最多占多少Token,超了就要做截断或摘要。fallback则定义了异常情况下的降级策略,避免因为某个数据源挂了导致整个对话不可用。
这里我踩过一个坑:一开始max_turns和max_tokens只限了会话源,没限业务源。结果某个大客户的CRM接口返回了极长的订单历史,一次性把7k Token吃满了,模型输出质量严重下降。后来统一对所有上下文源都加了上限,问题才解决。
3.2 上下文裁剪策略:截断、摘要与多级压缩
上下文窗口有限,而真实业务里用户的历史消息、引用文档、业务数据往往是海量的。context-mode 必须内置裁剪策略,我把它分为三级:
第一级:数量截断
最朴素的做法,只保留最近N轮对话。N的取值需要考虑两个因素:模型的最大上下文窗口,以及回答所需的最少信息量。以主流模型的32k窗口为例,如果知识库检索结果占8k,业务上下文占4k,那么留给会话历史的就只有20k左右,按每轮约1k Token算,一般保留10-15轮。
第二级:重要性保留
简单截断的缺点是,用户可能在前面几轮提到关键信息,直接丢掉会导致语义断裂。所以我会按“消息类型”做加权保留:系统消息、带有结构化信息的消息(如订单号、地址)、用户明确表达偏好或情绪的消息,优先级高于闲聊类消息。哪怕超过轮数限制,这些高优先级消息也会被保留。
第三级:摘要压缩
当历史信息实在太长、且重要信息分散在各处时,就用LLM做一次“增量摘要”,把前文压缩成几百字的短摘要,再接上最近几轮完整对话。我记得有一次用户连续问了二十多分钟,历史记录累积超过15k Token,摘要压缩后只剩1.2k,既保留了关键信息又大幅降低了Token成本。
def build_context(session_history, scene, business_data, knowledge_hits): session_part = trim_history( session_history, max_turns=current_mode.session_turns, max_tokens=current_mode.session_tokens, high_priority_keys=["order_id", "address", "preference"], ) # 场景源不超长就直接拼,超长则只保留scene_id scene_part = scene.to_prompt_fragment() # 业务源优先展示结构化字段;长文本字段做摘要 business_part = f"user_id={business_data.user_id}\n" business_part += f"order_status={business_data.order_status}\n" if len(business_data.remark) > 200: business_data.remark = summarize(business_data.remark, max_tokens=100) business_part += f"remark={business_data.remark}\n" # 知识源保留topK个结果,且按相关度倒序 knowledge_part = "\n\n".join( [h["content"] for h in sorted(knowledge_hits, key=lambda x: x["score"], reverse=True)[:topK]] ) return assemble_prompt(mode=current_mode, parts={ "instruction": current_mode.system_prompt, "scene": scene_part, "business": business_part, "knowledge": knowledge_part, "session": session_part, })3.3 模式的动态切换与场景识别
严格、智能、知识增强三种模式的静态定义只是基础,真正让context-mode发挥作用的是“对话过程中能够自动切换”。我在实现里加了一个轻量级的意图识别模块,每一次用户消息进来,会先做一个快速分类,判断当前对话属于哪种状态。
场景识别我用了两条腿走路:
一是规则兜底。比如URL匹配,如果用户是从/order/detail/12345页面发起的会话,那场景上下文直接定为“订单详情咨询”;如果用户点击了“申请售后”按钮,场景是“售后流程”。这些规则简单、可靠、零延迟,适合高频且路径清晰的场景。
二是模型兜底。当规则无法判断场景时,用小模型对用户首条消息做分类。你不需要在整个对话过程中反复调用大模型,只在会话开始或用户换话题时触发一次就行。这里可以用一个几千样本的分类模型,也可以直接调大模型API,但一定要控制调用频率,否则延迟和成本都受不了。
动态切换还有一个容易忽略的问题:切换时要同步清理旧上下文。比如用户上一轮还在走“退货流程”的业务上下文,这一轮突然问“那你们有没有新品推荐”,如果不把旧的业务上下文清掉,模型大概率会继续往退货方向带节奏。我在代码里对模式切换定义了一个context_refresh事件,一旦场景变化,相关上下文源自动失效,下一次组装时重新拉取。
3.4 Token预算如何算
在做context-mode时,我养成了“先算预算,再写代码”的习惯。这里给一个可以照抄的计算模板。
假设模型窗口是32k,安全系数建议留20%余量,也就是实际可用约25k。需要用到的上下文源包括:
- 系统提示词:约2k,固定
- 场景上下文:约1.5k,固定
- 业务上下文:约3k,平均
- 知识检索结果:约6k,最多
- 用户当前消息:约1k,可变
那么会话历史最多能占用的Token就是25 - 2 - 1.5 - 3 - 6 - 1 = 11.5k。如果你的对话平均每轮1k Token,那最多只能放11轮左右。这个数字就是后续配置里max_turns和session_tokens的取值依据。
这套计算方法帮我避免过很多次“上线后才发现上下文被截断得厉害”的尴尬。先定预算,再定参数,不要拍脑袋。
4. context-mode 上线后的高频问题与排查技巧
4.1 对话“跑偏”了,如何快速定位是哪个上下文源的问题
AI对话系统最让人头疼的就是“明明刚才还好好的,怎么突然答非所问”。context-mode 带来的一个巨大好处是:由于上下文被显式拆分成多个源,排查时可以逐个排除。
我的排查顺序是固定的:
- 先看场景上下文是否正确。打开日志,看当前场景识别成了什么。如果用户问售后,场景却识别成了售前,那答案基本必歪。
- 再看业务上下文是否有脏数据。比如订单状态字段过时了、用户身份拿错了,模型基于错误的数据给出“合理但错误”的回答。
- 然后看知识上下文的检索结果。最常见的问题是检索出来的topK文档和用户问题只存在字面匹配、没有语义相关性。
- 最后才怀疑会话历史。看是否因为历史消息过长导致早期关键信息被截断,或者模式切换时旧上下文没有清干净。
我一般会在日志里给每个上下文源加上唯一的标记ID,例如ctx:session:12、ctx:business:998,这样在排查时可以直接看到最终拼进Prompt的每一块内容是什么、来自哪里。上下文要可观测,否则出了问题只能靠猜。
4.2 多轮对话中Token消耗激增,怎么压
Token消耗过高通常有两个原因:一是历史消息越积越多,二是一次检索返回的文档太多。
如果你用的是截断策略,但发现仍超出预期,要重点检查是不是“重要性保留”环节出了问题。我遇到过一种情况:用户每轮都发“好的”“嗯”,这些消息虽然没有实际信息量,但仍然占Token。后来我对消息做了“有效内容判断”,纯语气词或极短消息直接跳过,不进上下文,Token消耗立刻降了一截。
知识库方向,一个非常管用的优化是为知识片段设置更细的切分粒度。不要整篇文档一梭子喂进去,按标题、段落、甚至语义块切分,检索时按块召回,每块控制在300-600字左右。这样做之后,同样的问题检索结果更精准,Token占用也更低。
另外还有一个容易被忽视的点:缓存的业务上下文不要放太长时间。有些接口的数据是频繁变动的(比如物流轨迹),如果把旧数据缓存5分钟,用户就会觉得“回答不实时”。但反过来,如果每个上下文源都实时请求,延迟又会飙升。我最后的策略是“高频变动的字段实时查,低频字段走缓存”,双管齐下,效果最好。
4.3 模式切换生效了,但模型还是“记着”上一轮的内容
这个坑我调试了好几天。代码逻辑上模式切换后旧上下文源已经被清除了,但模型回答里仍然带着上一轮的错误信息。后来发现原因不在拼接层,而在模型的服务端会话缓存。
很多模型API支持传入conversation_id来维持多轮记忆,如果你在切换context-mode时没有换一个新的会话ID,服务端仍会保留旧的消息记录,相当于你拼接的只是“可见”上下文,而模型实际“看到”的还有一截历史。
解决方式有两种:一是切换模式时生成新的conversation_id;二是如果同一会话需要保留部分上下文,则要在服务端手动清洗历史,只保留你想让模型看到的那部分。从工程稳妥性来看,我更推荐直接换新会话ID,然后把你认为有价值的历史消息以普通文本形式写进系统提示词里。这样就完全掌控了模型“看到”什么,不会被服务端缓存干扰。
4.4 一张问题速查表,直接保存到团队Wiki里
| 症状 | 可能原因 | 排查步骤 |
|---|---|---|
| 回答偏离主题 | 场景识别错误或未切换 | 检查scene resolver输出,确认当前场景值 |
| 回答过于啰嗦 | 历史轮数太长,低价值消息多 | 降低max_turns,增加有效内容判断 |
| 上下文太长报错 | Token预算没算好 | 用公式重算各源上限,加截断 |
| 模式切换后仍沿用旧数据 | 服务端会话缓存未清理 | 切换模式时替换conversation_id |
| 知识库回答空洞 | 检索相关度低或切块太大 | 检查topK与片段长度,优化切分策略 |
| 业务数据滞后 | 缓存TTL过长 | 缩短高频字段缓存时间或实时查询 |
这张表基本覆盖了我上线后的绝大多数工单。后来我把这套排查流程沉淀成了团队的SOP,新同学碰到类似问题,先照着表过一遍,基本能解决80%的Case,不用再来敲我门。
5. 关于 context-mode 的几条实战忠告
做得越多,越觉得context-mode不是“写个配置”那么简单,它本质上是给AI系统建立一套“信息过滤规则”。有几条心得,我觉得比代码本身更值钱。
第一,宁可少给上下文,也不要多给。我之前总觉得上下文越丰富回答越聪明,结果经常因为一个无关字段把答案带沟里去。信息越多,模型越容易“想太多”。context-mode的真正价值在于“克制的融合”,而不是“无限的堆叠”。
第二,上线前一定要做回归测试集。不要只测手工挑的几个好例子。我从实际对话日志里抽了200条,按业务场景分类,做成自动化回归集,每次改策略就跑一遍,准确率下降自动报警。有了这个底子在,后续调参才有安全网,不然改一次坏一次,改到后期根本不敢动。
第三,模式配置要收敛,不要追求无限灵活。我见过有的团队把context-mode做成了一套可视化编排工具,支持任意拖拽组合,结果没人能维护。灵活性和复杂度是伴生的,你要什么就要承担什么。对我目前遇到的大部分业务来说,三种基础模式加一套fallback规则已经足够了,再多就是过度设计。
最后再分享一个我后来一直在用的技巧:每次要调整context-mode之前,我会先把当前用户的完整上下文导出成纯文本,人肉读一遍,看哪些信息是真正有用的、哪些是干扰项。这个方法听起来原始,但极其有效。因为只有你真的站在“模型视角”去看眼前这堆输入时,才会理解为什么有些莫名其妙的回答会冒出来。这个习惯帮我省掉了不计其数的线上Debug时间。