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

一、项目全景
畅游 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、海滨/美食/亲子、舒适」:

点击生成后,后端日志里能看到模型自主发起的一连串工具调用:
[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 秒后,结果页出来了——顶部有行程地图(逐日路线连线与路程):

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

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

3. 停业酒店闭环
给演示数据标了一个”装修停业”的三亚湾酒店,然后观察 Agent 的行为:searchHotels 返回列表时带上 operating 字段 → 推荐前模型再调 checkHotelStatus 二次确认 → InventoryMerger 把停业推荐直接丢弃 → 即使绕过 AI 直接下单,OrderService.bookHotel 也会拒单(”该酒店暂停营业:酒店装修改造中…”)。AI 的”自觉”永远要有业务规则兜底,这是我在这条链路上最深的体会。
另外,下单类工具(扣库存)有意不暴露给 Agent——没有确认机制之前,绝不让模型直接改状态。
四、七道防线:让 LLM 输出永远可用
LLM 输出 JSON 的可靠性是老大难,我的解法是纵深防御,一道不过还有下一道:
- 提示词要求只输出 JSON;
- Spring AI
JSON_OBJECT响应格式约束; - 解析前剥离 ```json 围栏;
- 解析失败自动发起一次”修复 JSON”重试;
- 天数完整性校验(说好 4 天就必须有 4 天,缺天直接判废);
- 行程条目
refId回查失败时按名称模糊匹配,再不行降级为纯文案条目(不丢内容); - 以上全崩 → 规则引擎兜底生成行程,响应里标记
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 分钟。
八、踩坑记录
ddl-auto=update不会缩短/加长已存在的列。真实景点简介 300 字塞进默认VARCHAR(255)直接启动崩,最后用 H2 tools 手工ALTER TABLE ... ALTER COLUMN保住历史数据。- 深度思考拖慢生成:4 天行程默认要 90s,给豆包配
reasoning-effort: none后降到 20~35s(Agent 模式多轮工具往返约 40s,仍在前端 90s 超时内)。 - 模型偶尔把数组写成一个长字符串,标准反序列化直接崩且修复重试救不回——解析器单独开
ACCEPT_SINGLE_VALUE_AS_ARRAY后整体加固。 - 坐标系陷阱:OSM/高德的坐标基准不同(WGS-84 vs GCJ-02),不纠偏的话所有点位都”漂移”。
- 别让 Agent 直接改状态:下单类工具一律不暴露,先做确认机制再谈。
九、写在最后
回头看,这个项目最大的收获不是”接入了大模型”,而是把不确定的 LLM 关进确定性的工程笼子里:端口化隔离供应商、工具调用只读化、输出解析七道防线、业务规则兜底。AI 负责灵感,工程负责兑现。
接下来计划:接入支付沙箱、机票真实供应商、把 H2 切到 MySQL 做多实例。如果你也在做 Agent 落地,欢迎交流。