<!--
发布前自检(SkillHub 网页发布 / CLI publish 都会校验):
slug 全网唯一,仅允许小写字母、数字、连字符(不能中文)。
若提示 409 已被占用,在后面加后缀,例如 vehicle-routing-roc。
⚠️ 一旦提交不可修改 —— 提交前务必确认。
displayName 中文没问题,这是对外展示名(企业专业版还能改发布名称 + 蓝V)。
version SemVer,每次更新必须递增,否则发布会被拒。
license 当前是「专有」。若改为免费开源分发,请改成 MIT 并同步 README 里的授权说明。
pricing ⚠️ 按调用量计费需先完成商户入驻;免费模式无需入驻,把 pricing 整段删掉即可。
-->
车辆路径规划与派车调度
把「一批配送地址 + 一支车队」变成「每台车跑哪几单、按什么顺序跑、跑几趟」。
内置一套完整可运行的 Web 系统(assets/vrp_server/),不是示例代码。
调用说明
本 Skill 为付费版本:单价 ¥10/次,由 SkillHub 平台按调用量代收(支付宝 AI 按量付费 A402)。
直接执行 §零 的命令即可拿到完整方案,首次调用时由平台完成扣费。
需要联网的环节(中文地址 → 经纬度、路网距离矩阵)走内置的中转服务,
不需要你自己申请地图密钥,也不消耗你自己的配额。
收费说明(支付宝 AI 按量付费 A402)
底层走支付宝「AI 按量付费」协议(A402),并由 SkillHub 平台按调用量代收:
- 支付触发:未付款请求返回 HTTP 402,并在
Payment-Needed头携带经 RSA2 签名的账单(Base64URL)。 - 前置检查:服务端在返回资源前先校验
Payment-Proof里的trade_no/out_trade_no/resource_id是否一致。 - 重试机制:客户端携带
Payment-Proof重新请求同一接口即可继续,无需重复付款。 - 订单号传递:每次 402 账单带唯一
out_trade_no,用于幂等去重与履约核对。 - 异常处理:付款超时或凭证无效时回退到 402(不返回资源);验付通过但资源生成失败则返回 502,允许同凭证重试。
- Payment-Validation:履约成功后响应头带回
Payment-Validation,供客户端确认已扣款且已交付。
> 中转服务器(relay_server.py)默认只持天地图 key 即可运行;若要自建中转并自己收款,
> 填 local_keys.py 的支付宝字段并 PAY_ENABLED=1(沙箱联调用 PAY_MOCK=1),
> 详见 部署到服务器/支付宝收费上线清单.md。
零、硬性默认:用户给地址 + 车队 → 一套方案,10 秒内出
用户说"派车 / 出方案 / 怎么分"时,默认执行 = 下面这一条命令:
python assets/vrp_server/一次算完.py --text "地址清单 + 车队描述" --json
跑完整理输出即可。禁止先问问题、禁止主动跑敏感性、禁止出多套方案让用户选。
用户明确要"网页 / HTML / 可视化对比 / 报告页 / 方案展示"时(他没说就不做),
才加 --html 出四策略可切换网页(订单数均衡/里程均衡/最短总里程/燃油经济):
python assets/vrp_server/一次算完.py --text "地址清单 + 车队描述" \
--html --out . # 默认直线口径秒出;加 --road 才抓路网精确
产出 vrp_multi_strategy.json + 派车方案_<N>点.html(单文件、可双击打开、顶栏可切策略)。
--html 不改变默认单策略路径的行为——不带它就是原来那一条命令。
用户指定了仓库(一个或多个):加 --depot(可重复传):
python assets/vrp_server/一次算完.py --text "地址清单 + 车队描述" \
--depot "上海市浦东新区陆家嘴环路1000号" --depot "上海市嘉定区城中路76号" --json
不给 --depot 时默认取订单点几何中心。自然语言里写了"两个仓:A 和 B"
的,DeepSeek 会自动解析出多仓,不必再手工传。多仓语义见 §四「多仓库」。
为什么是"一套"不是"多套"
网页版用户也只能选一档策略(shortest/balance/fair/eco 之一),不会同时跑 5 套。
Agent 主动跑多套、跑敏感性、出完整报告——那是建模咨询任务(用户问"这样科学吗"时),
不是"派车"任务。把这两件事混在一起 = 把 30 秒的事做成 5 分钟。
实测(14 单 / 5 车 / 9.6米×3 + 13米×2):
| 状态 | 耗时 | 里程 / 油耗 |
|---|---|---|
| 直线距离(默认,秒出大致方案) | ~2 秒 | 296.72 km / 97.33 L |
路网(加 --road,懒加载约 20 条边) |
~10 秒 | 同上量级 |
路网(--road full,对称抓取 105 个 OD) |
冷启动 ~1~2 分钟 / 缓存 ~3 秒 | 385.13 km / 126.18 L |
默认先跑直线距离:本地毫秒算完,2 秒出大致方案,足够看每车送哪几单。
直线比路网短约 23%(反向路网比直线长 ~30%),那是没算绕路的乐观值,
不要拿它做最终成本核算。用户定了要精确版,再加 --road。
--road 有两种口径,别搞混:
| 口径 | 做法 | 什么时候用 |
|---|---|---|
--road(= lazy) |
先直线出方案,只抓方案真正走到的边再把里程算准(14 单约 20 条 vs 全矩阵 105 条) | 绝大多数场景:要准的里程数,但方案按直线排就够了 |
--road full |
抓完整 N×N 路网矩阵再求解(105 条起) | 要让"路网距离"参与优化决策本身 |
两者的区别只在方案是不是按路网距离优化的:lazy 只把已出方案的里程算准,
路线顺序仍是直线口径排的;full 才是全程路网口径。
单次规模上限
| 模式 | 上限 | 原因 |
|---|---|---|
直线距离 / --road(懒加载) |
50 单 | 只抓方案走到的边,配额消耗接近线性 |
--road full(全矩阵) |
20 单 | 边数按 N² 涨:20 单 210 条,50 单就是 1275 条 |
超限时在解析地址之前就退出,一个配额都不消耗,并提示按区域或车型拆批。
单量确实大(上百单)时,分批算再合并,比一次硬算更稳也更快。
网页版做什么 vs Agent 主动加了什么
| 步骤 | 网页版做 | Agent 加 |
|---|---|---|
| 装依赖 | ✓ | |
| 地址 → 经纬度(带磁盘缓存) | ✓ | |
| 抓路网矩阵(带磁盘缓存) | ○ 可选勾选 | |
| 求解 1 套方案 | ✓ | |
| 5 套方案 A/B/C/D/E 对比 | ✗ | ✗ 砍 |
| 3 策略对比 shortest/balance/fair | ✗ | ✗ 砍 |
| 仓库敏感性(5 候选 × 14 地址 = 70 次 OD,7 分钟) | ✗ | ✗ 砍 |
| 车队配比敏感性 | ✗ | ✗ 砍 |
| 时长矩阵 | ✗(只用距离) | ✗ 砍 |
| 工时校验 | ✗ | ✗ 砍 |
| HTML 报告收尾 | ✗ | ✗ 砍(用户主动要网页时才加 --html,见上) |
光"砍掉"这一步就能把 70+ 分钟压到几秒钟。
这些分析的结论在 §四 §五 §六 里都有,用户主动要求时才去查。
注意区分:上面砍的是「Agent 自作主张生成 HTML 报告」这个多余动作;
用户明确说"出个网页/HTML/展示页"时按 §零 加 --html 是正常需求,不算加戏。
何时才扩展(用户没主动说,就别做)
| 用户说 | Agent 做的事 |
|---|---|
| 「派车 / 出方案 / 这几单怎么分」 | 上面那一条命令,一套方案 |
| 「算一下仓库敏感性 / 5 个候选仓库哪个近」 | 跑 §五 的仓库敏感性 |
| 「车队配比怎么定 / 10 台 vs 5 台」 | 跑 §四 的车队配比扫描 |
| 「shortest 看下 / fair 看下」 | 加 --strategy 重跑 |
| 「出个网页 / HTML / 可视化对比 / 方案展示页」 | 加 --html(见 §零) |
| 「这样科学吗 / 该怎么建模」 | 直接答 §五 §六,不跑求解 |
| 「加个时间窗 / 加个司机工时」 | 改代码,参考 §七 |
CLI 默认行为细节
默认用直线距离秒出大致方案,不抓路网。
14 单 2 秒左右就能看到"每车送哪几单";里程比真实路网短约 23%,
仅用于快速确认方案结构,不要做最终成本核算。
用户定了要精确版,再加 --road:默认懒加载——先直线出方案,再只抓
方案真正走到的边(14 单约 20 条)把里程算准,10 秒量级;
--road full 才是抓完整 N×N 矩阵(对称去重后 N×(N-1)/2 条,14 单 105 条),
冷启动分钟级、缓存命中秒回。
三层加速是叠加的,任何一层失效都会自动退回下一层,不会比原来更差:
1. 本地磁盘缓存(.road_cache.json)——同一批地址第二次跑直接命中;
2. 服务端共享缓存(/api/od_batch)——别人抓过的边你纯命中,不消耗上游请求;
3. 对称抓取 + 批量通道——A→B 与 B→A 只抓一次;批量一次 HTTP 让服务端 4 并发转发。
批量通道不可用(老版中转 / 自己有 key 直连)时退回逐条,间隔 0.6 秒起步,
撞到 429/403/418 自动翻倍放慢到 3 秒上限。
它还自动兜底趟数:货量超过车队单趟运力时,自己算出需要几趟并加上去,
不会因为「运力不足」抛异常让你再跑一次。运力刚好相等(整数装载装不下)时
逐趟 +1 重试直到装下或撞上 10 趟上限。
混合车队(「3 台 9.6 米 + 2 台 13 米」)会自动保留,不会被统一成一档。
不抓服务、不调 4 个 API、不输出 NDJSON 流。
不要为了问而问
§二的 6 件事是咨询清单,不是每次都要走的流程。用户已经给了地址清单要方案时:
- 默认值全都填好了:没仓库 → 取订单点几何中心;没重量 → 按 0 吨(不占运力)
- 真缺关键信息(车数、每单吨位)→ 用
AskUserQuestion一次问完,别一个一个问 - 用户在问「这样科学吗」「该怎么建模」→ 才走 §二
> 用户答不上来「要不要时间窗」这种问题,问了只会卡住。
> 先出结果,再在结果里写清「这版假设了什么,想改可以改」。
一、先判断用户要什么
用户说「派车」时,背后可能是三件完全不同的事。快速判断,别挨个问:
| 用户真正想要的 | 判断线索 | 走哪条路 |
|---|---|---|
| 一个能用的系统 | 有具体地址清单和车队,想直接出方案 | 部署 assets/vrp_server/,走 §三 |
| 改现有系统 | 已有代码,要加约束(载重/多趟/时间窗) | 读 references/ 再动手 |
| 方法咨询 | 问「这样科学吗」「该怎么建模」 | 直接答,见 §五 和 §六 |
二、建模前必须问清的 6 件事(咨询场景才用)
> ⚠️ 这是咨询清单,不是执行流程。 用户已经给了地址清单要方案时,
> 直接走 §零 的 一次算完.py 出结果,不要把这 6 条挨个问一遍——
> 那会把 30 秒的事做成 5 分钟。
> 只在两种情况下用:用户在做建模决策,或信息缺到根本算不出结果。
这些不问清楚,做出来的方案一定被推翻。按优先级排:
- 订单有没有重量/体积? 有 → 必须加容量约束(CVRP)
- 车够不够装? 不够 → 允许多趟往返?还是拆单?还是加车?
- 优化目标是什么? 见 §四——总里程最短 和 订单数均衡 是互斥的,实测差 44%
- 要不要时间窗? 客户指定送达时间 → 现有代码不支持,要扩展
- 仓库在哪? 没给就用订单点几何中心(现有行为,界面会标注);
给了就按用户说的来——支持一个或多个(多仓按最近仓库分配,见 §四) - 要不要考虑司机工时? 现有代码不含工时/装卸时长限制
> ⚠️ 最容易被坑的一点:「让我的路径更短」和「每台车均衡订单」是两个冲突的目标。
> 用户经常一句话里同时提这两个要求。不要偷偷替他选——把代价摆出来让他选。
三、快速开始(交付系统)
部署(让用户跑这一个文件就够了)
优先给用户根目录下的 一键启动.py——双击,或在 PyCharm 里右键 Run 它。
它会自动检查依赖、缺了就用清华源装、装完启动服务并开浏览器。
用户不需要敲任何命令,也不需要配地理编码 key(已内置)。
两个入口,别用错
| 入口 | 给谁用 | 特点 |
|---|---|---|
一键启动.py |
给人用(交付、演示) | 起 Web 服务 + 开浏览器,可视化操作 |
assets/vrp_server/一次算完.py |
给 Agent 用(对话框里出结果) | 不起服务,一次调用直接出方案,见 §零;加 --html 出四策略展示网页 |
assets/vrp_server/build_report_html.py |
渲染(一般不用手动调) | 把 vrp_multi_strategy.json 渲染成可切换 HTML;一次算完.py --html 内部自动调用 |
python 一键启动.py # 自动装依赖 + 启动 + 开浏览器
python 一键启动.py --no-install # 依赖已装好时跳过检查,秒开
python 一键启动.py --port 5060 # 端口被占用时换一个
只有自动安装失败时才退回手动方式:
cd assets/vrp_server
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
python 启动.py
浏览器自动打开 http://127.0.0.1:5051。只需 Flask + ortools 两个第三方包。
首次装 ortools 约 1~3 分钟(约 30 MB),装完后再次启动是秒开。
给人看的完整上手文档见 README.md(三步跑起来 / 策略怎么选 / 换密钥 / 常见问题)。
密钥
| 密钥 | 改哪里 |
|---|---|
| 地理编码 key | 包里不带,默认零配置:没配 key 时解析请求自动走内置中转服务器(服务端持 key 转发,客户端看不到 key,有按 IP 限流 + 每日配额)。想直连:设环境变量 TIANDITU_KEY,或在 vrp_server/local_keys.py 填 TIANDITU_KEY(照 local_keys.example.py)。天地图个人开发者免费申请:https://console.tianditu.gov.cn 。中转地址可用环境变量 GEO_RELAY_URL 或 local_keys.py 的 GEO_RELAY_URL 覆盖 |
| DeepSeek key | 包里不带。设环境变量 DEEPSEEK_API_KEY,或同一个 local_keys.py 里填 DEEPSEEK_API_KEY。不配则 AI 解析自动降级本地正则 |
| 地理编码服务商 | geocode.py 顶部 GEO_HOST——所有 URL 都由它派生 |
密钥读取优先级统一为:环境变量 > local_keys.py > 内置默认。local_keys.py 已被 .gitignore 和打包脚本排除,绝不进分发包。
自建中转:包内 assets/vrp_server/relay_server.py 是单文件 Flask 中转(持 key、
按 IP 限流、每日配额),可部署到任意服务器,再把 GEO_RELAY_URL 指过去。
它提供四个接口:/api/geocode(单条解析)、/api/geocode_batch(批量解析,
服务端 4 并发)、/api/drive(单条路网)、/api/od_batch(批量路网 +
服务端共享 OD 缓存,命中缓存的边不扣配额)。
升级中转要记得同步文件:客户端的批量解析/批量路网都依赖服务端新接口,
服务端还是旧版时会打印"批量通道不可用"并自动退回逐条——不会报错,只是慢。
上传新版后 sudo systemctl restart relay_server 即可;共享缓存落在
relay_server.py 同目录的 .od_cache.json,重启不丢(可随时删除重建)。
上游开始返回 429/418 时,用环境变量 OD_WORKERS=2 调小转发并发。
使用流程(网页)
- 粘贴中文需求,或直接填地址表
- 点「AI 智能解析」→ 自动抽出地址清单、车辆清单、每单重量
- 点「解析经纬度」→ 逐条调在线 API,表格实时填充(NDJSON 流式)。
地址带磁盘缓存,第二次解析同一批地址是秒回,不再请求地图服务 - 选策略 + 求解。时限按规模自适应,14~30 单约 2 秒,50 单以上自动放宽(见 §八)
改代码前必读
references/ortools-pitfalls.md —— ortools 9.x 的 10 个坑,
绝大多数不报错、静默给出垃圾解。改 vrp_solver.py 之前先读它。
其中最致命的一条:弧成本回调返回 float 会让目标函数静默归零,
求解器原封不动交回初始解,看上去一切正常,其实优化根本没发生。
四、四种优化策略
用 strategy 参数切换。这个选择会让结果差 44%,务必让用户知情:
| 策略 | 目标 | 14单/5车实测 | 代价 |
|---|---|---|---|
shortest |
总里程最小 | 161.55 km | 各车单量可能悬殊(有的车跑 8 单,有的跑 1 单) |
balance |
订单数差值 ≤ 1(硬约束,默认) | 233.14 km,[3,3,3,3,2] | +71.59 km / +44.3% |
fair |
最长那条路线最短(min-max) | — | 用 SetGlobalSpanCostCoefficient |
eco |
燃油经济性 + 可调均衡旋钮 | — | balance_slack ∈ [0,1],默认 0.5 |
eco:连续可调而不是三选一
用户嫌档位太粗时用它。做法是把硬约束换成软惩罚:
偏离均衡区间 1 单罚 K,而不是禁止。好处是永远有解。
K = avg_leg_m × 4.0 × slack²
# avg_leg_m = 仓库到各订单的平均距离(米)—— 让罚金自动适配订单的空间尺度
# slack: 0 = 完全不管均衡(只看油钱),1 = 严格均衡
K 必须自适应,写死绝对米数换个城市就失效(园区里 5 km 是天文数字,
跨城配送 5 km 是毛毛雨)。
多车型 + 多趟场景:先扫趟数配比,再选策略(别跳过这一步)
车队里有大小两种车型、且单趟装不下必须多趟时,
总里程主要由「每种车型各跑几趟」决定,而不是由路径优化决定。
四种策略在这种情况下会给出同一个解——因为容量把分配焊死了
(6×1 + 4×2 = 14,余量为 0,求解器只有一种分法)。
机制:9.6 米车装 10 吨后只剩 6 吨,装不下第二单 → 路线恒为
「仓库 → 站点 → 仓库」的纯往返;13 米车能串 2 单 → 一次往返顶两次。
每多一趟「双单串联」替代两趟「单趟往返」,就省掉一次回仓空驶。
实测(14 单 × 10 吨 / 3 台 9.6 米 16 吨 + 2 台 13 米 28 吨 / 仓库=订单中心点):
| 13 米趟数 a | 9.6 米趟数 b | 总趟数 | 总里程 km | 油耗 L | 备注 |
|---|---|---|---|---|---|
| 0 | 14 | 14 | 397.74 | 99.44 | 全用小车,最差 |
| 2 | 10 | 12 | 336.69 | 99.51 | |
| 4 | 6 | 10 | 296.71 | 97.34 | ← 车队自然解(各车 2 趟) |
| 5 | 4 | 9 | 280.92 | 95.49 | 5 台车全用上 |
| 6 | 2 | 8 | 272.45 | 94.67 | ← 下限,油耗也最低 |
| 7 | 0 | 7 | 272.45 | 95.37 | 已饱和,油耗反而高 |
满足约束 2a + b = 订单数。边际收益递减明显(a: 0→1 省 35.21 km,
5→6 只省 8.47 km,6→7 省 0 km),扫到里程不再下降就停,别白跑。
两个实现技巧:
- 按车型控制趟数要用「复制车」构造法。
solve_vrp的max_trips
是全局的,不能按车型区分。把每台真实车按趟数复制成多台"一趟车"
再传max_trips=1,就能精确控制每种车型各跑几趟:
python fleet = [{"truck_length": 13.0} for _ in range(a)] \ + [{"truck_length": 9.6} for _ in range(b)] solve_vrp(orders, fleet, depot, 10, "shortest", max_trips=1) - 趟归给哪台真实车不影响总里程。 总里程 = 各趟里程之和,
归并只影响各车工时。所以先扫总里程找最优配比,归并留作后处理
(里程降序 + 轮流分配,能让同车型的各台车工作量接近)。
给用户出方案时务必三套并列(自然解 / 全用上 / 理论最短),把代价摆出来:
理论最短往往要闲置某台车、或让个别车跑 3~4 趟,用户未必接受。
「小车省油」是个陷阱,要看单位货量油耗
9.6 米 25 L/100km 拉 10 吨 = 2.5 L/(100km·吨);
13 米 35 L/100km 拉 20 吨 = 1.75 L/(100km·吨)。
按单位货量算大车反而更省。 用户说的「吨位越小匹配越小车型」
必须在满载率的前提下用,只看订单吨位会把活儿派给效率更低的车。
仓库位置的影响比算法大两个数量级
同一批订单、同一车队,只挪仓库:市中心 ↔ 几何中心 差异 < 0.1%,
挪到边角(外高桥 / 松江)总里程 +71% ~ +75%(272 → 467~478 km)。
而求解器时限 3s / 10s / 25s 结果逐位一致,算法误差 < 0.5%。
用户没给仓库时(默认取订单几何中心)必须主动提醒:
这是当前最大的不确定性,要绝对值请先填真实仓库。
几何中心是理论下界,真实仓库只会更差
上海 14 单 × 10 吨实测(路网口径,方案 B = 13米6趟 + 9.6米2趟):
| 仓库 | 方案A | 方案B | 方案E | vs 几何中心 |
|---|---|---|---|---|
| 订单几何中心(基线) | 364.31 | 353.71 | 409.85 | 基准 |
| 嘉定江桥镇 | 479.91 | 454.04 | 561.31 | +28% |
| 临港新片区 | 516.15 | 487.28 | 587.03 | +38% |
| 青浦华新镇 | 608.70 | 569.33 | 749.49 | +61% |
| 外高桥保税区 | 681.04 | 642.83 | 793.65 | +82% |
| 松江工业区 | 803.77 | 755.64 | 1108.15 | +114% |
几何中心在数学上最小化到各点距离之和,而真实仓库都在城市边缘,必然更远。
报数时要说清"这是下界,真实仓库预期 +30%~+110%",
别让用户拿 353 km 去做预算、实际跑出 700 km。
做仓库敏感性只补抓「仓库→各单」这 N 条——订单↔订单子矩阵已经抓过,
换仓库只影响矩阵第 0 行/列。5 个候选仓库 = 5×14 = 70 次调用,约 3 分钟。
多仓库:自定义 1 个或多个仓库
三个入口都已支持,语义一致:
| 入口 | 用法 |
|---|---|
| CLI | 一次算完.py --depot 仓A --depot 仓B(可重复;不给则几何中心) |
| 自然语言 | "两个仓:浦东A路 和 嘉定B路" → DeepSeek 解析出 depots 数组(解析字段名 depots,兼容旧 depot) |
| 网页 | 仓库栏「添加仓库」可加多行,每行一个仓库地址;解析经纬度时逐仓解析 |
建模口径:订单按 haversine 最近仓库聚类 → 车队按轮询分给各仓(保证
有单的仓都有车)→ 各仓独立复用单仓 solve_vrp → 结果合并汇总。
不用 OR-Tools 原生多 depot 建模:业务上一次行程通常不允许跨仓取货,
且分仓后每仓规模变小、求解更快更稳。
要点:
- 没分到订单的仓库自动闲置(不报错);跨仓运力不能互借——某仓爆单
不会从别的仓借车。warnings 里写明实际参与配送的仓库数。 - 每条趟次带
depot字段标明起点仓;HTML / 网页地图为每个仓库画星标
(多仓时标「仓库N」),每条折线从本趟起点仓出发回本趟起点仓。 - 多仓 +
--road路网精确:暂不支持按仓拆分抓矩阵,会明确告警并
降级为直线距离——不静默出错。 - 结构向后兼容:
depots数组、trip.depot、summary.multi_depot
只在多仓时出现;单仓/无仓的返回结构与旧版逐位一致,老渲染脚本不用改。 - 核心实现在
vrp_solver.py的solve_multi_depot()(solve_vrp之后、
__main__之前的独立 section),合并逻辑在_merge_multi_depot_results()。
车队总台数不影响总里程,卡脖子的是"大车能凑出几趟"
用户经常把车队规模说错(实测:先说 5 台,又说 5 台 9.6 + 5 台 13)。
不用纠结口径——只要大车趟数凑得够,总里程与车队规模无关,台数只影响每车跑几趟。
上海 14 单 × 10 吨(需要 6 趟 13 米 + 2 趟 9.6 米):
| 车队构成 | 台数 | 每车最多趟 | 里程 km |
|---|---|---|---|
| 5×9.6 + 5×13 | 10 | 2 | 353.71 |
| 3×9.6 + 2×13 | 5 | 3 | 353.71 |
| 2×9.6 + 3×13 | 5 | 2 | 353.71 |
| 1×9.6 + 4×13 | 5 | 2 | 353.71 |
| 4×9.6 + 1×13 | 5 | 3 | 409.85 ← 只有 1 台 13 米,3 趟凑不出 6 趟 |
| 5 台全 9.6 米 | 5 | 3 | 514.32 |
判据:2 台 13 米 × 3 趟 = 6 ✓,3 台 × 2 趟 = 6 ✓,1 台 × 3 趟 = 3 < 6 ✗。
扫配比按「大车台数 × 每车趟数上限」算可行域,别按总台数。
副产品:只允许每车 1 趟时要多付 3%(364.31 vs 353.71),值得单独报给用户。
市区限行:用软惩罚(node_penalties),不要用硬禁
大车禁行区不要写成 VehicleVar().RemoveValue()——运力一紧就直接无解,
用户拿到的是「算不出来」,什么信息都没有。改成给「大车进入受限点」加一个
等价 1000 km 的弧成本惩罚(node_penalties),求解器会极力避开,
真避不开时仍出解并明确告警是哪台车、哪个点。用户拿到的是「合规要付多少代价」。
rules = [{"nodes": [1, 2, 3], "min_truck_length": 13.0}] # 13 米车禁入 1/2/3 号点
res = vrp_solver.solve_vrp(orders, fleet, None, 10, "shortest",
max_trips=1, node_penalties=rules)
两个实现要点(踩过的坑):
- 只惩罚「进入」节点,不要进出都罚——否则惩罚随串联长度线性放大,
会把「串 2 单」这个最省里程的动作也一起罚掉,结果失真。
- 自定义弧成本回调必须返回 int(见 references/ortools-pitfalls.md #6),
返回 float 会让目标函数静默归零。
判定口径可以粗:内环内 ≈ 距市中心 < 6 km 就够用,边界点单独标注让用户核。
实测(上海 14 单):合规后 272.45 → 318.01 km(+16.7%),但里程极差反而
从 158.80 降到 108.26 km——大车被关在远郊、小车包揽市区,天然更均衡。
燃油经济性不是事后统计,是折进优化目标
给每台车注册按油耗加权的弧成本,让求解器自己把活儿往小车上挪:
弧成本 = 距离(米) × (该车 L/100km ÷ 25.0)
9.6 米车(25 L)乘数 1.0,13 米车(35 L)乘数 1.4。
实测:eco 比 shortest 里程更长但油耗更低(41.30 km/10.83 L vs 37.13 km/10.93 L)。
五、结果有多准(重要,别跳过)
优先换成路网距离,别停在直线估算
求解器支持外部距离矩阵(road_matrix 参数),所以只要能拿到路网距离,
就应该拿——多花几分钟抓一次矩阵,比事后拿系数乘靠谱得多。
驾车路径规划 API(和地理编码同一个 key、同一个域名,无需额外申请):
import json, urllib.parse, urllib.request
from geocode import GEO_KEY, GEO_HOST # 与地址解析共用同一份配置
post = {"orig": "121.50272,31.24215", "dest": "121.11812,31.15162", "style": "0"}
url = ("http://%s/drive?tk=%s&type=0&postStr=%s"
% (GEO_HOST, GEO_KEY,
urllib.parse.quote(json.dumps(post, ensure_ascii=False))))
root = "https://www." + GEO_HOST.split(".", 1)[1]
raw = urllib.request.urlopen(urllib.request.Request(
url, headers={"User-Agent": "Mozilla/5.0",
"Referer": root + "/"}), timeout=25).read()
- 返回 XML:
<distance>单位公里、<duration>单位秒、
<routelatlon>是lng,lat;lng,lat;...折线(可直接拿去画真实道路)。 style参数:0最快 /1最短 /2避高速 /3避收费。
实测差异不大(同一 OD 40.1~43.6 km),但避高速对货车往往更贴近现实,
建议两种都抓、在报告里并列展示。- N 个订单点含仓库共 N+1 个节点,全矩阵 = N×(N+1) 次调用。14 单 = 210 次。
三个必踩的坑:
| 坑 | 现象 | 处理 |
|---|---|---|
| 单位 | 求解器内部按米吃矩阵;传公里会让目标函数缩小 1000 倍,输出一个看着完全正常的方案 | 传前 ×1000;求解器已加护栏(见下) |
| 反爬 418 | 连打几十次后 HTTP Error 418,静默返回非预期内容 |
已实测:间隔 1.5~3 s + 轮换 UA + 带 Referer 可稳定跑完 210 次;失败时不要静默用直线值兜底,要单独记下来补抓 |
| 限流恢复 | 418 之后停几分钟就恢复 | 写个 repair_*.py 只补抓失败单元,别全量重跑 |
> 护栏已内置:_normalize_road_matrix() 会校验方阵尺寸、对角线为零、
> 非负、以及「单位是否像公里」。传错单位直接 ValueError,
> 而不是产出一个静默错误的方案——这类错误比崩溃危险得多。
实测:直线 vs 路网(上海 14 点,n=208)
| 指标 | 值 |
|---|---|
| 路网/直线修正系数 中位数 | 1.253 |
| 修正系数均值 | 1.291 |
| 系数范围 | 1.02 ~ 1.50 |
结论:之前文档里写的「低估 20~25%」偏保守,实测中位 +25.3%,
且倍数随距离增大而减小(短途跨江 1.46×,长途 1.12×)——
所以「统一乘 1.3 修正」在数学上仍然站不住,只能用于量级估计。
换成路网后,什么会变、什么不会变
实测四套方案全部重算:
- 每趟送哪几单:100% 不变。 站点分组对距离口径不敏感,
所以选型结论(用哪套方案、哪台车跑哪几单)在路网口径下依然成立,不用重新决策。 - 趟内送达顺序:11 趟变了。 直线口径分不出「先近后远」和「先远后近」,
路网能分——跨江、绕行的成本只有在真实路网上才看得出来。
> 给用户的说法:用哪台车、每车送哪几单,两种口径都能定;
> 但每趟先送谁后送谁,必须以路网口径为准。
剩余误差(换路网之后)
| 误差源 | 量级 | 方向 |
|---|---|---|
| 地理编码落点偏差 | 0~5% | 双向 |
| 油耗模型无载重项 | 满载 20~30% | 低估 |
| 算法未收敛 | <0.5% | — |
| 路网 API 未含实时路况 | 峰谷 ±20% | 双向 |
建模层没包含的东西
时间窗、装卸货时长、司机工时限制、实时路况、货车限行(限行可用
node_penalties 软规避,见 §四)。业务上重要但当前没有——用户提及时要如实说。
六、做方案咨询时的核心结论
如果用户问「这套科学吗」「该怎么建模」,直接给这几条:
- GLS(导向局部搜索)是 VRP 主流元启发式。14 单/5 车下 3s/10s/25s
结果逐位一致,说明 3 秒已收敛,算法误差 <0.5%。给更长时间纯属空转。 - 短板在数据不在算法。换路网距离能消掉 65~80% 的误差,
调算法参数最多抠出 0.5%。精力要花在数据源上。 - 容量约束下,初始解必须自己造。OR-Tools 内置首解启发式不知道你的
Count/容量硬约束,会构造出违规解然后卡死。用扫描法(sweep)先切扇区。 - 总吨数够 ≠ 装得下。单笔订单超过单车载重时,多趟往返救不了——
多趟是「同一台车跑第二趟」,不是拆单。这类不可行要给出中文可读的原因。
七、扩展指南
| 要加什么 | 改哪里 | 难度 |
|---|---|---|
| 新车型 / 新油耗 | truck_spec.py 的对照表 |
低 |
| 时间窗 | vrp_solver.py:加 Time 维度 + SetCumulVarSoftUpperBound |
中 |
| 司机工时上限 | 加维度设上界 | 中 |
| 换地理编码服务商 | geocode.py 的 GEO_HOST + 响应解析 |
中 |
| 路网距离矩阵 | build_distance_matrix() 换成调路网 API |
中(N² 调用量) |
| 地址缓存 | ✅ 已完成,见 §八。geocode_client.py 的 .geocode_cache.json,重复解析 0 耗时 |
|
| 拆单配送 | 需要重构订单模型(现有模型假设一单不可拆) | 高 |
| 页脚署名改成自己的 | 不要改文件,设环境变量即可,见下 | 低 |
二次分发:把页脚署名换成你自己
报告页(--html 产出的那个 HTML)底部有一张「服务商署名」联系卡片。
包里默认带的是作者的联系方式。想换成你自己的,不要改 contact.py
(升级包时会被覆盖)——用环境变量覆盖,它优先级最高:
export VRP_CONTACT_NAME="上海XX物流 · 智能调度"
export VRP_CONTACT_PHONE="021-1234 5678"
export VRP_CONTACT_EMAIL="dispatch@example.com"
export VRP_CONTACT_WECHAT="your-wechat-id"
export VRP_CONTACT_URL="https://example.com" # 选填
export VRP_CONTACT_SLOGAN="车队调度 / 路径优化定制"
Windows PowerShell 用 $env:VRP_CONTACT_NAME="..."。
另外 VRP_CONTACT_OFF=1 可以彻底关掉这张卡片。
设计上有两条保证:
- 全部留空 = 不渲染,绝不会在页面上留一个空白框;
- contact.py 被误删或写坏时,报告页照常生成,只是没有页脚。
一个产品层面的硬提醒
不要假设用户懂经纬度。 曾经有人建议「没有地图 key 就让客户手工填经纬度」——
真实客户根本不知道那是什么。地址 → 坐标这一步必须由系统完成,
地理编码是硬依赖不是可选增强。工程上正确做法是:
适配器模式(可挂不同服务商)+ 地址缓存(客户地址大多重复,命中缓存零 API 调用)。
八、性能:为什么慢、瓶颈在哪
用户反馈「等结果很久」时,先按这张表定位,不要凭感觉去调算法——
实测下来瓶颈几乎从来不在 OR-Tools。
实测耗时分解(14 单 / 5 车 / max_trips=2,本机)
| 环节 | 耗时 | 说明 |
|---|---|---|
import ortools |
0.14 s | 一次性,服务启动时付过就不再付 |
| 地理编码 14 条(冷) | 2.8 s | 纯网络往返,单条约 0.2 s |
| 求解(旧:写死 5 s) | 5.00 s | GLS 恒定吃满时限,1 s 就收敛也照样跑满 |
| 旧版合计 | ≈7.8 s | |
| 地理编码 14 条(热,命中缓存) | 0.00 s | 已加磁盘缓存 |
| 求解(新:自适应 2 s) | 2.00 s | 结果逐位不变 |
| 新版合计 | ≈2.0 s | 提速 74% |
三个根因与对应修法
1. GLS 不会提前退出 —— 时限给多少就烧多少秒。
这是最反直觉的一点:OR-Tools 的 Guided Local Search 是元启发式,
找不到更好的解也不会停,会一直搜到时限为止。实测墙钟耗时与设的时限
严格 1:1(设 1s→1.00s,设 10s→10.02s)。
而实测收敛点远早于时限:
| 规模(单/虚拟车) | 首次达到 20 s 结果的时限 |
|---|---|
| 14 / 5 | 1 s |
| 30 / 6 | 1 s |
| 50 / 8 | 20 s(真的需要长时间) |
→ 已加 vrp_solver.recommend_time_limit(order_count, vehicle_count) 按规模分档,
app.py 调用它而非写死常量。多趟时按虚拟车数(真实车数 × max_trips)算。
2. 地址解析没有缓存 —— 每次重跑都重新打一遍地图 API。
派车场景的常态是同一批地址反复微调重跑(改策略、改趟数、改车型),
地址表其实没变。已加磁盘缓存 geocode_client.py:
- 位置 .geocode_cache.json(与 geocode.py 同级,删除即清空)
- 键 = 地址 + 城市前缀(必须带 city,否则跨城同名门牌会错误命中)
- 只缓存成功结果(失败可能是限流,缓存住会让用户修不好)
- 原子写(临时文件 + os.replace),损坏则静默重建
- Flask 多线程,读写全程加锁
- 上限 5000 条,超出丢弃最旧
3. geocode_many(verify=True) 会让请求数翻倍。
verify_result() 对每个不同的区县前缀额外发一次请求取中心点做落点校验
(前缀有缓存,但地址跨 N 个区就是 N 次额外请求 + N 次节流 sleep)。
脚本里批量解析建议开;Web 端走 geocode_one,默认不带校验。
别碰的东西
- 不要把 250ms 节流去掉。它会让 key 被打进限流,403 之后整批解析全废——
省 3 秒换来全部失败,不划算。 - 不要并发解析。
geocode.py刻意串行,并发只会触发风控。 - 想再快,唯一安全方向是加缓存和降时限,不是加并发。
九、常见故障
| 症状 | 原因 |
|---|---|
No module named 'flask' |
PyCharm 解释器选错,不是没装 |
| 装 ortools 极慢 | 官方源在国外,必须用清华源 |
| 地理编码全 403 | key 配额耗尽;别为了提速去掉 250ms 节流(见 §八) |
| 坐标明显跑偏 | 地址太含糊,看 score 字段(<70 不可信) |
| 改了默认策略没生效 | 默认参数绑死在 def 求值时刻——本项目所有默认值写成 None,函数体内解析 |
max_trips 设很大还是无解 |
多趟不拆单;单笔超重永远装不下 |
| 求解结果「变慢了但答案一样」 | 正常,GLS 吃满时限。调 recommend_time_limit 而非加机器 |
| 改了地址但解析结果没变 | 命中了 .geocode_cache.json,删掉该文件即可 |
完整排查表见 references/deploy-and-deps.md。
附:参考文档
| 文件 | 什么时候读 |
|---|---|
references/ortools-pitfalls.md |
改 vrp_solver.py 之前必读。10 个静默失效坑 + 调试心法 |
references/modeling-and-accuracy.md |
距离/油耗怎么算的、四种策略、结果可信度评估 |
references/deploy-and-deps.md |
部署、依赖分层、API 契约、故障排查、回归基线 |