
1. 为什么单模型意图识别在线上扛不住我才转向分层漏斗先抛一个反直觉的结论一个线上日活几十万的Agent应用如果意图识别只有一个大模型在扛第一周看着效果很惊艳第三周你就会开始被三类问题反复折磨——延迟毛刺、账单膨胀、case分析无从下手。我最早做智能客服Agent时就是这么干的用户问一句我想查一下上个月账单直接把这句话连同系统提示词丢给大模型返回JSON里的intent字段。离线评测准确率看着有93%我以为这就够了。结果一上线P95延迟从800ms飙到2.4秒原因是高峰期LLM服务排队再加上部分请求触发重试。更头疼的是用户换个说法上个月话费为啥这么多模型偶尔会答非所问但你根本说不清是prompt问题、模型问题还是融合了历史对话的上下文理解问题。这其实就是工业级意图识别和Demo级意图识别最大的区别Demo只需要对工业级需要对、快、便宜还要能定位问题。单模型方案在这四个维度上全部失守。于是我开始重新设计意图识别链路最终沉淀成一套工业级Agent意图识别分层漏斗架构。核心思路一句话用最便宜的层接住最多的流量只有低频、复杂、歧义大的请求才逐级放行到更贵的模型层。规则层毫秒级返回向量召回层几十毫秒内返回LLM推理层作为最后的兜底。每一层各自负责一部分流量互不越权失败就降到下一层或进入澄清流程。这套方案上线后我的实际体感是LLM调用量砍掉了大约七成月度推理账单直接降下来一大截P95延迟从2秒级降到300毫秒以内因为绝大多数请求在规则层和向量层就被解决了出了badcase能快速定位是词典没覆盖、相似度误判还是模型真错了不再是一团黑盒。行业里那些讨论Agent架构的文章尤其是涉及LLM推理、工具编排Harness的话题默认前提其实都是意图得先被准确识别出来。意图识别是整个Agent流程的第一道闸门这道门不稳定后面接什么都白搭。下面我把这套漏斗每个环节的设计思路、落地细节和我在线上踩过的坑完整拆开讲一遍。2. 漏斗的每一层到底在干什么职责、边界与数据流2.1 整体形态宽进窄出逐级放大算力所谓漏斗结构上就是这样的阶梯最下面是入口流量100%进来第一层规则匹配用最高性价比接住高频意图剩下的疑难流量继续上探到向量语义召回层再剩下的少数复杂语义才走到LLM推理层最后一层是拒识兜底保证系统宁可不猜也不瞎猜。用表格描述每一层的职责看起来会更清楚漏斗层级处理手段典型耗时每千次调用成本接住流量占比实测L0 规则命中层词典、精确匹配、正则、AC自动机 5ms几乎为零45%-55%L1 语义召回层Embedding向量化 相似度召回10-50ms低向量检索便宜30%-40%L2 LLM推理层大模型上下文理解 结构化输出300ms-1.5s高Token计费5%-10%L3 拒识兜底层置信度阈值、澄清追问、人工通道即时或异步低约3%-5%这套比例不是拍脑袋定的它的价值在于最高频、最确定的意图用规则层接住成本几乎可以忽略模型只处理真正需要理解的那部分。如果你的线上流量分布不是这个形态也不用强求比例一致只要保证绝大多数请求止步于前两层就对了。2.2 层与层之间的数据流传递每一层都接收同一个query但处理逻辑不同。我实现在代码里的流程是输入归一化统一做文本清洗、繁体转简体、全角转半角、大小写归一送入L0规则层先查高频意图映射表再做多模匹配L0命不中就进入L1把query向量化和意图模板库的向量集合做Top-K检索相似度达到阈值就返回候选意图低于阈值但不低得离谱的进入L2让LLM做最终判断LLM输出结构化本金如果置信度不足或模型明确表示无法判断走L3澄清或转人工。这里有一个容易设计错的地方每一层不是在淘汰请求而是在接力判断。比如L1向量检索的结果不会直接被丢弃而是作为候选和上下文证据一起传给L2让LLM有一个之前的层认为可能是这些意图的参考范围。这样既省了LLM从零推理的时间也让模型输出更稳定。2.3 为什么要用漏斗而不是单一模型有人会问既然LLM准确率最高为什么不让它处理所有请求答案就三个字扛不住。我实测过一组数据单次LLM意图识别如果带完整工具定义和系统提示词输入在800-1200 token左右高峰期100 QPS时即使调用的是中档模型也会出现排队和限流。而同样的100 QPS规则层可以用一个很小的Go服务轻松处理向量层用HNSW索引也就多几十毫秒。算力要花在刀刃上而不是撒在所有流量上。工业级的本质是成本、延迟、准确率三者取平衡漏斗就是最自然的平衡结构。3. 前两层的工程实现规则命中层与语义召回层的细节3.1 L0规则层别小看词典和正则的力量很多人做意图识别时觉得有了大模型规则层就没必要了。这是最大的误解。我见过的最典型的线上高频意图比如查余额查订单退换货设置提醒它们的表达方式其实高度集中。一个几千词的词典加几十条正则就能做到95%以上的命中率而且稳定到让人放心。我实现的L0包含两个子模块第一精确和模糊词典匹配。用Aho-Corasick多模匹配算法一次遍历同时匹配上千个短语复杂度是线性的性能比逐一跑正则好一个数量级。词典分两层固定词表如退款退货查余额和动态词表从历史日志里挖掘出的高频说法每周离线更新一次。词表更新要做热加载不能重启进程。第二规则模板匹配。适合有明确模式特征的意图。比如把X添加到Y这种指令或者查询订单号ORD20240101这种带实体的查询。我用正则表达式组命名实体捕获把实体直接作为意图的槽位参数提取出来。下面是我在L0里用的一个简化示例Pythonimport re # 动态加载的意图规则表支持热更新 INTENT_RULES [ { intent: query_balance, pattern: re.compile(r(查|看|查一下|查下)\s*(一下|我的)?\s*(余额|话费|账户金额), re.IGNORECASE), }, { intent: refund_apply, pattern: re.compile(r(我要|想|帮我|申请).{0,4}(退款|退货|退钱), re.IGNORECASE), }, ] def match_rules(text: str): for rule in INTENT_RULES: m rule[pattern].search(text) if m: return {intent: rule[intent], matched: m.group(0), confidence: 1.0} return None这里有个关键设计规则层的返回置信度固定为1.0它一旦命中就直接终结漏斗不再往下走。只有未命中的才进入L1。这样做的目的是保证最快路径绝对短。3.2 L1语义召回层向量化意图模板库的构建规则层接不住的那部分流量开始进入语义召回层。它的核心思想是把意图本身变成一个带表述模板的向量库用query的向量去检索最相近的意图。这层要做好有三个要点第一Embedding模型选型。不需要追求最强的模型但一定要选在中文意图相似度任务上表现稳定的那种。我实测过bge-m3、text2vec-large-chinese、以及一些通用Embedding模型最终线上用的是bge-m3batch推理性能可观且对短文本相似度比较友好。如果你的场景偏英文也可以考虑其他的英文Embedding模型。关键是在自建的意图评测集上跑一遍用召回率选而不是看benchmark。第二意图模板库的构建。这是整个L1质量的根基。做法是每个意图由离线运营同学写8-15种自然表达方式再用同义词替换扩展两到三倍。比如查余额这个意图模板库里有看看我还有多少钱我想知道我卡里余额帮我查下账户余额还剩多少等几十条变体。把这些模板全部向量化建立索引。这步千万别省模板的覆盖度直接决定向量召回率。我第一版只写了每种意图5个模板上线后L1命中率只有52%扩充到20个以上后直接升到80%以上。数据很直观。第三向量检索的工程实现。量不大的时候用brute force都行量大了就上HNSW。我用的是Faiss的IndexHNSWFlat当然pgvector、Milvus这些也都能胜任。检索时取Top-K我常用K20再计算余弦相似度超过阈值初始设为0.72就返回该意图。import numpy as np import faiss class SemanticIntentMatcher: def __init__(self, dim1024): self.index faiss.IndexHNSWFlat(dim, 32) self.intent_names [] # 与index id对应 def recall(self, query_vec): scores, indices self.index.search(query_vec.reshape(1, -1), 20) results [] for score, idx in zip(scores[0], indices[0]): if score self.threshold or idx -1: continue results.append({intent: self.intent_names[idx], similarity: float(score)}) return sorted(results, keylambda x: -x[similarity])[:3]阈值在线调整用得上我把threshold设为动态可配置通过配置中心下发不需要重新发布服务。后面踩坑部分我会细讲为什么阈值一旦设死迟早出事。3.3 L0和L1之间如何协作还是互相隔离这两层之间还有一个值得注意的边界问题。线上经常出现的情况是某个query规则层命中了一个意图但语义上看用户表达的其实是另一个意图。比如用户说我每个月被扣好多钱帮我查查规则里的查询可能匹配到查余额但用户真实意图更像账单明细查询。我的经验是规则层命中后不直接返回而是把这个结果作为候选之一和L1的结果放在一起比较置信度。规则置信度默认最高但如果L1有多个高相似度候选且和规则命中不一致会触发一个冲突仲裁逻辑。做法很简单当L1最高相似度比规则命中的次优候选高出不少时说明语义信号足够强采用L1的结果否则保持规则命中结果。这个仲裁逻辑一开始我以为只有少数case会出现测下来发现占比居然有6%-7%。为了接住这批用户你不能让规则层太霸道。4. LLM推理层和拒识兜底什么时候把问题交给大模型4.1 什么情况必须走到L2L2不是给所有低置信度query准备的要限制入口条件否则LLM层还是会成为性能瓶颈。我设了三个条件只要满足其一才进入L2L1检索到Top-K候选但最高相似度落在0.5-0.72之间有点接近但不敢确认Top-K候选之间有严重冲突比如前两个意图的相似度只差0.01无法稳妥排序query本身涉及多轮对话、代词指代或带有明显复杂指令色彩比如刚才那个订单如果已经发货了就别退了没发货的话直接取消。第三种情况是Agent场景和纯文本分类最大的不同用户的意图往往和当前对话状态、历史动作强绑定。单纯用静态向量比很难判断那个订单指的是哪个订单。这时候必须交给LLM结合对话历史和工具状态做推理。4.2 LLM层的Prompt与结构化输出设计LLM层如果还像早期那样让模型自由发挥输出JSON很快就乱。我的做法是定义严格的输出Schema用函数调用的方式来约束模型返回而不是让模型自己拼JSON。下面是一个我在Agent场景里常用的输出结构{ intent: refund_or_cancel_order, confidence: 0.89, slots: { order_id: ORD20240101, action: cancel_if_not_shipped }, need_clarification: false, clarification_question: }关键字段有两个need_clarification和clarification_question。这两个字段的价值是允许模型主动承认自己不确定而不是硬猜一个意图。这对应漏斗的L3兜底——模型如果判断当前信息不足就返回一个澄清问句而不是强行输出一个高风险动作。Prompt模板的核心逻辑是这么组织的你是意图识别引擎负责判断用户请求的意图和参数。 可选意图如下严格遵守 - query_balance查询账户/余额 - query_bill查询账单明细 - refund_apply申请退款 - cancel_order取消订单需要order_id - set_reminder设置提醒需要time和content 规则要求 1. 只有当用户表达足够明确时才输出intent 2. 如果信息缺失把need_clarification设为true 3. 判断依据只能来自对话历史不要编造 对话历史 {history} 当前用户输入 {user_input}注意这里不搞思维链自由发挥而是要求模型先生成一个简短判断理由一两句话再输出结构化字段。实测这种带约束理由是很有用的出问题时你能从理由里看出模型是因为用户提到了退款所以选退款还是因为上下文里订单状态是已发货所以改判。这个理由会跟着日志走可观测性就靠它了。4.3 拒识兜底工业级的底线是不能瞎猜L3表面上看是啥也没干实际上它决定了系统的信任底线。我在三种情况下会触发L3L2返回的confidence低于0.6或need_clarificationtrue各层都没有返回任何候选意图用户对同一问题反复表达但意图始终不清晰。L3的动作也很简单不是直接转人工而是优先用澄清问句交互。比如您是想要查询账单还是办理退款最多两轮澄清仍无法确定则转人工客服或记录工单。这里有一个容易犯的错总想着把无法分类也做成一个意图放进模型里。我最初把其他/unknown当成一个类别参与训练结果这个类别反而吸收了各种垃圾样本把模型的置信度分布搞坏了。后来改成独立逻辑不参与分类训练只在分数不达标时走到L3效果反而好了。拒识不是一类意图是一条单独的出口。4.4 与Agent Harness的边界意图识别不负责工具编排顺着热搜词里harness和agent区别这个话题多讲一句很多Agent开发者在设计时会把意图识别和后续的工具编排混在一起。我在架构上把它们严格拆开——意图识别层只输出用户意图和槽位至于调哪个工具、按什么顺序调用、失败后怎么重试那是Harness工具编排层的事务。为什么要拆因为两个关注点完全不同意图识别关心用户想干什么Harness关心系统怎么执行。如果让意图识别直接输出工具调用序列一是有安全风险模型容易选择不该调的工具二是意图体系会被工具变化绑架——改一个工具名整个意图模型都得跟着改。拆开之后工具升级、新增工具都不会影响意图识别的稳定性。这也是工业级和能跑起来的重要区别。5. 分层漏斗的可观测性建设没有数据优化就是玄学5.1 关键指标每个漏斗层的体检报告漏斗设计好了如果每层没有埋点你根本不知道流量在哪一层被拦截、在哪一层漏掉了。我的做法是在每层出口统一打点日志结构里带上三个核心属性query_id全链路唯一的请求ID从进入L0到最后返回所有日志都带它layer_id标记当前是L0/L1/L2/L3中哪一层hit_or_pass命中返回还是交给下一层继续。然后聚合出这一组周期性观察的指标指标定义我的经验值L0命中率规则层直接返回占比45%-55%L1召回率语义层命中意图占比35%-45%L2调用量占比走到LLM层占比5%-10%L3兜底率拒识/澄清/转人工占比3%-5%P95延迟全链路95分位耗时500ms有LLM时的全链路单次请求成本平均Token消耗折合费用稳定下降这组指标一出来架构的健康度一目了然。比如某一天L0命中率从50%掉到38%要么是词典覆盖出了漏要么是新版本上线改了词表加载逻辑。没有漏斗分层这种波动在单模型方案里根本无从查起。5.2 Badcase回流机制漏斗的自我进化闭环意图识别上线后真正的持续优化靠的是badcase回流。我在L3和用户反馈通道都设置了抽样日志L3里转人工的case全量留存L0-L2虽然命中了但用户事后给了这不是我想问的这类负反馈的也全量进待分析队列。每周固定做一次badcase分析做法是把一周的badcase按最终落到哪一层分组属于L0的case检查词典和规则是否可以覆盖属于L1的case检查是否需要补充意图模板或调整相似度阈值属于L2的case检查prompt是否有歧义、槽位定义是否有冲突高频新表达补充进对应层。这个闭环跑起来之后L0L1的合计命中率以每周1-2个百分点的速度缓慢但持续上升。半年下来LLM调用量又降了一档。漏斗本身不会自动变好变好靠的是每一层的badcase都在正确的层被人看见、被人修掉。5.3 动态阈值配置不能每次调阈值都改代码我前面提到相似度阈值不能设死这里展开说。线上意图表达会随时间漂移——比如某个时间段大量用户说帮我把这笔费用取消掉而你的模板库里取消订单的模板全是取消订单退单这类词就很可能导致相似度下降误判率上升。我的解决方式是把阈值做成配置中心的动态参数不发布服务就可以调。具体配置项包括L1相似度阈值默认0.72L1冲突仲裁的差异阈值默认0.05L2的confidence阈值默认0.6。调阈值时只做单参数变更同时观察L3兜底率和用户澄清后的转化率。不要一次调多个参数否则出了问题定位不到是谁起的作用。6. 线上实战踩过的坑与调优心得6.1 坑一意图体系粒度失控模型一层直接废掉我最初设计意图列表时为了让每个用户的请求都能被准确归类意图定义得非常细光查询就拆出了查询余额查询账单查询积分查询订单状态查询优惠券等十几个。结果L1向量召回的候选之间相似度全都挤在一起0.72的阈值怎么调都区分不开L2的prompt也变得巨大模型输出准确率反而下降。后来我悟了意图识别分层漏斗的意图粒度应该是动作粒度而不是业务对象粒度。查询余额和查询账单本质上都是查询这个动作业务对象可以放到槽位里提取。于是我把上百个意图压到了40个左右的动作类意图剩下的细节全部放到slot里。模型精度立刻回暖L1的区分度也上来了。6.2 坑二为了KPI调低阈值把系统搞崩有段时间L3兜底率有点高产品同学建议把阈值调低一点让更多case走L1直接命中减少转人工量。我把L1相似度阈值从0.72调到0.65全量指标一跑L3兜底率确实降了但误识别的case涨了两倍多用户侧负反馈飙升。原因不难理解0.65-0.72之间聚集了大量语义模糊的case这些case本来就应该走L2让LLM仔细判断不是靠抬低门槛硬接。漏斗存在的意义是让每一层的接住都建立在对等的能力之上。强行让能力弱的层去处理它hold不住的流量结果必然反弹。从那以后我们调任何阈值前先看badcase分布而不是只看单一兜底率数字。6.3 坑三长尾意图的覆盖率永远要靠模板而非模型另一个我反复踩的坑总以为加了向量召回和大模型意图模板就不用持续维护了。事实是线上每周都会出现新的热点表达行业的新品名、新活动话术、新梗比如帮我把星期一的会议改为线上这种表达方式几十种变体向量模型覆盖率有限。每周补充新的意图模板看起来是最笨的活实际却是性价比最高的优化手段。L0和L1的覆盖率是运营出来的不是调参调出来的。模板库的版本管理、每周badcase回流的新表达入库是漏斗能否长期稳定运转的底层保障。6.4 踩坑后的心得并发、缓存与降级策略漏斗还是要面对高并发的实际场景。我在压测时发现L1的向量检索在QPS突破200后CPU和内存压力都不小。于是加了两个策略结果缓存对高频query的Top-K结果做LRU缓存key是query文本的hash缓存时间5分钟。热门表达直接命中缓存L1的实际检索QPS降了一半以上。L2降级如果LLM服务响应超时不返回错误而是退回L1的最佳候选并在响应里标记model_degradedtrue。这样用户在高峰期体验不会直接崩掉只是意图确定的精度稍微降一点。工业级系统讲的是活下来不是每次都最好。这套分层漏斗跑了一年多我最大的体会是架构的复杂度永远服务于可维护性。一个有清晰分层的系统出问题时你知道去哪一层找原因一个把所有逻辑塞进单一模型的系统出了问题只能盲猜。先把L0和L1做扎实再让L2去处理真正需要理解的流量这条路对任何规模、任何领域的意图识别需求都适用。最后再分享一个小技巧每层返回的意图候选里永久保留备选意图字段即使最终置信度不够也会带出来。这个字段在用户澄清时特别有用——你可以直接问您是想做A还是B而不是傻乎乎地问请问您想做什么。有时候让用户做选择题比让系统做判断题管用得多。