智能旅行规划助手——多智能体 + Agentic RAG 实战全记录
项目起源
前段时间每次出去玩都要在 12306、天气预报、百度地图、小红书攻略之间来回切换——查车票、看天气、搜景点、排路线,信息分散在多个平台,来回比对很花时间。开始的我是使用MCP对接本地的LLM,调用MCP去查询车票、位置信息和小红书的攻略。正好准备学习Agent,于是做了这个项目(虽然说做了这个才发现已经烂大街了)。
最终实现了一个基于 LangGraph 的旅行助手,接入了 12306 火车票查询、和风天气、百度地图 POI 和路线规划、阿里云 IQS 联网搜索,以及一套自建的旅行知识库。这篇文章会覆盖整个搭建过程,包括架构设计、关键决策和踩过的坑。以下是这个项目的聊天部分截图。
在线预览:trip.xiyanovo.com


一、整体架构
系统把任务拆给 5 个专业 agent,各自负责垂直领域。每个Agent设置权限。
1 | 用户 ──→ FastAPI (SSE 流式) ──→ Router (意图分类) |
Planner 的角色是”旅行顾问”——拿到用户需求后先分析需要哪些维度的数据(天气、车票、景点攻略),再派发任务给对应的专家 agent;Transport 负责 12306 火车票查询,Weather 调取和风天气预报,Destination 通过百度地图搜景点和规划路线。所有 agent 执行完毕后,由汇总节点将各维度结果整合为完整回复。
二、Agent 注册表
早期实现中,agent 名称直接硬编码在 graph 和 router 里。写到第三个 agent 时就发现不对劲——每加一个要改三四份文件,而且容易遗漏。
后来抽了一个注册表模式。每个 agent 模块在导入时调用 register_agent() 自动登记,graph 和 router 从注册表动态读取。新增 agent 只需要一行注册代码,其余部分完全不用动。
1 |
|
几个需要注意的点:
延迟构建。build_fn 是工厂函数而非实例——有些 agent 的工具初始化开销很大,比如加载 BM25 索引,如果在启动时全量初始化所有 agent,内存增长会很快。
并行标记。Planner 的 can_parallel 设为 False,因为它必须先完成需求分析和任务编排,其他 agent 才能执行;Transport、Weather、Destination 这些是 True,彼此之间没有依赖,可以同时调用。
路由提示词自动生成。build_router_prompt() 遍历注册表中所有 agent 的 description,自动拼接成 Router 的 system prompt。新增 agent 只要在注册时写好描述,路由规则会随之更新,不需要手动修改提示词。
三、Graph 编排
基于 LangGraph 的 StateGraph,核心节点链路:
1 | START → router_node → route_after_router |
Router 节点每次收到用户消息时调用 LLM 做意图分类,返回 agent 名称列表,如 ["planner", "weather", "transport"]。之后根据列表内容自动决定执行模式:单 agent 串行执行;多 agent 且全部标记了 can_parallel 则通过 LangGraph 的 Send[] 机制同时分发;如果包含 Planner,先将 Planner 串行执行完,剩余专家 agent 再并行。
有一个容易忽略的场景是追问。用户可能先问”北京三日游”,紧接着说”那天气呢”。如果 LLM 将这句判定为非旅行意图,会返回空列表。此时如果存在对话历史,系统会自动将空列表重定向到 Planner 处理,而不是直接拒绝——因为用户显然是在延续旅行话题。
3.1 故障感知与上下文注入
外部 API 的稳定性无法保证。当一个 agent 的所有工具调用全部失败时(比如 12306 接口不可用),advance_queue_node 会检测到这一情况,自动向后续 agent 的上下文注入提示:”上一步 transport agent 的工具全部失败,车票数据暂不可用”。Planner 在最终汇总时便能据此如实告知用户,而非静默跳过。
这部分逻辑并不复杂,但体验差异很大。AI 查不到数据时容易产生幻觉胡编乱造,明确告知限制反而是更好的做法。
四、记忆体系
Agent 的记忆分为三层。
4.1 Checkpoint 持久化
LangGraph 的 checkpoint 机制负责跨请求的对话状态持久化。开发环境使用内存模式(InMemorySaver),生产环境切换 PostgreSQL(AsyncPostgresSaver),通过环境变量 CHECKPOINT_BACKEND 一行切换。checkpoint 中保存完整的 SupervisorState——包括 messages 历史和 agent_queue,用户关闭浏览器后重新打开仍可继续对话。
4.2 运行时上下文注入
每个 agent 执行前,中间件会自动将当前日期和时区注入 system prompt。这让 LLM 能正确解析”五一””下周三””今天”等相对日期。这部分上下文不进入 checkpoint,每次请求重新计算,保证时间始终准确。
4.3 校验警告
SupervisorState 中有一个独立的 validation_warnings 字段,存放输出校验产生的问题——例如”回答中出现了 G123 车次但未调用 12306 工具”。这个字段既不追加到 messages,也不持久化到 checkpoint。这样做的目的是避免污染对话历史影响下游 agent 推理,同时防止下次加载对话时带着过期的警告信息。
五、Agent 安全约束
Agent 面临的核心问题不是”不够聪明”,而是自由度太高——可能用同一参数反复调用同一工具形成死循环,也可能在连续失败后持续重试。Agent的权限方面是比较重要的,这里做了四层约束。
5.1 ToolSafetyMiddleware
最核心的是 ToolSafetyMiddleware,在每个 sub-agent 内部生效,包含两条规则:
- 同一工具 + 同一组参数,最多调用 2 次。检测方式是将工具名和参数序列化为 key,在 messages 全量历史中匹配。
- 连续失败最多 3 次。从 messages 尾部向前扫描 ToolMessage,只计数连续出现的失败,遇到非失败的 AI message 即停止——表示 agent 已进入下一阶段。
触发拦截后不返回 error,而是返回一条软拒绝消息:”工具 X 已重复调用 N 次,请使用已有结果或换一个角度”。报错会让 agent 进入错误处理循环,软拒绝则是引导它在现有信息基础上调整策略。
5.2 Agent 级限制
Graph 层面设置了 agent_recursion_limit=25,限制单次请求的总步数,防止在图内无限循环。
5.3 输出校验
输出校验层用正则从回答中提取车次号(如 G123、D456),检查是否出现在 12306 工具的成功结果中。如果完全没有调用过 12306 但回答中出现了车次号,则可判定为编造。类似地,工具调用失败但回答中未出现”失败””无法””暂不可确认”等关键词时,会标记警告提示信息可能不完整。
5.4 速率限制
速率限制层采用 Redis + 内存双层实现:滑动窗口默认 60 秒内最多 20 次请求,Redis 不可用时自动降级到内存实现,不影响服务可用性。
六、外部 API 容错
12306、百度地图、和风天气这些外部 API 各有各的不稳定因素。所有工具统一返回 {"success": true/false, "data/error": ...} 格式的 JSON,agent 可以用统一逻辑判断调用结果。
每个工具也做了针对性的容错:
query_train_tickets:出发日超过 15 天预售期时,12306 不返回任何车次。工具会自动用”今天+14 天”的同线路车次作为参考,并在结果中标注”预计 X 月 X 日开售”。retrieve_knowledge:RAG 未命中时不返回空结果,而是给出一个 hint,Planner 据此自动降级到web_search。web_search:失败后不做反复调用,Destination 改用已有 POI 结果完成可确认部分。
Graph 层面的 _detect_agent_tool_failure() 扫描最近 agent 的全部工具结果,如果全部失败,在下一轮 advance_queue_node 时向后续 agent 注入失败上下文。
七、RAG 全链路优化
旅行场景对 RAG 有特殊要求:城市名不能出错,美食推荐需要具体店名和人均消费,亲子攻略要区分年龄段。常规的”切 chunk → 丢向量库 → 搜 Top K”流程不够用。整个链路中,我认为最关键的两个环节是数据源质量和短查询与长文档之间的语义鸿沟。
7.1 知识库数据构建
知识库最需要避免的是 LLM 凭空编造内容。data_synthesizer.py 覆盖了 30 多个热门城市的 150+ 搜索主题——每个城市的”三日游攻略””必吃美食””亲子路线””省钱技巧”等。流程是先用阿里云 IQS 搜索真实网页,获取实际存在的景点名、店名、价格区间、交通方式,再将搜索结果交给 LLM 摘要为统一格式的 Markdown。
这样做的好处是价格、营业时间、交通信息来自真实的搜索结果,不会出现”故宫门票 50 元”(实际 60 元)这类幻觉。
7.2 文档切分
采用 Markdown 标题切分 + 中文分隔符递归切分。先按 #/##/### 标题层级拆解,超长的段落再用中文标点(。!?,;)递归切分。每个 chunk 的 metadata 注入完整标题路径,如 {h1: "北京", h2: "景点攻略", h3: "故宫"},后续检索时可利用元数据做精确过滤。
7.3 混合检索:BM25 + Dense 双路融合
检索链路为 BM25 + Dense 双路融合。BM25 使用 jieba 分词做关键词匹配,Dense 使用 bge-m3 向量做语义检索,各取 Top 30,经 RRF 融合为 Top 10,再由 Reranker 精排至 Top 5。
选用 RRF 而非直接加权求和的原因是:BM25 的分数范围与余弦相似度完全不在一个量级,直接加权会导致一方主导融合结果。RRF 不需要分数归一化,天然适合跨量级场景。BM25 权重 0.4,Dense 权重 0.6——语义优先但关键词匹配不可丢弃。此外增加了城市名加分(rag_city_bonus=0.05),query 中匹配到的城市名获得额外权重。
7.4 HyDE:用假设性攻略桥接语义鸿沟
这是整个 RAG 链路中效果最显著的优化。
用户问”北京三日游怎么安排”,只有 8 个字;知识库存的是”Day1 天安门故宫景山,Day2 颐和园圆明园……”,数百字的长攻略。短查询与长文档在 embedding 空间中的距离很远——两者语义表征形态差异过大。
解决思路是:检索前先让 LLM 生成一段假设性攻略,例如”北京三日游建议第一天游览天安门广场、故宫博物院,第二天前往颐和园和圆明园……”,然后用这段”假设攻略”的向量去检索真实攻略——攻略搜攻略,匹配度大幅提高。
但并非所有查询都适合 HyDE。精确事实类查询(如”退票手续费多少”)做 HyDE 扩展反而引入噪音;超过 25 字的查询本身已经足够详细,也不需要扩展。针对这些情况设置了跳过规则,加上 8 秒超时保护和 64 条 LRU 缓存避免重复 LLM 调用。
7.5 Reranker 降级链路
主力使用 Qwen3-VL-Reranker-8B,毫秒级响应。LLM Reranker(0-10 分打分)作为备选,API 不可用时自动切换。两者都不可用时保留原始 RRF 排序结果。每批处理 20 篇,避免超出 token 限制。
7.6 RAGAS 评估
用 RAGAS 跑了一轮内部评估:忠实度 97.38%,检索精度 86.65%,检索召回 82.35%,检索相关性(NV)满分。答案相关性只有 71.05%,主要原因是 Planner 的标准输出范式是”需求分析 + 行程灵感 + 待获取数据清单”,相比参考答案显得信息密度偏低,后续会针对这个点做输出范式优化。
八、踩过的坑
Agent 死循环。LangGraph 的 agent 偶尔会用同一组参数反复调用同一工具。max_repeated_calls=2 可以拦住,但拦截后的处理方式很关键——必须返回软拒绝而不是 error,error 会触发 agent 的错误处理循环。
12306 预售期。出发日超过 15 天预售期时接口直接不返回车次。需要用”今天+14 天”的同线路数据做参考,并告知用户开售日期。这个问题一开始以为是代码 bug,排查了半天才发现是业务规则。
百度地图限流。免费配额的并发限制很严格,最终测试出 max_concurrency=1 + 调用间隔 0.35 秒才能稳定运行。
Milvus Lite flush 行为。3.0 版本 flush 之后必须手动 load_collection 才能查询新写入的数据,文档中没有说明。
Planner 输出范式。早期让 Planner 直接输出面向用户的回复,结果与其他 agent 的输出频繁冲突。后来将 Planner 的输出改为结构化的”待获取数据清单”,最终汇总由 summary_node 用专门的 prompt 完成,问题才解决。
九、后续计划
RAGAS 评估集成到 CI 流程,知识库每次更新后自动重跑。机票和酒店 agent 如果能接入,配合现有的火车票和天气,出行规划的完整链路就基本闭环了。
