日常使用地图导航时,我们多半已经习惯了系统自动推荐的“最快”或“最短”路线。但在某些特定场景下,用户真正关注的往往不只是时间和距离。例如夜间步行——从公司到地铁站,从商场到住处,从园区到公交站——路线短一些固然好,但很多人心中会多一层考量:这条路会不会太偏僻?沿途是否有便利店、商场、公交站、地铁口这类能让人“心里有底”的公共节点?如果多绕几分钟,能否换来一条更容易描述、更容易确认、沿途拥有更多参照物的路线?
带着这个真实需求,我开发了一个小型Demo:SafeWalk AI 安心夜行。它并非一个安全保证系统,也不会对现实安全性做出绝对判断。其核心思路是:将腾讯位置服务提供的地理编码、地点搜索、步行路线规划和地图可视化能力整合起来,再借助Agent式的任务拆解方法,把用户以自然语言表达的需求,转化成一条可执行的路线分析流程。
在这里插入图片描述
用户输入类似“晚上十点从深圳湾科技生态园走到后海地铁站,想走人多一点,有便利店经过,不想穿太偏的小路”这样的需求后,系统会依次完成:起终点解析、步行路线生成、沿线POI检索、候选路线构造、路线评分、地图渲染和解释输出。最终页面不只给出一条路线,还会展示候选路线对比、沿途公共锚点、评分拆解和推荐理由。
这篇文章将完整记录这个项目的设计背景、系统架构、腾讯位置服务接入方式、核心代码实现、运行效果,以及开发过程中踩过的坑。
一、为什么做“安心夜行”这个场景
起初想做这个项目,是因为发现很多地图类Demo容易停留在“展示点位”“展示路线”“搜索周边”这几个常规功能上。这些能力固然重要,但如果只是把接口调通,文章的辨识度会比较弱。
我希望找准一个更贴近真实生活的小场景:不必很大,但必须有明确痛点,也能真正体现AI和地图服务结合后的价值。
夜间步行正符合这个条件。
晚上从写字楼出来,地图可能给出一条最快路线,但最快路线不一定是用户当下最想走的。有些小路白天没问题,晚上却让人犹豫;有些路线多走几百米,但沿途有商场、地铁口、便利店、公交站,心理上会更容易接受。对用户来说,路线选择不只是一个几何问题,也是一个带有场景偏好的决策问题。
在这里插入图片描述
传统地图服务擅长提供真实、准确的空间数据;AI擅长理解自然语言、组织解释和做多条件拆解。这个项目的核心思路就是把两者分工做好:用户用自然语言描述出行偏好;系统把自然语言拆解成起点、终点、时间和偏好条件;腾讯位置服务负责提供真实路线和POI数据;评分模块根据路线周边公共锚点和绕行成本做比较;前端地图把路线、锚点和解释结果一起呈现出来。
这里要刻意说明一点:SafeWalk AI没有让AI直接“凭感觉”说哪条路安全,它输出的是“更符合夜间偏好的路线”,而不是绝对的安全结论。这个边界很重要——位置服务越接近真实生活,产品表达越要克制。
二、项目最终效果与运行方式
项目目录为:
safewalk-ai/
src/
client/ # React 前端
server/ # Express 后端
shared/ # 前后端共享类型
.env.example # Key 配置示例
package.json
README.md
在这里插入图片描述
本地运行方式:
cd safewalk-ai
npm install
npm run dev
启动后访问:https://localhost:5173
后端服务地址:https://localhost:8787
腾讯位置服务Key配置如下:
TENCENT_MAP_KEY=你的 WebService API Key
TENCENT_MAP_CLIENT_KEY=你的 JavaScript API GL Key
DEFAULT_CITY=深圳
PORT=8787
其中,TENCENT_MAP_KEY用于后端调用地理编码、地点搜索、步行路线规划等WebService API;TENCENT_MAP_CLIENT_KEY用于前端加载腾讯地图JavaScript API GL;DEFAULT_CITY用于POI名称解析兜底,比如“后海地铁站”更适合通过城市范围地点搜索来解析。
在这里插入图片描述
项目支持两种运行模式:
这种设计是为了保证参赛作品可演示、可截图、可复现。即使临时遇到Key配额、网络波动或接口失败,页面也不会直接崩掉。
三、整体架构设计
SafeWalk AI采用“前端交互 + 后端地图工具层 + 路线评估层”的结构。
在这里插入图片描述
前后端共享的数据结构定义在src/shared/types.ts里。核心返回结构是SafeWalkResult:
export type SafeWalkResult = {
type: "safe_walk_route";
mode: "mock" | "tencent";
summary: string;
recommendedRouteId: string;
intent: RouteIntent;
origin: { title: string; address: string; location: Coordinate; };
destination: { title: string; address: string; location: Coordinate; };
routes: SafeRoute[];
anchors: RouteAnchor[];
decisionTrace: string[];
notices: string[];
};
这个结构有几个好处:
- 前端不需要从大段自然语言里二次抽取路线;
- 地图折线、POI标记和评分面板可以直接消费结构化数据;
decisionTrace可以展示Agent式处理过程,让作品更像一个完整系统;notices可以把安全边界和接口回退信息明确告诉用户。
四、腾讯位置服务能力接入
在这里插入图片描述
本项目主要用到腾讯位置服务四类能力。
后端统一封装腾讯位置服务接口,避免把WebService Key暴露在浏览器中。核心请求函数如下:
const TENCENT_API = "https://apis.map.qq.com";
async function requestTencent
在这里插入图片描述
4.1 起终点解析:地理编码 + 地点搜索兜底
开发时遇到一个很典型的问题:“深圳湾科技生态园”可以通过地理编码解析,但“后海地铁站”这类POI名称直接走地理编码时可能返回参数错误。解决方式是:先尝试地理编码,失败后切换到指定城市范围内的地点搜索。
export async function resolvePlace(keyword: string) {
try {
return await geocode(keyword);
} catch (error) {
const city = process.env.DEFAULT_CITY || "深圳";
const pois = await searchRegionPois(keyword, city, 1);
const first = pois[0];
if (!first) { throw error; }
return { title: first.title, address: first.address, location: first.location };
}
}
这个修正让系统对真实用户输入更稳。用户不会区分“地址”和“POI名称”,但系统必须能兼容。
4.2 步行路线规划
步行路线规划用于生成基础省时路线,也用于生成带公共锚点的候选路线。
export async function planWalkingRoute(from: Coordinate, to: Coordinate): Promise
这里有一个细节:腾讯路线接口返回的耗时单位需要在项目内部统一处理。我在内部统一使用秒,前端显示时再转成分钟,避免真实接口和模拟数据单位不一致。
在这里插入图片描述
4.3 路线沿途POI检索
为了判断一条路线是否更符合“夜间偏好”,项目会沿路线抽样,搜索附近的公共锚点。
const anchorKeywords = ["便利店", "地铁站", "公交站", "商场", "医院", "派出所"];
async function collectRouteAnchors(route: WalkingRoute, routeId: string): Promise
这里使用Promise.allSettled,是因为POI检索不应该因为某一个关键词失败就中断整个路线分析。真实项目里,外部接口要尽量做局部容错。
在这里插入图片描述
五、核心链路实现
后端入口在src/server/index.ts:
app.post("/api/plan-route", async (request, response) => {
const parsed = planSchema.safeParse(request.body);
if (!parsed.success) {
response.status(400).json({ error: "请输入 1-500 字的路线需求" });
return;
}
const intent = parseIntent(parsed.data);
const result = await buildSafeWalkPlan(intent);
response.json(result);
});
在这里插入图片描述
5.1 自然语言意图解析
为了保证Demo可复现,这一版没有把大模型Key作为必需配置,而是先用轻量规则实现一个稳定的意图解析器。代码结构按Agent Tool Calling的方式组织,后续如果接入大模型,只需要替换intent-parser,腾讯位置服务工具层和评分层都不需要重写。
export function parseIntent(input: PlanRouteRequest): RouteIntent {
const rawText = input.query.trim() || "晚上十点从深圳湾科技生态园走到后海地铁站,想走人多一点,有便利店经过,不想穿太偏的小路。";
const extracted = extractOriginDestination(rawText);
const originText = cleanPlace(input.origin || extracted.origin || "深圳湾科技生态园");
const destinationText = cleanPlace(input.destination || extracted.destination || "后海地铁站");
return { originText, destinationText, travelTime: inferTravelTime(rawText), preferences: inferPreferences(rawText), rawText };
}
例如输入“晚上十点从深圳湾科技生态园走到后海地铁站,想走人多一点,有便利店经过,不想穿太偏的小路。”,解析结果会包含:
{
"originText": "深圳湾科技生态园",
"destinationText": "后海地铁站",
"travelTime": "night",
"preferences": {
"preferPublicPois": true,
"preferMainRoad": true,
"avoidDarkAlleys": true,
"needConvenienceStore": true,
"needTransitBackup": true
}
}
在这里插入图片描述
在这里插入图片描述
5.2 构造候选路线
核心规划函数是buildSafeWalkPlan。它先解析起终点,再调用步行路线接口生成省时路线,随后沿线搜索公共锚点,并用高价值锚点构造新的候选路线。
export async function buildSafeWalkPlan(intent: RouteIntent) {
if (!hasTencentKey()) { return buildMockResult(intent); }
try {
const origin = await resolvePlace(intent.originText);
const destination = await resolvePlace(intent.destinationText);
const fastest = await planWalkingRoute(origin.location, destination.location);
const fastestAnchors = await collectRouteAnchors(fastest, "fastest");
const viaAnchors = chooseViaAnchors(fastestAnchors);
const candidates: Candidate[] = [{
id: "fastest", title: "省时路线", strategy: "fastest", route: fastest, anchors: fastestAnchors
}];
if (viaAnchors.balanced) {
const route = await planRouteVia(origin.location, viaAnchors.balanced.location, destination.location);
candidates.push({
id: "balanced", title: "公共节点均衡线", strategy: "balanced", route,
anchors: await collectRouteAnchors(route, "balanced")
});
}
const routes = evaluateCandidates(candidates, intent);
const recommended = routes.reduce((best, route) => (route.score > best.score ? route : best), routes[0]);
return {
type: "safe_walk_route", mode: "tencent",
summary: `已基于腾讯位置服务生成 ${routes.length} 条候选路线,推荐「${recommended.title}」。`,
recommendedRouteId: recommended.id, intent, origin, destination, routes,
anchors: candidates.flatMap((candidate) => candidate.anchors),
decisionTrace: [
"地理编码与地点搜索解析起终点",
"调用腾讯位置服务步行路线规划",
"沿路线抽样调用地点搜索",
"构造候选路线并完成评分排序"
],
notices: [
"当前为真实腾讯位置服务接口结果。",
"路线评分只基于公开 POI 与路径数据做辅助比较,不代表现实安全保证。"
]
};
} catch (error) {
const message = error instanceof Error ? error.message : "未知错误";
return buildMockResult(intent, message);
}
}
在这里插入图片描述
5.3 路线评分模型
评分模型没有使用“安全分”这个说法,而是使用路线偏好匹配的综合分。当前包含四个维度:
- 公共锚点丰富度:路线沿途检索引到的便利店、地铁站、公交站、商场、派出所等相关POI数量和质量;
- 绕行成本:与最快路线相比的时间和距离增加比例;
- 偏好匹配度:路线沿途POI类型是否匹配用户提出的“想走人多”“经过便利店”等偏好;
- 解释完整度:路线是否容易用沿途公共锚点描述清楚。
核心代码如下:
export function evaluateCandidates(candidates: RouteCandidate[], _intent: RouteIntent): SafeRoute[] {
const fastest = candidates[0].route;
return candidates.map((candidate) => {
const scoreDetail = evaluateRoute(candidate, fastest);
const score = Math.round(
scoreDetail.anchorScore * 0.38 +
scoreDetail.detourScore * 0.24 +
scoreDetail.preferenceScore * 0.28 +
scoreDetail.explainScore * 0.1
);
return {
id: candidate.id, title: candidate.title, strategy: candidate.strategy,
distance: candidate.route.distance, duration: candidate.route.duration,
distanceText: formatDistance(candidate.route.distance),
durationText: formatDuration(candidate.route.duration),
score, scoreDetail,
reason: buildReason(candidate, scoreDetail),
polyline: candidate.route.polyline
};
}).sort((a, b) => b.score - a.score);
}
评分说明文本也基于真实POI类型生成:
function buildReason(candidate: RouteCandidate, score: RouteScoreDetail) {
const labels = [...new Set(candidate.anchors.map((anchor) => anchor.matchedKeyword))].slice(0, 4);
const anchorText = labels.length
? `沿线可参考 ${labels.join("、")} 等公共锚点`
: "沿线公共锚点较少";
const detourText = score.detourScore >= 80
? "绕行成本较低"
: "需要接受一定绕行成本";
return `${anchorText},${detourText},适合夜间希望路线更容易描述和确认的步行场景。`;
}
这样解释不会变成空泛的AI文案,而是能对应到地图上的真实锚点。
在这里插入图片描述
六、前端地图与交互实现
前端使用React + TypeScript,核心页面分为三块:
前端提交路线需求:
async function submit() {
setLoading(true);
setError("");
try {
const response = await fetch("/api/plan-route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query })
});
const data = (await response.json()) as SafeWalkResult;
setResult(data);
setSelectedRouteId(data.recommendedRouteId);
} finally {
setLoading(false);
}
}
腾讯地图脚本按需加载:
export function loadTencentMap(clientKey: string) {
if (window.TMap) return Promise.resolve(window.TMap);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(clientKey)}`;
script.async = true;
script.onload = () => resolve(window.TMap);
script.onerror = () => reject(new Error("腾讯地图脚本加载失败"));
document.head.appendChild(script);
});
}
路线渲染使用MultiPolyline,点位渲染使用MultiMarker:
const polylines = new TMap.MultiPolyline({
map,
styles: {
selected: new TMap.PolylineStyle({ color: "#1769e0", width: 8, borderWidth: 2, borderColor: "#ffffff" }),
candidate: new TMap.PolylineStyle({ color: "rgba(26, 112, 215, 0.35)", width: 5 })
},
geometries: result.routes.map((route) => ({
id: route.id,
styleId: route.id === selectedRouteId ? "selected" : "candidate",
paths: route.polyline.map((point) => new TMap.LatLng(point.lat, point.lng))
}))
});
在这里插入图片描述
路线评分面板展示四个维度:
在这里插入图片描述
七、运行效果与测试结果
本地真实接口测试输入“晚上十点从深圳湾科技生态园走到后海地铁站,想走人多一点,有便利店经过,不想穿太偏的小路。”,后端返回摘要:
{
"mode": "tencent",
"recommendedRouteId": "fastest",
"summary": "已基于腾讯位置服务生成 3 条候选路线,推荐「省时路线」。"
}
一次真实测试中,系统生成了3条候选路线,并检索到多类公共锚点:
可以看到,系统并没有简单地永远选择“公共节点最多”的路线,而是在“公共锚点”和“绕行成本”之间做了平衡。在这次测试里,省时路线本身已经检索到足够多的公共锚点,因此它综合得分最高。
这也符合产品设定:SafeWalk AI不追求强行绕路,而是在用户偏好和真实步行成本之间做解释性推荐。
八、开发过程中的问题与修正
8.1 .env.example不是实际配置文件
一开始我把Key写进.env.example后发现后端仍然识别不到。原因很简单:项目默认读取的是.env,而.env.example只是示例文件。解决方式是复制一份:
copy .env.example .env
正式提交仓库时,.env不应该上传,避免泄露Key。
8.2 POI名称不能完全依赖地理编码
“后海地铁站”这类输入更像POI名称,不是标准地址。直接调用地理编码可能返回参数错误。因此我加了resolvePlace:先地理编码,失败后走城市范围地点搜索。
这个问题很典型。真实用户不会按照接口类型组织语言,系统要替用户做兼容。
8.3 路线耗时单位需要统一
真实腾讯路线接口返回的耗时单位和项目内部展示单位需要统一。最初我按秒处理,导致页面显示异常。后来统一在接口封装层转换为秒,前端再格式化为“分钟”。
这个坑提醒我:接入地图服务时,不要只看字段名,还要仔细确认单位、坐标顺序和返回结构。
8.4 Express 5的兜底路由写法变化
后端最初用了app.get("*", handler);,在Express 5中,这个写法会被path-to-regexp报错。最终改成:app.get(/.*/, handler);
这是一个小问题,但如果不处理,项目会在启动阶段直接崩掉。
8.5 安全相关表达必须克制
“安心夜行”这个题目天然容易让人联想到安全判断。但项目不能说“这条路一定安全”,也不能用POI数量替代现实环境判断。所以我在页面和返回结构里都保留了提示:路线评分只基于公开 POI 与路径数据做辅助比较,不代表现实安全保证。
这类边界提示不是削弱产品,而是让产品更可信。
九、项目创新点总结
9.1 从“最短路线”扩展到“可解释路线”
传统导航更多关注距离和耗时。SafeWalk AI在此基础上加入了公共锚点、绕行成本、偏好匹配和解释文本,让路线推荐更接近真实夜间步行决策。
9.2 把地点搜索结果变成路线证据
地点搜索通常用于“找一个地方”。在这个项目里,地点搜索进一步参与路线评分:便利店、地铁站、公交站、商场等POI不只是搜索结果,而是解释路线推荐的空间证据。
9.3 Agent式工具编排,方便后续接入大模型
当前Demo为了可复现,使用规则解析器完成自然语言意图拆解。但整个工程已经按Agent Tool Calling思路拆分:意图解析、地图工具、路线评分、结果解释彼此独立。后续接入大模型时,只需要替换意图解析和解释生成层,不需要重写地图服务能力。
9.4 有真实接口,也有稳定回退
参赛Demo经常会遇到Key配额、网络或接口参数问题。如果没有回退机制,演示体验会很不稳定。SafeWalk AI在真实接口失败时会自动返回模拟数据,并把原因写入notices。这让项目更容易展示,也更像一个真正可用的工程原型。
9.5 可迁移到更多人群和场景
夜间步行只是一个入口。这套评分框架可以迁移到更多场景:
- 老年人步行:减少绕路,增加公交站、医院、休息点权重;
- 亲子出行:增加公园、商场、厕所、母婴室权重;
- 雨天通勤:增加地铁站、地下通道、商场连廊权重;
- 城市漫游:增加景点、咖啡馆、书店、拍照点权重。
从这个角度看,腾讯位置服务提供的不只是一组API,而是一套可以被AI编排的城市空间能力。
在这里插入图片描述
十、后续优化方向
SafeWalk AI目前已经跑通了从自然语言输入到腾讯地图展示的完整链路,但它仍然是一个原型。后续计划继续优化:
- 接入真正的大模型Tool Calling,让意图解析支持更多模糊表达;
- 加入路线途经点数量限制,避免候选路线过度绕行;
- 引入天气、时间段、节假日等动态因素;
- 增加移动端适配,做成更适合手机使用的页面;
- 支持用户自定义权重,例如“愿意多走5分钟换更多公共节点”;
- 增加路线分享功能,让用户把路线解释发送给朋友或家人。
结语
这次做SafeWalk AI,最大的感受是:AI+地图的价值,不一定要从一个很宏大的系统开始。一个清晰的小场景,只要把用户语言、真实空间数据、路线规划和解释输出串成闭环,就能让地图从“告诉你怎么走”进一步变成“解释为什么这样走”。
在这里插入图片描述
腾讯位置服务在这个项目里承担了非常扎实的底座角色:地理编码把文本变成坐标,地点搜索把城市公共节点找出来,步行路线规划把点连成真实可走的路径,JavaScript API GL再把结果呈现在用户面前。AI Agent式编排则把这些能力组织成一个完整流程。
在这里插入图片描述
从“最快的一条路”到“更适合当下的一条路”——这就是SafeWalk AI想完成的一小步。
