首页 > DeepSeek > 最新文章

【腾讯位置服务开发者征文大赛】基于腾讯地图 + DeepSeek 大模型的智能旅行规划器实战

CSDN博客 2026-04-28 17:18:19 人看过


一、项目背景与创意来源

痛点:AI 旅行规划"看着美,用不了"

随着大模型能力的爆发,用 AI 生成旅行行程已经成为很多开发者的入门项目。但市面上的 AI 旅行规划器几乎都存在一个通病——纯靠大模型的"幻觉"生成行程

推荐了一家评分很高的餐厅,但实际早就倒闭了

建议上午去景点 A,下午去景点 B,但两地相距 80 公里

行程看着很丰富,但完全无法落地执行

本质上,这些方案让 AI 扮演了一个"不查资料就能给出完美攻略的旅行达人",但这显然不现实。

解决思路:让 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     │  │              │  │              │          │ │ └────────┘  └──────────────┘  └──────────────┘          │ └─────────────────────────────────────────────────────────┘


三、核心实现:AI + 地图双引擎驱动

3.1 整体数据流

一次行程生成的完整流程分为 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 → 完成

3.2 腾讯地图 API 服务层

我将腾讯地图的 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 返回的完整错误信息打印到日志(包括 statusmessage),调试时非常有用——比如遇到 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 秒。

3.3 AI 智能关键词提取

用户输入的是自然语言(如"岳阳 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 参数"的翻译器。

3.4 POI 数据注入 AI Prompt

这是整个项目最核心的设计——将地图 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 不至于完全无法生成行程,但补充的内容会被标注提醒用户核实。

3.5 SSE 流式推送地图数据

用户不想等所有数据都准备好才看到结果。整个生成流程通过 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.vuewindow.TMapundefined,地图组件会静默失败,显示为黑屏。后端日志一切正常,前端也不报错,排查起来特别费时间。

在这里插入图片描述

地图初始化与标注

通过 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 搜索降级、关键词提取降级、地理编码兜底



六、踩坑记录与经验总结

坑 1:环境变量缺失导致"双重报错"

项目需要两个 API Key(DEEPSEEK_API_KEYTENCENT_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",    }

坑 2:前端地图黑屏——忘记加载 JS SDK

这是最容易忽略的坑。MapView.vueinitMap 函数依赖 window.TMap 全局对象,但这个对象需要通过 <script> 标签加载腾讯地图 JS API GL 才能获得:

const TMap = window.TMap if (!TMap) {  console.error('腾讯地图 JS API 未加载')  return  // ← 静默返回,地图不渲染,也不报错 }

如果 index.html 中没有引入 SDK 脚本,window.TMap 就是 undefinedinitMap 会直接 return——不会崩溃,不会抛异常,地图区域就静静地黑着

后端日志一切正常(API 调用成功、SSE 数据推送成功),前端也没有 JS 错误(只是 console.error),这种"两头都没问题但就是不工作"的情况排查起来特别费时间。

修复:index.html<head> 中添加:

<script src="https://map.qq.com/api/gljs?v=1.exp&key=你的Key"></script>

坑 3:路线规划返回的坐标格式不一致

腾讯路线规划 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])

坑 4:DeepSeek 输出 JSON 的稳定性

让 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"的指令。

坑 5:免费 Key 的日调用量限制

腾讯地图免费版 Key 有日调用上限。一次行程生成会并行发起 5-8 个 POI 搜索 + 若干路线规划请求,调试时反复测试很容易触发限制:

status: 121, message: '此key每日调用量已达到上限'

而且这个限制是按接口类型分别计算的——你可能地理编码还有额度,但 POI 搜索已经用完了。

应对策略:

在腾讯位置服务控制台查看各接口的用量统计

调试时减少重复测试,或在控制台申请提升配额

代码中已做容错:单个 POI 搜索失败不影响其他搜索(return_exceptions=True

坑 6:.env 文件路径容易搞错

main.pybackend/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 都能通过标准协议调用地图服务。

版权声明:倡导尊重与保护知识产权。未经许可,任何人不得复制、转载、或以其他方式使用本站《原创》内容,违者将追究其法律责任。本站文章内容,部分图片来源于网络,如有侵权,请联系我们修改或者删除处理。

编辑推荐

热门文章