先问一个问题:Agent 为什么需要意图识别?
直觉上,把用户输入直接丢给 LLM 就好了------LLM 自己会判断该怎么做。
这个思路在工具少、场景单一的时候没问题。但当 Agent 同时拥有搜索工具、代码工具、计算工具、知识库工具时,问题就来了:同样的 LLM,配上不同的工具集和 system prompt,在同一个任务上的表现差距极大。
专门回答知识问答的 Agent,会用知识库工具、深入解释概念;而一个通用 Agent 面对同样的问题,可能随手搜索了一下就返回了一个搜索摘要。
意图识别层做的事是:在把请求分发给专项 Agent 之前,先判断用户真正想要什么。这样每个专项 Agent 只需要把自己的那件事做好。
方案对比:关键词匹配 vs LLM 分类
先做一个对比实验,把结论建立在数据上。
关键词匹配的问题
传统 NLP 项目常用关键词规则:
python
_KEYWORD_RULES = {
"search": ["搜索", "查一下", "找找", "最新", "新闻"],
"code": ["代码", "写一个", "函数", "实现", "bug"],
"calculate": ["计算", "多少钱", "等于", "加", "减"],
"qa": ["是什么", "为什么", "怎么", "介绍", "解释"],
}
def keyword_classify(text: str) -> str:
scores = {k: 0 for k in _KEYWORD_RULES}
for intent, keywords in _KEYWORD_RULES.items():
for kw in keywords:
if kw in text:
scores[intent] += 1
best = max(scores, key=lambda k: scores[k])
return best if scores[best] > 0 else "unknown"
用真实输入测一下:
scss
△ [清晰-搜索]
输入:LangChain 最近发布了什么新版本?
关键词 → unknown ← 没有命中任何关键词
LLM → search (80%)
△ [清晰-代码]
输入:帮我写个冒泡排序算法
关键词 → unknown ← "写" 不在列表里
LLM → code (90%)
[清晰-问答]
输入:Transformer 模型的原理是什么
关键词 → qa ← 命中"是什么"
LLM → qa (90%)
[口语化计算]
输入:2 加 3 乘以 4 等于多少
关键词 → calculate ← 命中"等于"、"加"
LLM → calculate (100%)
关键词方案的致命缺陷 :只有当用户恰好使用了规则里的关键词,分类才会有结果。稍微换一种说法------"LangChain 出新版了吗"、"帮我实现一个排序"------就直接返回 unknown。
维护这个规则表的成本也非常高:新业务场景、新用户表达习惯,都需要手动更新规则。
LLM 分类器
换成用 LLM 来做分类,手动解析 JSON 输出:
python
def llm_classify(text: str, history: list[str] | None = None) -> IntentResult:
system_prompt = f"""你是意图分类器,将用户输入分类到以下 5 种意图之一:
search --- 搜索信息、查询最新动态
code --- 编写/调试/优化代码
calculate --- 数学计算、数值换算
qa --- 知识问答、概念解释
clarify --- 输入不清晰、指代不明
规则:
1. 如果有对话历史,"优化一下""改一下"等指令优先结合历史上下文判断
2. 置信度低于 0.6 时,统一返回 clarify,不要猜测
必须只返回以下格式的 JSON,不要包含任何其他文字:
{{"intent": "<意图>", "confidence": <0到1的数字>, "reasoning": "<一句话理由>"}}"""
response = llm.invoke([SystemMessage(system_prompt), HumanMessage(text)])
raw = response.content if isinstance(response.content, str) else str(response.content)
# 从响应中提取 JSON(兼容模型在 JSON 前后附加文字的情况)
try:
match = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
data = json.loads(match.group() if match else raw)
...
except Exception:
# 兜底:从原始文本中找意图关键词
for candidate in ("search", "code", "calculate", "qa"):
if candidate in raw.lower():
return IntentResult(intent=candidate, confidence=0.5, reasoning="(JSON解析降级)")
return IntentResult(intent="clarify", confidence=0.3, reasoning="(无法解析LLM输出)")
为什么手动解析而不是用
with_structured_output?实测中 GLM-4-Flash 的
with_structured_outputJSON schema 模式有时只返回部分字段,导致 PydanticValidationError。手动解析 + 正则提取 JSON + 兜底逻辑,兼容性更好,也是生产中常用的做法。
LangGraph 意图路由:把请求分发给专项 Agent
意图识别出来之后,怎么把请求路由给对应的专项 Agent?LangGraph 的条件边(Conditional Edges)天然适合做这件事。
图结构
css
START
│
▼
[classify 节点] ← LLM 分类,输出 intent + confidence
│
├─ "search" ──→ [search_agent] web_search 工具
├─ "code" ──→ [code_agent] run_code 工具
├─ "calculate" ──→ [calculator_agent] calculator 工具
├─ "qa" ──→ [qa_agent] knowledge_base 工具
└─ "clarify" ──→ [clarify_agent] (无工具,生成澄清问题)
│
END
代码实现:
python
class RouterState(TypedDict):
user_input: str
conversation_history: list[str]
intent: str
confidence: float
reasoning: str
response: str
def classify_node(state: RouterState) -> dict:
result = llm_classify(state["user_input"], state.get("conversation_history"))
print(f" [分类] 意图={result.intent} 置信度={result.confidence:.0%}")
return {"intent": result.intent, "confidence": result.confidence, "reasoning": result.reasoning}
def route_by_intent(state: RouterState) -> str:
return state["intent"] # 直接作为下一个节点名
def build_intent_router():
graph = StateGraph(RouterState)
graph.add_node("classify", classify_node)
graph.add_node("search", _make_specialist_node("search_agent", [web_search]))
graph.add_node("code", _make_specialist_node("code_agent", [run_code]))
graph.add_node("calculate", _make_specialist_node("calculator_agent",[calculator]))
graph.add_node("qa", _make_specialist_node("qa_agent", [knowledge_base]))
graph.add_node("clarify", clarify_node)
graph.set_entry_point("classify")
graph.add_conditional_edges(
"classify",
route_by_intent,
{"search": "search", "code": "code", "calculate": "calculate",
"qa": "qa", "clarify": "clarify"},
)
for node in ["search", "code", "calculate", "qa", "clarify"]:
graph.add_edge(node, END)
return graph.compile()
每个专项 Agent 用工厂函数创建,绑定不同工具集和 system prompt:
python
def _make_specialist_node(node_name: str, tools_list: list, system_text: str):
specialist = create_react_agent(model=specialist_llm, tools=tools_list)
def _node(state: RouterState) -> dict:
print(f" [路由] → {node_name}")
result = specialist.invoke({
"messages": [
("system", system_text),
("user", state["user_input"]),
]
})
...
return _node
路由实测结果
ini
[信息搜索] LangGraph 最近发布了什么新版本?
[分类] 意图=search 置信度=80%
[路由] → search_agent
[回答] LangGraph 0.2 引入了 functional API,支持更灵活的 Agent 编排方式。
[知识问答] RAG 是什么,它解决了 LLM 的哪些问题
[分类] 意图=qa 置信度=90%
[路由] → qa_agent
[回答] RAG = 从知识库检索相关文档 + 注入 Prompt + LLM 生成。
核心价值:减少幻觉、注入实时知识。...
[模糊输入] 帮我弄一下
[分类] 意图=clarify 置信度=50%
[路由] → clarify_agent
[回答] 您好,请问您需要我帮您做什么具体的事情呢?
置信度阈值与澄清机制
意图分类不是非黑即白的------"帮我改一下"这种输入,LLM 也无法确定用户在说什么。不猜测,直接问,是更好的策略。
通过置信度阈值控制路由行为:
python
# 置信度 < 0.6 → LLM 应返回 clarify 意图
# clarify_node:不调工具,直接生成澄清问题
def clarify_node(state: RouterState) -> dict:
resp = llm.invoke([
SystemMessage(
"用户的表达不够清晰。"
"请用一句简洁友好的中文问句请用户说清楚他的需求。不要猜测,直接问。"
),
HumanMessage(state["user_input"]),
])
return {"response": resp.content.strip()}
实测效果:
css
[低置信-歧义] 输入:「帮我改一下」
置信度:50% 意图:clarify
澄清回答:请问您需要修改什么内容呢?
[低置信-指代] 输入:「再来一个」
置信度:50% 意图:clarify
澄清回答:您具体想了解哪方面的信息或帮助呢?
[低置信-残缺] 输入:「那个怎么弄」
置信度:50% 意图:clarify
澄清回答:您是想了解具体操作步骤吗?能详细描述一下您需要帮助的问题吗?
对比高置信度的处理:
css
[高置信-明确] 「2 的 10 次方是多少」
置信度:100% 意图:calculate → 直接计算
[高置信-明确] 「Python 最新版本有什么新特性」
置信度:80% 意图:search → 直接搜索
多轮对话意图跟踪:上下文让模糊指令变清晰
这是本篇最有价值的一个发现。
场景 A:先进行一段代码对话,然后发出模糊指令:
python
# 已有代码对话历史:
用户:帮我写一个 Python 函数,计算列表的平均值
助手:def average(lst): return sum(lst) / len(lst) if lst else 0.0
用户:这个函数如果列表里有非数字怎么办
助手:可以用 try/except TypeError 捕获...
对"优化一下"这三个字的识别:
css
输入:「优化一下」
✗ 无历史 → clarify (50%) 输入不清晰,无法判断意图。
✓ 有历史 → code (80%) 用户请求优化代码,结合历史上下文,判断为code意图。
"换成英文注释"的识别对比:
scss
输入:「换成英文注释」
✗ 无历史 → search (50%) (JSON解析降级,分类失败)
✓ 有历史 → code (100%) 用户请求改写代码注释,明确是代码相关任务。
场景 B:计算话题延续:
scss
# 历史:用户:2 的 10 次方是多少 → 助手:2 ** 10 = 1024
输入:「再乘以 3」
✗ 无历史 → calculate (90%) (这个恰好关键词够明确)
✓ 有历史 → calculate (100%) 用户延续上一轮计算
实现上,把最近 4 条对话历史注入到分类 Prompt 里:
python
history_section = "\n\n最近对话历史:\n" + "\n".join(f" {h}" for h in history[-4:])
system_prompt = f"""你是意图分类器...
规则:
1. 如果有对话历史,"优化一下""改一下"等指令优先结合历史上下文判断指代的是什么
...{history_section}"""
真实运行中的有趣发现
运行 5 个 Demo 过程中,遇到了几个值得记录的现象:
现象一:JSON 解析失败导致错误路由
Demo 5 的 Turn 2 中,"帮我用它写一个最简单的 Hello World Agent"------明确的代码请求------却被路由到了 clarify_agent。原因是 GLM-4-Flash 这次没有输出标准 JSON,兜底逻辑找不到关键词,返回了 clarify (30%)。
css
Turn 2 帮我用它写一个最简单的 Hello World Agent
[分类] 意图=clarify 置信度=30%
理由:(无法解析LLM输出)
[路由] → clarify_agent
[回答] 您是想让我帮您用某个编程语言或工具写一个简单的"Hello World"示例吗?
有趣的是,澄清问题("您是想用某个编程语言写 Hello World 示例吗?")在逻辑上是正确的------模型理解了请求,只是没有输出正确格式的 JSON。
现象二:错误路由下专项 Agent 的表现
"帮我写一个 Python 函数,计算列表的平均值" 因 JSON 解析降级被分类为 calculate (50%),路由到了 calculator_agent。但 calculator_agent 的工具只有计算器,无法写代码------它实际上直接用 LLM 写出了函数,没有调用工具:
python
[路由] → calculator_agent(本应是 code_agent)
[回答] def calculate_average(numbers):
if not numbers:
return 0
return sum(numbers) / len(numbers)
这说明:即使路由错误,配置了合适 system prompt 的专项 Agent 也有一定容错能力。但不能依赖这个------需要在分类层做好稳定性。
现象三:模型语言偏移
部分 reasoning 字段的输出是英文("The user is asking for a mathematical calculation."),即使用户输入是中文。GLM-4-Flash 在某些情况下会用英文思考,这不影响功能,但在需要把 reasoning 展示给用户时要注意。
现象四:多轮历史的效果最显著
整个 Demo 中,影响最大的改进是把对话历史注入到分类 Prompt。"优化一下"这三个字,在没有历史时 100% 会触发澄清;有代码对话历史后,识别准确率大幅提升,且置信度从 50% 跳到 80%。这在生产系统中意味着:对话历史不只是给 LLM 提供上下文,它直接影响意图路由的准确性。
工业级落地:三层分类 + OOD 拒识 + 数据闭环
前面的 Demo 用大模型直接做意图分类,适合演示和快速验证,但不是生产系统的最佳实践。
真实的工业意图识别系统,通常是这样的三层漏斗结构:
yaml
用户输入
│
▼
┌─────────────────────────────┐
│ Layer 1: 规则路由 │ < 1ms
│ 精确关键词 + 正则 + 有限状态机│ ← 处理 ~5% 的明确指令
└─────────────┬───────────────┘
│ 未命中
▼
┌─────────────────────────────┐
│ Layer 2: 微调小模型 │ 10~50ms
│ 5B/7B SLM(Qwen/GLM/Llama)│ ← 处理 ~90% 的常规意图
└─────────────┬───────────────┘
│ 低置信 / 长尾
▼
┌─────────────────────────────┐
│ Layer 3: 大模型兜底 │ 100~500ms
│ GPT-4o / Claude / Qwen-72B │ ← 处理 ~5% 的复杂边界场景
└─────────────┬───────────────┘
│
▼
OOD 拒识层
(过滤与系统无关的请求)
Layer 1:规则路由(<1ms)
第一层只处理语义确定、表达固定的指令,不涉及语义理解,直接做字符串匹配或有限状态机跳转:
python
RULE_ROUTES = {
r"^转人工$|^人工服务$|^我要投诉$": "human_handoff",
r"^打开.+应用$|^帮我开启.+": "app_launch",
r"^(退出|返回|取消|不了)$": "cancel",
r"^(你好|hi|hello|在吗)$": "greeting",
}
def rule_route(text: str) -> str | None:
for pattern, intent in RULE_ROUTES.items():
if re.fullmatch(pattern, text.strip()):
return intent
return None # 未命中,交给下一层
优点:零 LLM 调用,延迟 <1ms,可预测,逻辑可审计。
适用场景:客服系统的"转人工"、智能助手的快捷指令、开关控制类操作。
Layer 2:微调小模型(10~50ms)
第一层未命中的请求,交给专门微调过的 5B 或 7B 参数的小型语言模型(SLM):
- 模型选型:Qwen2.5-7B-Instruct、GLM-4-9B、Llama-3.1-8B 微调版
- 训练数据:业务真实日志标注 + 数据增强,几千到几万条样本即可达到生产水平
- 部署成本:单张 A10(24G 显存)可部署 7B 模型,推理 batch 下 QPS 可达 100+
- 准确率:覆盖 90% 以上的常规意图,准确率通常在 92%~97%
python
# 伪代码示意:调用部署在本地的微调小模型
def slm_classify(text: str, history: list[str]) -> IntentResult:
response = slm_client.chat(
messages=build_classify_prompt(text, history),
temperature=0.1, # 分类任务用低温度
max_tokens=64, # 只需要输出意图 + 置信度
)
return parse_intent(response)
关键指标:平均延迟 20~50ms,相比大模型降低 80% 以上的推理成本。
Layer 3:大模型兜底(100~500ms)
小模型置信度低、遇到长尾表达、或涉及复杂多意图的请求,升级到大模型处理:
- 触发条件:SLM 置信度 < 0.6,或输入超出小模型训练分布
- 流量占比:通常只有 5~10% 的请求会升级
- 成本控制:由于升级比例小,整体成本仍然可控
三层对比:
| 层级 | 延迟 | 成本 | 流量占比 | 适用场景 |
|---|---|---|---|---|
| 规则路由 | <1ms | 极低 | ~5% | 固定指令、快捷操作 |
| 微调小模型 | 10~50ms | 低 | ~90% | 常规意图主分类 |
| 大模型兜底 | 100~500ms | 高 | ~5% | 长尾、复杂、边界场景 |
OOD 拒识:过滤无效请求
OOD(Out-of-Distribution)拒识 是意图识别中容易被忽视但极为重要的模块:它负责识别并拒绝超出系统服务范围的请求。
典型场景:用户对一个购物助手说"帮我写一首诗"------这是合法的自然语言,但不在服务范围内。如果不做拒识,这个请求会进入意图分类,产生一个置信度很低的错误路由。
python
# OOD 拒识的几种常见方案
# 方案 A:相似度阈值(基于 embedding)
def ood_reject_by_embedding(text: str, threshold: float = 0.5) -> bool:
emb = embed_model.encode(text)
# 计算与业务意图样本库的最大相似度
max_sim = max(cosine_sim(emb, sample) for sample in intent_samples)
return max_sim < threshold # True = OOD,需要拒识
# 方案 B:意图置信度兜底(三层都低置信则拒识)
def should_reject(intent: str, confidence: float) -> bool:
return intent == "clarify" and confidence < 0.4
拒识后的响应要友好、引导性强,而不是直接报错:
css
"抱歉,我目前只能帮您处理 [XX 相关] 的问题,
您描述的需求超出了我的服务范围。
您可以尝试:[相关建议] 或 [转人工]"
数据-评测闭环:意图识别的持续进化
意图识别系统上线只是开始,持续优化依赖数据飞轮:
arduino
线上请求
│
▼
日志采集 ──→ bad case 挖掘 ──→ 标注修正
│ │
│ ▼
用户行为信号 训练数据更新
(点击/重试/转人工) │
│ ▼
└──────────────────→ 小模型增量微调
│
▼
金标准评测集验证
│
▼
灰度上线 → 全量
三个核心实践:
① 每日 bad case 修复:从线上日志中挖掘路由错误的请求(用户投诉、重试、转人工等负向信号),人工标注后加入训练集,次日迭代。
② 高频意图设置金标准(Golden Set):对于出现频次最高的 Top 20 意图,建立固定的金标准测试集(每个意图 50~100 条用例),每次模型更新前必须在金标准上通过,防止修复长尾 case 破坏高频场景。
③ 捕捉用户行为信号:不依赖人工标注,用用户行为反推意图识别质量:
- 用户回复"你理解错了" → 路由错误信号
- 用户在机器人回答后立即转人工 → 质量差信号
- 用户点击了推荐结果 → 路由正确信号
- 用户直接结束会话 → 可能命中或未命中,需结合上下文判断
这套闭环能在不增加人工标注成本的前提下,持续提升意图识别准确率。
意图路由设计清单
实现一套可靠的意图路由层需要考虑的点:
分类器设计
- 意图类型不超过 10 个(太多难以准确区分)
- 每种意图的描述要互斥,边界清晰
- 加入
clarify作为兜底意图(置信度低时的安全出口) - 输出格式要求 JSON,并做手动解析 + 兜底逻辑
路由图设计
- 每个专项 Agent 只配最小工具集
- system prompt 明确专项 Agent 的职责范围
-
clarify节点只生成问题,不调工具,不猜测
多轮对话
- 最近 3-5 条历史注入到分类 Prompt
- 历史过长时截断(建议不超过最近 4 条)
- 区分"话题延续"和"话题切换"
稳定性
- JSON 解析失败时有兜底逻辑(关键词兜底 → clarify 兜底)
- 监控错误路由率(intent == clarify 但实际上意图明确)
- 高并发时分类节点是瓶颈,可考虑缓存相似输入的分类结果
本篇小结
几个核心观点:
- 关键词方案在生产中很脆弱:用户说话不可预测,维护规则表成本极高,自然语言输入覆盖率低
- LLM 分类需要健壮的 JSON 解析 :模型输出不稳定,手动解析 + 兜底逻辑比
with_structured_output更可靠 - 置信度阈值是安全出口:低置信度时澄清而不是猜测,用户体验远好于错误路由
- 对话历史是意图识别的放大器 :"优化一下"在没有历史时无法识别,有代码历史后直接识别为
code (80%) - 专项 Agent 的价值:每个 Agent 专注一件事,不论是系统提示还是工具集都针对性强,质量更好
下一篇:记忆管理 ------Agent 的四种记忆类型(感觉/工作/情景/语义),以及如何用 LangGraph 的 checkpointer 和 store 让 Agent 真正记住用户说过的话。
参考资料
- LangGraph StateGraph 文档
- LangGraph 条件边(Conditional Edges)
- 本系列完整代码:
agent-04-intent-routing/intent_routing_demo.py
欢迎来我的个人主页找到更多有用的知识和有趣的产品