旅迹:把一句旅行愿望,变成真正可编辑的行程

一个由 AI 对话驱动,同时保留结构化编辑、版本管理与人工确认的旅行规划工作台。

旅行攻略从来不缺,但把散落在攻略、地图、天气、车票和酒店页面里的信息,整理成一份真正能执行的逐日计划,依然很费时间。

这也是我做「旅迹」的起点:我希望用户只需要告诉 AI“想去哪、什么时候去、几个人、预算多少、喜欢什么”,就能得到一份清晰的行程;同时,它又不能只是一次性生成一大段文字,而应该允许继续修改、锁定重要安排、查看地图与天气,并在出发前随时调整。

屏幕截图 2026-07-21 112322.png

它不只是“让 AI 写一篇攻略”

旅迹的核心是一份可以持续操作的结构化行程。目的地、日期、人数、预算和旅行偏好是规划输入;每天的景点、交通、住宿、用餐、时间与费用则会落到具体安排中。

用户既可以手动编辑,也可以直接对 AI 说:

第二天别排得太满,把博物馆换到上午,晚上想吃当地菜,人均控制在 150 元以内。

AI 会理解这次修改,但不会立刻覆盖原计划。所有变更先进入预览,用户确认后才会正式应用。对于旅行计划这种会不断调整、又可能包含重要预订的内容,这一步很有必要。

1
2
3
4
5
6
7
flowchart LR
A["输入目的地、日期、预算与偏好"] --> B["AI 生成结构化方案"]
B --> C["预览本次变更"]
C -->|确认| D["写入逐日行程"]
C -->|修订| E["继续对话调整"]
E --> B
D --> F["地图、天气、日历与外部工具"]

目前项目主要包含这些能力:

  • 根据目的地、日期、人数、预算和偏好生成行程;
  • 按天管理景点、交通、住宿、餐饮、时间和费用;
  • 通过自然语言继续确认、修订、取消或重试规划;
  • 展示地图与天气信息,辅助判断路线和出行条件;
  • 将带有明确日期和时间的安排导出到日历;
  • 接入 12306、搜索、酒店、机票等 MCP Server;
  • 保存版本快照,保护已经锁定的重要安排;
  • 隔离不同用户的数据与服务凭证;
  • 按预算自动匹配经济型/舒适型/高品质档位;
  • 出发地、货币、节奏、交通偏好等个性化设置;
  • 匿名用户无需登录即可体验完整流程;
  • 支持 DeepSeek、SiliconFlow、Volcengine、OpenRouter 等多家 AI 提供商;
  • 思维链推理(Thinking)模式,兼容不同提供商的 API 差异;
  • 深色模式切换。

从“能生成”到“敢修改”

这次开发中,我花了不少精力处理 AI 产品里不太显眼、却很影响可靠性的部分。

1. 所有修改都经过 preview → apply

AI 和手工操作最终走同一套变更流程,并且预览和实际应用分别有独立的 API。/api/trips/[id]/operations/preview 可以先计算操作结果并展示差异,确认后才调用 /api/trips/[id]/operations/apply 正式写入。这样既能降低误操作,也让 AI 的行为更容易理解。

2. 用 revision 处理并发冲突

每次提交都要携带当前行程的 revision。如果用户在另一个页面或请求中已经更新过行程,旧版本的操作会收到 HTTP 409,而不是悄悄覆盖新内容。

3. 用 idempotencyKey 避免重复提交

网络重试、连续点击或任务恢复,都可能让同一操作被发送多次。幂等键在数据库中建立唯一约束,确保一次修改只生效一次。

4. 锁定真正不能动的安排

已经买好的车票、订好的酒店不应该被 AI 随意挪动。旅迹允许锁定安排,锁定后不能修改、移动或删除;较重要的变更还会生成版本快照,方便恢复。

调度器:先判断用户想做什么,再决定调用哪条链路

对话式产品有一个很容易被忽略的问题:用户发来的每句话,并不都意味着“重新生成行程”。

例如,“酒店多少钱?”只是一个问题;“把第二天的景点换掉”是在修订方案;“不用了,就这样吧”在存在待确认方案时通常表示接受现有方案;而“不要这个方案”才是在拒绝方案。如果把这些表达全部交给同一个生成接口,很容易出现答非所问或误写入行程。

