Skip to content

助手 · 意图识别是怎么做到的(LLM 分诊 → Java 派工) ​

日期: 2026-08-18 | 作者: sunxin(+ Cursor AI 结对讲解) 关联代码: OpenAiCompatibleLlmAdapter.systemPrompt/parseIntent · MockLlmAdapter · ILlmPort · AssistantApplicationService.chat 关联专题: 北极星与终局蓝图 §6.1 · 出行助手全场景流程图 姊妹篇: 一句话如何变成查真实业务(翻译层 vs 查询层 vs 知识层)· 助手-Java编排vs模型编排与FunctionCalling · 助手-大模型幻觉与grounding

TL;DR ​

  • 「意图识别」在本项目 = 让 LLM 把一句话翻译成一个结构化 JSON(intent 枚举 + 目的地/时间/预算等槽位)。模型只干「听懂」,不查库、不写文案。
  • 顺序问题的答案:运行时 LLM 先走(一句话进 → 模型吐 intent JSON 出),然后 Java 读 JSON 再派工。不是「代码先分类再告诉模型」。
  • 但「代码告诉模型」发生在设计期:你在 System Prompt 里写死「有哪些 intent、输出什么 schema、遵守什么规则」——这叫教模型,只发生一次(写 Prompt 时),不在每次请求时。
  • 本项目同时存在两种意图识别实现,都藏在 ILlmPort 后面,这恰好把「代码分类 vs 模型分类」摆在一起给你看:
    • MockLlmAdapter = 代码做意图识别(关键词规则,离线、确定、免费,但脆)。
    • OpenAiCompatibleLlmAdapter = LLM 做意图识别(语义理解,能扛错别字/别名/复杂句,但要花 token)。
  • 意图识别 ≠ 工具调用。模型吐的是「分诊单」(一个 intent 字段),不是 tool_call。谁调工具由 Java 的 if/else 决定(见姊妹篇《编排》)。

目录 ​


1. 场景:为什么第一步是「意图识别」 ​

用户在对话框里可能说:

  • 「想去赛里木湖,7 天内,2000 以内」→ 要查活动
  • 「有什么好玩的推荐吗」→ 要推荐热门
  • 「帮我写篇游记 / 我要退款」→ 做不了,得拒答
  • (将来)「乌鲁木齐这几天天气咋样」→ 要查天气

这些话先要判断「用户到底想干嘛」,才知道下一步走哪条代码。这个「判断想干嘛 + 把关键信息抽出来」的动作,就是意图识别(Intent Recognition / NLU)。

它是整条助手链路的第一个岔路口:判断错了,后面查库、拼文案全错。所以它值得单独讲清楚。

关键认知:在 TourMate 里,意图识别是模型唯一的工作。查活动是 Java 干的、文案是 Java 拼的、天气是 IWeatherPort 查的——模型只负责把「人话」变成「机器能分诊的结构」。


2. 「顺序」到底是怎样的 ​

你问的核心问题:是 LLM 先做意图识别,还是代码先分类再告诉 LLM?

答案要分「设计期」和「运行期」两层看,别混:

2.1 设计期(写代码时,只发生一次) ​

你(通过代码)先告诉模型规则——这就是 System Prompt:

你是 TourMate 出行助手的意图解析器。
你的唯一任务:把用户的一句话解析成结构化的活动搜索意图。
严格只输出 JSON,字段如下:{ intent: SEARCH_ACTIVITIES|RECOMMEND|OUT_OF_SCOPE, destinationKeywords, dateStart, ... }
规则:
1. 只处理「找活动」。写游记/退款/订票 → OUT_OF_SCOPE
2. 把「7天内/本周末」换算成绝对日期
...

这段话在 OpenAiCompatibleLlmAdapter.systemPrompt() 里写死。它相当于「岗前培训」:告诉模型有哪些意图、输出什么格式、边界在哪。这一步是「代码告诉模型」,但它是静态的、设计期的,不是每次请求现算。

2.2 运行期(每次用户说话) ​

用户一句话
    │
    ▼
┌─────────────────────────────────────────────┐
│ ① LLM 先走:System Prompt(岗前培训) + 用户话   │  ← 模型在这里做意图识别
│    → 模型吐出 intent JSON(分诊单)            │
└─────────────────────────────────────────────┘
    │  {"intent":"SEARCH_ACTIVITIES","destinationKeywords":["赛里木湖"],"maxPrice":2000}
    ▼
┌─────────────────────────────────────────────┐
│ ② Java 后走:读 JSON,按 intent 分流派工       │  ← 代码在这里做编排
│    OUT_OF_SCOPE→拒答 / RECOMMEND→推荐 / SEARCH→查库 │
└─────────────────────────────────────────────┘

