随着大模型能力的爆发,用 AI 生成旅行行程已经成为很多开发者的入门项目。但市面上的 AI 旅行规划器几乎都存在一个通病——纯靠大模型的"幻觉"生成行程:
推荐了一家评分很高的餐厅,但实际早就倒闭了
建议上午去景点 A,下午去景点 B,但两地相距 80 公里
行程看着很丰富,但完全无法落地执行
本质上,这些方案让 AI 扮演了一个"不查资料就能给出完美攻略的旅行达人",但这显然不现实。
我的思路很直接:在 AI 生成行程之前,先用地图 API 获取真实数据,再把真实数据"喂"给 AI。
具体来说,用户输入"我要去成都旅行 3 天"后,系统会:
调用腾讯地图 WebService API 搜索成都的真实景点、餐厅、酒店
调用路线规划 API 计算景点之间的距离和驾车时间
将这些真实 POI 数据注入大模型的 Prompt
大模型基于真实地点生成可执行的行程方案
同时在前端腾讯地图上展示所有标注点和路线
这样一来,AI 不再"编造"景点,而是从真实的地图数据中选择最优组合,行程的可行性和准确性大大提升。
| 层级 | 技术 | 选型理由 |
|---|---|---|
| 后端框架 | FastAPI (Python) | 原生支持异步、SSE 流式响应,适合 AI + API 聚合场景 |
| AI 大模型 | DeepSeek Chat | 性价比高,中文能力强,支持流式输出 |
| 地图服务 | 腾讯位置服务 WebService API + JS API GL | 国内覆盖全面,POI 数据质量高,免费额度可满足个人开发 |
| 前端框架 | Vue 3 + Pinia + TailwindCSS | 组件化开发,状态管理清晰 |
| 前后端通信 | SSE (Server-Sent Events) | 实现流式文本 + 地图数据的实时推送 |
ai-travel-planner/ ├── backend/ │ ├── .env # 环境变量(API Key,需手动创建,项目仅提供 .env.example) │ ├── requirements.txt │ └── app/ │ ├── main.py # FastAPI 入口,加载 .env,配置 CORS │ ├── models/__init__.py # Pydantic 数据模型 │ ├── prompts/__init__.py # Prompt 模板 │ ├── routers/ │ │ └── trip_router.py # 核心路由(地图+AI串联) │ └── services/ │ ├── tencent_map_service.py # 腾讯地图 API 封装 │ └── deepseek_service.py # DeepSeek API 封装 ├── frontend/ │ ├── index.html # 入口(需在此引入腾讯地图 JS API GL) │ └── src/ │ ├── main.js │ ├── App.vue # 三栏主布局 │ ├── api/trip.js # SSE 请求封装 │ ├── stores/trip.js # Pinia 状态管理 │ └── components/ │ ├── TripForm.vue # 旅行需求表单 │ ├── MapView.vue # 腾讯地图展示组件 │ ├── ItineraryPanel.vue # 行程卡片面板 │ ├── DayCard.vue # 单日行程卡片 │ └── ChatBox.vue # 对话微调窗口
注意:项目没有自带
.env文件,只有根目录下的.env.example模板。首次运行需要在backend/下手动创建.env并填入真实的 API Key。
┌─────────────────────────────────────────────────────────┐ │ 前端 (Vue 3) │ │ ┌──────────┐ ┌───────────────┐ ┌──────────────────┐ │ │ │ TripForm │ │ MapView │ │ ItineraryPanel │ │ │ │ +ChatBox │ │ (腾讯地图GL) │ │ (行程卡片) │ │ │ └────┬─────┘ └───────▲───────┘ └──────▲───────────┘ │ │ │ │ │ │ │ └───────────────┴──────────────────┘ │ │ SSE (plan + map_data + done) │ └────────────────────────┬────────────────────────────────┘ │ ┌────────────────────────┼────────────────────────────────┐ │ ▼ 后端 (FastAPI) │ │ ┌──────────────────────────────────────────────────┐ │ │ │ trip_router.py │ │ │ │ ① 地理编码 → ② AI提取关键词 → ③ POI搜索+路线规划 │ │ │ │ ④ 数据注入Prompt → ⑤ AI流式生成行程 │ │ │ └──┬──────────────┬─────────────────┬───────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │腾讯地图 │ │ DeepSeek AI │ │ SSE Event │ │ │ │WebService│ │ 大模型 │ │ Stream │ │ │ │ API │ │ │ │ │ │ │ └────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────┘
一次行程生成的完整流程分为 5 个阶段,在后端的 event_generator 中依次执行,通过 SSE 将中间结果实时推送到前端:
用户提交 "岳阳 3天" │ ▼ ① 腾讯地图地理编码:岳阳 → (29.36, 113.13) │ 推送 map_data 事件(前端立即定位地图中心点) ▼ ② DeepSeek 提取搜索关键词(temperature=0.3): [{"keyword":"岳阳楼","category":"attraction","limit":3}, {"keyword":"洞庭湖景点","category":"attraction","limit":3}, {"keyword":"岳阳特色美食","category":"food","limit":5}, {"keyword":"岳阳酒店推荐","category":"hotel","limit":3}] ▼ ③ 腾讯地图 POI 搜索(asyncio.gather 并行)+ 景点间路线规划(串行) - 并行搜索:5 个关键词同时请求,~0.5 秒完成 - 按距离排序景点后,逐对计算驾车路线 │ 推送 map_data 事件(前端显示标注点和路线) ▼ ④ 将 POI 数据 + 路线数据格式化为上下文,注入 Prompt ▼ ⑤ DeepSeek 基于真实数据流式生成行程 │ 推送 plan 事件(前端逐字显示) ▼ done → 完成
我将腾讯地图的 WebService API 封装为独立的服务模块 tencent_map_service.py。所有接口的 Key 通过 _params() 统一注入 URL 参数:
API_BASE = "https://apis.map.qq.com/ws" API_KEY = os.getenv("TENCENT_MAP_KEY", "") def _params(**extra) -> dict: """构造通用请求参数""" return {"key": API_KEY, **extra}
模块提供 4 个核心能力:
地理编码——将城市名称转为经纬度坐标:
async def geocode(address: str, region: str = "") -> Optional[dict]: """地址 → 坐标(地理编码)""" params = _params(address=address) if region: params["region"] = region async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(f"{API_BASE}/geocoder/v1/", params=params) data = resp.json() if data.get("status") == 0 and data.get("result"): loc = data["result"]["location"] return { "lat": loc["lat"], "lng": loc["lng"], "formatted_address": data["result"].get("formatted_addresses", ""), } logger.warning(f"地理编码失败: address={address}, response={data}") return None
注意腾讯 API 返回的格式化地址字段名是 formatted_addresses(带 s),与直觉不同,容易拼错。
POI 搜索——支持周边搜索和城市区域搜索两种模式:
async def search_poi(keyword, city="", lat=None, lng=None, radius=50000, limit=10) -> list[dict]: """关键词搜索 POI""" params = _params(keyword=keyword, page_size=min(limit, 20), page_index=1) if lat is not None and lng is not None: # 有坐标时使用周边搜索 params["boundary"] = f"nearby({lat},{lng},{radius})" elif city: # 按城市区域搜索 params["boundary"] = f"region({city},0)" params["city_limit"] = "true" else: params["boundary"] = f"region({keyword},0)" async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(f"{API_BASE}/place/v1/search", params=params) data = resp.json() if data.get("status") != 0: logger.warning(f"POI 搜索失败: keyword={keyword}, response={data}") return [] # 解析返回的 POI 列表...
搜索失败时 response={data} 会把腾讯 API 返回的完整错误信息打印到日志(包括 status 和 message),调试时非常有用——比如遇到 status: 121, message: '此key每日调用量已达到上限' 就能立刻定位问题。
路线规划——计算两个坐标点之间的驾车路线:
async def plan_driving_route(from_lat, from_lng, to_lat, to_lng): """驾车路线规划""" params = _params( from=f"{from_lat},{from_lng}", to=f"{to_lat},{to_lng}", ) # 调用 /direction/v1/driving/ 接口 # 返回 distance(距离)、duration(时间)、polyline(路线坐标点)
批量搜索——使用 asyncio.gather 并行发起多个 POI 搜索请求:
async def search_multi_poi(queries: list[dict]) -> dict[str, list[dict]]: """并行搜索多个关键词的 POI""" tasks = [] keys = [] for q in queries: tasks.append(search_poi( keyword=q["keyword"], city=q.get("city", ""), lat=q.get("lat"), lng=q.get("lng"), radius=q.get("radius", 30000), limit=q.get("limit", 5), )) keys.append(q["keyword"]) results = await asyncio.gather(*tasks, return_exceptions=True) output = {} for key, result in zip(keys, results): if isinstance(result, Exception): logger.error(f"POI 搜索异常: {key}, error={result}") output[key] = [] else: output[key] = result return output
这里有两个设计要点:一是用 keys 列表维护查询关键词与结果的映射关系(因为 asyncio.gather 返回的结果顺序与输入一致);二是通过 return_exceptions=True 保证单个搜索失败不会拖垮整体——异常会被捕获记入日志,对应关键词的结果置为空列表,后续 AI 生成行程时会少一些该类别的地点,但不至于整个流程崩溃。
并行设计的效果很明显:如果串行搜索 5 类 POI,每类耗时约 0.5 秒,总共需要 2.5 秒;并行后只需要 0.5 秒。
用户输入的是自然语言(如"岳阳 2 天带小孩"),但腾讯地图 POI 搜索需要的是结构化关键词。这里我用 DeepSeek 做了一个"意图 → 搜索词"的转换层:
async def extract_poi_keywords(user_request: str) -> list[dict]: """调用 DeepSeek 从用户需求中提取 POI 搜索关键词""" messages = [ {"role": "user", "content": KEYWORD_EXTRACTION_PROMPT.format( user_request=user_request )}, ] # 使用较低温度(0.3)确保输出稳定 full_response = "" async for content in _stream_completion(messages, temperature=0.3): full_response += content # 解析 JSON(DeepSeek 可能会包裹在 markdown 代码块里) cleaned = full_response.strip() if cleaned.startswith("```"): cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] cleaned = cleaned.rsplit("```", 1)[0] if "```" in cleaned else cleaned try: result = json.loads(cleaned) if isinstance(result, list): return result except json.JSONDecodeError: pass # 降级:AI 输出解析失败时返回默认关键词 return [ {"keyword": "著名景点", "category": "attraction", "limit": 8}, {"keyword": "特色美食", "category": "food", "limit": 5}, {"keyword": "酒店住宿", "category": "hotel", "limit": 3}, ]
这里有一个工程细节值得注意:设计了降级策略。当 DeepSeek 返回的 JSON 解析失败时(格式异常、网络中断等),不会直接报错让整个流程崩溃,而是返回一组通用的默认关键词,保证后续的 POI 搜索和行程生成仍能继续。这属于"优雅降级"——用户拿到的是一个不那么精准但至少能用的行程,而不是一个报错页面。
Prompt 的核心设计是让 AI 输出结构化的 JSON:
请输出 JSON 数组,每个元素包含: - keyword:搜索关键词 - category:类别(attraction/food/hotel) - limit:搜索数量 规则: 1. 根据旅行天数和风格调整搜索数量,3天至少搜8个景点、3个餐厅 2. 如果有特殊需求(带小孩、老人等),关键词要体现 3. 关键词尽量具体,如"亲子乐园"、"网红咖啡厅"
为什么不让用户直接填关键词? 因为普通用户不会说"我要搜索 POI 关键词 attraction:著名景点,limit:8",他们会说"带小孩去岳阳玩,想吃当地美食"。AI 在这一层充当了"自然语言 → 地图 API 参数"的翻译器。
这是整个项目最核心的设计——将地图 API 返回的真实数据注入大模型的 Prompt:
def _build_poi_context(markers: list[dict], routes: list[dict]) -> str: """将 POI 数据格式化为 AI 可读的上下文""" lines = ["## 真实 POI 数据(来自腾讯地图)\n"] # 按类别分组输出 categories = {} for m in markers: cat = m["category"] if cat not in categories: categories[cat] = [] categories[cat].append(m) for cat, pois in categories.items(): lines.append(f"### {cat}") for i, poi in enumerate(pois, 1): lines.append(f"{i}. **{poi['title']}** — {poi['address']}") lines.append("") # 输出景点间距离参考(通过坐标匹配回 POI 标题) if routes: lines.append("### 景点间距离参考") for r in routes[:10]: dist_km = r["distance"] / 1000 dur_min = r["duration"] / 60 from_name = next( (m["title"] for m in markers if m["lat"] == r["from_lat"] and m["lng"] == r["from_lng"]), "起点" ) to_name = next( (m["title"] for m in markers if m["lat"] == r["to_lat"] and m["lng"] == r["to_lng"]), "终点" ) lines.append( f"- {from_name} → {to_name}:" f"约 {dist_km:.1f} 公里,驾车约 {dur_min:.0f} 分钟" ) lines.append("") return "\n".join(lines)
路线信息中有一个细节:通过坐标匹配回 POI 标题(from_name / to_name),让 AI 看到的是"岳阳楼 → 君山岛:约 15.3 公里"这样的可读信息,而不是一堆坐标数字。
生成的方案如下:
## Day 1 — 千古名楼与南湖风光 ### 上午 - **景点**:岳阳楼景区(湖南省岳阳市岳阳楼区洞庭北路60号,建议游览时长2-3小时) - **交通**:从住宿出发,建议打车或乘坐公交至岳阳楼景区。若入住岳阳兰花主题宾馆或岳阳龙源大酒店,打车约10-15分钟。 - **Tips**:建议早上去,游客相对较少,能更好地感受“先天下之忧而忧”的意境。登楼可俯瞰洞庭湖全景,记得带好相机。 ### ☀️ 下午 - **景点**:湖南岳阳洞庭湖旅游度假区(南湖景区)(湖南省岳阳市岳阳楼区南湖游路西3正东方向140米,建议游览时长2小时) - **餐饮推荐**:可在南湖景区周边寻找岳阳本地菜馆,品尝洞庭湖鲜鱼(如回头鱼、银鱼)。具体餐厅可到现场根据评价选择。 - **交通**:从岳阳楼景区到南湖景区,距离约5.4公里,打车约15分钟,公交也可直达。 - **Tips**:南湖景区适合散步或骑行,湖光山色非常惬意。可以租一辆共享单车沿湖慢行。 ### 晚上 - **餐饮推荐**:返回岳阳楼生活区附近,推荐在**岳阳楼生活区**周边(巴陵东路)寻找餐馆,这里餐饮选择丰富,可品尝岳阳烧烤或特色小吃。 - **活动**:夜游岳阳楼生活区,感受当地夜市氛围,或前往洞庭湖边散步,欣赏洞庭湖夜景。 - **交通**:从南湖景区打车返回住宿,约10-15分钟车程。 > Day 1 预估花费:约 ¥250(含门票80元、午餐60元、晚餐80元、交通30元) --- ## Day 2 — 君山寻古与江豚之约 ### 上午 - **景点**:君山岛景区(湖南省岳阳市君山区柳林洲街道,建议游览时长3-4小时) - **交通**:从住宿出发,建议打车至岳阳楼码头或城陵矶码头,乘坐轮渡前往君山岛(轮渡约30分钟)。若直接打车到君山岛景区,距离较远(约20公里),费用较高。 - **Tips**:君山岛是洞庭湖中的一座小岛,以爱情文化和自然风光闻名。岛上有湘妃祠、柳毅井等古迹,建议预留充足时间游览。 ### ☀️ 下午 - **景点**:岳阳市君山区江豚湾景区(湖南省岳阳市君山区芦苇总场七弓岭河段,建议游览时长1.5小时) - **餐饮推荐**:在君山岛景区附近或返回君山区吃午餐,推荐品尝洞庭湖鱼鲜,如清蒸鲈鱼或剁椒鱼头。 - **交通**:从君山岛景区到江豚湾景区,距离约10.1公里,打车约20分钟。 - **Tips**:江豚湾是长江江豚的重要栖息地,运气好的话可以看到江豚跃出水面。建议带望远镜。 ### 晚上 - **餐饮推荐**:返回岳阳楼区,推荐在**岳阳楼生活区**或**南湖广场**附近就餐,可选择湘菜馆。 - **活动**:前往**洞庭湖大桥**(湖南省岳阳市岳阳县)附近散步,欣赏洞庭湖日落和桥梁夜景。 - **交通**:从江豚湾景区打车返回岳阳楼区,约20-30分钟车程。 > Day 2 预估花费:约 ¥350(含轮渡票60元、午餐70元、晚餐80元、交通80元、其他60元) --- ## Day 3 — 城市漫步与休闲收尾 ### 上午 - **景点**:岳阳楼生活区(湖南省岳阳市岳阳楼区巴陵东路91号,建议游览时长1.5小时) - **交通**:从住宿步行或骑车前往,生活区是开放式区域,适合闲逛。 - **Tips**:这里是岳阳的繁华地段,可以逛逛本地商场和特色小店,购买一些岳阳特产(如君山银针茶、洞庭湖鱼干)。 ### ☀️ 下午 - **景点**:岳阳市君山公园(湖南省岳阳市君山区柳林洲街道,建议游览时长2小时) - **餐饮推荐**:在君山公园附近找一家农家乐,品尝地道的农家菜,如腊肉炒笋、土鸡汤。 - **交通**:从岳阳楼生活区打车前往君山公园,距离约10公里,打车约20分钟。 - **Tips**:君山公园与君山岛不同,是陆地上的公园,以自然生态和休闲为主,适合慢慢散步。 ### 晚上 - **餐饮推荐**:返回市区,可在**岳阳龙源大酒店(南湖广场店)** 附近的南湖广场周边选择晚餐,那里餐饮选择丰富。 - **活动**:在南湖广场散步,欣赏南湖夜景,结束愉快的岳阳之旅。 - **交通**:从君山公园打车返回住宿或酒店,约20分钟车程。 > Day 3 预估花费:约 ¥200(含午餐60元、晚餐70元、交通50元、其他20元) --- ## 行程总览与实用建议 - **总预估花费**:约 ¥800(不含住宿,住宿可根据预算选择:岳阳兰花主题宾馆、岳阳龙源大酒店或迪拜大酒店) - **住宿推荐**:建议选择**岳阳龙源大酒店(南湖广场店)** 或 **岳阳兰花主题宾馆**,位于市区中心,交通便利。 - **交通建议**:岳阳城区不大,打车或网约车是最便捷的方式。前往君山岛需乘坐轮渡,建议提前查询班次。 - **美食推荐**:洞庭湖鱼鲜(回头鱼、银鱼、鲈鱼)、岳阳烧烤、君山银针茶、剁椒鱼头。 - **安全提示**:游览洞庭湖和君山岛时注意防滑,江豚湾观豚时请勿靠近危险水域。
最后一句话是实际代码中的设计——给 AI 留了一个"安全出口"。如果搜索结果太少,AI 不至于完全无法生成行程,但补充的内容会被标注提醒用户核实。
用户不想等所有数据都准备好才看到结果。整个生成流程通过 SSE 将不同阶段的数据实时推送到前端:
async def event_generator(): try: # 阶段1:地理编码 → 推送地图中心点 center = await geocode(req.destination) if not center: center = {"lat": 30.57, "lng": 104.07} # 默认成都坐标 logger.warning(f"地理编码失败,使用默认坐标: {req.destination}") map_init = { "type": "map_data", "center": {...}, "markers": [], "routes": [] } yield {"data": json.dumps(map_init, ensure_ascii=False)} # 阶段2:AI 提取搜索关键词 keywords = await extract_poi_keywords(user_request_text) # 阶段3:POI 搜索 + 路线规划(_collect_map_data 中完成) markers, routes = await _collect_map_data( req.destination, keywords, center ) # 推送完整地图数据(标注点 + 路线) map_data = { "type": "map_data", "center": {...}, "markers": [...], "routes": [...] } yield {"data": json.dumps(map_data, ensure_ascii=False)} # 阶段4:AI 基于真实数据流式生成行程 poi_context = _build_poi_context(markers, routes) full_prompt = f"{user_prompt}\n\n{poi_context}\n\n请基于以上真实POI数据生成行程方案。" async for content in stream_generate(SYSTEM_PROMPT, full_prompt): yield {"data": json.dumps( {"type": "plan", "content": content}, ensure_ascii=False )} yield {"data": json.dumps( {"type": "done", "plan_id": plan_id}, ensure_ascii=False )} except Exception as e: logger.error(f"生成行程异常: {e}", exc_info=True) error_data = {"type": "error", "content": f"服务异常:{str(e)}"} yield {"data": json.dumps(error_data, ensure_ascii=False)}
几个工程细节:
地理编码失败有兜底:如果腾讯地图的地理编码接口调用失败(Key 额度用完、网络问题等),会使用默认的成都坐标,日志记录 地理编码失败,使用默认坐标 以便排查。
POI 搜索和路线规划在同一个函数 _collect_map_data 中:先并行搜索所有类别的 POI,再对景点按距离中心点排序,最后逐对计算相邻景点间的驾车路线,整个流程返回 (markers, routes) 一次性推送给前端。
异常被完整捕获:最外层的 try/except 确保任何阶段的错误都会通过 SSE 推送 error 事件到前端,而不是让连接直接断开。
ensure_ascii=False:确保中文地名在 JSON 序列化后不会被转义为 \uXXXX,前端解析后直接可用。
前端收到不同 type 的事件后分别处理:
map_data → 初始化地图、添加标注点、绘制路线
plan → 追加行程文本到卡片区域
error → 显示错误提示
done → 标记生成完成
用户体验是:提交需求后,地图先亮起来(1-2 秒内显示标注点和路线),然后行程文字逐字流出来,整个体验很流畅。
前端使用腾讯地图 JavaScript API GL 实现地图展示。需要在 index.html 中通过 <script> 标签加载 SDK:
<head> <script src="https://map.qq.com/api/gljs?v=1.exp&key=你的Key"></script> </head>
⚠️ 这一步很容易遗漏——如果 SDK 未加载,
MapView.vue中window.TMap为undefined,地图组件会静默失败,显示为黑屏。后端日志一切正常,前端也不报错,排查起来特别费时间。