旅迹为此增加了一层对话调度器。它把判断拆成两个互相独立的维度:

  • pendingPlanDecision:用户对待确认方案的态度,可能是 acceptreviserejectundecided
  • requestKind:用户除此以外想做的事,可能是普通问答 answer、规划 plan 或没有额外请求 none

随后,程序再把这两个判断映射成三种动作:

判断结果 调度动作 后续行为
接受待确认方案 apply 把方案送入结构化生成与应用流程
创建或修订计划 plan 生成新的候选方案,等待用户确认
普通问题或尚未决定 reply 只回复消息,不改动行程
1
2
3
4
5
6
7
8
9
flowchart TD
M["用户消息"] --> P{"存在待确认方案?"}
P --> D["确定性规则"]
D -->|高置信度命中| A{"apply / plan / reply"}
D -->|表达含糊| S["语义模型分析"]
S --> A
A -->|apply| F["结构化生成与变更预览"]
A -->|plan| C["生成候选方案"]
A -->|reply| R["仅回答,不写入行程"]

这里采用的是“确定性规则优先,语义模型兜底”的混合方式。像“确认”“就按这个”“不要这个方案”“把行程调整一下”这类高置信度控制语句,会直接在本地完成判断,响应更快,也更可预测;问题、否定和转折较复杂时,才交给模型结合待确认方案和最近几轮对话做语义分析。

调度器还会尝试从最近完成的 AI 任务中找回尚未应用的候选方案。这样即使前端没有继续携带 pendingPlan,用户随后说“就这样吧”,系统仍有机会恢复正确的上下文。同时,代码会检查较长回复是否具有“完整行程”“逐日行程”、多个 Day 标题和确认提示等特征,避免一份实际的规划结果被误当成普通聊天消息。

AI 任务本身也是一个可恢复的状态机

确定动作之后,耗时的 AI 与 MCP 工作不会挤在一次黑盒请求中完成,而是由任务调度器按两套模式分阶段推进。

当用户在对话中要求调整方案时,任务会以 conversation 模式运行,重点是与模型交互并输出待确认的候选方案:

1
2
3
4
5
6
7
8
9
flowchart LR
Q["queued"] --> D["discovering_mcp"]
D --> P["planning_tool_calls"]
P --> C["composing_itinerary"]
C -->|需要实时数据| T["calling_mcp"]
T --> C
C --> V["ready_for_review"]
C -->|结构不合格| R["repairing_response"]
R --> V

当用户确认采用某个方案后,任务则以 format 模式执行——先生成符合当前日期、锁定安排和既有条目的精确结构化变更,再通过工具补全地点描述与图片,最后输出可执行的 JSON:

1
2
3
4
5
6
7
8
9
10
11
flowchart LR
Q["queued"] --> PF["preparing_format"]
PF --> D["discovering_mcp"]
D --> P["planning_tool_calls"]
P --> C["composing_itinerary"]
C --> T["calling_mcp"]
T --> C
C -->|发起 finish_research| F["finalizing_itinerary"]
F -->|结构不合格| R["repairing_response"]
R --> V["ready_for_review"]
F --> V

format 模式中,模型会使用一个独有的 finish_research 哨兵工具,在收集足够地点和图片信息后主动终止工具调用、进入最终结构化阶段。最终输出必须通过内容校验:JSON 被截断、为空或不符合可执行格式时,任务会进入 repairing_response 做一次严格纠错。校验通过后还会异步检查方案中图片 URL 的可达性(HTTP GET + content-type 验证),确保前端展示的都是有效图片。

模型在多次工具调用中可能收集大量返回数据——搜索、酒店列表、路线详情等。调度器会在提交给最终结构化模型之前,对历史工具证据做一次智能压缩:去重相同调用、裁剪过深的对象嵌套、省略非关键字段(如 polylineraw_data),确保上下文窗口不会被冗余信息挤占。

每个阶段都使用数据库状态进行抢占:只有 queued 任务能原子地变为 running,因此重复请求不会同时执行同一阶段。阶段失败会自动重试两次,仍然失败才把任务标为 failed。前端因此可以展示真实进度和工具轨迹,任务也能在短暂网络故障后从当前阶段继续,而不是每次都从头生成。

用 MCP 把旅行信息接进来