所以运行时是「LLM 先、代码后」:模型先把话分诊成一张结构化「分诊单」,Java 拿着分诊单去派工。代码不会在模型之前先分类一遍再喂给模型——那样等于做了两遍,没必要。

一句话记住:「教模型」在设计期(写 Prompt),「用模型」在运行期(模型先分诊、代码后派工)。


3. 三种做意图识别的路子(本项目占了两种) ​

意图识别不是只有「上大模型」一条路。历史上有三代做法:

路子怎么做优点缺点本项目
① 规则 / 关键词代码里 if (msg.contains("退款")) → OUT_OF_SCOPE免费、确定、离线可测、零延迟脆:错别字、别名、复杂句一律抓瞎✅ MockLlmAdapter
② 训练一个分类模型标注语料 → 训练 BERT/FastText 分类器比关键词准、可控要标数据、要训练、加新意图要重训❌ 没用(过度工程)
③ LLM 结构化输出把「意图枚举 + schema」写进 Prompt,让大模型一次性分类 + 抽槽语义强、扛错别字/别名/复杂句、加新意图只改 Prompt花 token、有延迟、是概率系统需评测盯✅ OpenAiCompatibleLlmAdapter

本项目的巧妙之处:① 和 ③ 同时存在,都实现同一个 ILlmPort 接口。

                    ┌────────────────────────┐
   业务只认 ────────▶│  ILlmPort              │
   (AssistantApp)   │  extractSearchIntent() │
                    └───────────┬────────────┘
                                │  同一个接口,两种实现
                 ┌──────────────┴───────────────┐
                 ▼                               ▼
   ┌──────────────────────────┐   ┌──────────────────────────────┐
   │ MockLlmAdapter (默认)     │   │ OpenAiCompatibleLlmAdapter     │
   │ = 代码做意图识别(关键词)   │   │ = LLM 做意图识别(DeepSeek)      │
   │ 离线/确定/免费,作评测门禁 │   │ 花 token,扛复杂语义,真机跑     │
   └──────────────────────────┘   └──────────────────────────────┘

这就直接回答了你的疑问——「代码做意图识别」和「LLM 做意图识别」在项目里都真实存在:

  • MockLlmAdapter:用关键词规则做分类(代码分类),无 Key 也能跑,是评测集的确定性回归门禁(24 条确定性用例必须 100%)。
  • OpenAiCompatibleLlmAdapter:把话丢给 DeepSeek 做分类(模型分类),扛「塞木屋河」这种错别字、「乌市」这种别名、「带爸妈看海人均一千五」这种复杂句。

为什么要两套?便宜的兜底 + 贵的兜准。日常 CI / 无网 / 无 Key 用 Mock 保证链路不崩;真机评测用 LLM 验证语义能力。切换只改一个配置 tourmate.assistant.llm.provider(mock / openai-compatible),业务代码零改——这就是 ILlmPort 隔离的价值。


4. 结构化输出:怎么逼模型只吐 JSON ​

大模型天生爱「说人话」,你问它意图它可能回「好的,我觉得您应该是想找赛里木湖的活动呢~」。这没法给代码用。结构化输出(Structured Output) 就是逼它只吐机器能解析的 JSON。三个手段叠加:

  1. Prompt 里给死 schema:明确列出字段名、类型、枚举值,并写「严格只输出 JSON,不要多余文字、不要 markdown 代码块」。
  2. response_format = json_object:OpenAI 兼容协议的开关,从协议层强制返回合法 JSON(本项目 AssistantLlmProperties.jsonObjectResponse 控制,可关以兼容不支持的中转)。
  3. temperature = 0:把随机性压到最低,让「同样的话尽量得到同样的 JSON」——评测能复现的前提。

配合意图抽取是「简单任务」的定性,还关了思考模式(thinking=disabled)、单轮无历史、收紧 max_tokens,把成本压到最低(详见 ADR-039 D2)。


5. 项目里的具体落地 ​

5.1 意图枚举(分诊的「科室」) ​

domain/assistant/model/AssistantIntentType.java:

java
public enum AssistantIntentType {
    SEARCH_ACTIVITIES,  // 找活动:自然语言 → 结构化筛选 → 查库
    RECOMMEND,          // 求推荐:没明确目的地,想随便看看 → 推热门
    OUT_OF_SCOPE        // 越界:写游记/退款/订票/规划 → 拒答引导
}

M3a 会在这里扩枚举(加 QUERY_WEATHER / MAKE_TRAVEL_PLAN)。加意图 = 改这个枚举 + 改 Prompt + Java 加一条分流 + 扩评测集,不动模型权重。

5.2 抽出来的结构(分诊单) ​

