1. 为什么我的聊天机器人突然变成了"路痴"
做聊天机器人跑过一阵子的人应该都有过这种体验:模型回答什么都能聊,写作、翻译、写代码、算数学都行,但一旦涉及地理位置,它就开始打太极。用户问"我附近哪里能修电脑",它给你推荐一堆全国连锁的品牌;用户说"从公司到机场大概多远",它只能告诉你"开车大约40分钟吧",但你追问它是按哪条路线算的,它就沉默了。
我第一次意识到这个短板是在一个内部demo里。当时测试的对话场景是"帮我找一个今天还营业的咖啡厅,要能步行到达",机器人确实很认真地给出了三家店的名字,但一问"它们在哪一栋楼、离我几步路",答案全靠编。那一刻我彻底明白:纯语言模型的常识里没有"附近"这个概念。它知道"附近"是个关系词,但它没有任何一种数据能够回答"谁在附近"。
于是就有了 lingbot-map 这个项目。说白了,它不是让大模型变得更聪明,而是给已经足够聪明的大模型配一张真实的地图。我做的这件事可以拆成三个目标:
- 让机器人听得懂用户口中极不规范的地址描述,比如"某某大厦楼下""高架桥旁边那个红绿灯右转的地方";
- 让机器人能查得到真实存在的POI(兴趣点),比如店铺、办公楼、景点,而不是靠幻想编名字;
- 让机器人能计算出有实际意义的空间关系,包括直线距离、步行耗时、粗略骑行路线,然后以可视化的卡片形式抛回对话里。
如果你也在折腾自己的机器人、自动回复脚本或者智能客服,并且经常被"位置类"问题卡住,那么我这篇项目复盘应该可以对得上你的胃口。我接下来会把这套模块的设计思路、核心代码套路、踩过的坑和最终的实测数据一次说清。
2. 整体架构:四个层次把"地点"变成"对话变量"
在真正写代码之前,我先画了张很粗暴的逻辑图——没有花哨的架构设计工具,就在白板上写了四个单词:听懂、查清、算准、展示。
2.1 听懂:意图识别与地址实体抽取层
这一层的工作是从用户输入里把"位置意图"挖出来。我测试过不少现成的NLP库,但最终决定不引入额外的重模型。原因有两个:一是多数场景其实可以用简单的规则+关键词兜底,二是真正难的是地址实体的归一化,这不是通用NLP能解决的。
我的实现思路是这样的:先用基础的关键词表做快筛,比如"附近、周边、哪里、怎么去、多远、几点开门、营业到几点"这些词命中任意一个,就把这条用户语句标记为"位置相关"。然后再用正则和分词结果去抽地址片段,比如"在科技路和软件街交叉口""天河北路某某大厦"这类结构。
import re INTENT_KEYWORDS = ["附近", "周边", "哪里", "多远", "怎么去", "几点开门", "营业时间", "在哪里"] LOCATION_PATTERN = re.compile( r"([\u4e00-\u9fa5]{2,12}(?:路|街|大道|巷|弄|广场|大厦|中心|园区|小区|商场|医院))" r"([\u4e00-\u9fa5]{0,10}(?:交叉口|路口|旁边|对面|楼下|附近))?" ) def detect_location_intent(text: str) -> dict | None: if any(kw in text for kw in INTENT_KEYWORDS): matched = LOCATION_PATTERN.findall(text) return { "is_location_query": True, "raw_entities": [m[0] + m[1] for m in matched], "query_text": text } return None这段代码写得很糙,但足够启动整个流程。实际生产环境里,可以在它外面再接一层实体链接服务,把"某某大厦"这类口语化称呼映射到标准地址。我后面会单独说这个问题。
2.2 查清:地理编码与POI检索层
意图层抽出来的地址是字符串,检索层要把它换成经纬度。这一步我接了一个主流地图开放平台的Web API做地理编码——就是那种输入文本返回坐标和标准化地址的接口。
不过这里有个关键设计:不会每个请求都直接打外部API。原因很简单,外部API有配额限制,而且延迟高。对于一个对话机器人来说,地理编码调用一次常常要200~500毫秒,这个时间如果在关键路径上,用户体验会非常难受。
所以我加了一层两级缓存:
- 第一级是内存LRU缓存,key为原始地址文本的哈希值,value为地理编码结果;
- 第二级是本地SQLite持久化,防止进程重启后缓存全丢。
CREATE TABLE IF NOT EXISTS geo_cache ( addr_hash TEXT PRIMARY KEY, raw_text TEXT NOT NULL, lng REAL NOT NULL, lat REAL NOT NULL, std_addr TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'api', created_at DATETIME DEFAULT CURRENT_TIMESTAMP );命中缓存就直接读库,不经过网络。我实测下来,缓存命中时整个"听懂+查清"阶段从平均380毫秒降到30毫秒左右,体感完全不一样。
2.3 算准:空间计算与距离估算层
拿到坐标以后,真正核心的空间计算全部在本地完成。这一层没有任何外部依赖,也不许有,因为距离计算这种东西你打API一次要等半天,在本地用公式几微秒就出来了。
距离用Haversine公式,适合地球上两点间的球面最短距离。公式长这样:
import math EARTH_RADIUS_KM = 6371.0 def haversine_km(lng1: float, lat1: float, lng2: float, lat2: float) -> float: phi1, phi2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) dlmb = math.radians(lng2 - lng1) a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlmb / 2) ** 2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return EARTH_RADIUS_KM * c这里必须提醒一句:Haversine算出来的是直线距离,不是驾车距离,也不是步行距离。如果用户问"走过去多久",用直线距离除以步行速度会明显偏小,因为实际路线总比直线长。所以我做步行耗时估算时乘了一个修正系数。
先说一下修正系数的思路。步行线路曲折系数在城市里通常取1.3~1.5,我取1.4;步行速度取每小时4.5公里(大约每公里13分钟)。这样估算公式就是:
def walk_time_minutes(distance_km: float) -> int: detour_factor = 1.4 speed_kph = 4.5 return int(round(distance_km * detour_factor / speed_kph * 60))这个估算值不可能很精确,但作为对话回复已经够用了。它给用户的是一个合理预期,而不是导航软件里的精确ETA。
2.4 展示:对话回复卡片化
最后一步是把计算结果变成用户能直观理解的内容。如果是纯文本,机器人可以说"距离大约3.2公里,步行约60分钟"。但更有意义的是在支持卡片消息的平台上,直接渲染一张包含地图缩略图的信息卡片。
我抽象了一个统一的消息结构,后续无论接到什么渠道(命令行、办公软件机器人、网页聊天框),只要实现了一个渲染函数就能复用:
{ "type": "map_card", "title": "从起点到终点", "summary": "距离3.2km 预计步行60分钟", "center": {"lng": 113.34, "lat": 23.13}, "markers": [ {"lng": 113.322, "lat": 23.118, "label": "起点:公司"}, {"lng": 113.401, "lat": 23.155, "label": "终点:某购物中心"} ] }前端拿到这份数据后,再去地图SDK里加载对应区域,画出两个标记和一个连线。整个项目里我最大的心得就是这一条:不要让上层业务直接绑定某一家地图厂商的SDK结构,统一抽象成自己的数据格式,后面想换地图服务就是改一个适配器的问题。
3. 核心模块逐个拆解:每个功能到底怎么写
前面的是整体架构,喷完了框架,接下来进入真正耗时间的地方:核心功能的细节实现。我把开发过程中代码迭代最多的几个模块拿出来说,这些也是后来被问得最多的部分。
3.1 地址解析:为什么"某某大厦附近"比想象中复杂
地址解析是所有流程的开始,但它难的地方不是提取,而是归一化。同样一个地方,用户可以叫它"某某大厦""某某国际广场""那栋经常排队的写字楼",三个表达指向的是同一个坐标。
我跑了一个小样本统计:拿200条真实对话记录去看,有47条包含地址指向,但其中21条都是口语化表达,没法直接用标准地址去地理编码。所以解析层必须允许"逐级宽松匹配"。
我的处理办法是做一个三级队列:
- 精确匹配:地址片段直接作为地理编码API的输入,拿到结果就成功;
- 关键词降级:如果精确匹配失败,从地址片段里提取核心词(比如去尾部的方位词、状语词),再做一次地理编码;
- 交叉检索:如果前两步都失败,把地址里的"地标词"抽出来,去POI检索接口查,查到了取第一个结果作为候选。
def resolve_address(raw_addr: str) -> dict | None: steps = [ ("exact", raw_addr), ("degraded", degrade_addr(raw_addr)), ("poi_search", extract_landmark(raw_addr)), ] for mode, query in steps: if not query: continue result = geocode(query) if result: result["match_mode"] = mode return result return None这条链路最容易被忽略的是"地标词"提取。比如"红绿灯右转那个路口""天桥底下那家烧烤",真正的地址信息就是"路口""天桥底下"这种模糊词,算法必须能向下兼容这种表达。我最后用了一个很土但很有效的方法:维护一张常见地标后缀表,按优先顺序做子串匹配。
3.2 附近搜索:网格索引比每次全量计算快得多
用户问"我附近有哪些便利店",核心操作是以用户坐标为圆心,画一个半径圈,找出所有圈内的POI。如果POI表只有几百条,直接遍历计算距离也很快;但如果你期望未来装进几百万条数据,每次遍历Haversine就是一场灾难。
我采用的做法是固定网格索引。先把整个城市按经纬度切分成边长大约0.005度的小格子(在低纬度地区大约相当于500米左右),每个POI入库时算一次它落在哪个格子。搜索的时候,把圆形的查询变成"覆盖这个圆的一组格子",然后只在相关格子里做精确距离过滤。
GRID_SIZE = 0.005 def grid_key(lng: float, lat: float) -> tuple[int, int]: return (int(lng // GRID_SIZE), int(lat // GRID_SIZE)) def nearby_pois(center_lng: float, center_lat: float, radius_km: float, max_results: int = 5): # 根据半径计算需要覆盖的网格范围 lat_span = radius_km / 110.0 lng_span = radius_km / (111.0 * max(1, math.cos(math.radians(center_lat)))) min_gx, max_gx = grid_key(center_lng - lng_span, 0)[0], grid_key(center_lng + lng_span, 0)[0] min_gy, max_gy = grid_key(0, center_lat - lat_span)[1], grid_key(0, center_lat + lat_span)[1] candidates = [] for gx in range(min_gx, max_gx + 1): for gy in range(min_gy, max_gy + 1): candidates.extend(poi_grid.get((gx, gy), [])) # 这里再做精确距离过滤和排序 filtered = [] for poi in candidates: dist = haversine_km(center_lng, center_lat, poi["lng"], poi["lat"]) if dist <= radius_km: filtered.append((dist, poi)) filtered.sort(key=lambda x: x[0]) return [poi for _, poi in filtered[:max_results]]有一说一,这个实现不算最优解。真正的工业级方案可能是用R树或H3六边形网格,但对我来说,固定网格在十几万条POI量级下实测单次查询小于5毫秒,已经远远足够了。过度工程优化,才是这段代码最需要警惕的坑。
3.3 多轮对话中的上下文位置管理
刚开始我犯了个错误:每一轮对话都是独立的,用户问"XX大厦附近有什么吃的",我查完返回了结果。但当用户接着说"那远不远",机器人压根不知道"那"指的是哪个位置。
这个问题的本质是位置实体的跨轮复用。我后来引入了一个简单的对话状态槽(slot),只存三个字段:当前锚点位置(经纬度+名称)、当前候选列表(上一轮返回的若干POI)、当前行动半径。
class MapDialogState: def __init__(self): self.anchor: dict | None = None self.last_poi_set: list[dict] = [] self.last_radius_km: float = 3.0 def update_anchor(self, pos: dict): self.anchor = pos self.last_poi_set = [] def resolve_implicit_target(self, user_text: str) -> dict | None: if "远" in user_text or "多久" in user_text or "怎么去" in user_text: if self.anchor is not None: return self.anchor if "刚才那个" in user_text or "那家" in user_text: if self.last_poi_set: return self.last_poi_set[0] return None这个状态槽不需要放进大模型的上下文窗口,它在机器人侧单独维护,只在地理相关流程中生效。这样既省token,也不会污染模型对普通对话的理解。
3.4 地图可视化的一张图拼接方案
卡片消息如果要显示地图缩略图,我一开始想的是后台截图,但后来觉得太绕了。更轻量的办法是直接生成一个静态地图URL,把中心点和标记点编码进查询参数,让前端负责加载。
这里要特别提醒一个格式问题:国内主流地图服务商对外网开放的平台,坐标体系是加密坐标,不是GPS原生坐标。如果你拿手机GPS采集的WGS-84坐标直接拼URL,地图上的位置会偏移几十米到几百米不等,看起来就是"标记点跑偏"。解决的办法是统一入口做坐标转换,所有进入展示层前的坐标先过一次转换函数,再把转换后坐标拼进URL。
坐标转换的坑我详细写一节,因为这个问题实在太隐蔽了,而且一旦出错特别难排查。
4. 坐标系迷局:WGS-84、加密坐标和地图偏移的纠葛
说实话,我第一次跑通"附近搜索"功能时特别兴奋,结果一打开地图卡片,差点怀疑人生——明明搜索出来的POI经纬度看着没问题,拿手机一比对,位置整体往东南方向偏了两三百米。当时我以为是地图SDK加载有问题,花了整整一个下午排查,最后才发现是坐标体系不统一。
这个领域有几个术语必须弄清楚:
- WGS-84:全球定位系统使用的原始坐标体系,手机GPS芯片直接输出的坐标就是它;
- 加密坐标(GCJ-02):国内地图厂商为了合规要求,在WGS-84基础上加入非线性偏移后的坐标体系;
- 其他投影坐标:用于区域精细计算,日常对话场景基本用不到。
我的代码里维护了一个全局的坐标策略:内部存储统一用WGS-84,凡是送往国内地图API请求的都转成加密坐标,凡是地图API返回的坐标都转回WGS-84再入库。这样保证内部计算一致,外部展示也不偏。
def to_display_coord(lng: float, lat: float) -> tuple[float, float]: # WGS-84 -> GCJ-02 return wgs84_to_gcj02(lng, lat) def from_display_coord(lng: float, lat: float) -> tuple[float, float]: # GCJ-02 -> WGS-84 return gcj02_to_wgs84(lng, lat)转换公式我不在这里贴了,算法公开资料很多,但有一个经验值得分享:转换后会存在不可逆的精度损耗,因为加密过程是非线性的。所以如果你要对同一个点反复转来转去,每次都会损失一点精度,累计多了位置就会漂移。正确做法是永远不要来回转,只在必要边界处转换一次。
5. 开发过程中踩过的三个典型坑
项目写完再回头看,遇到的坑其实都可以归类为"你对某个中间环节的理解不够深"。下面这三个坑对我触动最大,值得单独写出来。
5.1 缓存里的坐标和API里的坐标"打架"
这是我踩过最无语的一个坑。地理编码缓存表里存储的是第一次查询时API返回的坐标,而查询API本身返回的是加密坐标。我一开始没做转换,直接把缓存坐标扔给距离计算模块。这样就会造成一种诡异现象:同一个地址,第一次查显示距离1.2公里,第二次命中缓存后却变成了2.5公里。
排查了很久才发现缓存里的坐标体系跟实时查询的坐标体系不是同一个。修完转换逻辑后此后再没出现过这种"同地址不同距离"的情况。所以这里给同行一个提醒:引入缓存表的时候,一定要在表结构里明确标注坐标体系字段,否则后期一定会出事。
5.2 地址重名的优先级陷阱
"某广场"这种词,在绝大多数城市里都能命中好几处。地理编码API通常会返回一个默认的第一候选,但那个候选往往不是用户真正想去的地方。
我做了一个策略优化:如果对话上下文里已经有一个锚点坐标,那么当检索结果有多个匹配时,优先选择离锚点最近的那一个。这个逻辑很好理解:用户在东边问"某广场",大概率指的是东边那个,而不是西边那个。
def disambiguate(candidates: list[dict], anchor: dict | None) -> dict | None: if anchor is None or not candidates: return candidates[0] if candidates else None candidates.sort(key=lambda c: haversine_km( anchor["lng"], anchor["lat"], c["lng"], c["lat"] )) return candidates[0]这个改动很小,但实际对话体验提升非常明显。准确率从73%左右跳到了90%上下。
5.3 对话状态槽的过期问题
状态槽设计出来之后,我把锚点存在内存里,但忘了设过期时间。这就导致一个场景:用户早上问了"XX大厦附近有咖啡厅吗",晚上吃完饭回来随口问一句"那附近有药店吗",机器人居然还在用早上那个锚点。可能那个锚点根本不在用户当前所在城市。
后来我加了一个简单的生命周期管理:每个槽位默认存活30分钟,只要超过时间就直接清空;如果对话里出现了新的地理位置词,立即覆盖旧值。如果你是在生产环境做,建议把槽位时间调成可配置,不同业务场景容忍度会很不一样。
6. 回归测试与效果评估:数据不会骗人
写功能一时爽,评估参数才是硬仗。我搭了一套简单的回归测试框架,核心方法就是准备一批真实的对话样本,每条样本标注好期望答案,跑完自动化断言后对比结果。
我准备了90条测试样本,分成四类:
| 测试类别 | 样本数 | 典型问题 | 期望行为 |
|---|---|---|---|
| 纯距离查询 | 30 | "从天河路到滨江公园多远" | 返回距离+步行参考 |
| 附近搜索 | 25 | "我附近300米有便利店吗" | 返回POI列表及方向参考 |
| 多轮指代 | 20 | 上一轮问过位置,这一轮问"那远不远" | 正确指向上轮锚点 |
| 地址歧义 | 15 | "我想去某广场那个商场" | 返回距离锚点最近候选 |
跑完的整体结果是这样:
- 距离计算类全部通过,准确率100%,验证了本地公式的稳定性;
- 附近搜索类有3条失败,原因是POI数据库覆盖不足,偏远的点位查不到;
- 多轮指代类全部通过,说明状态槽设计有效;
- 地址歧义类有2条失败,都是因为锚点信息过于模糊,无法判断用户到底以哪里为基准。
总体通过率94.4%。我觉得这个数据对一个轻量级模块来说已经很能打了。剩余的失败案例基本可以通过扩充POI库和优化地址抽取规则来解决,并不影响核心体验。
另外我做了性能统计:完整处理一次"附近搜索"的对话,从用户发送消息到机器人返回卡片,平均耗时约620毫秒。其中地理编码API占了大头,约350毫秒;如果命中缓存,总耗时直接降到180~250毫秒。对于大多数对话机器人场景,这个响应速度完全够用。
7. 项目边界与隐私考虑:哪些事我尽量不去碰
为了安全性,我明确给 lingbot-map 划了一条边界:它只负责回答"地点在哪里、距离有多远、附近有什么",不碰用户轨迹记录,不上传任何对话原文到不可信的服务端。
对话文本送到地图开放平台做地理编码时,我做的处理是:尽可能只发送地址片段,而不是把整段对话塞过去。如果某条消息里地址片段提取失败,宁可答"抱歉没听懂位置信息",也不把完整原文外带。在当前大家对数据隐私越来越敏感的背景下,这条底线一定要守住。
另外一个边界是"实时导航"。lingbot-map给用户的是一个估算值和地图卡片,不提供逐路口的导航引导。原因很实际:实时路径需要交通流数据、路况信息和收费规则,这已经超出了"地图模块"的职责。真要无缝对接导航,应该把用户引导到专业导航软件去完成最后一公里的体验。
项目内部还有一块"POI数据采集"功能,但我只保留了用户可以主动提交"添加一个点"的入口,没有做大批量爬取。原因有两个:一方面是对数据版权和采集合规的顾虑,另一方面爬来的数据质量无法保证,反而会拉低搜索准确率。宁缺毋滥。
8. 后续规划:从"回答位置"走向"理解空间关系"
到目前这个版本,lingbot-map解决的是"静态位置问题"。接下来我想做的是让它理解动态空间关系,也就是让机器人能感知到"用户正在变化的位置"。
第一个打算加的功能是位置订阅提醒。用户设定"离我3公里以内的加油站提醒我",系统每隔一段时间算一次用户锚点与目标POI集合的距离。这个功能技术上不复杂,复杂的是推送方式的选择和功耗控制。
第二个打算是简单的路径估算优化。当前只用道路曲折系数修正,但不同城市的道路密度差异很大。下一版我会把城市级修正系数表做成可配置,根据目标城市自动调整估算模型。
第三个想法是对接语音输入场景。很多人压根不会打字输入地址,语音里说"在那什么大厦楼下""高架桥下面"这样的描述堪比鬼故事。语音转文字之后的地址歧义会更严重,需要走一套单独的降噪流程。
最后再分享一个我做这个项目时最深的体会:给机器人加"空间感"这件事,难点不在于模型,而在于工程侧的确定性。模型天生擅长模糊,但地址坐标和距离计算偏偏不允许模糊。把好这个边界,你做的任何对话机器人都能瞬间变得"靠谱"很多。