作者: 指令集梦境 发布时间: 最新推荐文章于 2026-05-15 16:31:25 发布
来源: https://blog.csdn.net/qq_53563507/article/details/160598553
用 AI 让地图"长脑子"——从纯文本幻觉到真实地点推荐,一次完整的 AI + 地图融合实践。
随着大模型能力的爆发,用 AI 生成旅行行程已经成为很多开发者的入门项目。但市面上的 AI 旅行规划器几乎都存在一个通病——纯靠大模型的"幻觉"生成行程:
本质上,这些方案让 AI 扮演了一个"不查资料就能给出完美攻略的旅行达人",但这显然不现实。
我的思路很直接:在 AI 生成行程之前,先用地图 API 获取真实数据,再把真实数据"喂"给 AI。
具体来说,用户输入"我要去成都旅行 3 天"后,系统会:
这样一来,AI 不再"编造"景点,而是从真实的地图数据中选择最优组合,行程的可行性和准确性大大提升。
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)}
几个工程细节:
前端收到不同type的事件后分别处理:
用户体验是:提交需求后,地图先亮起来(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 生成的行程卡片。用户可以一边看地图上的标注点,一边阅读详细的行程安排,直观且实用。

项目需要两个 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 搜索已经用完了。
应对策略:
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 + 地图的核心融合,但还有很大的扩展空间:
在移动应用开发中,精准定位是用户体验的核心需求之一。无论是出行导航本地生活服务,还是物流配送,安卓定
作者: 木斯佳 发布时间: 已于20260507 22:41:13修改 来源: https:bl
作者: 夜郎king 发布时间: 于20260428 22:30:00发布 来源: https: