最近做位置类业务的团队,很容易陷入一种误区:拿到地址解析、区域匹配、兴趣点标注的需求,第一反应是先接一个大模型。好像地理工具不带上模型,就缺少“智能感”。但如果你真的负责一个每天要跑几百万次位置匹配的服务,最该追求的品质不是“会聊天”,而是“每次都能算出同一个结果”。
标题里的Geo tool with no LLM run,我理解成两层含义:一是这个地理工具本身可以在完全不运行大模型的情况下完成核心工作;二是在架构上刻意让大模型离开主链路,只把模型留给更需要语义理解的可选环节。这是一个非常值得讨论的设计选择,尤其当业务涉及交易风控、物流路径、行政区域打标、到店服务半径判断时,“无 LLM 的主链路”往往比“什么都用模型生成”更稳、更省、更可解释。
这篇文章会从架构判断出发,讲清楚为什么地理信息工具的核心链路不推荐依赖 LLM,然后给出一套完整的最小示例代码。你可以照着跑通一个支持坐标定位、文本城市解析、距离半径过滤的地理小工具,整个过程不需要调用任何大模型接口,也不需要联网推理环境。
1. 为什么地理工具要刻意“不运行大模型”
很多人觉得地址识别和位置理解是自然语言问题,于是直接把文本扔给大模型。但从实际项目看,地理工具要解决的任务绝大多数不是语义理解,而是确定性计算。坐标是否落在某个多边形内、两个点之间的球面距离是多少、一个地址是否属于某个城市,这些问题的正确答案是客观的,只要数据源准确,规则明确,结果就应该唯一。
把这类任务交给 LLM,会遇到几个真实问题。
第一是输出不稳定。同一个坐标,同一段地址,模型可能因为温度参数、上下文长度、模型版本变化给出不同结构。位置类接口往往要落库、要审计、要按批次重跑,一旦结果不可复现,后续数据比对和问题追溯都会很痛苦。第二是计算幻觉。模型并不会真正执行几何计算,它只是根据训练语料预测下一个词。当被问到“这个点在哪个区域里”时,模型可能会从记忆里抽出一个“看起来像”的答案,但那个答案并不是经过空间计算得到的。对于风控和计费类场景,这种幻觉不可接受。第三是成本不可控。如果每次请求都走模型接口,批量清洗几千万条位置数据会产生很高的 token 开销,而其中大量计算用哈希表、空间索引和几何库几毫秒就能完成。
所以更合理的判断是:**地理工具的核心链路是否运行 LLM,不应该由技术潮流决定,而应该由业务性质决定。**行政区划匹配、多边形包含判断、坐标距离计算、轨迹路网匹配,这些本质上属于空间计算,应该交给 Shapely、PostGIS、Geopandas 这类确定性工具。大模型只在“地名拼写极其混乱、需要语义归一化”这类问题上才值得进入前置处理环节。
2. 不运行 LLM 的 geo tool 适合谁用
不是所有地理工具都需要去掉 LLM。反过来,也不是所有带 LLM 的地理功能都是画蛇添足。先看清楚适用场景,再决定架构,比盲目跟风重要得多。
适合做成no LLM run的场景往往具备以下几个特征:
- 输入是结构化或半结构化的坐标、城市字段、区域编码。
- 业务要求结果可复现,比如批量回刷历史数据。
- 请求量比较大,单次响应的成本必须足够低。
- 错误需要能解释,出了问题需要能定位是数据问题还是规则问题。
典型例子包括:给用户 LBS 记录打城市标签、判断坐标是否进入某片商业围栏、按半径筛选附近的配送站、清洗历史地址数据并补全区域编码。这些任务直接用行政区划 GeoJSON 配合空间索引就能完成,把结果输出成标准字段,团队也好理解。
不适合做成完全无 LLM 的场景也有,例如用户输入一段极其口语化的描述:“公司楼下那个卖煎饼的摊位”,要转成地图 POI,这种任务涉及强常识推理,传统规则很难覆盖。但即便如此,多数项目也不需要让 LLM 直接参与核心计算。更稳妥的做法是先由 LLM 做候选提取,再由空间计算校验,模型建议和算法结论互相印证,不能只信模型。
下表可以直接用于架构选型时的判断:
| 业务场景 | 是否建议主链路跑 LLM | 推荐实现方式 |
|---|---|---|
| 批量地址打城市标签 | 不运行 LLM | 本地城市词表 + 区域GeoJSON |
| 坐标围栏判断 | 不运行 LLM | Shapely 空间索引与多边形判断 |
| 门店或配送站半径筛选 | 不运行 LLM | Haversine 距离公式或投影坐标计算 |
| 口语化地名理解 | 可让 LLM 辅助 | LLM 提取候选 + 空间库校验 |
| 地图问答助手 | 需要 LLM | 知识检索结果约束生成,避免自由发挥 |
这里的关键不是“绝对不用模型”,而是把是否运行 LLM 变成可配置、可摘除的选项,而不是主流程中绕不开的重组件。
3. 核心架构:先分层,再决定哪一层不跑 LLM
一个清晰的 no-LLM 地理工具,通常可以分成四层。
数据层负责持有区域边界、城市词表、POI 列表。这些数据应该是静态、可版本化的,最好能随发布包一起部署。不要把行政区划边界数据放在接口里每次实时拉取,既慢又不稳定。更推荐构建阶段生成一份 GeoJSON 或 Parquet,随程序一起发布,数据更新走 CI/CD,而不是运行时临时下载。
索引层负责把坐标变成可检索的空间对象。用 Shapely 构建 STRtree,或者用 PostGIS 的 GiST 索引,本质上都是把数十万甚至上百万个多边形组织起来,让一次点查询不必遍历全部图形。没有这一层,匹配性能会非常难看。
计算层负责真正的地理运算,包括多边形包含判断、球面距离计算、投影转换、坐标与区域的映射。这一层只依赖成熟地理算法库,不依赖网络,不依赖模型,也不依赖外部 API。
服务层在最外面,负责接收参数、组装结果、处理缓存和异常。这一层可以预留两个入口:一个纯规则入口,所有计算都走本地算法;另一个可选增强入口,当规则判断置信度不足时,才把文本提取这类任务交给外部模型。但即便引入模型,重试、缓存、空值兜底也仍然由服务层控制。
这种分层的好处是,LLM 即便未来要接,也只是服务层一个旁路模块,不会反过来污染核心计算的正确性。新人接手代码时,看到主链路上没有模型调用,也能很快理解系统在做什么。
4. 环境准备与目录结构
本文演示代码使用 Python 3 和 Shapely。Shapely 负责几何对象构建和空间索引,代码本身不依赖 GeoPandas,方便你快速理解核心逻辑。
建议先创建独立虚拟环境:
python3 -m venv .venv source .venv/bin/activate pip install shapely如果你希望直接读取大批量 GeoJSON 并做字段分析,也可以额外安装 GeoPandas:
pip install geopandas项目目录可以这样组织:
geo-tool-no-llm/ ├── main.py ├── core.py ├── geo_math.py ├── text_utils.py ├── data_demo.py └── data/其中core.py放空间索引与区域匹配逻辑,geo_math.py放距离计算,text_utils.py放文本城市解析,data_demo.py负责生成一份可运行的演示区域数据,main.py组装命令入口。这样拆开主要是为了让每一层都能单独测试。实际工程中,你还可以把不同模块对应到数据层、索引层、计算层和服务层,后续替换数据源或增加模型插件时,不需要重写整个文件。
5. 完整实现:一个不跑 LLM 的地理小工具
下面从数据准备开始,逐步实现一个最小可运行的 geo tool。这个工具会提供三个能力:把一个经纬度点匹配到区域名称;从非结构文本中提取城市名称;按距离过滤给定中心点附近的 POI。整个过程完全不运行 LLM。
5.1 准备演示区域数据
为了避免读者先去找复杂的行政区划文件,我用一个脚本生成两份演示区域数据。注意:这里生成的坐标只是为了跑通流程,不代表任何真实边界。
# 文件路径:data_demo.py import json from pathlib import Path DEMO_REGIONS = [ { "type": "Feature", "properties": {"name": "demo-alpha"}, "geometry": { "type": "Polygon", "coordinates": [[[116.0, 39.5], [116.6, 39.5], [116.6, 40.1], [116.0, 40.1], [116.0, 39.5]]] }, }, { "type": "Feature", "properties": {"name": "demo-beta"}, "geometry": { "type": "Polygon", "coordinates": [[[117.0, 40.0], [117.8, 40.0], [117.8, 40.6], [117.0, 40.6], [117.0, 40.0]]] }, }, ] DEMO_POIS = [ {"name": "point-a", "lng": 116.35, "lat": 39.9}, {"name": "point-b", "lng": 116.31, "lat": 39.81}, {"name": "point-c", "lng": 117.5, "lat": 40.2}, ] DEMO_CITIES = ["北京市", "上海市", "广州市"] def ensure_demo_data(data_dir: Path): data_dir.mkdir(parents=True, exist_ok=True) region_path = data_dir / "regions.geojson" if not region_path.exists(): region_path.write_text( json.dumps({"type": "FeatureCollection", "features": DEMO_REGIONS}, ensure_ascii=False), encoding="utf-8", ) return region_path这段代码里的DEMO_REGIONS是两个简单的四边形区域。真实项目里你应当把这里换成有效的行政区划或业务围栏 GeoJSON,通常是文件或数据库中读取,发布阶段就把数据固定下来,避免运行期不确定。
5.2 空间索引与区域匹配
接着实现核心的空间匹配逻辑。先构建一个区域几何列表,再用STRtree建立空间索引。查询点时先通过索引缩小候选范围,再对候选区域做精确的包含判断,避免每个点都遍历全量多边形。
# 文件路径:core.py import json from pathlib import Path from shapely.geometry import Point, shape from shapely.strtree import STRtree class GeoIndex: def __init__(self, geojson_path: Path): with open(geojson_path, "r", encoding="utf-8") as f: feature_collection = json.load(f) self.region_names = [] geometries = [] for feature in feature_collection["features"]: self.region_names.append(feature["properties"]["name"]) geometries.append(shape(feature["geometry"])) self.tree = STRtree(geometries) def locate(self, lng: float, lat: float): point = Point(lng, lat) candidate_indexes = self.tree.query(point) for idx in candidate_indexes: # 对于边界采样点,用 covers 比 contains 更符合业务直觉 if self.tree.geometries[idx].covers(point): return { "matched": True, "region": self.region_names[idx], } return {"matched": False, "region": None}在 Shapely 2.x 中,STRtree.query()返回与查询对象相交的几何候选索引。索引只能帮忙快速缩小范围,最终是否算作命中,还需要用covers或contains做精确判断。这里选择covers是因为边界上的点通常也应该归属于该区域,边界归属在很多业务中会直接影响到订单计费或网格分配。
5.3 从文本中提取城市名
城市提取并不复杂。做法是维护一份规范城市词表,匹配时先做基本文本清洗,再按名称长度做最大匹配。最大匹配的意思是,如果文本里同时出现“北京市”和“北京”,优先取更长的“北京市”,因为更长表示信息更完整。
# 文件路径:text_utils.py import re def clean_address_text(raw_text: str) -> str: """只处理格式,不改变语义。""" if not raw_text: return "" text = raw_text.strip() text = text.replace(",", ",") text = re.sub(r"\s+", "", text) return text def extract_city(text: str, city_list): cleaned_text = clean_address_text(text) candidates = [] for city in city_list: if city in cleaned_text: candidates.append(city) if not candidates: return None # 相同文本中出现多个城市名时,取最长的那个 candidates.sort(key=len, reverse=True) return candidates[0]你可能会问,真实地址里经常有“朝阳区”和“北京市朝阳区”,这种规则匹配能行吗?答案是:文本是否包含城市词只是一个快速召回动作,真正判断地址有效性,最终还是要依赖城市词表和业务地址库。如果发现大量地址都匹配不上,优先检查发数方的数据规范,而不是无脑换模型。
5.4 距离计算与半径过滤
第三个能力是给定中心点和半径,返回半径范围内的 POI。这里用球面距离公式 Haversine 计算两点距离,不调用外部接口,不运行模型,也不依赖地图服务。
# 文件路径:geo_math.py import math EARTH_RADIUS_METERS = 6371008.8 def haversine_distance_meters(lng1: float, lat1: float, lng2: float, lat2: float) -> float: rad_lat1 = math.radians(lat1) rad_lat2 = math.radians(lat2) delta_lat = math.radians(lat2 - lat1) delta_lng = math.radians(lng2 - lng1) a = math.sin(delta_lat / 2) ** 2 + math.cos(rad_lat1) * math.cos(rad_lat2) * math.sin(delta_lng / 2) ** 2 c = 2 * math.asin(math.sqrt(a)) return c * EARTH_RADIUS_METERS def filter_by_radius(lng: float, lat: float, radius_meters: float, pois): result = [] for poi in pois: distance = haversine_distance_meters(lng, lat, poi["lng"], poi["lat"]) if distance <= radius_meters: result.append({"name": poi["name"], "distance_m": round(distance, 1)}) result.sort(key=lambda item: item["distance_m"]) return resultHaversine 公式把地球近似成球体,在几十公里范围内做半径筛选,误差通常可以接受。更精确的工程做法是使用投影坐标系或者 Vincenty 公式,但本文只为演示通用思路,所以保留这个公式实现。
5.5 组装命令行入口
最后组装一个命令行入口,同时展示区域匹配、城市解析、半径过滤三个能力。这里使用 Python 标准库argparse,不额外引入 Click 等第三方库,方便你直接复制运行。
# 文件路径:main.py import argparse import csv import json from pathlib import Path from core import GeoIndex from data_demo import DEMO_CITIES, DEMO_POIS, ensure_demo_data from geo_math import filter_by_radius, haversine_distance_meters from text_utils import extract_city def load_pois(csv_path): if not csv_path: return DEMO_POIS result = [] with open(csv_path, "r", encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: result.append({"name": row["name"], "lng": float(row["lng"]), "lat": float(row["lat"])}) return result def main(): parser = argparse.ArgumentParser(description="Geo tool with no LLM run") parser.add_argument("--point", required=True, help="经纬度,例如 116.3,39.8") parser.add_argument("--text", default="", help="待解析城市文本") parser.add_argument("--radius-km", type=float, default=20.0, help="过滤半径,单位千米") parser.add_argument("--geojson", type=Path, default=None, help="区域GeoJSON路径") parser.add_argument("--pois", type=Path, default=None, help="POI CSV路径") args = parser.parse_args() lng_text, lat_text = args.point.split(",") lng = float(lng_text.strip()) lat = float(lat_text.strip()) if args.geojson: region_path = args.geojson else: region_path = ensure_demo_data(Path("data")) geo_index = GeoIndex(region_path) locate_result = geo_index.locate(lng, lat) city_result = None if args.text: city_result = extract_city(args.text, DEMO_CITIES) pois = load_pois(args.pois) nearby_pois = filter_by_radius(lng, lat, args.radius_km * 1000, pois) print("input point:", lng, lat) print("region match:", locate_result) if city_result: print("city extracted:", city_result) print("nearby pois in %s km:" % args.radius_km) for poi in nearby_pois: print(" -", poi) if __name__ == "__main__": main()main.py把坐标分割后传给区域匹配索引,把文本传给城市解析函数,再把 POI 列表传给半径过滤函数。如果--geojson没有指定,就自动生成一份演示区域数据,保证首次运行也能直接看到效果。
6. 运行演示并验证输出
在项目根目录执行:
python main.py --point "116.3,39.8" --text "我住在北京市朝阳区某街道"如果一切正常,你会看到类似下面的输出:
input point: 116.3 39.8 region match: {'matched': True, 'region': 'demo-alpha'} city extracted: 北京市 nearby pois in 20.0 km: - {'name': 'point-b', 'distance_m': 11812.3} - {'name': 'point-a', 'distance_m': 20718.7}验证的重点不是“结果好看”,而是可复现。连续运行两次,三次,输出应该完全一致。这正是 no-LLM 主链路最重要的价值之一。
如果失败,先检查几个地方:data/regions.geojson是否生成,命令是否真的在项目目录下执行,Shapely 是否安装成功。也可以先单独执行:
python -c "import shapely; print(shapely.__version__)"确认 Shapely 能正常导入,再继续排查数据文件路径。
7. 常见问题与排查思路
很多首次尝试自建 geo tool 的团队,遇到的问题并不一定在算法本身,而更多在数据、边界和工程习惯上。我整理了几个高频问题:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 所有点都匹配不到区域 | GeoJSON 坐标顺序写反 | 检查坐标经纬度顺序,确认是“经度,纬度” | 统一数据写入约定,并增加单元测试 |
| 边界上的点归属不稳定 | 使用了 contains,对边界点不友好 | 检查命中点在边界还是在外部 | 需要包含边界时改用 covers |
| 城市文本提取总是为空 | 城市词表和实际地址口径不一致 | 打印清洗后的文本,检查地名用词 | 扩充词表,或在上游做地址规范化 |
| 数据量太大,查询变慢 | 没有使用空间索引 | 查看匹配函数是否全量遍历 | 使用 STRtree 或数据库空间索引 |
| 生产环境结果和本地不一致 | 数据文件不是随版本发布,运行期被更新 | 对比两端数据文件哈希 | 数据文件纳入版本管理,禁止运行期改动 |
这里特别想说一下数据文件版本化的问题。地理位置工具的正确性不仅取决于算法,还取决于区域数据。如果发布的 GeoJSON 在线上被运维手动覆盖过一次,点匹配结果立刻就变了。数据出问题往往比代码出问题更难排查,因为现象看起来像是算法坏了。所以从一开始就要让数据路径、数据版本、数据更新流程成为工程的一部分。
8. 最佳实践与工程建议
如果你准备把“无 LLM run 的 geo tool”落地到真实项目,下面几条经验值得参考。
第一,把空间数据纳入版本管理。行政区划、商圈多边形、门店围栏这类数据要放到独立的仓库或制品库中,每次变更都要有 commit、有 diff、有回滚能力。不要直接在生产服务器上修改 GeoJSON。一个好的中间表示是 GeoJSON 或 GeoParquet,读取完后在内存中构建空间索引,发布包中固定一份数据快照。
第二,对匹配结果加上“数据版本号”和“算法版本号”字段。同一个点在行政区划 V2025.1 和 V2025.2 里的匹配结果可能不同,这很正常。返回结果里带上版本号,数据团队在跑数时就能追查出哪一批数据是基于旧版本生成的,避免上线前后口径不一致。
第三,幂等与缓存要分开设计。幂等是指同一个输入在同样的数据版本下永远产生同样的输出。缓存是性能优化手段,两者不要混在一起。对于热门的坐标点,可以用 Redis 做短时间缓存,但过期策略要明确。对于批量回刷,最好关闭动态缓存,直接按批次跑,确保整批数据使用同一个数据快照。
第四,错误不要静默吞掉。区域匹配不到时,返回结果要区分“确实不在任何区域”和“数据缺失”两种状态。这两个状态在业务上的意义完全不同,前者可能是用户真的在覆盖范围外,后者可能是你的区域数据漏掉了某块。
第五,LLM 如果需要接入,只能作为旁路增强。比如地名很不规范时,先用规则召回候选,再由模型对候选做消歧,最后仍由空间计算确认坐标。要记住,模型只是提供建议,是否采用建议的判断权应该交给算法和规则层。生产环境里还要给 LLM 调用加超时、熔断、预算控制和审计日志,避免一个外部接口超时拖垮整个地理服务。
9. 什么时候才需要考虑引入 LLM
尽量别把话说死。虽然本文强调主链路不要跑 LLM,但在特定条件下,加入 LLM 是合理的。
用户输入包含大量口语化描述,而且没有标准地址字段时,可以引入一个轻量识别层。例如“公司北门往东走一百米那个饮料店”这种描述,规则方法很难覆盖。此时可以让模型先输出候选的语义要素,再用地图检索和空间计算去验证。当且仅当规则匹配置信度较低时,才触发这个模型侧模块,而不是每个请求都调用一次模型。
这些模型侧调用要满足几个工程约束:单次超时可控,失败后能自动降级回规则链路;结果结构化,由后端的空间校验逻辑决定是否采纳;调用记录完整,方便后续统计“规则失败率”和“模型挽救率”。如果模型效果并不显著,也可以直接下线。核心思想是保留一条随时可以完全运行、不依赖外部服务的基线路径,所有增强能力都构建在这条基线之上。
这类工具有一个很好的特性:随着区域数据越来越规范、城市词表越来越全,规则链路能解决的问题比例会越来越高。原本看起来“必须用大模型才能理解”的地址,很多只是数据录入格式差。先做字段清洗和词表建设,再评估模型的价值,会比一上来就接模型更接近问题的本质。真正值得投入的,往往是数据质量而不是模型参数。
如果你接下来要研究更深入的方向,可以从这几条线入手:把区域数据切换到 PostGIS,用 SQL 完成空间计算;尝试 GeoPandas 做批量回刷和高性能空间连接;研究投影坐标系与距离计算在不同纬度下的误差控制;再进一步,构建一个完整的地址标准化服务和配套评测集,把规则链路与模型链路放在同一个评测框架里反复对比。这样的路线,比单纯追逐“是否引入大模型”这个表象更有工程价值。