单靠语言模型无法保证实时车次、酒店价格、天气或搜索结果准确,因此旅迹把外部能力设计成可扩展的 MCP 工具。

目前已内置以下 Streamable HTTP MCP Server:

标识 服务 提供的能力
rail12306 12306 车站查询、车次搜索、中转查询、列车路线
searxng SearXNG 搜索 Web 搜索、搜索建议、网页正文读取
amap 高德地图 地理编码、地点详情、公共交通路线规划、天气
tavily Tavily 搜索 智能搜索与网页内容提取
dida RollingGo 酒店 酒店搜索、详情、标签
didaFlight RollingGo 机票 机场查询、航班搜索

应用负责工具发现、调用和轨迹记录,模型则根据规划任务选择合适的工具。为了避免在大量工具中选错,系统还会根据当前任务关键词做语义工具过滤:酒店相关的任务只暴露酒店工具,交通相关的只暴露交通工具,核心旅行工具(地图、路线、天气)则始终可用。

1
2
3
4
5
6
7
8
9
flowchart TB
U["用户"] --> UI["旅迹工作台"]
UI --> AI["OpenAI-compatible 模型"]
AI --> G["MCP Gateway"]
G --> T1["12306"]
G --> T2["搜索服务"]
G --> T3["高德地图"]
G --> T4["酒店与机票"]
UI --> DB["PostgreSQL"]

允许用户填写外部 Server 地址也带来了安全问题。项目中的 MCP Gateway 会限制目标为公开 HTTPS 地址,阻止回环、私网、链路本地和云元数据地址,同时控制重定向次数、请求超时(12 秒)与响应体积。AI/MCP 凭证使用 AES-256-GCM 加密后存储,仅在服务端解密和发送,列表接口只展示掩码,尽量缩小 SSRF 与密钥泄露风险。

每次 MCP 调用都会记录到审计日志(内存环形缓冲区,保留最近 100 条),包含调用方、工具名、耗时和状态。系统还按 IP 实施了速率限制(每分钟最多 30 次),防止单个客户端的意外或恶意高频调用。对 RollingGo 酒店和机票等外部服务,网关还会在校验参数合法性后,在响应中附加数据使用提示(如"displayRate 仅为参考展示价"),帮助模型准确理解返回结果。

技术选型

Web 主分支目前采用以下技术栈:

部分 技术
前端框架 Next.js 16(App Router)、React 19、TypeScript 5.9
桌面端 Electron 43、better-sqlite3(drfccv/electron-local 分支)
样式与界面 Tailwind CSS 4、Lucide React、React Markdown
服务端与数据 Node.js 22、PostgreSQL 18、Drizzle ORM
AI 与外部服务 OpenAI-compatible API、MCP(Streamable HTTP)、Headroom 上下文压缩
校验与工程化 Zod 4、ESLint 9、Node.js Test Runner
部署 Docker、Docker Compose

代码按职责拆分:app/ 负责页面、组件与 API Routes,lib/ai/ 处理 AI 规划和任务分发,lib/mcp/ 实现 MCP 注册、网关、安全和审计,lib/trips/ 则集中管理行程操作规则。数据库 Schema 与迁移分别位于 db/drizzle-pg/

上下文压缩(Headroom)

AI 对话中,反复携带完整的工具调用历史会迅速撑满上下文窗口。旅迹集成了可选的 Headroom 代理,在请求到达 AI 提供商之前自动压缩对话上下文,通常可节省 10–35% 的 Token 消耗。部署时只需设置 HEADROOM_PROXY 环境变量并在 Docker Compose 中启用 headroom Profile 即可,功能透明、无需修改代码。

预算与偏好:不仅是数字,更是档位与风格

输入预算后,系统会按人均每天花费自动划分档位:

  • 经济型(< 500 元/人/天):推荐公共交通、平价餐饮、免费或低票价景点;
  • 舒适型(500–1500 元/人/天):平衡性价比与体验,中高档住宿加特色正餐;
  • 高品质型(≥ 1500 元/人/天):升级到高档酒店、精品餐饮,加入私享导览、演出等深度体验。

如果未设预算,系统默认采用舒适但不浪费的中档方案。预算指导在每次生成行程时作为系统提示的一部分注入,确保模型从一开始就理解费用约束。