通过 TMap.MultiMarker 在地图上批量添加标注点,按类别使用不同颜色的 SVG 图标:
// 创建标记图层,为不同类别配置不同样式 markerLayer = new TMap.MultiMarker({ map: map, styles: { 'attraction': new TMap.MarkerStyle({ width: 24, height: 34, anchor: { x: 12, y: 34 }, src: createMarkerSvg('#ef4444'), // 红色 - 景点 }), 'food': new TMap.MarkerStyle({ width: 24, height: 34, anchor: { x: 12, y: 34 }, src: createMarkerSvg('#f97316'), // 橙色 - 美食 }), 'hotel': new TMap.MarkerStyle({ width: 24, height: 34, anchor: { x: 12, y: 34 }, src: createMarkerSvg('#3b82f6'), // 蓝色 - 住宿 }), }, geometries: [], })
图标使用内联 SVG 转 base64 的方式生成,不依赖外部图片资源:
function createMarkerSvg(color) { const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="34" viewBox="0 0 24 34"> <path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 22 12 22s12-13 12-22C24 5.4 18.6 0 12 0z" fill="${color}"/> <circle cx="12" cy="12" r="5" fill="white"/> </svg>` return `data:image/svg+xml;base64,${btoa(svg)}` }
使用 TMap.MultiPolyline 绘制景点间的驾车路线。腾讯路线规划 API 返回的 polyline 坐标格式为 [lng, lat],需要转换为 [lat, lng]:
function updateRoutes(routes) { const geometries = routes.map((r, idx) => { // polyline 格式为 [[lng, lat], ...],需交换为 TMap.LatLng const paths = (r.polyline || []).map(p => new TMap.LatLng(p[1], p[0])) if (paths.length === 0) { // 无详细路线点时,用起终点画直线 paths.push(new TMap.LatLng(r.from_lat, r.from_lng)) paths.push(new TMap.LatLng(r.to_lat, r.to_lng)) } return { id: idx, styleId: 'route', paths } }) polylineLayer.setGeometries(geometries) }
当路线 API 未返回详细坐标点(polyline 为空)时,代码会用起终点坐标画一条直线作为兜底,避免路线完全消失。
当标注点较多时,使用 LatLngBounds 自动调整地图视野,确保所有标注点可见:
const bounds = new TMap.LatLngBounds() geometries.forEach(g => bounds.extend(g.position)) map.fitBounds(bounds, { padding: 60 })
地图组件通过 Pinia store 的 watch 响应式监听数据变化,每个 watch 都在 nextTick 中执行,确保 DOM(地图容器)已经渲染完毕后再操作地图实例:
watch(() => store.mapCenter, (center) => { if (!center) return nextTick(() => initMap(center)) }) watch(() => store.mapMarkers, (markers) => { if (!markers || markers.length === 0) return nextTick(() => updateMarkers(markers)) }) watch(() => store.mapRoutes, (routes) => { if (!routes || routes.length === 0) return nextTick(() => updateRoutes(routes)) })
采用三栏布局:左侧为旅行需求表单 + 对话微调窗口,中间为腾讯地图展示区域,右侧为 AI 生成的行程卡片。用户可以一边看地图上的标注点,一边阅读详细的行程安排,直观且实用。

在左侧表单填写旅行需求:目的地(如"岳阳")、天数(2天)、旅行风格(勾选"美食"、“文化”)、预算档位
点击「 生成行程」
中间地图区域立即定位到目的地城市,并逐步显示搜索到的景点(红色标注)、餐厅(橙色标注)、酒店(蓝色标注)
景点之间自动绘制蓝色路线
右侧行程区域逐字流式展示 AI 生成的详细行程方案
如果对行程不满意,在左下角对话窗口输入修改意见(如"第二天换成博物馆"),AI 会基于上下文调整方案
| 特性 | 实现方式 |
|---|---|
| 行程中的地点均为真实 POI | 腾讯地图 WebService API 搜索 |
| 距离和时间数据准确 | 腾讯地图驾车路线规划 API |
| 生成过程实时可见 | SSE 流式推送 + 地图渐次展示 |
| 支持对话微调 | 多轮对话 + 行程上下文保持 |
| 前端地图交互 | 腾讯地图 JS API GL 标注 + 路线 |
| 单点故障容错 | POI 搜索降级、关键词提取降级、地理编码兜底 |
项目需要两个 API Key(DEEPSEEK_API_KEY 和 TENCENT_MAP_KEY),如果 .env 文件未创建或 Key 未填写,会同时触发两个看似不相关的报错:
腾讯地图端:
status: 311, message: 'key格式错误'
空字符串被当作非法 Key 发给腾讯 API,返回 311。
DeepSeek 端:
httpx.LocalProtocolError: Illegal header value b'Bearer '
DEEPSEEK_API_KEY 为空时,构造的 Authorization header 值为 "Bearer "(Bearer 后面什么都没有)。httpx 认为这不是合法的 HTTP header 值,直接拒绝发送请求。
两个报错根因完全相同(环境变量缺失),但错误信息完全没有提示方向,很容易让人以为是代码逻辑问题而非配置问题。
应对: 实际代码中已在 main.py 的 /api/health 接口做了检查,启动后先调用一次就能快速定位配置状态:
@app.get("/api/health") async def health_check(): tencent_key = os.getenv("TENCENT_MAP_KEY", "") return { "status": "ok", "tencent_map": "configured" if tencent_key else "missing", }
这是最容易忽略的坑。MapView.vue 中 initMap 函数依赖 window.TMap 全局对象,但这个对象需要通过 <script> 标签加载腾讯地图 JS API GL 才能获得:
const TMap = window.TMap if (!TMap) { console.error('腾讯地图 JS API 未加载') return // ← 静默返回,地图不渲染,也不报错 }
如果 index.html 中没有引入 SDK 脚本,window.TMap 就是 undefined,initMap 会直接 return——不会崩溃,不会抛异常,地图区域就静静地黑着。
后端日志一切正常(API 调用成功、SSE 数据推送成功),前端也没有 JS 错误(只是 console.error),这种"两头都没问题但就是不工作"的情况排查起来特别费时间。
修复: 在 index.html 的 <head> 中添加:
<script src="https://map.qq.com/api/gljs?v=1.exp&key=你的Key"></script>
腾讯路线规划 API 返回的 polyline 数组中每个元素是 [lng, lat](经度在前),而腾讯地图 JS API 的 TMap.LatLng 构造函数接受 (lat, lng)(纬度在前)。在前端绘制路线时需要交换坐标顺序,否则路线会画到完全错误的位置(通常是非洲西海岸附近的大西洋上)。
// ❌ 错误:直接用 polyline 的 [lng, lat] 顺序 new TMap.LatLng(p[0], p[1]) // ✅ 正确:交换为 (lat, lng) new TMap.LatLng(p[1], p[0])
让 DeepSeek 输出纯 JSON 时,它经常会在 JSON 外面包裹 Markdown 代码块(json ... ),偶尔还会在 JSON 前后加一句解释文字。需要做清理后再 json.loads():
cleaned = full_response.strip() if cleaned.startswith("```"): cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] cleaned = cleaned.rsplit("```", 1)[0] if "```" in cleaned else cleaned
另外,关键词提取使用 temperature=0.3(而非默认的 0.7),能显著降低 JSON 格式出错的概率。低温度让模型更倾向于严格遵循 Prompt 中"只输出 JSON"的指令。
腾讯地图免费版 Key 有日调用上限。一次行程生成会并行发起 5-8 个 POI 搜索 + 若干路线规划请求,调试时反复测试很容易触发限制:
status: 121, message: '此key每日调用量已达到上限'
而且这个限制是按接口类型分别计算的——你可能地理编码还有额度,但 POI 搜索已经用完了。
应对策略:
在腾讯位置服务控制台查看各接口的用量统计
调试时减少重复测试,或在控制台申请提升配额
代码中已做容错:单个 POI 搜索失败不影响其他搜索(return_exceptions=True)
.env 文件路径容易搞错main.py 在 backend/app/ 目录下,加载 .env 时需要用 Path(__file__).resolve().parent.parent / ".env" 来定位到 backend/.env(向上两级到 backend/):
env_path = Path(__file__).resolve().parent.parent / ".env" if env_path.exists(): load_dotenv(env_path)
多一层或少一层 .parent 都会导致找不到配置文件,而且 Python 不会报错——env_path.exists() 返回 False 后直接跳过加载,Key 保持为空字符串,回到坑 1。
目前的项目已经实现了 AI + 地图的核心融合,但还有很大的扩展空间:
Agent 化改造:让 AI 具备调用腾讯地图 API 的 Tool Calling 能力,而不是由后端代码硬编码调用流程。用户可以说"帮我搜一下岳阳楼附近的咖啡厅",AI 自主决定调哪个 API。
多出行方式支持:目前路线规划只用了驾车模式,可以增加步行、公交、骑行等模式,让行程更贴合实际出行方式。
用户收藏与历史:将生成的行程保存到数据库,支持用户查看历史行程和收藏的地点。
实时数据融合:接入天气 API、节假日人流数据,在行程中给出更智能的建议(如"今天是节假日,建议上午提前出发")。
MCP 协议对接:将腾讯地图能力封装为 MCP Server,让任何 AI Agent 都能通过标准协议调用地图服务。