Agent系列(五):意图识别与路由——让 Agent 听懂用户在说什么

先问一个问题: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_output JSON schema 模式有时只返回部分字段,导致 Pydantic ValidationError。手动解析 + 正则提取 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 但实际上意图明确)
  • 高并发时分类节点是瓶颈,可考虑缓存相似输入的分类结果

本篇小结

几个核心观点:

  1. 关键词方案在生产中很脆弱:用户说话不可预测,维护规则表成本极高,自然语言输入覆盖率低
  2. LLM 分类需要健壮的 JSON 解析 :模型输出不稳定,手动解析 + 兜底逻辑比 with_structured_output 更可靠
  3. 置信度阈值是安全出口:低置信度时澄清而不是猜测,用户体验远好于错误路由
  4. 对话历史是意图识别的放大器 :"优化一下"在没有历史时无法识别,有代码历史后直接识别为 code (80%)
  5. 专项 Agent 的价值:每个 Agent 专注一件事,不论是系统提示还是工具集都针对性强,质量更好

下一篇:记忆管理 ------Agent 的四种记忆类型(感觉/工作/情景/语义),以及如何用 LangGraph 的 checkpointerstore 让 Agent 真正记住用户说过的话。


参考资料


欢迎来我的个人主页找到更多有用的知识和有趣的产品

相关推荐
hnult1 小时前
考试云:九重防作弊体系与六大AI能力,打造安全智能在线笔试系统云平台
人工智能·笔记·安全
青椒大仙KI111 小时前
线代讲解0
人工智能·线性代数
可信AI Coding1 小时前
AI产业周报|AI安全需求将爆发式增长
人工智能·ai·大模型
卷毛的技术笔记1 小时前
Java后端硬核实战:用Spring AI Alibaba+Redis给LLM装上“超强记忆中枢”
java·人工智能·redis·后端·spring·ai·系统架构
oo哦哦2 小时前
星链引擎矩阵系统深度解析:AI驱动下的全域智能营销SaaS新范式
大数据·人工智能·矩阵
oo哦哦2 小时前
轻量化内容中台如何破解企业矩阵运营困局?以星链引擎为例的技术解析
大数据·人工智能·矩阵
养肥胖虎2 小时前
完整学习LLM(五):Embedding是什么,为什么文本能变成向量
llm·embedding·rag
我爱cope2 小时前
【Agent智能体6 | 智能体AI评估】
人工智能·职场和发展
Raink老师2 小时前
【AI面试临阵磨枪-68】设计一个端侧(手机 / 浏览器)轻量化 AI Agent 系统
人工智能·面试·智能手机