意图路由:用5个Token决定该找谁

8 阅读7分钟

意图路由:用5个Token决定该找谁

SmartInspector架构解读系列第2篇。上一篇聊了CLI工具的整体架构,这篇聚焦第一个核心节点——意图路由。

先看问题

SmartInspector不是一个单Agent工具。用户输入一句话,背后有5条可能的执行路径:

  1. full_analysis:全量分析流水线(采集→分析→归因→报告)
  2. startup:冷启动分析(特殊的全量分析变体)
  3. explorer:源码搜索(grep/glob/read)
  4. android:设备性能采集(trace/FPS/CPU)
  5. end:闲聊、建议、引导

用户的输入千奇百怪。有人说"帮我全面分析一下",有人说"搜一下LazyForEach",有人说"测一下FPS",还有人说"怎么优化列表滑动"。

这就需要一个路由节点,快速判断用户意图,把请求分到正确的Agent。

方案选型:为什么用LLM而不是规则

第一反应是写规则:关键词匹配呗。"全面分析" → full_analysis,"搜索" → explorer。

写了几十条规则后发现不对劲:

  • "分析一下这份数据"和"分析一下页面性能"都是"分析",但前者应该走analyze(已有数据),后者应该走end(没有数据,先引导用户)
  • "启动耗时分析"应该走full_analysis,但"怎么优化启动"应该走end
  • "采集trace"走android,"全面分析"也要采集trace但走full_analysis

规则的边界case太多,维护成本比写一个prompt还高。而且每加一个意图分支,规则就要改一遍。

最终选择用LLM做分类——准确说,用一个极轻量的LLM调用做分类。

核心实现:一个Prompt + 5个Token

整个意图路由的实现浓缩在一个文件里,核心逻辑不到100行。

Prompt设计

_ROUTE_PROMPT = """Classify this user message. Reply with ONE word only.

Categories (pick ONE):
- full_analysis : wants a COMPLETE performance analysis pipeline
- explorer : wants to SEARCH or READ source code
- android : wants to COLLECT or ANALYZE performance from Android device
- analyze : deep interpretation of an ALREADY EXISTING perf JSON summary
- end : general Q&A, advice, or vague analysis request WITHOUT existing data

CRITICAL:
- If the user wants the full pipeline → MUST be full_analysis
- 启动/冷启动 related analysis MUST be full_analysis
- If the user mentions 源码/代码/搜索/查看文件 → MUST be explorer
- If the user says 分析性能 but has NOT provided perf data → MUST be end
- analyze should ONLY be used when user explicitly references existing perf data

Examples:
- "帮我全面分析一下这个页面的性能" → full_analysis
- "分析冷启动耗时" → full_analysis
- "搜索一下 LazyForEach 的实现" → explorer
- "采集一下 trace" → android
- "你好" → end
- "怎么优化列表滑动" → end
- "分析一下刚才采集的这份数据" → analyze

Reply with exactly one word: full_analysis explorer android analyze end"""

几个设计要点:

第一,中英混合prompt。 模型能理解中文意图,但分类标签用英文(full_analysis),这样解析更稳定——不用担心模型返回"全量分析"还是"完整分析"还是"全面分析"。

第二,CRITICAL段落比Examples更重要。 起初我只有Categories和Examples,准确率大约85%。加了CRITICAL段落后,边界case的准确率明显提升。原因是Examples覆盖不了所有情况,但规则性的约束可以泛化。

第三,Examples是few-shot,不是穷举。 选的例子都是真实用户输入过的,不是拍脑袋想的。每遇到一个分类错误的case,就把对应的输入-输出加到Examples里。

为什么max_tokens=5就够了

_route_llm = ChatOpenAI(**get_llm_kwargs(temperature=0, max_tokens=5))

模型只需要输出一个词:full_analysisexplorerandroidanalyzeend。最长的full_analysis是12个字符,按tokenize大约2-3个token。设5个token足够覆盖,还有余量应对模型偶尔多输出一个空格或换行。

为什么要限制?三个原因:

  1. 成本。每次用户输入都会触发路由调用,这是最高频的LLM调用。max_tokens=5意味着每次调用输出成本几乎为零(主要是input token的钱)。
  2. 速度。模型生成越少token,响应越快。路由是整个pipeline的第一步,延迟直接影响用户体验。
  3. 防幻觉。限制输出长度,模型就没法输出一段废话。如果分类错了,至少错得干净利落。

实际的token消耗数据:路由调用的input平均50-80 tokens(system prompt + user message),output固定1-3 tokens。对比下游的分析Agent(input 2000+ tokens,output 500+ tokens),路由的成本可以忽略不计。

