意图路由:用5个Token决定该找谁
SmartInspector架构解读系列第2篇。上一篇聊了CLI工具的整体架构,这篇聚焦第一个核心节点——意图路由。
先看问题
SmartInspector不是一个单Agent工具。用户输入一句话,背后有5条可能的执行路径:
- full_analysis:全量分析流水线(采集→分析→归因→报告)
- startup:冷启动分析(特殊的全量分析变体)
- explorer:源码搜索(grep/glob/read)
- android:设备性能采集(trace/FPS/CPU)
- 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_analysis、explorer、android、analyze、end。最长的full_analysis是12个字符,按tokenize大约2-3个token。设5个token足够覆盖,还有余量应对模型偶尔多输出一个空格或换行。
为什么要限制?三个原因:
- 成本。每次用户输入都会触发路由调用,这是最高频的LLM调用。max_tokens=5意味着每次调用输出成本几乎为零(主要是input token的钱)。
- 速度。模型生成越少token,响应越快。路由是整个pipeline的第一步,延迟直接影响用户体验。
- 防幻觉。限制输出长度,模型就没法输出一段废话。如果分类错了,至少错得干净利落。
实际的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节点会根据_route是STARTUP还是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怎么串起来、数据怎么流转、一个挂了怎么办。