助手 · 意图识别是怎么做到的(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. 场景:为什么第一步是「意图识别」
- 2. 「顺序」到底是怎样的
- 3. 三种做意图识别的路子(本项目占了两种)
- 4. 结构化输出:怎么逼模型只吐 JSON
- 5. 项目里的具体落地
- 6. 容错:模型不听话怎么办
- 7. 教学收获
- 8. 附录
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。三个手段叠加:
- Prompt 里给死 schema:明确列出字段名、类型、枚举值,并写「严格只输出 JSON,不要多余文字、不要 markdown 代码块」。
response_format = json_object:OpenAI 兼容协议的开关,从协议层强制返回合法 JSON(本项目AssistantLlmProperties.jsonObjectResponse控制,可关以兼容不支持的中转)。temperature = 0:把随机性压到最低,让「同样的话尽量得到同样的 JSON」——评测能复现的前提。
配合意图抽取是「简单任务」的定性,还关了思考模式(thinking=disabled)、单轮无历史、收紧 max_tokens,把成本压到最低(详见 ADR-039 D2)。
5. 项目里的具体落地
5.1 意图枚举(分诊的「科室」)
domain/assistant/model/AssistantIntentType.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:
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 做了三层兜底:
- 包裹容错:模型可能吐
```json {...} ```,extractJson截取首个{到末个}。 - 枚举容错:
parseIntentType兼容大小写/引号/空白,未知枚举一律按SEARCH_ACTIVITIES(宁可去查活动,也不误伤成拒答)。 - 语义归一:模型可能给了
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 项目内关联
- 全景链路: 出行助手全场景流程图
- 里程碑与意图扩展: 北极星与终局蓝图 §6.1
- 编排与 Function Calling:
助手-Java编排vs模型编排与FunctionCalling - 防编造:
助手-大模型幻觉与grounding