domain/assistant/model/ActivitySearchIntent.java:intent + destinationKeywords/province/city + dateStart/dateEnd + maxPrice + needClarification/clarifyQuestion。这就是模型运行时的全部产出(对用户不可见)。

5.3 出站契约 ​

domain/assistant/port/out/ILlmPort.java:

java
public interface ILlmPort {
    IntentExtraction extractSearchIntent(String userMessage, LocalDate today);
}
  • 传 today 是为了把「7 天内 / 本周末 / 8 月」换算成绝对日期(相对时间锚定)。
  • 返回 IntentExtraction = 意图 + 模型用量(token / 耗时),用量用于审计。
  • 接口里不出现任何 HTTP / 模型框架类型,保证换厂商、换框架不污染 Domain,也方便评测打桩。

5.4 代码锚点 ​

环节代码
教模型(System Prompt 8 段)OpenAiCompatibleLlmAdapter.systemPrompt(today)
发请求 + 结构化输出开关OpenAiCompatibleLlmAdapter.buildRequestBody
解析 JSON → 意图对象OpenAiCompatibleLlmAdapter.parseIntent
代码版意图识别(关键词)MockLlmAdapter
拿分诊单派工AssistantApplicationService.chat(见姊妹篇《编排》)
意图枚举AssistantIntentType
分诊单结构ActivitySearchIntent

6. 容错:模型不听话怎么办 ​

LLM 是概率系统,偶尔会「叛逆」。parseIntent / parseIntentType 做了三层兜底:

  1. 包裹容错:模型可能吐 ```json {...} ```,extractJson 截取首个 { 到末个 }。
  2. 枚举容错:parseIntentType 兼容大小写/引号/空白,未知枚举一律按 SEARCH_ACTIVITIES(宁可去查活动,也不误伤成拒答)。
  3. 语义归一:模型可能给了 RECOMMEND 又置 needClarification=true(推荐却反问,自相矛盾)→ 适配器强制 RECOMMEND ⇒ needClarification=false,和 Mock 行为对齐。

还有一层「双保险」在编排侧:即使模型漏判把越界当成了 SEARCH,工具白名单里只有「查活动」,模型也执行不了写操作(拒答是模型 + Java 双保险)。

这三层容错的心法:不信任模型的每一个字,但给它犯错留余地。相关的「幻觉/编造」问题见姊妹篇《幻觉与 grounding》。


7. 教学收获 ​

7.1 意图识别是「翻译」,不是「决策」 ​

模型把「人话」翻译成「分诊单」,决策(调哪个工具)是代码做的。把模型定位成「可靠的翻译器」而非「不可控的决策者」,是可控 AI 应用的地基。

7.2 「代码分类 vs 模型分类」不是二选一 ​

本项目用 ILlmPort 把两者都装进来:便宜的关键词版兜底 + 贵的 LLM 版兜准。接口隔离让你能同时拥有两种、随时切换、还能拿一套去打桩测另一套。

7.3 加新意图的成本,决定了架构好坏 ​

好的意图识别架构,加一个新意图应该只需:改枚举 + 改 Prompt + 加一条分流 + 扩评测集,不碰模型、不碰旧意图。如果加意图要重训模型或大改代码,说明架构选错了(这就是为什么没走「训练分类器」那条路)。

7.4 结构化输出 = 把散文关进笼子 ​

response_format=json_object + Prompt schema + temperature=0 三件套,是把「爱说人话的模型」变成「稳定吐 JSON 的组件」的关键。没有这一步,后面所有确定性 Java 编排都无从谈起。


8. 附录 ​

8.1 术语对照 ​

术语中文说明
Intent Recognition意图识别判断「用户想干嘛」
NLU自然语言理解意图识别 + 槽位抽取的统称
Slot Filling槽位抽取从话里抽出目的地/时间/预算等结构化字段
Structured Output结构化输出逼模型只吐可解析的 JSON
System Prompt系统提示词「教模型」的岗前培训文本
Grounding事实锚定让回答只基于真实数据,见《幻觉与 grounding》

8.2 常见疑问 ​

Q:意图识别算不算「工具调用 / Function Calling」? 不算。模型吐的是一个 intent 字段(分诊单),不是 tool_call。谁调工具由 Java 的 if/else 决定。详见姊妹篇《Java 编排 vs 模型编排与 Function Calling》。

Q:多轮补槽(先说时间预算、再说目的地)是模型记住的吗? 不是。跨轮记忆在 Java 侧 Redis 会话(AssistantSession),Java 把多轮的槽位合并,不是把历史丢回模型——省 token、可控。

Q:换模型(DeepSeek → Qwen / GPT)意图识别要重做吗? 不用。改配置 provider / model 一行,然后用评测集跑回归,掉分处调 Prompt / 加 few-shot。业务代码零改。

8.3 项目内关联 ​

Powered by VitePress