此外,用户还可以设置出发地、货币种类、旅行节奏(紧凑/适中/宽松)和交通偏好(公共交通/打车/自驾),这些偏好持久化在浏览器 localStorage 中,下次创建行程时自动填充。

多提供商与思维链推理

旅迹的 AI 层不仅兼容 OpenAI,还支持 DeepSeek、SiliconFlow(硅基流动)、Volcengine(火山引擎方舟)和 OpenRouter。系统会检测提供商类型,自动调整 API 参数差异:

  • max_tokensmax_completion_tokens 的选择;
  • parallel_tool_calls 的启用;
  • tool_choice: required 的兼容性处理;
  • 思考(Thinking)模式的请求格式适配。

这意味着用户可以根据自己的模型偏好和成本选择提供商,DeepSeek 的深度推理能力在复杂的多步行程规划中尤其有帮助。

匿名用户:零门槛开始使用

为了让用户在没有配置登录的情况下也能体验,项目内置了一个轻量匿名用户系统。中间件通过 HMAC 签名的 Cookie 管理匿名会话,自动生成稳定的用户标识,无需注册即可创建行程。后端对匿名用户也有速率限制,避免滥用。等用户决定注册后,数据可以平滑迁移到正式账户。

顺带聊聊 Electron 分支

除了 Web 版,仓库还保留了一个独立的 drfccv/electron-local 分支,用来维护 Windows Electron 单机版。

它并不是把网页直接塞进一个窗口这么简单。桌面版复用了现有 React 界面和 Trips、AI Jobs、MCP、天气、版本等业务能力,同时增加了 Electron Main、preload、Renderer transport、本地数据库、备份恢复和安装包构建链路。

Web 主分支 Electron 本地分支
Next.js 服务端运行 Electron Main 进程承载本地运行时
PostgreSQL 18 better-sqlite3 + Drizzle
数据保存在服务器 数据保存在 %APPDATA%\Lvji\data\trip-planner.db
服务端加密保存凭证 Electron safeStorage 绑定当前 Windows 账户加密
Docker / Node 服务部署 electron-builder 生成 Windows NSIS 安装程序
适合在线访问和多用户 适合个人离线管理本地行程数据

Electron 侧采用了尽量收敛的权限边界:

1
2
3
4
5
6
flowchart LR
R["Renderer:React UI"] -->|"白名单 IPC + Zod 校验"| P["Preload"]
P --> M["Electron Main"]
M --> API["本地 Route Dispatcher"]
API --> S["SQLite + Drizzle"]
M --> SS["safeStorage"]

窗口启用了 contextIsolation: truenodeIntegration: falsesandbox: true,并设置 CSP;Renderer 只能通过最小化的 preload 白名单调用可信 Main 进程。生产版本默认关闭 DevTools,外部访问限制为经过允许的 HTTPS 域名,MCP 仍沿用原有的 SSRF 防护。

本地数据库会在启动时自动执行迁移,并启用外键、WAL 和 busy timeout。普通 JSON 备份不会包含 AI/MCP 凭证;导入前会先校验格式,并在数据目录中保留当前数据库副本。安装后的应用已经包含 Electron 与 SQLite 运行时,普通用户无需再安装 Node.js、pnpm 或 Wrangler。

当然,桌面分支目前仍有一些明确的限制:没有配置自动更新服务;普通备份不能迁移凭证,换电脑后需要重新填写;AI、地图、天气与 MCP 仍依赖网络和用户自己的服务配置;正式分发前也还需要准备代码签名证书。

如果你想体验或参与桌面版开发,可以查看 GitHub 仓库的 drfccv/electron-local 分支。

详细的安装与运行说明请参阅 GitHub README

写在最后

旅迹想解决的并不是“让 AI 多写一篇攻略”,而是如何把模型生成、实时工具和用户确认组合成一套可信、可编辑、可恢复的旅行规划流程。

Web 版让它可以随时访问,Electron 分支则尝试把数据和运行时留在用户自己的电脑里。两条路线面向不同场景,但共享同一个目标:让 AI 负责繁琐的信息整理,同时让最终决定始终掌握在旅行者手中。

如果这个项目对你有帮助,欢迎在 GitHub 提交 Issue、参与改进,或者分享你希望加入的旅行规划能力。