Tour Pass 是我做的一个 C++17 城市自由行行程规划服务。
它的目标不是接一个地图 API,然后把结果包一层页面。这个项目更像一次“算法服务怎么做成可演示作品”的练习:把城市里的景点、餐厅、酒店、夜间活动点建模成 POI 图,再结合最短路、时间窗、兴趣评分、候选策略、检索和自然语言解释,生成多日旅行计划。
当前 MVP 用的是长沙样例数据。它可以在本地离线跑起来,也可以在面试展示时直接打开 Web 演示台,看候选路线、评分拆解、时间窗复核、路径查询、替换方案和服务端指标。
2026-06-18 更新:这篇保留 Tour Pass 作为 C++ 算法服务阶段的架构复盘。后续项目已经演进到 C++ + Python Agent 双引擎、21 城市 15000+ POI、React 行程编辑器和 Render 线上演示;最新口径见《Tour Pass 6 月更新:从 C++ 算法服务到 AI 行程平台》。
为什么用 C++ 写这个项目
旅行规划这个题目天然适合讲算法,但如果只写一个命令行程序,又很难展示真实产品链路。
我最后选择用 C++17 写核心服务,主要是想把几个能力放到同一个作品里:
- 图搜索和路径规划。
- 带时间窗的多日行程生成。
- 多候选方案对比和多目标取舍。
- 本地 HTTP API 和可视化演示台。
- 不依赖远程服务也能跑的解释兜底。
项目本身用 cpp-httplib 做 HTTP 服务,nlohmann/json 处理 JSON,数据来自本地 data/pois.json 和 data/edges.json。这样它不需要数据库,也不需要真实地图服务,演示时的变量会少很多。
这点很重要。作品集项目如果每次演示都依赖网络、额度、地图服务状态和临时配置,稳定性会被外部因素拖走。Tour Pass 的核心规划结果全部由本地算法生成,远程 LLM 只负责解释增强。
后来我又给它补了一层服务运行时:显式线程池、请求中间件、进程内缓存、异步规划任务和 JSON 指标。这个变化让它不只是“一个能返回路线的 C++ API”,而是更接近一个可讲工程治理的小服务。
最近一轮迭代把项目从小样例演示继续往“可信作品集证据”推进了一步。默认离线样例仍然保留 25 POI / 46 edges,方便任何机器快速复现;同时新增了高德 Web 服务真实 POI 采集、通勤边生成、边来源门禁、真实规模实验、Docker 容器冒烟和部署说明。也就是说,Tour Pass 现在可以清楚地区分三件事:稳定离线演示、真实地点数据接入、以及还没有承诺的生产级实时地图路网。
再往后,项目接入了 SQLite 持久化和可配置最短路缓存。SQLite 记录规划请求、异步任务、benchmark 和数据版本,用来复盘演示过程;规划热路径仍然读取启动时加载的内存图。最短路缓存默认在 500 POI 以内使用全量 all-pairs 缓存,超过阈值才切到按需 LRU,这个取舍很朴素:几百点作品集规模优先简单可靠,LRU 是扩展保护策略,不是为了把小数据集说得很复杂。
启动时先把城市变成图
服务启动后会加载两份数据:
data/pois.json:景点、酒店、餐厅、夜生活和交通点。data/edges.json:POI 之间的距离和步行、公交、打车耗时。
每个 POI 不只是一个名字,还包含区域、经纬度、开放时间、建议游玩时长、标签、热度和价格等级。边数据则提供不同交通方式的耗时。
规划时默认优先使用公交时间:
transit_minutes -> taxi_minutes -> walk_minutes这样做是因为城市自由行里,公共交通比纯步行更接近普通游客行为;同时保留打车和步行字段,后续要扩展多交通方式也不会推倒重来。
图模块 PoiGraph 负责建立 id/name 索引和邻接表,并提供 Dijkstra 与 A* 路径查询。也就是说,行程规划器不直接关心数据文件怎么存,只问图模块:“从 A 到 B 需要多久?”
真实数据入口没有替换掉这条主链路,而是把外部 POI 标准化成同样的 pois.json / edges.json 形状。scripts/fetch_amap_pois.js 负责分页采集长沙真实 POI、去重和统计类型/区域覆盖;scripts/build_commute_edges.js 为近邻 POI 生成通勤边,并为每条边标记 source=amap 或 source=geo_estimated。这让文章和面试里可以明确说明:真实 POI 是真实地点,通勤边是否真实要看来源比例,不能把估算边包装成完整真实路网。
HTTP API 是演示入口,不只是调试口
服务默认监听:
http://127.0.0.1:8080核心接口包括:
GET /health:检查服务、数据和 LLM 配置。POST /trip/plan:生成行程。GET /route/shortest:查询两个 POI 的最短通勤路径。GET /poi/search:检索 POI。POST /trip/alternatives:按下雨、闭馆、太累、预算降低等场景给替换方案。POST /itinerary/explain:生成行程解释。POST /trip/jobs:提交异步规划任务。GET /trip/jobs/{id}:查询异步任务状态和结果。GET /metrics:查看请求、缓存、任务和运行时指标。
所有响应都会带上 X-Request-Id 和 X-Response-Time-Ms。支持缓存的接口还会通过 X-Cache 标出 HIT 或 MISS。
错误格式统一成:
{ "error": { "code": "VALIDATION_ERROR", "message": "请求参数不合法", "details": { "reason": "days must be between 1 and 7" } }}这类统一响应看起来不刺激,但对演示很有用。它让 API 更像一个稳定服务,而不是一组临时函数。
服务运行时这层解决什么
最开始 Tour Pass 的重点是算法:图、Beam Search、评分、候选方案。后来我意识到,如果只讲算法,项目还是容易被看成“一个比较完整的 demo”。所以我补了一层更偏后端工程的运行时能力。
服务启动时会从环境变量读取运行时参数:
$env:TOURPASS_WORKERS="8"$env:TOURPASS_MAX_QUEUE="64"$env:TOURPASS_CACHE_ENTRIES="64"$env:TOURPASS_CACHE_TTL_SECONDS="120"$env:TOURPASS_MAX_TRIP_JOBS="32"TOURPASS_WORKERS 控制 cpp-httplib 线程池 worker 数,TOURPASS_MAX_QUEUE 控制队列上限,TOURPASS_MAX_BODY_BYTES 限制请求体大小。这样服务不是完全依赖默认行为,而是能解释“并发请求来了以后由谁接、最多排多少、请求体过大怎么办”。
中间件层会做几件事:
- 请求进入时生成 request id。
- 统一写入 CORS 和安全响应头。
- 请求结束时记录耗时和状态码。
- 异常统一转成
{ "error": { "code", "message", "details" } }。 - 请求体超过限制时返回
PAYLOAD_TOO_LARGE。
这不是为了把本地演示项目包装成生产系统,而是为了补上后端服务最基本的治理表达。
缓存和异步任务
/route/shortest、/poi/search 和 /trip/plan 都接入了进程内 LRU + TTL 热点缓存。对演示来说,这有两个好处。
第一,重复查询路径、搜索关键词、候选行程时,可以通过 X-Cache: HIT 直接看到缓存命中。第二,性能基准能区分冷缓存和热缓存,不会只给一个模糊平均值。
异步任务接口则解决另一个问题:如果候选方案较多,或者想模拟削峰链路,可以把 /trip/plan 的请求体提交给:
POST /trip/jobs服务返回 job id 和状态查询地址。后台 worker 执行规划,调用方再通过:
GET /trip/jobs/{id}查询 QUEUED、RUNNING、SUCCEEDED、FAILED 或 CANCELLED。这条链路让 Tour Pass 能讲“同步接口”和“异步任务”两种服务形态,而不是所有事情都卡在一个请求里。
行程规划不是直接贪心
/trip/plan 是项目的主链路。
请求里可以传城市、天数、起止时间、酒店位置、兴趣标签、节奏、必去点、避雷项和候选数量。例如:
{ "city": "长沙", "days": 2, "start_time": "09:30", "end_time": "21:30", "hotel_location": "五一广场酒店", "interests": ["历史文化", "美食", "夜景"], "pace": "轻松", "must_visit": ["橘子洲", "湖南博物院"], "avoid": ["排队太久"], "candidate_count": 5}我没有用纯贪心做日内路线。纯贪心每个时间段只选当前分数最高的 POI,很容易因为前面选得太“爽”,导致后面的餐饮窗口、闭馆时间或通勤顺序变差。
Tour Pass 把一天拆成上午、午餐、下午、晚餐、晚上几个时间槽,然后用 Beam Search 保留 Top-K 局部状态。每个状态会记录已选站点、当前位置、当前时间、累计通勤、累计游玩和累计兴趣得分。
这种设计不保证全局最优,但它很适合这个项目:
- 比纯贪心更不容易早早锁死局部最优。
- 比全排列搜索更容易控制性能。
- 每个时间槽保留了什么状态,可以输出为调试轨迹。
- 面试讲解时能清楚解释“为什么这么选”。
候选方案要真的不同
旅行规划如果只返回一个方案,用户很难判断好不好。Tour Pass 在 candidate_count > 1 时会生成多种策略候选:
- 平衡推荐。
- 轻松少走路。
- 紧凑多覆盖。
- 文化优先。
- 美食优先。
- 雨天室内。
这些方案不是换个名字而已。不同策略会改变评分权重,比如低通勤方案会提高通勤惩罚,文化方案会加权历史文化、博物馆、古建筑、书院和寺庙,雨天方案会偏向室内 POI。
响应里还会输出 comparison,包括总站点数、总通勤、总评分、必去覆盖、开放时间风险、未安排数量、Pareto 层级、相对基线的 POI 重合率、区域重合率、独有 POI 和多样性摘要。
我比较喜欢这个设计。因为它把“推荐不同路线”从一句产品文案,变成了可被数据检查的结果。
LLM 只做解释增强
Tour Pass 支持 OpenAI/DeepSeek 兼容接口,但 LLM 不是核心决策者。
核心路线由结构化算法生成。/itinerary/explain 会把行程交给 LLM 生成更自然的中文说明;如果没有配置密钥、远程调用失败,或者显式设置:
$env:LLM_DISABLED="1"服务会自动返回本地中文模板。
这条边界是刻意设计的。旅行规划不能把路线可信度完全交给模型自由发挥,否则很难解释为什么选这个点、为什么绕这条路、为什么某个必去点没安排。LLM 在这里更像“讲解员”,不是“规划器”。
Web 演示台让项目可被看到
项目里有一个 web/ 静态演示页面,由 C++ 服务直接托管。
它不是简单放几个按钮,而是按演示流程拆成几个视图:规划概览、候选对比、路线明细、算法解释和工具箱。页面可以展示:
- 候选方案概览。
- 每日路线带和时间轴。
- 候选对比指标。
- Pareto 非支配层级。
- Beam Search 调试轨迹。
- BM25 排序贡献。
- 站点评分拆解。
- 时间窗复核。
- 路径查询。
- 场景替换。
- LLM 或模板解释。
- 请求缓存和服务指标。
这让 Tour Pass 不只是一个 C++ 算法库,而是一个能在浏览器里完整演示的算法服务。
工程门禁也要补上
项目有 Makefile 和 CMake 两套构建入口。常用命令是:
mingw32-make buildmingw32-make testmingw32-make runmingw32-make validate-dataCI 会在 Ubuntu 和 Windows 上跑数据校验、CMake 构建、CTest,并在 Windows 上启动服务做 API 冒烟测试。
数据校验脚本 scripts/validate_data.js 会检查 POI 字段、坐标、时间窗、类型覆盖、边引用、边权合法性和图连通性。这个门禁很朴素,但对算法项目很关键:如果输入数据坏了,算法再漂亮也会输出奇怪路线。
当时的最新版本还补上了容器和部署证据。Dockerfile 可以把 C++ 服务构建成镜像,容器默认监听 0.0.0.0:8080 并设置 LLM_DISABLED=1,避免公开视频依赖外部模型密钥。scripts/container_smoke.js 会在容器启动后检查 /health 和核心规划链路;docs/deployment.md 则把 GHCR、Render/Fly/Railway 这类 Docker 部署口径和 SQLite 持久卷边界写清楚。当时我刻意只说“可容器化演示”,不说“已经上线生产服务”;6 月之后项目才补上 Render 线上演示和 Agent 健康检查门禁。
这个项目真正想展示什么
Tour Pass 最想展示的不是“我会写一个最短路”,而是把算法能力放进可运行系统里以后,怎么处理边界。
路径查询要有,行程生成要能解释,候选方案要能对比,时间窗要能复核,数据要能校验,LLM 要能兜底,缓存、指标和异步任务要能说明服务治理,演示台要能把这些东西清楚展示出来。
这也是我觉得它比普通 demo 更有价值的地方:它不是只有一个算法答案,而是一条从数据、算法、API、可视化到工程验证的完整链路。