这是 RouteBook Agent 开发记录的第四篇。上一篇介绍了工作流循环如何让需求补问、地点确认和行程修改逐步收敛。
这一篇进入路书最核心的部分:用户选好地点以后,系统怎样综合天气、距离、交通方式和旅行节奏,把它们编排成一份真正可执行的分日路线。
项目地址:github.com/lukeSuperCo...


一、路线规划不是把景点平均分到几天
经过需求理解和地点确认以后,系统已经拿到一组真实 POI。以北京三日游为例,用户可能选择了故宫、天安门、国家博物馆、景山公园、颐和园、圆明园和八达岭长城。
每个地点都有高德 POI ID、经纬度、行政区和类别,但这些数据还不是行程。
如果简单按照选择顺序平均分配,可能把故宫和八达岭放在同一天,也可能在雨天安排大量户外活动。看起来每天都有内容,实际却需要频繁跨区,交通时间甚至比游览时间还长。
所以当前项目把路线编排看成一个多因素约束问题:
text
天气情况
+ 地点距离
+ 交通方式
+ 旅行节奏
+ 必去和排除规则
+ 每日活动与交通上限
= 分日行程和日内路线
完整过程不是让模型一次生成最终答案,而是分成几步:
text
检查真实地点
→ 获取旅行日期的天气
→ 按区域、天气和每日容量分配地点
→ 优化每天的游览顺序
→ 查询高德正式路线
→ 校验距离、耗时和跨区约束
→ 不可行时有限修复
→ 保存分日行程并展示地图
二、旅行节奏先决定每天的容量
同样三个景点,对不同用户可能意味着完全不同的强度。
当前项目把旅行节奏分为轻松、适中和紧凑三档。每一档不仅限制地点数量,还限制活动时间、全天交通时间和单段交通时间:
| 节奏 | 每日地点 | 活动时长 | 交通总时长 | 单段交通 |
|---|---|---|---|---|
| 轻松 | 最多 2 个 | 420 分钟 | 120 分钟 | 75 分钟 |
| 适中 | 最多 3 个 | 540 分钟 | 180 分钟 | 100 分钟 |
| 紧凑 | 最多 4 个 | 660 分钟 | 240 分钟 | 120 分钟 |
代码中,这些限制被定义为确定性的容量模板:
python
CAPACITY_TEMPLATES = {
"relaxed": DailyCapacity(
maximum_places=2,
maximum_activity_minutes=420,
maximum_transport_minutes=120,
maximum_segment_minutes=75,
),
"moderate": DailyCapacity(
maximum_places=3,
maximum_activity_minutes=540,
maximum_transport_minutes=180,
maximum_segment_minutes=100,
),
"compact": DailyCapacity(
maximum_places=4,
maximum_activity_minutes=660,
maximum_transport_minutes=240,
maximum_segment_minutes=120,
),
}
这样,"轻松一点"不再只是一句写进攻略的文案,而会真正减少每天的地点数和可接受交通时间。
容量模板也是后续校验的边界。即使某几个地点从地图上看距离不远,如果游览时间加起来已经超过当天强度,系统仍然会判定方案不合理。
三、让天气真正参与分日安排
很多行程生成器是在路线排完以后才附上一句"下雨记得带伞"。在 RouteBook Agent 中,天气会更早进入规划过程,直接影响地点被安排到哪一天。
规划开始时,系统先根据旅行日期调用和风天气逐日预报,再把天气归纳为三种倾向:
text
雨、雪、雷暴、雾等不利天气 → 优先室内
晴、少云 → 优先户外
其他天气 → 室内外均衡
地点类别也被分成室内和户外偏好:
python
INDOOR_CATEGORIES = {"museum", "dining", "shopping", "religious"}
OUTDOOR_CATEGORIES = {"park", "attraction", "landmark"}
假设北京三日游的天气是:
text
第一天:中雨
第二天:晴
第三天:多云
那么国家博物馆等室内地点在第一天的优先级会更高,景山公园、颐和园或长城等户外地点更倾向安排在第二天,第三天则根据区域和剩余容量均衡分配。
实现时,每个地点都会对所有未满的日期计算一个排序值:
python
return weather_penalty, len(result[index]), index
三个值依次表示地点类别是否符合当天天气、当天已有地点数量和日期顺序。系统优先选择天气惩罚更低、当前负载更小的日期。
因此,"第一天有雨就安排室内活动"不是模型临时生成的一句建议,而是天气数据实际参与了分日计算。
天气偏好也不是绝对禁令。有雨不代表必须删除所有户外必去地点,晴天也不代表不能参观博物馆。天气只是多个约束之一,还要和必去优先级、区域距离及每日容量共同判断。
如果天气接口暂时不可用,规划器会跳过天气权重,继续按距离和容量生成路线,并把天气标记为 unavailable,而不是编造预报。
四、让距离近的地点尽量安排在同一天
天气解决"哪一天更适合室内或户外",距离解决"哪些地点适合放在一起"。
当前实现先使用城市和行政区做粗粒度聚合:
python
grouped[f"{place.city}:{place.district}"].append(place)
同一区域的地点优先作为一组进入分日安排,必去地点优先于普通推荐地点,避免普通候选先占满容量。
例如故宫、天安门和景山空间上接近,更容易进入同一天;颐和园和圆明园可以组成另一天;八达岭距离市区较远,后续正式路线校验会避免它与多个市中心地点混排。
初始分日的决策顺序可以概括为:
text
必去地点优先
→ 同城市、同行政区优先聚合
→ 符合当天的天气类型优先
→ 不超过旅行节奏规定的每日容量
→ 各天地点数量尽量均衡
行政区不是最终距离。两个地点即使在同一个区,也可能相距较远;相邻地点也可能跨越行政区。因此它只用于低成本地缩小搜索空间,后面还要继续进行日内排序和正式路线验证。
五、用"最近邻 + 2-opt"优化日内顺序
地点分配到某一天后,还要决定先去哪里、再去哪里。
当前项目先用最近邻算法生成初始顺序:从第一个地点出发,每一步选择距离当前地点最近的未访问地点。
python
while remaining:
next_place = min(
remaining,
key=lambda item: distance_meters(current, item),
)
ordered.append(next_place)
初始排序使用经纬度估算直线距离,并根据平均纬度修正经度方向的尺度。最近邻速度快,但可能产生局部绕路,所以系统再执行有限 2-opt:尝试反转路线中的一段,只要总长度变短就保留新顺序。
text
A → B → C → D
尝试反转 B 到 C
A → C → B → D
当前最多执行四轮改进,足以消除小规模路线中的明显交叉和绕行,又不会引入过高计算成本。
这一步使用的仍然是直线距离,只负责快速得到候选顺序。展示给用户的正式公里数和耗时,必须由高德道路路径验证。
六、交通方式和正式路线怎样参与校验
日内顺序确定后,后端对相邻地点逐段查询高德路径:
text
地点 A → 地点 B
地点 B → 地点 C
地点 C → 地点 D
当前适配器接入了高德驾车和步行路径规划接口:
python
/v5/direction/driving
/v5/direction/walking
当交通方式为 walking 时调用步行接口,其他模式在当前版本暂时走驾车接口。公共交通、骑行和混合出行已经在领域模型中预留,但还需要接入对应的供应商路径能力。
高德返回的距离和耗时会被保存为结构化路线段:
python
PlannedSegment(
origin_place_id=left.id,
destination_place_id=right.id,
mode=mode,
distance_meters=route.distance_meters,
duration_seconds=route.duration_seconds,
provider=route.provider,
status=route.status,
)
正式路线结果会反过来参与可行性检查:
- 全天交通总时长是否超过当前旅行节奏;
- 任意一段交通是否过长;
- 是否同一天跨越三个以上行政区且路线超过 60 公里;
- 用户不接受远郊时,是否出现超过 30 公里的市区与远郊混排;
- 地点数量和活动时间是否超过当天容量。
因此,距离和交通方式不是地图展示阶段才使用的数据,它们会直接决定当前分日方案能否被提交。
如果发现可修复冲突,规划器会调整普通候选地点并再次验证,最多进行三轮。如果必去地点、旅行天数和交通上限无法同时满足,系统返回结构化冲突,不能通过删除必去地点或伪造更短耗时让方案通过。
高德接口暂时失败时,对应路线段会标记为 unverified。行程可以降级生成,但不会把估算数据伪装成已经验证的道路事实。
七、多因素编排是怎样组合起来的
天气、距离、交通方式和旅行节奏并不是简单加成一个总分,而是在不同阶段解决不同问题:
| 阶段 | 主要因素 | 解决的问题 |
|---|---|---|
| 规划前检查 | POI、必去、排除 | 地点能否进入规划 |
| 每日容量 | 旅行节奏 | 每天能安排多少内容 |
| 分日选择 | 天气、区域、优先级 | 地点应该放在哪一天 |
| 日内排序 | 经纬度距离 | 当天按什么顺序游览 |
| 正式验证 | 交通方式、道路距离、耗时 | 路线是否真的可执行 |
| 修复循环 | 容量、跨区、远郊、优先级 | 不可行时怎样有限调整 |
以"第一天有雨的北京三日游"为例,整个决策过程可能是:
text
第一天有雨
→ 室内地点获得更高分日优先级
→ 国家博物馆等室内地点进入第一天
故宫、天安门、景山距离较近
→ 优先聚合到同一组
→ 最近邻和 2-opt 调整游览顺序
用户选择轻松节奏
→ 每天最多安排两个主要地点
→ 超出容量的普通候选被调整
用户选择步行
→ 相邻地点调用高德步行路径
→ 单段或全天步行时间过长则触发冲突
方案通过校验
→ 保存每天的地点顺序、路线段、天气和提示
这种分层设计比"让模型综合考虑所有条件"更容易验证。每一步都有明确输入、规则和输出,出现不合理结果时也能判断问题来自天气分类、区域分组、容量模板还是正式路径。
八、怎样把规划结果展示在地图上
规划完成后,路书版本中保存三类核心数据:
text
places:地点名称、地址和经纬度
days_plan:每天的地点 ID 与顺序
route_segments:相邻地点的交通方式、距离和耗时
前端读取同一份结构化快照,而不是从聊天文本重新猜测路线。
分日面板展示每天的地点顺序、相邻路段公里数和预计耗时;地图使用高德 JavaScript API 创建编号 Marker,并通过 AMap.Driving 绘制相邻地点之间的道路路线。
后端和前端调用路径能力的目的不同:
text
后端高德 Web 服务:计算和保存可信距离、耗时,用于业务校验
前端高德 JS API:绘制线路、缩放视野和响应地图交互
如果没有配置高德 JS Key 或 SDK 加载失败,页面会降级为内置坐标视图,仍然保留地点编号和相对路线。
当前地图主视图使用 AMap.Driving 绘制驾车道路形状。若要让折线严格跟随每段交通方式,后续还需要根据 segment.mode 接入步行、骑行和公共交通绘制插件。
九、这一阶段的体会
路线规划真正困难的地方,不是生成一句"第一天游览故宫和天安门",而是让这句话同时满足天气、距离、交通和旅行强度约束。
如果第一天有雨,天气应该改变地点分配;如果两个景点距离近,它们应该尽量进入同一天;如果用户想轻松游,每日地点数和交通时间都应该下降;如果选择步行,路线能否执行必须用步行耗时重新判断。
当前实现还不是一个通用的最优旅行求解器。天气使用的是类别级偏好,初始聚合主要依赖行政区,公共交通和多模式地图绘制也需要继续扩展。但它已经建立了一条可以解释和验证的技术链路:
text
真实 POI
→ 天气与节奏参与分日
→ 距离参与聚合和排序
→ 交通方式与正式路线验证可行性
→ 结构化路书统一保存
→ 分日列表和地图读取同一份结果
这使 RouteBook Agent 生成的不再只是一段旅行攻略,而是一份知道每天去哪里、为什么这样安排,以及地点之间应该怎么走的结构化路书。