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 写核心服务,主要是想把几个能力放到同一个作品里:

  1. 图搜索和路径规划。
  2. 带时间窗的多日行程生成。
  3. 多候选方案对比和多目标取舍。
  4. 本地 HTTP API 和可视化演示台。
  5. 不依赖远程服务也能跑的解释兜底。

项目本身用 cpp-httplib 做 HTTP 服务,nlohmann/json 处理 JSON,数据来自本地 data/pois.jsondata/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=amapsource=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-IdX-Response-Time-Ms。支持缓存的接口还会通过 X-Cache 标出 HITMISS

错误格式统一成:

{
"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}

查询 QUEUEDRUNNINGSUCCEEDEDFAILEDCANCELLED。这条链路让 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 build
mingw32-make test
mingw32-make run
mingw32-make validate-data

CI 会在 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、可视化到工程验证的完整链路。