上个月我在重构内部任务助手的时候,把“context-mode”从一个临时方案落成了一个正经模块。说白了,它就是让系统先读懂当前对话上下文,再自动决定进入哪种工作模式。之前我们一直是手动切模式,用户点按钮、我们按关键词硬匹配,结果上下文稍微长一点就切错,整个体验稀碎。这篇就把我在这个设计上踩过和填平的坑整理出来,包括一份可以直接抄作业的代码骨架和参数调优思路。如果你也在做对话系统、AI 助手、自动化流程这类对上下文敏感的东西,这套思路基本可以平移过去。
1. context-mode 到底解决什么问题?
1.1 一个典型场景:多模式表单助手
我们当时做一个智能表单填写助手,需求分成三种模式:新手引导模式、数据填报模式、专家速录模式。新手进来需要一步步提示,老用户希望快速批量录入,中间状态的人可能一直在改表。最初的实现很粗暴,界面上放三个按钮让用户自己切,后台再用一堆 if-else 判断关键词。
按钮切模式的问题在于,用户根本不知道自己该用哪种模式。填到一半觉得交互方式不对,还得手动跳出去找开关,非常打断心流。关键词匹配更不靠谱,“金额”“日期”这种词在三种模式里都会出现,按词命中就等于随机开盲盒。真正触发切换的应该是整段对话的个人意图和当前进度,而不是某一句话里的某个词。
context-mode 本质上把“模式”从用户手动操作的按钮,变成系统基于上下文包自动计算出来的状态机输出。输入是一段对话历史、用户画像、当前步骤,输出是当前最该激活的模式。这样用户不需要理解模式差异,系统替他判断。
1.2 “先识别,后切换”比硬编码规则强在哪
传统的关键词规则像开车时“看到路口就打转向灯”,它不看你车速、不看你所在车道、也不看你导航提示,所以很容易误打。context-mode 的思路是,先把车速、车道、导航、前后车距离全部收集起来,综合判断后决定要不要打灯,以及打左还是打右。
在实现层面,这意味着三个环节的解耦:
第一是特征提取。原始对话要转成结构化特征,比如用户轮次里有没有明确动作词、有没有文件相关词、当前操作是否已经完成某个步骤。
第二是决策判断。基于特征打分,而不是单点命中。它会看特征出现的轮次距离、特征之间的组合关系、用户画像的一致性,最终算出一个置信度分数。
第三是模式执行。决策完成后才把模式切换出去,执行层不关心你用什么规则算出来的,它只接收一个目标模式。
这套分层结构的好处是,任何一个环节坏了都能单独修。特征提取不准就加特征,打不准就调权重,执行层有 bug 不会牵连决策逻辑。硬编码规则恰恰是把这三层揉在一起,出了问题你得在一堆 if-else 里考古。
我后来把“模式”的所有定义都抽成了数据:每个模式有名字、有描述、有特征权重表、有冷却时间。切换逻辑变成纯数据驱动的计算流程,新增一种模式不用改代码,只要往配置表里塞一条记录。这个改造带来的维护成本降低非常明显。
1.3 模式矩阵:先把“模式”变成数据
每个模式在 context-mode 里就是一个注册项:
| 字段 | 类型 | 说明 |
|---|---|---|
| mode_id | string | 模式的唯一标识,比如guide、fill、expert |
| display_name | string | 对外展示名称,给日志和审计用 |
| description | string | 描述该模式适合什么场景 |
| features | dict | 特征名到权重的映射 |
| priority | int | 平票时的优先级 |
| cooldown | int | 进入该模式后的最短保持轮数 |
这里最费心的是 features 字段。它不是简单列几个关键词,而是定义“什么样的上下文证据能支持这个模式”。比如专家速录模式,不能只靠“批量”两个字触发,得让“批量”和“导入”“跳过提示”“连续录入”这些词共现,分数才够高。
把模式变成数据之后,测试也好做了。我要验证某个模式切换逻辑对不对,不需要真去聊一轮天,直接把构造好的上下文包喂给引擎看它选谁就行。
2. 核心组件拆解:上下文包、特征识别与状态机
2.1 上下文包:把一句话扩展成一个结构化快照
context-mode 的输入不能是原始文本列表,那样特征提取会在每个环节重复解析,性能差也容易漏。我设计了一个ContextSnapshot数据结构,每次需要决策时,把当前状态打包塞进去。
from dataclasses import dataclass, field import time @dataclass class ContextSnapshot: dialogue_text: str # 当前完整会话文本 recent_turns: list # 最近 N 轮对话,N 一般为 8~12 user_profile: dict # 用户画像,如熟练度、使用频率 current_step: str # 当前流程步骤标识 timestamp: float # 决策时间戳这里面recent_turns和current_step是决定模式切换的关键。dialogue_text虽然完整,但直接喂给打分器太噪,特征提取时主要看recent_turns。而current_step用来做“进度感知”:比如用户正在做第三步“确认数据”,这时候即便说了“换个方式”,也大概率只是在调整当前步骤,不是要切换全局模式。
谁去构造这个快照?我建议在业务层做一个拦截器,每次用户消息进来先更新快照,然后才让业务逻辑执行。这样 switch 决策和业务执行共用同一份上下文,不会出现两边信息不一致。
2.2 识别器:对特征打分而不是命中关键词
识别器是 context-mode 里最核心的模块,我把它做成了一个打分系统。每个模式会从上下文包里提取一组特征,每个特征计算命中强度,然后根据特征的轮次距离做近因衰减。
衰减公式我用了指数形式:
import math def decayed_score(raw_score: float, turn_distance: int, half_life: float) -> float: return raw_score * math.exp(-turn_distance / half_life)为什么用指数衰减而不是线性衰减?因为用户意图的短期记忆衰减更接近指数形态。刚说的东西影响最大,隔了两三圈之后影响迅速下降,但不会直接归零。用半衰期这个概念来表达更直观:half_life=5意味着 5 轮以前的特征权重会衰减到一半。
打个分吧。假设用户在距离当前第 2 轮时说了“这批数据要批量导入”,“批量导入”命中专家速录强度的权重是 0.6,半衰期取 5,那么它贡献的分数就是:
0.6 × exp(-2/5) ≈ 0.6 × 0.670 = 0.402
如果这条特征是 10 轮以前说的,衰减后只剩下 0.6 × exp(-2) ≈ 0.081,几乎可以忽略。这个设计就是为了防止一个很常见的错误:用户开头提过一个诉求,后面话题早就变了,结果开头那句话还在持续影响模式判断。
识别器对每个模式汇总完分数之后,还要做归一化。我当时的做法是,把每个模式的得分除以所有模式得分的总和,得到相对置信度。相对置信度比绝对分更有意义,它表达的是“当前所有候选模式里,哪一个最占优势”。
2.3 状态机:target_mode 和 active_mode 分离
识别器算出来的只是 target_mode(目标模式),不能直接切换。真正生效的是 active_mode(当前激活模式)。两者之间隔一个状态机,目的是避免模式在边界上来回摆动。
状态机的迁移逻辑我简化为三个条件同时满足:
- 目标模式的置信度 > 当前激活模式的置信度 + delta(滞后差)
- 距离上次切换已经超过 cooldown 设定的轮数
- 当前模式没有被业务逻辑锁定
def try_switch(engine, snapshot): candidate = engine.evaluate(snapshot) active = engine.active_mode turns_since_switch = engine.turns_since_last_switch if (candidate.score > active.score + engine.delta and turns_since_switch > active.cooldown and not active.locked): engine.activate(candidate.mode_id) engine.log_switch(snapshot, candidate)这个delta是滞后比较的关键。它相当于给切换加了一道坎:新模式的分数不仅要超过当前模式,还得超过一定幅度才算数。这样能挡掉很多边界上的抖动场景。
cooldown 是一个更直观的约束。某个模式一旦激活,至少保持 N 轮,即使下一秒识别到更强的模式信号,也要等冷却时间过了再考虑切换。用户感知上,系统是在稳定地跟随他的节奏,而不是神经质地在模式之间跳来跳去。
3. 直接可抄的 context-mode 代码骨架与参数调优
3.1 模块划分:每块只干一件事
我落地的目录结构是这样:
context_mode/ ├── snapshot.py # 上下文包定义 ├── features.py # 特征提取与打分 ├── registry.py # 模式注册表 ├── engine.py # 状态机与切换逻辑 └── metrics.py # 切换日志、审计指标snapshot 管输入数据的结构化,features 管怎么从快照里抽取证据,registry 管模式有哪些、权重是多少,engine 管决策和执行,metrics 管观测。这个划分是我踩了很多坑之后才固定的,之前把特征逻辑写进 engine 里,结果每次加特征都要动核心逻辑,非常痛苦。
3.2 核心代码:模式引擎骨架
下面这段是我简化后的引擎核心,保留了完整的决策链路,可以直接跑通一个小 demo。
class ModeEngine: def __init__(self, registry, half_life=5.0, delta=0.15): self.registry = registry self.half_life = half_life self.delta = delta self.active_mode = registry.default_mode self.last_switch_turn = 0 def evaluate(self, snapshot): scores = {} for mode in self.registry.modes: mode_scores = [] for hit in mode.match_features(snapshot): turn_distance = max(0, snapshot.current_turn - hit.turn) decayed = hit.raw_score * math.exp(-turn_distance / self.half_life) mode_scores.append(decayed) scores[mode.mode_id] = sum(mode_scores) total = sum(scores.values()) or 1.0 return {mid: score / total for mid, score in scores.items()} def try_switch(self, snapshot): scores = self.evaluate(snapshot) candidate = max(scores, key=scores.get) current_score = scores[self.active_mode.mode_id] candidate_score = scores[candidate] if (candidate != self.active_mode.mode_id and candidate_score > current_score + self.delta and snapshot.current_turn - self.last_switch_turn > self.active_mode.cooldown): self.active_mode = self.registry.get(candidate) self.last_switch_turn = snapshot.current_turn return True return False这段代码的核心就是evaluate和try_switch。evaluate 负责把快照变成每个模式的置信度,try_switch 负责用滞后比较和冷却约束做最终决定。注意我引入了current_turn这个概念,它由快照维护,用来算事件距离。没有轮次信息,近因衰减就无从谈起。
3.3 参数怎么调:阈值、半衰期、冷却轮数
很多同学拿到代码最容易问的就是参数怎么设。我给出一份当前比较稳的推荐参考:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| half_life | 5~8 轮 | 太短会只认最近一句话,太长会导致模式切换非常粘滞 |
| delta | 0.15~0.3 | 小于 0.1 容易抖动,大于 0.4 切换会迟钝 |
| cooldown | 2~3 轮 | 小于 2 挡不住抖动,大于 5 用户会明显觉得系统反应慢 |
| single_turn_cap | 0.3 | 单轮特征对总分的贡献上限,防止一句话带偏全局 |
半衰期的调法我建议拿真实对话样本去测。先取 50 条已经人工标注好“该是什么模式”的会话,跑一遍日志,看每条切换事件离关键特征出现的轮数距离。如果发现某类模式经常在特征出现 4~5 轮之后才被识别出来,说明 half_life 太长或者特征权重偏低;如果频繁误切,就检查是不是单一轮次贡献过高。
delta 的调整逻辑也很有讲究。我把 delta 从 0.05 往上调,每档跑一天的灰度日志,看 switch 频率的变化。0.05 时每天的切换次数可能有 800+,抖动严重;调到 0.2 左右切换次数降到 120 次,误切率明显下降。但要小心,如果 delta 超过 0.4,用户明显说出格式词句的时候系统也不切换,这时就得回调。
cooldown 我固定在了 2 轮。为什么不是 1 轮?因为一轮对话往往包含“系统追问 + 用户回答”两个信息,连续两轮的信息才能构成一个有效证据。1 轮冷却无法验证意图稳定性,3 轮以上又会让模式转移变得迟缓。
3.4 日志与指标:没有观测就没有优化
context-mode 上线后,最重要的不是调参,而是埋好切换日志。我每次切换都记录这么几个字段:
- 触发切换前的 active_mode 和候选 target_mode 的置信度分数
- 命中的特征列表,包括特征名、所在轮次、衰减后得分
- 当前上下文包的 current_step 和 user_profile
- 切换耗时
这些日志的用途是两个:一是出了问题能回放完整决策链路,二是积累数据做后续优化。我见过很多团队把特征权重调得很神秘,但从来没验证过切换到底对不对,全靠感觉。用日志数据抽样回放,哪怕一周只看 20 条,也远好过盲调。
4. context-mode 实战踩坑:误切换、上下文丢失和模式抖动
4.1 误切换:被单句关键词带偏
第一个坑最典型。用户说的是“帮我把这个列表导出为 PDF”,系统直接切到了专家速录模式。原因很简单,之前的特征表里“导出”这个词权重给得过高,而它单独出现时根本不能代表用户想切模式。
这个 case 的问题是单轮特征贡献没有上限。用户随口说一句带关键词的话,分数直接拉满,覆盖了前面几十轮的上下文信号。我的第一个修复方案,是给单轮特征贡献加了上限single_turn_cap=0.3。不管这一轮里命中多少特征,最多只能占总分的三成,剩下七成必须从更早的上下文里来。
第二个修复是特征共现约束。“导出”这个动作要跟“PDF”“文件”“下载”等对象词同时出现才算强特征,单独出现只能算弱特征。逻辑上这更贴近真实用户意图:说“我要导出”不一定是要切换到导出模式,可能只是当前操作的一小步。
后来我还加了 user_profile 的修正项。如果一个用户已经连续两周每天用两个小时,那他大概率是专家用户,新手引导模式对他的特征阈值就应该整体抬高一点。这个修正不用太复杂,做成一个乘法系数就行。
4.2 上下文丢记忆:窗口截断剪掉了关键偏好
第二个坑来自上下文窗口限制。我们的对话系统底层接了大模型,输入长度有上限,早期的对话内容往往会被截断。问题在于,截断很容易把用户的关键偏好扔掉。有一个真实案例:用户在第 3 轮说过“报表维度只要月维度”,到第 20 轮系统因为长度限制把这句话裁掉了,结果模式判断从“数据处理模式”跳到“通用对话模式”,后面所有交互都像失忆了一样。
这个问题的本质是,决策引擎不能只依赖一个会被截断的原始文本窗口。我给 context-mode 增加了一个持久化卡槽机制,专门承载那些“必须记住”的关键信息。
卡槽设计上分成几类:长期偏好槽、临时任务槽、流程进度槽。长期偏好槽放用户明确说过的偏好,比如“报表只要月维度”“导出时不要备注列”,一旦写入就不随窗口截断而消失。临时任务槽放当前任务的目标和约束,任务完成时清空。流程进度槽就是 current_step,每一步都实时更新。上下文包在构造时,除了 recent_turns,还会把这三类槽里的内容拼接进去,作为决策信号的一部分。
对长会话,我还会做一个轻量级摘要。大模型每次更新后生成一段 300 字左右的关键摘要,代替被截断的早期对话进入上下文包。虽然摘要可能丢失细节,但保住主线的收益远大于损失。
4.3 模式抖动:用户做同一件事,模式来回跳
模式抖动是 context-mode 最让人抓狂的问题,表现为用户明明一直在做同一件事,模式却每隔几轮就跳一下。我当时见过一个案例,用户处理一批表格,系统在“数据处理模式”和“通用对话模式”之间来回切换了 6 次,每切一次界面布局都跟着变,用户直接被整懵。
抖动的原因是目标模式和当前模式分数非常接近,都在 0.5 附近震荡,任何微小的文本变化都能打破这个平衡。光是加冷却时间只能减少切换次数,不能完全消除,因为冷却时间一过还是会跳。
我最后的方案是同时上两把锁:滞后比较 delta 和冷却 cooldown。delta 意味着分数必须拉开明显差距才允许切换;cooldown 保证切换之后至少稳定两轮。实测调整前,该场景下 30 分钟内抖动 6 次;delta 调到 0.2、cooldown 设为 2 轮后,同样场景 30 分钟内抖动降到 0 次,模式选择也基本和人工标注一致。
这个现象也给了一个提醒:别让 context-mode 承担它不该承担的任务。如果两个模式本身边界模糊,比如“数据处理模式”和“通用对话模式”在很多对话里确实难分,那首先要做的是合并模式或者重新定义模式边界,而不是硬调参数让系统乱猜。
4.4 问题速查表
| 问题现象 | 常见原因 | 处理方法 |
|---|---|---|
| 单句话触发切换 | 关键词权重过高、无单轮上限 | 设置single_turn_cap,给特征加共现约束 |
| 早期关键信息丢失 | 上下文窗口直接截断 | 增加卡槽持久化,加入摘要机制 |
| 模式持续抖动 | 分数双峰震荡、冷却太短 | 提高 delta,增加 cooldown,考虑合并模式 |
| 响应迟钝 | delta 太大或 half_life 太长 | 降低 delta,缩短半衰期,检查特征权重 |
| 冷启动不準 | 特征没积累、上下文包为空 | 用 user_profile 和当前步骤做默认初值 |
5. context-mode 还能怎么用?三个扩展方向
5.1 智能工作台自动切换界面模式
今年做编辑器类产品的人越来越多,context-mode 完全可以用在自动布局上。用户正在写文档时,系统判断进入“专注写作模式”,侧边栏收起来、辅助面板隐藏、连校正提示都降噪;用户切到代码文件时,自动进入“编码模式”,文件树展开、终端面板弹出、命令联想增强。
这里的难点是 UI 模式切换不像对话模式切换那么容易被原谅,跳来跳去非常干扰。所以我建议把 cooldown 拉长到 5 轮以上,并且只在用户停顿时才执行切换,不要在打字过程中突然改变界面结构。
5.2 客服机器人自动分流售前售后
客服场景里,context-mode 可以做成一个自动前置分类器。用户说“我要退货”,系统进入“售后模式”,操作路径和话术全部换成售后流程;用户问“这个套餐还有吗”,进入“售前模式”,推荐逻辑自动激活。加上情绪信号之后,还能切到“安抚模式”,在用户有负面情绪时自动调整话术节奏。
这个场景和对话助手很像,但额外要注意一点:客服分流一旦切错,代价非常高。用户可能已经等了十分钟,结果分流到另一个部门。建议在切换置信度达不到阈值时,默认停留在通用模式,并主动询问用户“您是需要咨询还是售后”,把决策权交还给用户,比硬切好得多。
5.3 用反馈回路做自动校准
context-mode 目前的参数调整还是靠人工。下一步可以加一个反馈回路:当用户主动切换模式、或者用语言表达不满时,把这次事件作为负样本,回传到特征权重系统里做微调。
比如用户在当前模式下说“怎么又给我弹这个”,那就是一个负反馈信号。系统可以降低最近命中特征的权重,或者提高导致该模式的证据阈值。这个机制做起来不复杂,本质上就是在线学习,但要记得配一个样本审核流程,防止系统被个别异常用户带偏。
如果让我重新做一遍 context-mode,我会先把日志和审计做得足够厚,再谈模型和调整。没有观测数据,所有的调参都是碰运气;有了观测数据,这个系统才能持续变好。另外,尽量别把模式定义得太细,能合并就合并,模式边界清晰比模式数量多重要得多。