起初只有一句话需求:「我想做一个旅游预订平台,可以给客户提供旅游行程、推荐旅游地点、预订机票、预订酒店」。
一个多月后,它长成了这样——AI 行程规划、机票酒店预订、目的地推荐、露营攻略四大链路全部可用,
AI 挂了还有规则引擎兜底,行程结果永远出得来。这篇文章复盘整个项目:架构、Agent 模式、真实数据链路、踩坑与部署。

畅游 TripAgent 首页

一、项目全景

畅游 TripAgent 是一个前后端分离的 AI 旅游预订平台,当前各模块状态:

模块 说明
AI 行程规划 Agent 模式:模型自主调用只读工具(查景点/查航班/搜酒店/查天气/确认酒店运营状态/位置查询)后生成逐日行程,停业酒店自动规避
目的地推荐 30 个热门城市,热度/兴趣/预算规则打分,景点攻略带真实门票与简介
机票预订 航线搜索、实时余票、下单扣座(乐观锁)、取消回补
酒店预订 省/市/区县级联选城市、日期/星级搜索、房型明细、30 城约 700+ 家真实采集酒店
露营 30 城 1600+ 真实露营地、驾车导航真实道路路线、天气预报驱动装备清单、评价 LLM 分析
订单中心 类型/状态筛选、详情快照、取消订单
在线地图 Leaflet + 高德底图,行程逐日路线连线、景区酒店分布、酒店位置图

技术栈一句话概括:后端 Java 21 + Spring Boot 3.4 + Spring AI 1.0(火山方舟豆包模型),前端 Vue 3 + Vite + TypeScript + Element Plus + Pinia,数据层 JPA + H2 文件库(可平滑切 MySQL)。

二、架构:一切依赖都是”端口”

做集成类项目最容易把第三方 API 写得到处都是。我的原则是:凡是会换的东西,一律收口成端口接口。

// 上层只依赖这三个口子,Mock/真实实现可随时替换
public interface LlmPort { /* 单轮对话 + 工具调用两个重载 */ }
public interface FlightSupplierPort { /* 航班搜索/下单/取消 */ }
public interface HotelSupplierPort { /* 酒店搜索/详情/下单 */ }
浏览器 (Vue3)
   │  /api
   ▼
Spring Boot 3.4
   ├── ItineraryService ──> LlmPort ──> Spring AI ──> 火山方舟(豆包)
   │        │  工具循环:findAttractions / searchFlights / searchHotels
   │        │            getWeather(Open-Meteo) / checkHotelStatus / searchLocation
   ├── FlightSupplierPort / HotelSupplierPort ──> Mock 或真实采集数据
   └── JPA + H2 文件库(MODE=MySQL,切 MySQL 改配置即可)

好处立竿见影:机票目前是 Mock 种子数据,将来接真实供应商时上层业务代码零改动;LLM 从豆包换到 GLM/DeepSeek 也只是换一个配置。

三、AI 行程规划:从”提示词生成”到”真 Agent”

1. 两代实现

  • 单轮模式(一代):把目的地/天数/预算/兴趣拼成一个大 prompt,让模型一次性吐出 JSON。能用,但模型只能”编”,机酒价格经常脱离实时库存。
  • Agent 模式(二代,app.ai.agent-mode 默认开启):把系统提示词改写成”你可以调用这些工具,先查再答”。模型自己决定查什么、查几次,最后基于服务端真实数据输出行程。

2. 实测一次真实的规划过程

在向导里选「北京 → 三亚、4 天 2 人、预算 9000、海滨/美食/亲子、舒适」:

AI 行程规划向导

点击生成后,后端日志里能看到模型自主发起的一连串工具调用:

[agent-tool] getWeather      三亚 2026-10-04~2026-10-07 返回 4 天
[agent-tool] checkHotelStatus 147(Holiday Inn Resort SANYA BAY by IHG) operating=true
[agent-tool] searchLocation  亚龙湾@三亚 返回 3 条
[agent-tool] searchLocation  蜈支洲岛@三亚 返回 3 条
[agent-tool] searchLocation  南山文化旅游区@三亚 返回 3 条
[agent-tool] searchLocation  第一市场@三亚 返回 3 条

约 40 秒后,结果页出来了——顶部有行程地图(逐日路线连线与路程):

AI 行程结果:地图与概览

逐日行程按上午/中午/下午/傍晚/夜晚组织,每个条目带预估花费,右侧是预算明细与按实时库存校准过的推荐航班(含一键预订):

AI 行程结果:Day1 与预算明细

最有意思的是「出行贴士」:模型把工具查到的真实天气预报融进了建议里——“10月5-7日三亚多雷阵雨天气,降水概率 80%-100%,建议携带雨衣、雨伞和防水鞋套””蜈支洲岛若遇风浪过大可能停航……可改去亚特兰蒂斯室内海洋馆作为备选”。这不是提示词里教它说的话术,是 Open-Meteo 的真实预报数据。

AI 行程结果:出行贴士引用真实天气

3. 停业酒店闭环