解析逻辑

response = llm.invoke(orch_input)
raw = response.content.strip().lower()

# 提取有效标签
valid = {rd.value: rd for rd in RouteDecision}
decision = RouteDecision.END
for v, rd in valid.items():
    if v in raw:
        decision = rd
        break

用的是子串匹配而不是精确匹配。模型偶尔会在分类词前后加内容,比如"Category: full_analysis"或者"I choose full_analysis"。用in匹配比==匹配容错性高得多。匹配不到就默认走end(fallback),由通用LLM节点处理。

路由准确率调优过程

这个路由不是一次写对的,经历了三轮迭代。

V1:纯分类标签,没有Examples

Categories: full_analysis, explorer, android, analyze, end
Classify the user message.

准确率约75%。问题出在边界case:模型分不清"分析"和"全面分析",分不清"查看代码"和"分析性能"。

V2:加了Examples

Examples:
- "帮我全面分析一下" → full_analysis
- "搜索一下 xxx" → explorer
- "采集trace" → android

准确率提升到85%。常见case解决了,但还是有问题:

  • "分析冷启动耗时"被分到end而不是full_analysis
  • "分析一下刚才采集的数据"被分到end而不是analyze

根本原因:模型把所有带"分析"的都倾向分到end,因为end的描述里有"vague analysis request"。

V3:加CRITICAL规则 + 调整描述

在V2基础上加了CRITICAL段落,明确每个容易混淆的case的处理规则。同时调整了end的描述,从"vague analysis request"改为"general Q&A, advice, or vague analysis request WITHOUT existing data",把"without existing data"这个条件前置。

最终准确率稳定在95%以上。剩下5%主要是非常模糊的输入(比如单独一个"分析"),这种case走fallback也不影响体验。

冷启动检测:路由之上的路由

还有一个特殊情况。用户说"分析冷启动耗时",应该走full_analysis,但冷启动分析需要特殊的采集流程(不等app连接、直接抓取启动过程)。

所以在路由之后加了一层关键词检测:

_STARTUP_KEYWORDS = (
    "冷启动", "启动耗时", "启动时间", "启动性能", "cold start",
    "启动分析", "启动优化", "app启动", "应用启动",
)
user_msg_lower = user_msg.lower()
skip_wait = any(kw in user_msg_lower for kw in _STARTUP_KEYWORDS)
if skip_wait:
    decision = RouteDecision.STARTUP

这里用关键词匹配是合理的——冷启动的关键词是有限集合,不会频繁变化。LLM负责粗分类,关键词负责细分类,各干各的。

整体架构:路由在Graph中的位置

START → orchestrator → [collector | explorer | android_expert | perf_analyzer | fallback]
                           ↓
                       analyzer → [attributor → reporter | startup | END]

orchestrator是所有请求的入口。它不干任何业务逻辑,只做一件事:判断该找谁。

def route_from_orchestrator(state: AgentState) -> str:
    decision = state.get("_route", "end")
    mapping = {
        RouteDecision.FULL_ANALYSIS: "collector",
        RouteDecision.STARTUP: "collector",
        RouteDecision.ANDROID: "android_expert",
        RouteDecision.ANALYZE: "perf_analyzer",
        RouteDecision.EXPLORER: "explorer",
        RouteDecision.END: "fallback",
    }
    return mapping.get(decision, "fallback")

路由结果存在AgentState["_route"]里,下游节点通过这个字段决定自己的行为。比如analyzer节点会根据_routeSTARTUP还是FULL_ANALYSIS来决定后续走startup还是attributor。

实际效果

指标数值
路由调用延迟200-500ms
单次路由token消耗~60 input + ~2 output
单次路由成本≈ ¥0.0001
分类准确率~95%
fallback兜底匹配失败时走通用LLM回复

对比一下,如果用规则匹配:

  • 维护成本:每加一个意图分支要改规则
  • 准确率:边界case容易出错,需要持续补规则
  • 开发速度:先写规则再测case,不如直接调prompt

LLM路由的代价是多花几分钱的token费用,换来的是不用维护规则、自动适应新case、开发效率高得多。

一句话总结

意图路由的本质是用一次极轻量的LLM调用(max_tokens=5),把用户的自然语言输入映射到确定性的执行路径。Prompt engineering的重点不是让模型更聪明,而是用CRITICAL规则和few-shot examples消除歧义,让简单任务保持简单。


下一篇:多Agent协作——采集→分析→归因→报告,四个Agent怎么串起来、数据怎么流转、一个挂了怎么办。