背景
我在开发一个项目协同平台的嵌入式 AI 助手。它不是独立的 chatbot,而是嵌在业务页面里的------用户可以在首页、项目详情页、任务抽屉等不同位置唤起它,用自然语言完成任务查询、创建、删除等操作。
和通用对话 AI 不同,这个助手有两个硬约束:
- 它连接真实数据库。用户说"删除这个任务",它会真的去删。所以"理解错了"的代价不是"回答不准确",而是"操作不可逆"。
- 它跑在业务页面里。用户的预期是"点一下就出结果",不是"等 3 秒 AI 想一想"。
核心矛盾
AI 助手收到用户输入后,第一件事不是回答,而是判断这句话要干什么------这就是意图识别(Intent Recognition)。
听起来简单,实际上有三个互相矛盾的需求:
| 需求 | 含义 | 典型场景 |
|---|---|---|
| 快 | 高频操作零延迟响应 | 用户说"我的任务",不应该等 1 秒 LLM 回来才开始干活 |
| 准 | 长尾表达不误判 | 用户说"帮我把上周那几个没做完的清理一下",纯规则覆盖不了 |
| 稳 | LLM 挂了系统不白屏 | 凌晨 3 点 LLM 服务商 503,用户正在赶 deadline |
这三个需求无法用单一方案同时满足。纯 LLM 快不了,纯规则准不了,什么都不做稳不了。
所以我做了分层。
整体架构
用户输入 + PageContext
|
v
+-----------------------------+
| 第一层:确定性 Fast-Path | 0ms / 0 成本 / confidence 0.96-0.99
| (模式匹配 + 页面上下文) | 覆盖 ~60-70% 高频请求
+-------------+---------------+
| 未命中
v
+-----------------------------+
| 第二层:LLM Structured | 500-1500ms / 有成本 / confidence 0.5-0.95
| Output(schema 约束输出) | 覆盖剩余 ~25-35% 长尾
+-------------+---------------+
| LLM 调用失败(网络/限流/服务端错误)
v
+-----------------------------+
| 第三层:应急兜底 | 0ms / 0 成本 / confidence 0.1-0.5
| (关键词启发式 + 追问) | 保底可用,不白屏
+-----------------------------+
三层共享同一个输出协议,包含四个核心字段:
- capability:识别出的业务能力(任务管理、项目管理、延期分析等,共 8 种),null 表示闲聊
- mode:执行模式(直接执行、工具循环、固定流水线、多步编排)
- confidence:0-1 的置信度,决定下游是直接执行还是先追问用户
- routeType:标记结果来自哪一层,用于监控统计和消息窗口策略
下游节点不关心"这个结果是哪一层产出的",它只看 capability 和 confidence。三层对外是一个统一的函数签名,内部是渐进降级。
第一层:确定性 Fast-Path
为什么需要这一层
先算一笔账:
| 指标 | LLM 路由 | 确定性规则 |
|---|---|---|
| 延迟 | 500-1500ms | < 1ms |
| 成本 | ~0.005-0.01 元/次 | 0 |
| 可靠性 | 依赖外部服务 | 100% 本地 |
一个活跃用户每天和 AI 交互 30-50 次,其中 60% 是"我的任务""创建任务 XXX""删除这个"这种高频操作。全走 LLM 意味着每天白白浪费 20-30 次 LLM 调用,而且用户每次都要多等半秒。对于"我的任务"这种请求,用户的预期是即时响应。
四种 fast-path 模式
模式一:Quick Action(前端按钮直达)
用户根本没有输入自然语言,而是点击了一个预设按钮。比如项目详情页的"分析延期风险"按钮,前端会带上一个 action 标识。
路由器维护了一张 action → capability 的映射表,大约 18 个预设操作。命中后直接返回 confidence 0.99,零歧义。不需要做任何自然语言理解------按钮已经告诉你用户要干什么了。
为什么 confidence 给 0.99 而不是 1.0? 不写 1.0 是有意为之。1.0 意味着"绝对不可能错",但 action 有可能从不该出现的页面触发(前端 bug)。0.99 表达的是"极高置信但保留审计余地",在评测指标统计时不会造成"完美命中率"的假象。
模式二:页面感知的模式匹配
这是 fast-path 的核心,也是嵌入式助手和通用 chatbot 最大的区别。
举个例子:用户说"删除这个"。
纯文本"删除这个"------删什么?在首页说这话和在任务抽屉里说这话完全不一样。所以判断逻辑不是纯文本匹配,而是文本 + 页面上下文 联合判断:必须同时满足两个条件------文本匹配操作类模式且当前页面有选中的任务实体。两个条件缺一不可:只有文本匹配但没有选中实体,不命中(无法确定要删哪个);只有选中实体但文本不匹配,也不命中(用户可能在问别的)。
一个更微妙的例子 :用户在项目详情页说"这个项目有哪些延期任务"。这里"延期任务"同时可以匹配"任务管理"和"延期分析"两个 capability。我的处理方式是设置排除规则------当文本命中"延期分析"的特征时,即使当前有选中实体,也不走单任务操作路径。因为用户关注的是全局延期分布,不是当前选中的那一个。
这类互斥规则的设计原则:先排除,再命中。 先判断"一定不是什么"(负面条件),再判断"可能是什么"(正面条件)。这样新增正面模式时不会意外吞掉已有的排除规则。
模式三:对话跟进
多轮对话中,用户的第二句话往往不会重新表述完整意图:
用户:这个项目有哪些任务?
AI: 共 5 个任务:1. 首页重构 2. 接口联调 3. 数据迁移 ...
用户:第二个
"第二个"如果作为独立输入,无法判断意图。但结合上一轮的 capability 是"任务管理",就知道用户在引用上一轮的查询结果。
跟进识别覆盖三种子模式:显式跟进语 ("继续""展开""还有吗")、序数跟进 ("第二个""第三个",需要上一轮是查询类操作)、澄清回复(上一轮 AI 在追问"请告诉我任务名称",这一轮用户直接回了一个标题)。
但这里有个容易出错的地方:用户改主意了怎么办?
用户:我有哪些任务?
AI: 共 3 个任务 ...
用户:生成本周工作周报
如果无脑继承上一轮的 capability,就会把"生成周报"当成任务查询的跟进。所以在跟进判断之前,先做强意图排除------如果用户的消息包含明确的新业务意图关键词(周报、创建、删除等),直接判定为独立意图,不走跟进路径。
原则和页面感知匹配一样:先排除,再命中。
模式四:精确短语
最简单的一种,只拦截完全确定的独立短句------"你好""谢谢""再见"等。严格要求整句匹配(必须是句首到句尾),"你好,帮我查一下任务"不会被拦截,会正常进入后续层级。
这类规则的设计原则:宁可漏判,不可误判。 漏判只是多花一次 LLM 调用,误判是把业务请求当问候语忽略。
Fast-Path 小结
| 子模式 | 匹配条件 | confidence | 占比估算 |
|---|---|---|---|
| Quick Action | action 标识查表 | 0.99 | ~15% |
| 页面感知匹配 | 文本 + pageContext | 0.96-0.97 | ~25% |
| 对话跟进 | 文本 + 上一轮 capability | 0.88 | ~15% |
| 精确短语 | 独立短句匹配 | 0.99 | ~5% |
合计约 60% 的请求在这一层就完成路由,零 LLM 调用。
第二层:LLM Structured Output
什么时候进这一层
第一层没命中,说明用户的表达不在预设的高频模式里。比如:
"帮我看看数据分析平台最近有没有卡住的任务"
"把上周那几个没做完的都归档掉"
"李四手上现在忙不忙"
这些表达方式多变、包含隐含语义、省略了主语或宾语,规则穷举不了。
约束 LLM 的输出空间
直接让 LLM 自由回答"这是什么意图"是危险的------它可能发明一个系统中不存在的 capability 名称,或者用自然语言描述意图而不是返回结构化标签。
所以 LLM 的输出被 schema 严格约束:它只能从预定义的枚举值中选择 capability 和 mode,并给出一个 0-1 的置信度分数,以及一个三分类的路由判断(业务意图 / 闲聊 / 需澄清)。LLM 不能越界------框架层面保证返回值严格符合类型定义。
LLM 输入:不是只有用户消息
LLM 收到的不是裸文本,而是用户消息加上一段页面上下文的自然语言摘要。比如"当前页面:项目详情,当前已选中任务,触发来源:任务抽屉"。
注意这里只传摘要,不传原始 ID。LLM 不需要知道 projectId=42,它只需要知道"当前有项目上下文"。这是上下文隔离设计的一部分------路由层只拿到裁剪后的上下文摘要,不接触执行层数据。
三种路由结果的处理
LLM 的三分类结果各自有不同的后处理逻辑:
业务意图:直接使用 LLM 给出的 capability 和 mode,进入执行层。
闲聊:capability 设为 null,走通用对话模型回复,不触发任何业务工具。
需澄清 :这个最微妙。LLM 说"我不太确定",但要分两种情况------如果 LLM 连 capability 都猜不出来,说明这句话大概率不是业务意图(比如"今天天气真好"),这时候追问"你想操作什么"是荒谬的,直接降级为闲聊更自然。只有当 LLM 猜了一个 capability 但不确定时,才走追问流程,并且强制把 confidence 压到 0.5 以下,确保下游一定走追问而不是直接执行。
重试与容错
LLM 调用可能因为网络波动或服务商限流而失败。重试策略是:最多尝试 2 次,只有瞬时错误(超时、429 限流、5xx 服务端错误)才重试,400 这种客户端错误不重试(因为重试也不会变)。
为什么只重试 1 次? 路由是用户操作的第一步,用户在等着。多等 1-2 秒还能接受,多等 5 秒体验就崩了。而且连续两次 503 大概率是服务商全面故障,第三次也不会成功。两次都失败,就进入第三层。
第三层:应急兜底
什么时候进这一层
LLM 两次调用都失败了。这在生产环境中确实会发生------服务商全面故障、网络隔离、账户欠费。
系统不能因为 LLM 挂了就白屏。用户正在赶 deadline,他说"帮我创建一个任务",回复"AI 服务不可用请稍后再试"是不可接受的。
兜底逻辑
分三档处理:
- 乱码/空输入:统计输入中有效字符(中英文、数字、常见标点)的占比,不足一半判定为乱码。返回 confidence 0.1,当闲聊处理。
- 包含业务关键词(任务、项目、待办、周报、延期等):猜一个最可能的 capability,但 confidence 只给 0.3。
- 其他:当闲聊处理,confidence 0.5。
关键在 confidence 0.3。 兜底层知道自己在猜,所以刻意给低置信度。下游路由的追问阈值是 0.75------也就是说,兜底层的结果永远会触发追问,不会直接执行业务操作。
系统会对用户说类似"我大概理解你想操作任务,但不太确定具体要做什么,能再说清楚一些吗?"的话。这比"AI 服务不可用"好得多------用户知道系统还活着,而且只要他说得更明确一点(触发 fast-path 的高频模式),下一轮就能正常工作。
层间协议:confidence 和 routeType
三层架构能工作的前提是:下游不需要知道结果来自哪一层。 这靠两个字段实现。
confidence:控制流变量,不是评分
| 来源 | confidence 范围 | 下游行为 |
|---|---|---|
| fast-path | 0.96-0.99 | 直接执行 |
| LLM 业务意图 | 0.7-0.95 | >= 0.75 直接执行,< 0.75 追问 |
| LLM 需澄清 | <= 0.5(强制压低) | 追问 |
| 兜底 | 0.1-0.5 | 追问 |
confidence 不是在评价"我识别得准不准",而是在告诉下游"该不该让我直接干"。这是一个控制流变量,不是一个准确率指标。
routeType:可观测性 + 策略适配
routeType 标记了结果来自哪一层(fast-path / llm-fallback / emergency-fallback / general-chat / conversation-followup)。它不影响执行逻辑,但影响两件事:
消息窗口策略:不同来源需要的历史上下文量不同。fast-path 已经用规则完成了意图判断,执行层只需要当前消息,历史消息反而会污染工具调用的判断。而对话跟进必须带最近几轮历史,否则执行层不知道"第二个"指的是什么。闲聊需要更长的历史来维持对话连贯性。
监控和调优:通过统计 routeType 分布可以发现问题------fast-path 命中率下降说明用户表达模式变了需要补规则,emergency-fallback 突增说明 LLM 服务可能有问题,llm-fallback 的 confidence 分布偏低说明 prompt 需要调优。
页面上下文:嵌入式助手的核心差异
同一句话,不同页面,不同路径
用户说:"删除这个"
场景 A --- 在任务抽屉里(有选中实体):
→ fast-path 命中,confidence 0.97,直接执行
场景 B --- 在首页(没有选中实体):
→ fast-path 全部不命中
→ 进入 LLM 层
→ LLM 大概率返回"需澄清":"你想删除什么?"
这不是 bug,这是设计。"删除这个"在没有明确上下文时就是有歧义的,系统应该追问而不是猜。
上下文分层:按需裁剪,互不越权
前端采集的页面上下文是完整的------包含当前页面、路由参数、选中实体、可见列表 ID、筛选条件、搜索关键词、分页信息、触发来源等。但不是所有信息都给所有节点。
路由层只拿到裁剪后的子集:页面名称、路由参数、选中实体、触发来源。不包含可见列表 ID、筛选条件、分页信息------这些是执行层需要的,路由层不需要。
为什么要隔离?
- 防止信息泄露:如果路由层的 LLM prompt 包含实体 ID 列表,LLM 可能直接引用这些 ID 绕过正常的权限和解析流程。
- 节省 token:一个 20 条任务列表的 ID 数组塞进路由 prompt 纯属浪费。
- 职责清晰:路由层的职责是"判断要干什么",不是"拿什么数据去干"。
真实 Bug:为什么纯规则走不远
Bug 1:中文子串踩踏
用户说:"这个项目中有几个未完成的任务"
状态推断的匹配逻辑按顺序检查:先查"进行中",再查"已完成/完成了/完成的/做完的",最后查"未完成/没完成/没做完"。
问题出在中文没有空格分词。"未完成的"这个词在字符串层面包含了"完成的"这个子串。当正则引擎扫描"未完成的"时,"已完成/完成的"那条规则先命中了其中的"完成的"三个字,函数直接返回"已完成"。"未完成"的规则排在后面,根本没机会执行。
结果:用户问"未完成的任务有几个",系统回答"已完成任务共 1 个"。答非所问。
这不是个别正则写错了,而是中文的结构性问题。英文的 incomplete 和 complete 也有类似的包含关系,但英文可以用词边界 \b 解决。中文没有词边界标记。
修复:否定形式优先匹配------把"未完成/没完成/没做完"放到"已完成/完成的"前面。先匹配否定,再匹配肯定。
更深层的教训:这类冲突在规则数量增长后会指数级增加。每新增一条规则,都可能与已有的几十条产生子串互踩。人工 review 无法覆盖所有组合。
Bug 2:文本清洗破坏语义结构
用户说:"我已完成的任务有几个"
系统有一个"自我识别"机制,检测"我"开头的句子并标记为查询自己的任务。但自我识别只覆盖了"我的""我有""我负责"三种组合,没覆盖"我已""我未"。"我已完成的"------"我"后面跟的是"已",不在识别列表里,自我识别失败。
接下来进入人名提取流程。为了从文本中提取人名,需要先清洗掉状态词,避免干扰。清洗把"已完成的"删掉后,文本变成了"我任务有几个"。人名模式匹配了"X 有几个",提取出人名"我任务"。
系统去数据库找名为"我任务"的成员------找不到------返回错误。但执行层没有检查错误标识,直接用默认值 0 继续格式化输出------"我任务负责的已完成任务共 0 个"。
两个 bug 叠加:自我识别覆盖不全 + 执行层把错误静默吞掉。用户看到一个"看似合理但完全错误"的回答,比直接报错更糟糕。
这个 bug 说明的问题:文本清洗是危险操作。删除文本片段会改变句子结构,产生原始文本中不存在的语义拼接。规则系统中,预处理步骤越多,意外组合越多。
为什么不用纯 LLM / 为什么不用纯规则
纯 LLM 的问题
| 问题 | 说明 |
|---|---|
| 延迟 | 每次意图判断 500-1500ms,高频操作用户感知明显卡顿 |
| 成本 | 70% 的高频请求本不需要 LLM,全走 LLM 等于 70% 的钱白花 |
| 可用性 | LLM 服务商故障时整个系统不可用,没有降级方案 |
| 确定性 | "我的任务"这种请求,LLM 偶尔会判断成闲聊而不是任务管理 |
纯规则的问题
| 问题 | 说明 |
|---|---|
| 覆盖率天花板 | 中文表达多样性极高,"最近有啥活没干完"这种说法规则写不出 |
| 子串冲突 | 规则数量增长后互相踩踏的概率指数增加 |
| 维护成本 | 每加一条规则都要验证不和已有规则冲突,成本随规则数线性增长 |
| 清洗副作用 | 为提取实体做的文本清洗会破坏语义结构,引入难以预见的 bug |
分层是工程答案
不是"LLM 好还是规则好"的选择题,而是"什么场景用什么工具"的设计题。
高频 + 无歧义 → 规则(快、稳、零成本)
长尾 + 有歧义 → LLM(准、灵活、有代价)
全挂了 → 兜底规则 + 追问(保底可用)