陆运智报价 概念封面

一个真实的摩擦点

整车运输询价,客户开口往往是不完整的:「广州番禺到深圳,5 吨货,报个价」。

这句话缺体积、缺托数,而这两项决定了车型能不能装下、要不要计抛。传统做法是人工来回问三四轮,或者让客户填一张表单。这个 demo 把这段来回交给状态机:缺什么问什么,一次只问一项。

状态机而不是表单

用 LangGraph 搭的显式状态机,条件路由只有两条岔路:还有缺项就去追问,齐了就去算价。有两个设计我认为比技术选型更重要:

slots 是唯一业务事实源,messages 只保留最近 5 轮。 对话历史只用于理解这一轮的意图,报价判断只读槽位。这样用户中途改了发货地,槽位被覆盖,历史里的旧说法不会污染结果。

第 10 轮优先熔断,即使当轮信息已经完整。 这是反直觉的一条:补全了就报价不行吗?不行——对话拖到 10 轮说明 AI 没听懂或用户在绕圈,继续下去只会更糟。熔断后把这一轮的负样本写进 negative_samples.jsonl,留给后续优化。

地址解析:三级降级,每一级都说得出来

用户写的地名不会规整。这里做了三级:

级别 做法 例子
L1 别名精确匹配 「羊城」→ 广州
L2 去掉行政后缀,比较无声调拼音,编辑距离 ≤2 且唯一最优才接受 「番禹」→ 番禺区
L3 仅对已知城市和带行政后缀的中文地名做模拟判定 明显未知地址返回 none,不瞎猜

关键在于这套判断是可见的。报价单里每个地点都会标注「匹配方式 + 置信度 + 原文」——截图里写的就是「别名匹配,置信度 1.0,原文:广州番禺」。用户能一眼看出系统有没有听错,而不是等到车派错了才发现。

L2 那条「唯一最优才接受」也是刻意的:多个候选同分时宁可返回失败让人确认,也不擅自选第一个。这和模糊匹配最容易犯的错误正好相反。

费用怎么算的

两套口径分开,不能混:

  • 实际重量只负责载重限制(装不下就换车型)
  • **体积重(计抛)**只负责费用

车型用三条 SQL 区间匹配,区间都含边界,命中多个时取基础价最低的。费用拆成基础运费、计抛费、超托费三项,报价单上逐项列出,总价是加出来的,不是查表查出来的。

时效同理:基础时长 × 天气因子,界面上写「基础 2.5 小时 + 天气因子 1.0(晴)」,而不是只给一个孤零零的「2.5 小时」。

前端是零依赖的原生实现

web/ 里只有三个文件:HTML、CSS、JS,没有构建步骤、没有框架。加上 web_server.py 用的 Python 标准库 HTTP 服务,整个项目 pip install 完依赖就能跑,不需要 Node.js。

界面把「信息完整度 5/5」「当前对话 3/10 轮」「报价状态」做成顶部进度条,用户随时知道自己离一张报价单还有多远。

工程边界

  • 数据全部合成:车型 CSV、运价区间、天气权重都是演示用的,不连真实高德、天气服务或运价库。
  • 地址解析是模拟判定,不代表真实行政区校验。
  • 会话在内存里,进程退出即丢;SQLite 和 LangGraph 的 MemorySaver 都仅用于演示。
  • 报价不按距离加价,遵循 demo 指定的固定公式。
  • README 里逐个列出了 8 个模块的「生产替换点」(哪个文件换成鉴权 API、哪个换成持久化 checkpointer、哪个换成真实地理编码),而不是笼统地说「生产环境需要改造」。

项目图集

报价完成:发货地番禺区(别名匹配,置信度 1.0,原文「广州番禺」)、5 吨标准厢车、计费重量与计抛费分列、总价 680 元、预计时效 2.5 小时
报价完成:发货地番禺区(别名匹配,置信度 1.0,原文「广州番禺」)、5 吨标准厢车、计费重量与计抛费分列、总价 680 元、预计时效 2.5 小时
多轮追问过程:一次只问一个缺失项(先体积、再托数),右侧运输信息随对话实时更新
多轮追问过程:一次只问一个缺失项(先体积、再托数),右侧运输信息随对话实时更新