众所周知,过去小长假经常出去玩,五一格鲁吉亚自驾游超详细记录、国庆土耳其自驾游超详细记录、曼谷、甲米、吉隆坡:一次没做攻略的旅行,恰逢最近前端失业转 Agent 中,索性做一个旅行助手的 Agent 作为找工作的准备。
产品介绍
产品主要用对话承载,可以聊天录入机酒、行程、小红书资料。

日程可以查看每日的安排,可以录入酒店、机票、地点、门票。机票、酒店也可以单独点击查看。

之前一些有用的小红书链接会发到微信群里,后边找的时候很麻烦,可以把小红书链接发给 Agent,会自动归档到资料里。
意图定义
最开始做的时候,并不知道「意图识别」是什么。最初的想法就是:用户说一句话,直接交给大模型,让它自己判断要不要调用工具、调用哪个工具,最后再生成回答。
这个想法很自然,因为大模型看起来什么都懂。用户说「第 3 天干嘛」,它知道是在问日程;用户发一个酒店订单截图,它也能看懂是酒店;用户说「这个地方安全吗」,它也能推断出和旅行有关。
所以最开始,想象中的系统大概是:
1 | 用户输入 |
但真正往产品里做的时候,发现这条路并没有那么稳。后来又看了一些关于意图识别和 Agent 工程的资料,才意识到这里应该拆出一个更明确的路由层。
比如用户输入:
1 | 股票买哪个 |
如果全交给大模型,它可能真的开始分析股票。但我做的是旅行助手,它不应该回答投资建议,否则既越过了产品边界,也浪费 token。
再比如用户输入:
1 | https://windliang.wang/ 加入到资料 |
这里有链接,也有「加入资料」的动作。如果不先判断范围,系统很容易直接抓取、总结、入库。但这个链接本身没有旅行语境,并不应该直接进入旅行资料库。
还有这种问题:
1 | 这个地方安全吗? |
它确实和旅行有关,但也不能简单地当成普通问答。因为安全信息最好结合当前目的地、必要时搜索最新资料,并且给出来源。
Agent 不能只靠大模型自由发挥。它需要一个路由层,先判断这句话属于什么范围、对应哪个产品能力、是否需要追问、能不能继续执行。
也就是意图识别,不是给一句话贴标签,而是决定系统下一步该走哪条业务链路:查数据库、读附件、保存资料、搜索网页、写入行程、追问用户,还是直接拒绝。
意图梳理
项目是一个旅行助手,核心场景是帮用户管理一次旅行。
用户可以创建一个行程,比如:
1 | 2026 年国庆 · 西班牙 |
然后围绕这个行程做几类事。
1. 查询每日安排
用户可能会问:
1 | 第 3 天干嘛? |
这类问题对应到:
1 | day_query |
系统会读取当前行程里对应 Day 的安排,按时间整理出来。
2. 整理机票、酒店、门票订单
用户可以上传截图、PDF 或订单信息:
1 | 帮我整理这张机票 |
这几类分别对应:
1 | flight |
系统会提取航班时间、入住退房、门票日期、地址、订单号等结构化信息,并写入当前行程。
3. 导入行程表
比如用户上传 Excel、CSV,或者说:
1 | 把这个计划表导入 |
对应的是:
1 | itinerary |
系统会解析日期、时间、地点、事项,然后写入每日安排。
4. 保存旅行资料
比如:
1 | 这篇西班牙攻略加入资料 |
对应的是:
1 | memory |
系统会抓取链接内容,总结后放进当前行程的资料库。后续用户追问时,这些资料会成为上下文。
5. 分析攻略或截图
用户可能发一张小红书截图、攻略图片,然后问:
1 | 这张图里有什么推荐? |
对应的是:
1 | note |
它更像“资料阅读”和“攻略分析”,不一定马上写入行程。
6. 旅行通用问答
比如:
1 | 这个地方安全吗? |
对应的是:
1 | general |
系统会结合当前行程、资料库、历史对话,必要时调用搜索或回答模型。
7. 需要追问
比如:
1 | https://example.com 加入资料 |
这句话有「加入资料」的动作,但链接本身没有旅行语境。系统不知道它是不是旅行资料,所以我把它归到:
1 | clarify |
这时不应该直接入库,而应该先问:
1 | 这个链接和当前旅行有什么关系? |
8. 超出范围
比如:
1 | 股票买哪个? |
对应的是:
1 | out_of_scope |
旅行助手不应该接这些任务。
所以,产品能力和意图的关系大致是:
1 | day_query -> 查询每日安排 |
意图识别不是为了给用户一句「分类结果」,而是为了决定系统下一步应该进入哪个产品能力。
为什么不直接让大模型判断所有事
如果系统足够简单,当然可以直接让大模型判断。
但旅行助手不是一个纯聊天机器人。它背后有数据库、有用户资料、有附件解析、有搜索、有写入动作。
这时,大模型判断错一次,后果不只是「回答差一点」。
它可能会:
1 | 把非旅行问题当成旅行问题回答 |
尤其是带写入动作的场景,我不敢让模型凭感觉决定。
比如:
1 | 这个加进去 |
这里的「这个」是什么?是上一条攻略?是一个景点?是一张门票?还是用户只是想保存资料?
如果系统直接让大模型调用写入工具,就很危险。
更接受的做法是:
1 | 高确定性的,用规则快速判断 |
也就是说,大模型可以参与意图识别,但不应该独占意图识别。
那回到意图识别,可以把它看成一个从粗到细的路由过程:
1 | 先把用户的话整理清楚 |
对应到项目里,就是三层:
1 | 第一层:输入归一化 |
这三层解决的问题不一样。
输入归一化解决的是:用户说得太口语、太省略,系统要先把话补完整。
旅行范围判断解决的是:这件事是不是旅行助手该接的,不能什么都往后丢。
业务意图识别解决的是:如果它属于旅行场景,那到底应该走日程查询、订单识别、资料保存、攻略分析,还是旅行问答。
这样拆完之后,大模型就不是整个系统的入口,而是其中一个兜底判断器。规则能确定的,直接走规则;规则不确定的,再让便宜模型帮忙复判;真正执行时,仍然由业务代码控制。
第一层:先把用户的话整理一下
真实用户不会按照系统字段说话。
他们会说:
1 | 机酒帮我整理一下 |
这里有缩写、口语、代词、省略和上下文引用。
所以现在先做输入归一化。
例如:
1 | 机酒 -> 机票 酒店 机酒 |
代码大概是这样:
1 | function normalizeInputForIntent(input: string, trip?: Trip) { |
原始输入:
1 | 这个地方安全吗? |
如果当前行程是“2026 年国庆 · 西班牙”,归一化后,系统就能把“这个地方”放回当前行程语境里判断。
这一步的目的不是润色文本,而是把用户的自然表达变成系统更容易判断的表达。
第二层:判断是不是旅行范围
旅行范围有三种:
1 | type TravelScope = "travel" | "non_travel" | "uncertain"; |
例如:
1 | 股票买哪个 |
代码里会先做范围判断:
1 | const rawQuestion = getActualQuestion(message); |
如果是明确非旅行问题,就直接变成 out_of_scope:
1 | if (travelScope.scope === "non_travel") { |
这一步其实是在回答一个更基础的问题:
这个请求是不是旅行助手应该处理的?
只有这个问题过了,才继续判断它是 memory、hotel、ticket 还是 general。
第三层:判断具体业务意图
当系统确认请求属于旅行范围,或者至少不确定但可能相关时,才进入业务意图识别。
当前项目的意图有十种:
1 | day_query |
这些意图不是标签,而是后续链路。
例如:
1 | day_query |
规则识别会输出候选意图、分数和原因:
1 | type IntentCandidate = { |
比如:
1 | if (isDayScheduleQuestion(normalizedInput)) { |
再比如机票、酒店、门票:
1 | if (/机票|航班|起飞|落地|登机|flight/.test(combined)) { |
最后按分数选出当前主意图:
1 | const [winner] = [...candidates].sort((left, right) => { |
链接的例子
旅行助手里有一个典型场景:用户发链接。
最开始的规则很简单:
1 | 只要输入包含 URL,就识别为 memory |
也就是保存到资料库,但这样用户随便发一个个人网站,也会被当成旅行资料。
所以把规则更加细化:
1 | 旅行平台链接 -> 可以倾向 memory |
代码里会先提取安全 URL,并排除 localhost、内网地址这类不适合抓取的链接:
1 | function getSafeMemoryUrl(input: string) { |
然后判断它是不是旅行平台、有没有旅行文本、有没有保存动作:
1 | function resolveUrlTravelSignal(input: string) { |
最后落到不同意图:
1 | if (urlSignal?.shouldSaveMemory) { |
比如:
1 | 小红书攻略链接 加入资料 |
这里的关键是:动作不等于意图成立。
「加入资料」只是动作信号。系统还要判断这个资料是不是属于当前旅行场景。
如果不确定,就追问,而不是硬执行。
规则和模型怎么分工
不能完全靠规则,也不能完全靠模型。
规则适合高确定性的场景:
1 | 第 3 天干嘛 -> day_query |
规则快、便宜、稳定。能确定的事情,不需要花一次模型调用。
但规则也有边界。
比如:
1 | 这个能不能带老人去? |
这些句子依赖上下文、代词和隐含动作。继续堆正则,会越来越脆。
所以引入了一个便宜模型:
1 | glm-4.7-flash |
它只在规则不确定时调用。
触发条件在代码里大概是这样:
1 | function shouldUseIntentClassifier({ |
模型只做结构化分类:
1 | { |
调用时会限制它的行为:
1 | const callOptions = { |
它不负责回答,不负责搜索,不负责写库。
也就是说,现在的分工是:
1 | 规则负责确定性 |
为什么模型不能直接执行
因为「理解」和「执行」是两件事。
模型可以判断:
1 | 用户可能想把这个攻略加到行程里 |
但真正要不要写入数据库,应该由代码判断:
1 | 有没有明确写入动作? |
当前项目里有一个原则:
只有工具返回成功,系统才能说“已写入”。
代码里会检查写入工具是否真的成功:
1 | if (isSaveItineraryToolCall(toolCall) && isSuccessfulToolWrite(result)) { |
如果模型没有成功调用写入工具,却在回答里声称已经写入,系统会拦截:
1 | if ( |
这就是为什么不想让模型直接接管执行。
Agent 的可靠性,不来自模型一句「我认为」,而来自流程里的硬约束。
代码不是不相信模型的文字,而是先确认模型调用了 save_itinerary_items,再确认工具返回 success: true。只有这两个条件同时满足,系统才允许说“已写入”。
调试意图
为了调试给项目做了两件事。
第一,页面上有 Debug 面板,会展示:
1 | 输入归一化 |

代码里每个关键节点都会写 Debug:
1 | pushDebugEntry(debugEntries, "输入归一化", { |
第二,每一轮 Debug 都会落到本地文件:
1 | .agent-debug/ |
简化后的代码是:
1 | async function writeAgentDebugLog({ entries, response, tripId }: Args) { |
这样可以回看每一次判断为什么出错。
比如看到一条:
1 | https://windliang.wang/ 加入到资料 |
如果结果是:
1 | memory |
说明规则过宽。
如果结果是下边的就更合理。
1 | scope: uncertain |
没有 Debug,意图识别只能靠猜。有了 Debug,就能像调业务系统一样调 Agent。
当前架构和优化
当前项目的整体流程是:

当前这套已经比「一句 Prompt 识别意图」稳很多,但产品毕竟还没经历大量真实用户输入,用户多了肯定会有各种意料之外的问题,后续还有很多方向可以优化。
尤其是现在规则层里还有不少正则。正则不是不能用,它适合处理高确定性的信号,比如:
1 | 第 3 天干嘛 -> day_query |
这些规则快、便宜、可控。
但问题也很明显:正则很容易越写越厚,最后变成一张很难维护的网。
比如:
1 | 住哪来着 |
它们表达的是相近意思,但关键词不一定稳定。继续补正则,短期能解决一个 case,长期会出现几个问题:
1 | 覆盖不全:用户换个说法就识别不到 |
后续可以有下边的优化点:
1. 把单意图升级成多意图
现在系统本质上还是单意图。
比如:
1 | 机酒帮我整理一下 |
它其实同时包含:
1 | flight |
再比如:
1 | 这篇攻略不错,帮我加到第 3 天 |
它可能同时包含:
1 | memory |
现在只能选一个主意图,后面更合理的是输出:
1 | { |
然后由流程决定是串行处理、追问确认,还是只执行其中一个。
2. 引入 Slot Filling,而不是只判断 intent
现在已经让模型分类结果里可以带 slots,但业务上还没有充分利用。
这一步其实就是意图识别里很关键的 Slot Filling。
Intent 解决的是:
1 | 用户想做什么? |
Slot Filling 解决的是:
1 | 这件事需要哪些关键信息? |
比如用户说:
1 | 把这个加到第 3 天 |
只识别出 itinerary 还不够,还要抽出:
1 | { |
再比如:
1 | 这篇西班牙攻略加入资料 https://example.com |
应该得到:
1 | { |
这样系统不只是知道用户“想保存资料”,还能知道保存什么、是否和当前行程有关、缺不缺关键字段。
如果关键槽位缺失,就应该进入:
1 | clarify |
而不是继续执行。
3. 把正则从「业务判断」降级成「候选召回」
现在有些正则还承担最终判断职责。后面应该把它们改成候选召回。
也就是说,正则不再直接决定最终意图,而是先召回可能的候选:
1 | 命中“酒店/住宿/住哪” |
然后再结合上下文、槽位完整度、模型复判,决定最终走哪条链路。
这样正则的角色会更轻,也更可维护。
4. 把规则拆成可配置的意图词表
现在规则主要写在代码里,改起来快,但长期会膨胀。
后面可以把它拆成配置:
1 | { |
这样新增表达不用一直改代码,也更容易做评测和版本管理。
5. 用 Embedding 或检索替代一部分正则
有些表达靠关键词很难覆盖,比如:
1 | 这个适合带老人去吗 |
这类更适合用相似样本召回。
比如系统里沉淀一些已标注样本:
1 | 这个适合带老人去吗 -> general, slots: { topic: "suitability" } |
新输入来了以后,先用 Embedding 找相似样本,再把这些样本作为 few-shot 给便宜模型。
这比无限补正则更自然。
6. 做更细的澄清策略
现在 clarify 只是一个意图。
但追问本身也可以分类型:
1 | 缺少旅行语境 |
不同原因应该问不同的问题。
比如:
1 | https://example.com 加入资料 |
应该问:
1 | 这个链接和当前旅行有什么关系?是攻略、酒店、门票,还是其他参考资料? |
而不是泛泛地说「请补充信息」。
7. 建一套意图识别评测集
现在主要靠手动测试。
后面应该把典型输入沉淀成测试集,例如:
1 | 股票买哪个 -> out_of_scope |
每次改规则或 Prompt,都跑一遍评测。
否则意图识别很容易出现「修好一个,弄坏另一个」的情况。
8. 记录准确率、延迟和成本
意图识别不是只看准不准,还要看:
1 | 规则命中率 |
如果模型调用率太高,说明规则层不够好。
如果 clarify 太多,说明系统太保守。
如果 memory 误判高,说明 URL 和保存动作的边界还要继续收紧。
如果槽位缺失率高,说明很多意图不该直接执行,而应该先追问。
这些指标会比单次 Debug 更有价值。
总
如果把大模型看成发动机,意图识别就是方向盘。
没有方向盘,发动机越强,跑偏得越快。
一个好的意图识别系统,不是追求一次判断永远正确,而是要做到:
1 | 明确的,快速处理 |
Agent 开发中,一部分还是传统的工程,一部分是让渡给大模型,这个度的把握也挺有意思。测试也变得困难起来,因为面对的输入变成了无穷多,不再像之前一样把固定链路测了即可。
文章中大部分知识都是看极客时间的「AI Agent 系统设计面试现场」,这里也推荐下,感兴趣的同学也可以看下,比我这里提到的会更系统。

从 https://coursesub.top/ 这里去买的话可以省 18 元。