给演示数据标了一个”装修停业”的三亚湾酒店,然后观察 Agent 的行为:searchHotels 返回列表时带上 operating 字段 → 推荐前模型再调 checkHotelStatus 二次确认 → InventoryMerger 把停业推荐直接丢弃 → 即使绕过 AI 直接下单,OrderService.bookHotel 也会拒单(”该酒店暂停营业:酒店装修改造中…”)。AI 的”自觉”永远要有业务规则兜底,这是我在这条链路上最深的体会。

另外,下单类工具(扣库存)有意不暴露给 Agent——没有确认机制之前,绝不让模型直接改状态。

四、七道防线:让 LLM 输出永远可用

LLM 输出 JSON 的可靠性是老大难,我的解法是纵深防御,一道不过还有下一道:

  1. 提示词要求只输出 JSON;
  2. Spring AI JSON_OBJECT 响应格式约束;
  3. 解析前剥离 ```json 围栏;
  4. 解析失败自动发起一次”修复 JSON”重试;
  5. 天数完整性校验(说好 4 天就必须有 4 天,缺天直接判废);
  6. 行程条目 refId 回查失败时按名称模糊匹配,再不行降级为纯文案条目(不丢内容);
  7. 以上全崩 → 规则引擎兜底生成行程,响应里标记 source=RULE, degraded=true。

前端对用户永远是可用的,差别只在响应标记。机酒价格与库存则一律以服务端实时数据重算,模型只负责”编排”,不负责”报价”。

五、真实数据链路:爬虫 + 高德 + Open-Meteo

演示数据再好看也是假的,这块花了不少功夫:

  • 酒店:tools/hotel-crawler 用高德 POI 拿真实名称/前台电话/坐标,再配 trip.com 的房价/早餐/评分,采集 30 城约 700+ 家。列表页真实酒店优先展示并带「真实信息」徽标;详情页有电话、地址和高德位置地图。

真实酒店列表:评分/点评数/价格

酒店详情:真实照片 + 高德位置地图

  • 露营地:高德采集 30 城 1600+ 个真实露营地(电话/评分/人均),点击任一营地可看驾车导航路线(高德路径规划,真实道路里程与耗时)、天气预报驱动的装备清单、以及评价的 LLM 正负面分析。

露营:真实营地 + 导航路线 + 天气装备清单

  • 景点:马蜂窝 POI 爬虫补充真实门票(亚龙湾免费/蜈支洲岛 ¥144/南山 ¥129)与简介。
  • 天气:Open-Meteo 免费无 key,16 天日级预报,WMO 代码转中文。
  • 地图:Leaflet + 高德街道底图;WGS-84 坐标上图前自动纠偏到 GCJ-02,否则点位会偏出几百米。

六、预订与订单链路

机票搜索结果带实时余票,下单即扣座(乐观锁防超卖),取消自动回补——下图里 HU6538 显示”余 15 张”,正是我在结果页订掉 2 张之后的数字:

机票搜索与实时余票

订单中心支持类型/状态筛选、详情快照与取消:

订单中心

诚实声明(MVP 边界):机票为 Mock 种子数据(通过 Supplier Port 可替换);金额与库存是演示逻辑;无支付。但扣座/扣房/回补/停业拒单这些业务规则都是真实生效的。

七、部署上线

线上是一台阿里云轻量应用服务器(宝塔镜像),架构简单可靠:

:80 nginx
 ├── /        → /opt/trip-agent/dist   # Vue3 打包静态文件
 ├── /api/    → http://127.0.0.1:8080   # 反代 Spring Boot
 └── /images/ → http://127.0.0.1:8080   # 爬虫图片
  • 后端用 systemd 守护(EnvironmentFile 加载 LLM/高德 key,权限 600);
  • H2 文件库直接落盘 /opt/trip-agent/data/,备份 = 复制一个文件;
  • 更新流程:mvn package → 传 jar → systemctl restart trip-agent,全程 1 分钟。

八、踩坑记录

  1. ddl-auto=update 不会缩短/加长已存在的列。真实景点简介 300 字塞进默认 VARCHAR(255) 直接启动崩,最后用 H2 tools 手工 ALTER TABLE ... ALTER COLUMN 保住历史数据。
  2. 深度思考拖慢生成:4 天行程默认要 90s,给豆包配 reasoning-effort: none 后降到 20~35s(Agent 模式多轮工具往返约 40s,仍在前端 90s 超时内)。
  3. 模型偶尔把数组写成一个长字符串,标准反序列化直接崩且修复重试救不回——解析器单独开 ACCEPT_SINGLE_VALUE_AS_ARRAY 后整体加固。
  4. 坐标系陷阱:OSM/高德的坐标基准不同(WGS-84 vs GCJ-02),不纠偏的话所有点位都”漂移”。
  5. 别让 Agent 直接改状态:下单类工具一律不暴露,先做确认机制再谈。

九、写在最后

回头看,这个项目最大的收获不是”接入了大模型”,而是把不确定的 LLM 关进确定性的工程笼子里:端口化隔离供应商、工具调用只读化、输出解析七道防线、业务规则兜底。AI 负责灵感,工程负责兑现。

接下来计划:接入支付沙箱、机票真实供应商、把 H2 切到 MySQL 做多实例。如果你也在做 Agent 落地,欢迎交流。