不依赖LLM的轻量地理空间工具箱:设计、实现与工程落地

发布时间:2026/9/4 23:12:44
不依赖LLM的轻量地理空间工具箱:设计、实现与工程落地 在不少业务系统里Geo 相关能力总给人一种“应该很智能”的预期用户输入一句自然语言系统就能在地图上找到最合适的门店上传一份包含地址或坐标的 CSV就能自动完成空间归类。于是很多人第一时间会想到“接个大模型跑一下”。可实际上当把大模型接入地理工具后新的问题又出现了调用成本高、请求超时、返回格式不稳定、部分数据不能外传。于是“Geo tool with no LLM run”这种思路开始被更多人接受也就是让地理工具先拥有不依赖大模型也能独立运行的能力。本文会从定位、设计到代码实现完整展示一个“不带 LLM”的轻量地理工具箱。它依然能完成坐标距离计算、空间筛选、POI 最近邻查询、简单的自然语言指令解析和 HTTP 接口暴露同时保留一个可插拔的 LLM 入口方便以后按需增强。整个过程不依赖外部大模型 API适合本地开发演练也适合对数据私密性有要求的内部工具。1. 为什么需要不带 LLM 的 Geo 工具1.1 Geo tool with no LLM run 是什么场景在开头先对齐一个关键词这里的 Geo 更偏向地理空间数据方向和 GeoJSON、坐标、POI、距离计算、空间索引相关。而“no LLM run”描述的是工具的运行状态核心流程不调用大模型接口也不要求模型进程常驻。这种形态有很现实的落地背景。在很多企业里地理位置数据属于敏感性较高的业务数据。用户位置、门店坐标、车辆轨迹通常不能直接发送给外部模型服务。如果一套 Geo 工具必须把数据传给 LLM 才能完成基础查询项目就很难通过安全和合规评审。反过来如果核心 Geo 功能完全跑在本地进程里只把脱敏后的语义理解任务交给 LLM甚至干脆不用 LLM那么部署和运维都会简单很多。另一个场景是成本。大模型 API 按 Token 计费地理系统里的高频请求往往不适合每次都走大模型。比如每分钟上千次的“查附近门店”请求只要批量跑传统几何计算一台普通服务器就能承担如果改成调用 LLM 解析每个请求成本会成倍增加。1.2 GEO、Geo 和 LLM 的关系最近“GEO”这个词很火它有一个大模型相关含义叫生成式引擎优化即通过优化内容让内容更容易被 ChatGPT 这类生成式搜索引擎引用和推荐。这与地理空间领域里的 Geo 是两个不同的概念。当我们在技术社区里看到“Geo 工具”“Geo 数据”“Geo 分析”时通常默认指地理位置、地图、坐标、空间关系等。不要把“Generative Engine Optimization”和“Geography”混为一谈。而 LLM 则更像是一个增强理解能力的组件它的价值在于把自然语言转换成机器可以执行的指令。比如用户输入“帮我找离国贸最近的咖啡馆”LLM 可以把它转成 JSON 参数。但如果没有 LLM我们同样可以通过规则模板、正则表达式和关键词解析实现有限场景的指令理解。只要设计好任务边界传统方法完全够用。那么哪些 Geo 能力天然不需要 LLM我整理了一张参考表Geo 能力传统实现方式是否依赖 LLM说明坐标距离计算Haversine 公式 / 球面距离否数学公式结果稳定点在面内判断射线法 / GIS 库否适合多边形包含判断最近邻 POI 查询空间索引 / 暴力搜索 排序否小数据量完全可用逆地理编码本地地址库 / 网格索引否需要数据源支持自然语言意图解析规则模板 / 正则 / 词典否覆盖常见固定句式开放性地理问答大模型 外部知识是需要动态知识时用如果应用场景集中在前 5 类就可以用 no-LLM 的架构来落地。2. 整体方案设计思路2.1 需求拆分在动手写代码前需要把一套“不带 LLM 的 Geo 工具”拆成几个清晰模块。第一基础数据层。我们需要准备一批带经纬度的兴趣点数据比如便利店、餐厅、地铁站、停车场等。这些数据可以放在 CSV、JSON 或数据库里。为方便演示我直接用一个带坐标的 CSV 文件。第二计算层。围绕坐标点提供一些不依赖网络的几何能力计算两个经纬度点之间的距离。根据一个中心点和半径筛选出范围内的点。给定一个坐标返回最近的几个兴趣点。对带标签的数据做关键词过滤。第三查询解析层。很多系统希望用户能用自然语言查询但未必要上大模型。我们可以做一个轻量的“意图模板”例如固定句式find cafe near 116.40, 39.90 within 2km center 116.40,39.90这种结构化句式用正则就能可靠解析。第四服务接口层。可以把能力包装成 HTTP 接口对外提供查询服务。服务端只暴露必要的接口不把内部数据裸传到 LLM。2.2 主流程内部调用流程可以简化为一条单向链路输入坐标 / 输入文本 ↓ 查询解析模块规则引擎不调用大模型 ↓ 基础 Geo 计算距离、范围、最近邻 ↓ 返回结构化结果JSON ↓ 可选后续可用 LLM 做结果摘要注意最后一步是可选的。即使去掉 LLM前面所有流程依然能正常运行。这就是“Geo tool with no LLM run”的最大价值无论模型能力是否可用核心工具都不挂。2.3 技术选型代码部分我会以 Python 为主要语言有两个原因Python 数据处理语法简洁。地理相关库生态丰富如 geopandas、shapely、pyproj同时标准库也能满足很多轻量任务。演示阶段尽量少依赖第三方库。坐标计算和规则解析都使用 Python 标准库。HTTP 服务部分选择了 FastAPI因为它的接口文档和异步支持比较方便。如果运行环境没有安装可以只运行命令行客户端。3. 环境准备与数据初始化3.1 环境准备需要准备一个 Python 3.9 以上的环境。可以新建一个虚拟环境mkdir local-geo-tool cd local-geo-tool python -m venv venv source venv/bin/activate如果后续要运行 HTTP 接口再安装 FastAPI 和 uvicornpip install fastapi uvicorn如果只是运行核心脚本可以不用安装任何第三方库。这样也能证明“不依赖 LLM 的 Geo 工具”可以运行在很干净的环境里。3.2 准备示例 POI 数据建立数据文件data/pois.csvid,name,category,lat,lon 1,城市公园A,park,39.9219,116.4542 2,中央咖啡馆A,cafe,39.9138,116.4237 3,深夜书店A,bookstore,39.9089,116.4312 4,科技园停车楼,parking,39.9021,116.4456 5,东区地铁站,subway,39.9261,116.4387 6,西区快餐店,fastfood,39.9092,116.3981 7,社区便利店,convenience,39.9176,116.4079 8,河畔图书馆,library,39.9258,116.4195 9,中心广场美食街,food,39.9056,116.4153 10,南门公交站,busstop,39.8931,116.4227这组数据只为了演示。你的项目里可以替换成真实门店、车辆、设备传感器等数据。需要注意的是一份经纬度数据要统一坐标系常见是 WGS84 经纬度坐标也就是 GPS 设备直接给到的漫游坐标系。3.3 项目文件结构这里规划一个比较清晰的目录local-geo-tool/ ├── data/ │ └── pois.csv ├── geo_engine.py ├── cli_client.py ├── api_server.py └── README.mdgeo_engine.py放核心 Geo 逻辑cli_client.py用于命令行调用api_server.py用于对外提供 HTTP 接口。4. 核心代码地理引擎实现4.1 基础距离计算两个经纬度点之间不能直接用平面距离因为地球表面是一个球面。常用做法是使用 Haversine 公式计算球面距离。打开geo_engine.py写入基础代码# 文件路径geo_engine.py import csv import math from typing import List, Dict, Optional def haversine_distance( lat1: float, lon1: float, lat2: float, lon2: float ) - float: 计算两个 WGS84 坐标点之间的距离单位为公里。 radius 6371.0 # 地球平均半径单位公里 dlat math.radians(lat2 - lat1) dlon math.radians(lon2 - lon1) sin_lat math.sin(dlat / 2) sin_lon math.sin(dlon / 2) a ( sin_lat * sin_lat math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * sin_lon * sin_lon ) c 2 * math.asin(min(1.0, math.sqrt(a))) return radius * c这段代码用到了地球平均半径 6371 公里。math.asin的结果如果不做限制可能因为浮点误差略大于 1 而报错因此使用min(1.0, ...)做保护。4.2 本地数据读取与矩形区域过滤经纬度文件读取后需要能够做初步的空间过滤。比如限定某个矩形范围例如经度在 116.40 到 116.46 之间、纬度在 39.90 到 39.93 之间的区域内。继续在geo_engine.py中添加一个GeoDB类class GeoDB: 本地 POI 数据集支持从 CSV 加载并提供空间过滤功能。 def __init__(self, points: Optional[List[dict]] None): self.points points or [] classmethod def load_csv(cls, path: str) - GeoDB: points [] with open(path, moder, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: points.append({ id: row[id], name: row[name], category: row[category], lat: float(row[lat]), lon: float(row[lon]), }) return cls(points) def filter_by_bbox( self, min_lat: float, max_lat: float, min_lon: float, max_lon: float, ) - List[dict]: 按经纬度范围过滤。这里的范围是闭区间。 result [] for p in self.points: if ( min_lat p[lat] max_lat and min_lon p[lon] max_lon ): result.append(p) return result def filter_by_category(self, category: str) - List[dict]: 按类别过滤例如 categorycafe。 return [p for p in self.points if p[category] category]filter_by_bbox虽然简单但在很多场景中很有用。我们在前端地图上显示一个可视范围时往往只需要先把视野范围内的数据取出来再做后续计算。4.3 最近邻查询最近邻查询是地理工具中使用频率最高的能力。这里实现一个不依赖外部搜索引擎的算法def nearest( self, lat: float, lon: float, top_k: int 3, category: Optional[str] None, ) - List[dict]: 返回距离指定坐标最近的 top_k 个兴趣点。 可传入 category 做类别过滤。 candidates self.points if category: candidates self.filter_by_category(category) scored [] for p in candidates: dist_km haversine_distance(lat, lon, p[lat], p[lon]) scored.append({ id: p[id], name: p[name], category: p[category], lat: p[lat], lon: p[lon], distance_km: round(dist_km, 4), }) scored.sort(keylambda x: x[distance_km]) return scored[:top_k]这段代码的逻辑是遍历所有候选点计算距离后排序。如果数据量在几千到几万级别这种暴力搜索完全够用。当数据量达到几十万甚至百万级别时可以引入四叉树或 R 树索引。本文先不引入复杂索引机制因为工程上需要先跑通功能再做优化。4.4 无需 LLM 的自然语言查询解析现在考虑一个常见需求用户输入 “find cafe near 116.4237, 39.9138 within 3km”。如果没有大模型可以使用正则表达式把这句话拆成结构化参数。import re QUERY_PATTERN re.compile( rfind\s(?Pcategory[a-zA-Z_])\s rnear\s(?Plon-?\d(?:\.\d)?)\s*,\s* r(?Plat-?\d(?:\.\d)?)\s rwithin\s(?Pradius\d(?:\.\d)?)\s*(?:km|公里)?, re.IGNORECASE, ) def parse_local_query(query: str) - Optional[dict]: 解析固定的本地意图句式。 成功时返回结构化参数失败时返回 None。 match QUERY_PATTERN.search(query.strip()) if not match: return None return { category: match.group(category).lower(), lon: float(match.group(lon)), lat: float(match.group(lat)), radius_km: float(match.group(radius)), }为它增加一个执行方法放进GeoDB类def search_nearby_by_text(self, query: str) - List[dict]: 根据本地自然语言规则查询附近 POI。 不调用任何大模型接口。 params parse_local_query(query) if params is None: return [] nearby [] for p in self.points: if p[category] ! params[category]: continue dist_km haversine_distance( params[lat], params[lon], p[lat], p[lon] ) if dist_km params[radius_km]: nearby.append({ **p, distance_km: round(dist_km, 4), }) nearby.sort(keylambda x: x[distance_km]) return nearby这里需要重点说明解析器只能覆盖固定句式这是不用 LLM 的代价。如果你只需要对系统内部用户开放并且要求他们按固定模板输入那么这种方案非常稳定。如果输入是自由聊天内容比如“帮我看看公园西边那个饭店最近还营业吗”规则引擎就很难处理。此时如果确实需要开放理解能力可以再加 LLM 解析层但 Geo 核心计算仍然保持本地化。4.5 命令行客户端为了让核心引擎可以独立运行写一个cli_client.py# 文件路径cli_client.py from geo_engine import GeoDB def demo_nearby(db: GeoDB): result db.nearest(39.9138, 116.4237, top_k5) print( 最近邻查询示例 ) for item in result: print( item[name], item[category], f{item[distance_km]} km, ) def demo_text_search(db: GeoDB): question find cafe near 116.4237, 39.9138 within 3km print(f 自然语言规则查询 \n {question}) result db.search_nearby_by_text(question) for item in result: print( item[name], item[category], f{item[distance_km]} km, ) if __name__ __main__: db GeoDB.load_csv(data/pois.csv) demo_nearby(db) demo_text_search(db)这是一个最小可运行入口。后面我们会把它进一步包装成 API 服务。5. 包装成 HTTP 服务并验证5.1 使用 FastAPI 暴露接口如果企业内部系统需要给前端或小程序提供接口可以把它包成一个 HTTP 服务。# 文件路径api_server.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel from geo_engine import GeoDB app FastAPI() db GeoDB.load_csv(data/pois.csv) class NearbyQuery(BaseModel): lat: float lon: float top_k: int 5 category: str | None None class NearbyTextQuery(BaseModel): text: str app.get(/health) def health(): return {status: ok} app.post(/nearby) def nearby(query: NearbyQuery): result db.nearest( latquery.lat, lonquery.lon, top_kquery.top_k, categoryquery.category, ) return {items: result} app.post(/nearby/text) def nearby_text(query: NearbyTextQuery): result db.search_nearby_by_text(query.text) if not result: raise HTTPException(status_code400, detail无法解析查询或半径内无结果) return {items: result}这里没有数据库也没有缓存。每次请求都会遍历内存中的列表。对演示项目来说足够了。category在NearbyQuery中允许为空默认返回所有类别最近的 POI。nearby/text接口处理本地文本解析。若解析失败则返回 400 状态码。5.2 启动服务启动命令uvicorn api_server:app --host 0.0.0.0 --port 8000注意这里的0.0.0.0表示监听所有网卡。如果只是本机调试使用127.0.0.1更安全。启动后可以用 curl 测试curl -X POST http://127.0.0.1:8000/nearby \ -H Content-Type: application/json \ -d {lat: 39.9138, lon: 116.4237, top_k: 3}预期结果为{ items: [ { id: 2, name: 中央咖啡馆A, category: cafe, lat: 39.9138, lon: 116.4237, distance_km: 0.0 }, { id: 6, name: 西区快餐店, category: fastfood, lat: 39.9092, lon: 116.3981, distance_km: 2.3191 } ] }再测试文本搜索接口curl -X POST http://127.0.0.1:8000/nearby/text \ -H Content-Type: application/json \ -d {text: find cafe near 116.4237, 39.9138 within 3km}预期会返回category为 cafe 的 POI。5.3 结果说明从结果能看到整套链路完全没有调用外部大模型服务。没有 API Key没有网络等待没有 Token 费用。核心逻辑全部运行在本地进程中非常适合企业内部的高频空间查询场景。6. 高频问题与排查思路6.1 没有 LLM Key 或出现 provider 报错怎么办很多系统原本已经集成了 LLM 调用但在切换到本地 Geo 引擎时会看到类似下面的报错error: llm request failed: provider rejected the request schema or tool payload llm request timed out. the model did not produce a response before the model timed out这类报错通常说明 LLM 配置、接口请求参数或网络环境出现了问题。如果你只是想确认 Geo 工具是否会因此停止工作最简单的办法就是把核心流程直接改为本地计算。上文代码中的GeoDB完全不感知 LLM因此即使 LLM 服务不可用空间查询也依然能返回结果。如果在架构设计上必须保留 LLM 模块那也应该设计成可降级策略LLM 调用失败后转入规则解析或默认参数而不是直接让整个接口报 500。6.2 经纬度坐标经常填反坐标顺序是最常见的低级错误。WGS84 坐标系通常写成lat, lon但在 GeoJSON 中又是[lon, lat]的顺序。很多工程师在对接前端地图时踩过这个坑。排查思路检查数据源格式确认是纬度在前还是经度在前。看返回值里的 distance 是否夸张。如果两个明明在同一城市的点计算出来几千公里大概率是经纬度写反了。统一在代码入口处做一次坐标格式转换避免散落在业务代码中。6.3 数据量变大后性能下降当 POI 数据从几百条增长到几十万条每次请求都遍历全部点做距离计算会逐渐变慢。优化方向有几个先通过矩形范围或 GeoHash 粗筛缩小候选集数量。使用shapely配合 STRtree 建立空间索引。将数据载入 PostgreSQL PostGIS利用数据库索引解决大规模查询。对固定热区做预计算结果例如把“每个 200 米网格最邻近 POI”提前算好查询时直接查表。no-LLM 不代表低技术含量恰恰相反它迫使你在数据结构和算法层面做优化。6.4 自然语言解析覆盖不全规则解析器最大的问题是覆盖面不高。比如用户没有按模板输入或者写了一个英文同义词都可能导致解析失败。应对方式在界面层提供下拉选项或按钮减少自由输入。为常见词做同义词表如cafe与coffee映射到同一个含义。如果确实需要很强的语义理解能力再在文本解析之前接入 LLM将 LLM 输出限制为系统预设 JSON 格式。这里要明白一个边界LLM 是增强能力而不是必需依赖。7. 最佳实践与后续建议7.1 工程落地建议把这套工具用于真实项目时建议注意以下几点。第一不要让地理算法代码与业务代码强耦合。独立模块边界能让你后续替换数据库、引入空间索引时不至于影响上层接口。第二线上使用时要对请求参数做校验。经纬度范围有限制经度应在 [-180, 180] 之间纬度应在 [-90, 90] 之间。避免出现非法输入导致计算结果异常。第三权限控制必不可少。如果接口是对外的需要加上访问认证并限制查询频率。这属于通用安全要求基于最小权限原则只给需要使用的系统开放接口。第四考虑数据更新策略。POI 数据会有新增、下架、位置变化因此数据加载不能只在进程启动时做一次。可以通过定时刷新、数据库变更监听等机制保持内存数据新鲜。7.2 本地优先 LLM 可插拔形态如果你后续还是希望接入 LLM不要推倒这套设计。建议把 LLM 当作外部增强模块查询入口 ├── 第一层本地规则解析 ├── 第二层可选 LLM 语义解析 └── 第三层Geo 本地计算当规则解析失败再把文本交给 LLMLLM 返回 JSON 参数后依然回落到本地 Geo 计算。这样既保留了扩展空间也让核心能力更稳定。7.3 学习路线扩展沿着这个方向继续深入建议按以下顺序学习GeoJSON 规范和坐标系统。空间索引基础例如 GeoHash、四叉树、R 树。使用shapely、geopandas处理复杂地理数据。使用 PostGIS 进行距离查询、空间连接、动态栅格计算。可视化层使用 Leaflet、MapLibre GL 或 OpenLayers 展示查询结果。当系统需要自由文本理解时再学习 prompt 编排和结构化输出解析。对大多数业务来说先从 no-LLM 的本地地理工具箱开始反而是性价比最高、最容易维护的路线。先把空间计算能力做稳再根据真实需求引入大模型就不会把系统变成一碰就碎的“模型壳子”。如果你正在搭建或重构一套地理位置服务不妨把“Geo tool with no LLM run”当作最初版本的验收指标断开外网没有 API Key只用一份静态坐标数据核心功能仍然可用。这一步做到了再谈后续智能化增强也不迟。