实战教程:零基础跟着做一个物流大模型
这不是一篇"讲原理"的文章,而是一份跟着敲命令就能做出来的操作手册。 全程在一台苹果芯片的 Mac 上完成,不需要显卡服务器,不需要花钱买 API。 每一步都有四个部分:做什么 → 你会看到什么 → 发生了什么 → 出错怎么办。 预计总耗时:半天到一天(其中等电脑跑的时间约 3 小时,可以去干别的)。
先看效果再动手:做完后的成品已经部署在线上,点开就能玩——
- 🤗 HF Spaces(海外,免费 GPU):https://huggingface.co/spaces/zhatrix/logistics-llm
- 🚀 ModelScope 创空间(国内直连):https://www.modelscope.cn/studios/zh4trix/logistics-llm
试试问"帮我查一下运单 LL2026080258",展开"处理过程"能看到它翻手册、按按钮的全过程。这就是你跟着本文要做出来的东西。
开始之前:你需要什么
| 需要 | 要求 | 怎么确认 |
|---|---|---|
| 电脑 | 苹果芯片 Mac(M1 以上),内存 ≥ 32GB(推荐 64GB+) | 左上角 → 关于本机 |
| 硬盘 | 空余 20GB | 模型文件约 7GB,训练产物约 1GB |
| 软件 | 终端(Terminal)会打开就行 | 聚焦搜索输入 Terminal |
| 网络 | 能访问 huggingface.co(下载模型) | 不能的话见第 7 步的镜像设置 |
| 基础 | 会复制粘贴命令、会用文本编辑器打开文件 | 不需要会写代码 |
怎么读本文的命令:以
$开头的行是要你在终端里输入的(不要输入$本身);下面没有$的是预期看到的输出。
第 1 步:装三个工具(10 分钟)
做什么
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Homebrew,Mac 上的软件包管理器
$ brew install uv ollama # uv:Python 环境管理;ollama:本地跑小模型
$ uv --version && ollama --version
你会看到
uv 0.9.x
ollama version is 0.x.x
发生了什么
- uv 会帮我们创建一个独立的 Python 环境,项目用到的几十个库都装在项目文件夹里,不污染系统。
- Ollama 是一个"本地模型播放器",一条命令就能下载并运行开源大模型,我们用它先把流程跑通。
出错怎么办
brew: command not found:关掉终端重新打开,或按 Homebrew 安装结束时提示的两行命令把它加进 PATH。- 公司网络装不了 Homebrew:直接去 https://docs.astral.sh/uv 和 https://ollama.com 下载安装包。
第 2 步:拿到项目并安装依赖(5 分钟)
做什么
$ cd ~/Project # 换成你放项目的目录
$ git clone https://github.com/zhatrix/logicLLM.git
$ cd logicLLM
$ uv sync
你会看到
最后几行类似:
+ uvicorn==0.52.4
+ ...
Installed 180 packages in 12s
发生了什么
项目目录里多了一个 .venv 文件夹——这就是独立的 Python 环境。以后所有命令都用 uv run xxx 来跑,它会自动用这个环境。
先认识一下目录(不用全记,用到再回来看):
logicLLM/
├── data/kb/ ← 8 篇物流"员工手册"(Markdown,你可以改)
├── data/seed/ ← 手写的练习题
├── logicllm/tools/ ← 10 个"按钮":查运单、算运费、规划路线……
├── logicllm/agent/ ← "大脑"的指挥逻辑:先翻手册,再决定按哪个按钮
├── logicllm/server/ ← 网页和接口
├── scripts/ ← 构建知识库 / 生成练习题 / 训练 / 评测 的脚本
├── eval/cases.jsonl ← 20 道考题
└── Makefile ← 把常用命令起了短名字:make kb、make serve…
出错怎么办
uv sync卡在下载:国内网络可以先export UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple再执行。
第 3 步:先跟一个"什么都没学过"的模型聊聊(5 分钟)
做什么
$ ollama pull qwen2.5:3b # 下载一个 30 亿参数的小模型,约 2GB
$ ollama run qwen2.5:3b
>>> 从深圳寄一个2.2公斤、40×30×30厘米的箱子到拉萨,运费多少?
输入问题后回车,看它怎么答。看完按 Ctrl + D 退出。
你会看到
它会一本正经地编一个价格,比如"大约 30–50 元",而且多半不会提"体积重"。
发生了什么
这就是通用模型的原始状态:会说话、懂常识,但不知道你公司的计费规则,也没法真的去算。接下来我们要做的所有事,都是为了把它从"会聊天"变成"会干活"。
把它的回答截图存下来,最后一步你会回来对比。
第 4 步:给它一本"员工手册"——构建知识库(3 分钟)
做什么
先打开 data/kb/02_计费规则与时效标准.md 随便看看,这就是手册。然后:
$ make kb
你会看到
第一次会下载一个 2GB 的"检索模型"(bge-m3),然后:
索引完成:35 个切块 → .../data/kb_index.npz
Q: 没保价丢了赔多少
0.738 理赔与投诉处理规则 / 赔偿标准
0.653 常见客户问题 FAQ
Q: 充电宝能不能寄
0.698 禁寄、限寄物品与危险品管理 / 限寄物品(需满足条件)
...
发生了什么
程序把 8 篇手册按小标题切成 35 段,每段算出一个"语义指纹"。以后用户提问时,先拿问题的指纹去比对,找出最相关的 4 段塞给模型——这叫 RAG(检索增强)。上面 0.738 就是相似度分数,“没保价丢了赔多少"正确地命中了"赔偿标准"那一段。
动手试试(可选但强烈推荐)
用文本编辑器打开 data/kb/02_计费规则与时效标准.md,把"偏远地区附加费 10 元/票"改成 15 元,保存,再 make kb。你刚刚改了公司规则,而且不需要重新训练任何东西。改完记得改回来(或者不改,那后面计算工具和手册会不一致——这也是一个好实验)。
出错怎么办
- 下载 bge-m3 很慢或失败:执行
export HF_ENDPOINT=https://hf-mirror.com后重试。 No module named 'logicllm':确认在项目根目录,并且第 2 步uv sync成功。
第 5 步:启动助手,第一次真正对话(5 分钟)
做什么
$ make serve-ollama
浏览器打开 http://127.0.0.1:8010。页面顶部勾选"显示过程”。
依次点页面上的示例问题,或自己输入:
帮我查一下运单 LL2026080258没保价的快递丢了能赔多少?从武汉出发给长沙、郑州、合肥、南昌送货,怎么排顺序最省路?从深圳寄一个2.2公斤、40×30×30厘米的箱子到拉萨,运费多少?
你会看到
问题 1 下面会先出现两个黄色小块:
🔧 调用 query_waybill {"waybill_no": "LL2026080258"}
↩ 结果 {"no": "LL2026080258", "sender": "黄敏", "origin": "东莞", "destination": "济南", "status": "到达转运中心", ...}
然后才是模型写的客服话术。问题 2 会出现 📚 知识库命中:理赔与投诉处理规则 / 赔偿标准。问题 3 会调用 optimize_delivery_order,给出"武汉 → 长沙 → 南昌 → 合肥 → 郑州 → 武汉,2480 公里"。
问题 4——注意看它调用 calc_freight 时传的参数:多半只传了 weight_kg: 2.2,把箱子尺寸丢了,算出 36 元。正确答案是 57 元(体积重 6kg)。
发生了什么
同一个 3B 小模型,什么都没训练,只是给了它手册和按钮,就已经能查单、能翻规则、能规划路线。这说明:行业助手的大部分能力来自架子,不来自训练。
而问题 4 暴露的就是训练要解决的事:它不懂"有尺寸就要传尺寸"这种行业默认动作。
出错怎么办
- 页面顶部显示
LLM ✗:Ollama 没在运行,另开一个终端执行ollama serve。 - 端口被占:
make serve-ollama PORT=8020,然后访问 8020。 - 点了问题没反应超过 1 分钟:看终端报错;3B 模型第一次加载要十几秒。
第 6 步:动手加一个新"按钮"(15 分钟)
这一步你会亲手给助手加一个能力:查网点营业时间。做完你就明白"工具"是怎么回事了。
做什么
新建文件 logicllm/tools/branch.py,内容如下(整段复制):
"""网点信息查询(演示用,数据写死)。"""
from logicllm.tools import tool
BRANCHES = {
"杭州": {"name": "杭州西湖营业部", "hours": "08:00–20:00", "phone": "0571-88000001", "self_pickup": True},
"深圳": {"name": "深圳南山营业部", "hours": "08:30–21:00", "phone": "0755-26000002", "self_pickup": True},
"拉萨": {"name": "拉萨城关代办点", "hours": "09:00–18:00", "phone": "0891-63000003", "self_pickup": False},
}
@tool(
"query_branch",
"查询某个城市网点的营业时间、电话和是否支持自提。",
{"type": "object", "properties": {"city": {"type": "string", "description": "城市名,如 杭州"}},
"required": ["city"]},
)
def query_branch(city: str):
info = BRANCHES.get(city.strip())
if not info:
return {"error": f"{city} 暂无网点信息", "available": list(BRANCHES)}
return {"city": city, **info}
然后打开 logicllm/tools/__init__.py,找到最后一行:
from logicllm.tools import waybill, pricing, eta, route, address # noqa: E402,F401
在末尾加上 , branch:
from logicllm.tools import waybill, pricing, eta, route, address, branch # noqa: E402,F401
回到运行 make serve-ollama 的终端,按 Ctrl + C 停掉,再次 make serve-ollama。刷新网页,问:
杭州网点几点关门?能自提吗?
你会看到
🔧 调用 query_branch {"city": "杭州"}
↩ 结果 {"city": "杭州", "name": "杭州西湖营业部", "hours": "08:00–20:00", "self_pickup": true, ...}
杭州西湖营业部营业时间 08:00–20:00,支持自提……
发生了什么
你写了一个普通的 Python 函数,用 @tool(名字, 一句话说明, 参数说明) 给它贴了个标签。程序启动时会把所有贴了标签的函数列成清单交给模型;模型读到"查询某个城市网点的营业时间"这句说明,就知道什么时候该按这个按钮。
真实项目里,把 BRANCHES 换成查数据库或调公司接口,其余一个字都不用改。
出错怎么办
- 启动报
SyntaxError:多半是复制时缩进乱了,Python 对缩进敏感,确保函数体前面是 4 个空格。 - 模型没调用新工具:3B 模型偶尔会犯懒,换个问法"帮我查杭州网点信息",或者到第 11 步用 7B 微调后的模型再试,会稳定很多。
第 7 步:换成 70 亿参数的"正式员工"(20 分钟,主要是下载)
做什么
$ uv run hf download mlx-community/Qwen2.5-7B-Instruct-4bit # 4.3GB
下载完成后,新开一个终端(后面会一直开着两个终端:一个跑模型,一个跑应用):
# 终端 A:模型服务
$ cd ~/Project/logicLLM && bash scripts/serve_mlx.sh
看到 Uvicorn running on http://127.0.0.1:8080 之类的字样后,回到原来的终端:
# 终端 B:应用服务
$ LLM_BACKEND=mlx make serve
刷新 http://127.0.0.1:8010,顶部应显示 mlx:default_model · LLM ✓。
发生了什么
- 第 3–6 步用的是 Ollama 跑的 3B 模型;现在换成 MLX(苹果自家的机器学习框架)跑的 7B 模型。MLX 能直接用 Mac 的统一内存,是后面训练的基础。
4bit的意思是模型被压缩到每个参数 4 位,7B 模型只占 4.3GB 内存,速度每秒 100 多个字。- 应用通过
LLM_BACKEND这个环境变量决定连哪个后端,代码完全不变。
出错怎么办
- 下载失败/极慢:
export HF_ENDPOINT=https://hf-mirror.com后重试,支持断点续传。 - 终端 A 报内存不足:关掉其他大程序;7B 4bit 推理只需约 6GB,一般不会。
第 8 步:给它考一次试——基线评测(10 分钟)
做什么
先看看考卷:打开 eval/cases.jsonl,每行一道题,例如:
{"cat":"客服","q":"从深圳寄 2.2kg 的包裹到拉萨,箱子 40×30×30cm,保价1000元,运费多少?","expect_tool":"calc_freight","keywords":["57"]}
意思是:这道题应该调用 calc_freight,而且答案里要出现 57。然后:
$ LLM_BACKEND=mlx make eval
你会看到
逐题打分,最后汇总:
✅ [客服] 帮我查一下运单 LL2026080258 1.00
❌ [抽取] 提取收件信息:收件人王小明 13812345678 浙江省杭州市… 0.00 JSON 字段 0/5
...
总分 0.825
客服: 0.833 (6)
知识: 0.938 (8)
抽取: 0.000 (2)
调度: 1.000 (4)
(分数可能有小幅波动,模型输出有随机性。)
发生了什么
这是没训练的 7B 模型的成绩,0.825。记住这个数字。
特别注意"抽取"是 0 分:让它把一段地址整理成固定格式的 JSON,它偏要写成一段话。程序读不了一段话,所以在实际业务里这个功能等于不可用——这就是微调的目标。
出错怎么办
- 很慢:第一次评测要 5–10 分钟(每道题的提示词有 2000 多字,模型要先读完)。
Server disconnected:终端 A 的模型服务偶尔崩一次,脚本会自动重试一次;多次失败就重启终端 A。
第 9 步:生成练习题(首次约 2 小时,挂着就行)
做什么
确认终端 A 的模型服务还开着(它要当"出题老师"),然后:
$ LLM_BACKEND=mlx make data
想先快速体验,可以只生成少量:
$ LLM_BACKEND=mlx uv run python scripts/gen_sft.py --n-kb 30 --n-tool 50 --n-extract 30 --n-route 20
你会看到
生成工具调用样本…
生成抽取样本…
生成路径样本…
用教师模型 default_model 生成知识问答…
kb: 60/240
...
拆分出工具调用前缀样本 467 条
完成:train 1435 条,valid 124 条 → .../data/sft
发生了什么
打开 data/sft/train.jsonl,随便看一行(很长,用编辑器的自动换行)。一道"练习题"长这样:
system: 你是「物流通」…(+ 翻到的手册内容) ← 和线上一模一样的开场白
tools: [查运单, 算运费, …] ← 按钮清单
user: 从郑州寄笔记本电脑到长春,2.8kg,保价500元,标准快递多少钱?
assistant: <调用 calc_freight(origin=郑州, destination=长春, weight_kg=2.8, declared_value=500)>
tool: {"total_fee": 26.5, "breakdown": "首重12元 + 续重2kg×6元 + 保价费2.5元"}
assistant: 郑州 → 长春(跨区),计费重量 3.0kg:… 合计 26.5 元
四类题的来源:
| 类型 | 标准答案从哪来 | 数量 |
|---|---|---|
| 客服+工具 | 程序随机出题,真的去按按钮算出结果,再套话术 | 400 |
| 地址抽取 | 程序拼地址,规则解析器给答案 | 240 |
| 路线调度 | 真实算法算 | 120 |
| 行业知识 | 7B 模型读手册出题(这一步最慢) | 244 |
工具类练习题的答案是算出来的,100% 正确——这是整套方法靠谱的根基。
拆分出工具调用前缀样本 467 条 这行很重要:每道带工具的题被额外拆成"用户问 → 模型决定按按钮"这半道题单独练。不拆的话,模型只学会"拿到结果怎么总结",学不会"要去按按钮"(我第一次就栽在这里)。
出错怎么办
- 进度很久不动:正常,教师模型每道题要读 700 字手册再出 3 题;可以
--n-kb 0跳过,先用工具题训练。 - 中途断了:知识问答部分会缓存在
data/seed/teacher_kb_qa.json,下次运行自动复用。
第 10 步:训练(约 1 小时,挂着就行)
做什么
先停掉终端 A 的模型服务(Ctrl + C,训练要独占 GPU),然后:
$ make train ITERS=200
你会看到
Trainable parameters: 0.151% (11.534M/7615.617M)
Starting training..., iters: 200
Iter 1: Val loss 1.280
Iter 20: Train loss 0.912, ... Peak mem 74.470 GB
...
Iter 100: Val loss 0.129
Iter 200: Val loss 0.104
训练完成 → adapters/logistics-lora
发生了什么
Trainable parameters: 0.151%:这就是 LoRA——76 亿参数纹丝不动,只训练旁边挂的 1150 万个新参数。训练产物是一个 46MB 的小文件adapters/logistics-lora/adapters.safetensors,可以理解为给模型配的"便签本"。Val loss(验证集错题率)从 1.28 降到 0.10:模型在没见过的题上也越来越准。Peak mem 74 GB:训练吃内存。
出错怎么办
- 内存不够 / 电脑卡死:打开
scripts/train.sh,在--seed 42上面加一行--grad-checkpoint \,峰值降到约 14GB,速度慢 30%。 Val loss nan:练习题太长被截断了。确认scripts/train.sh里是--max-seq-length 4096。- 想缩短时间:
ITERS=100也能看到明显效果。
第 11 步:先离线验证,再上线(5 分钟)
这一步是我踩坑后加的:训练完不要直接上线,先确认"便签本"真的有效。
做什么
把下面内容保存为 scripts/check_adapter.py(项目里已经附带了这个文件,直接运行即可):
"""离线对比:不带 / 带 LoRA 时,模型对同一个问题的输出。"""
from mlx_lm import load, generate
from logicllm.agent.prompts import build_system, openai_tools
from logicllm.tools import all_specs
msgs = [{"role": "system", "content": build_system()},
{"role": "user", "content": "提取收件信息:收件人王小明 13812345678 浙江省杭州市西湖区文三路123号5楼"}]
for adapter in [None, "adapters/logistics-lora"]:
model, tok = load("mlx-community/Qwen2.5-7B-Instruct-4bit", adapter_path=adapter)
prompt = tok.apply_chat_template(msgs, tools=openai_tools(all_specs()), add_generation_prompt=True)
print("便签本:", adapter, "→", generate(model, tok, prompt=prompt, max_tokens=120, verbose=False)[:150], "\n")
$ uv run python scripts/check_adapter.py
你会看到
便签本: None → <tool_call>{"name": "parse_address", ...}</tool_call>
便签本: adapters/logistics-lora → ```json
{
"name": "王小明",
"phone": "13812345678",
"province": "浙江",
"city": "杭州",
...
发生了什么
同一个问题,不带便签本时模型去调工具然后写散文;带上便签本后直接输出整齐的 JSON。看到这个差异,才说明训练真的起作用了。
然后上线:
# 终端 A
$ bash scripts/serve_mlx.sh # 它会自动发现 adapters/ 目录并加载
# 终端 B(如果还开着,Ctrl+C 后重启)
$ LLM_BACKEND=mlx make serve
出错怎么办
- 两次输出一样(都没 JSON):训练没生效,检查
adapters/logistics-lora/adapters.safetensors是否存在、第 10 步有没有报错。 - 上线后行为和离线不一样:这是我遇到的最大的坑——推理服务有个 bug 不加载便签本。项目已经绕过了(每次请求显式带路径),但如果你升级了
mlx-lm版本,请重新做这个对比。
第 12 步:再考一次(5 分钟)
做什么
$ LLM_BACKEND=mlx make eval
然后回到网页,把第 3 步截图的那个问题再问一遍:
从深圳寄一个2.2公斤、40×30×30厘米的箱子到拉萨,运费多少?
你会看到
总分 0.880
客服: 0.833 (6)
知识: 0.875 (8)
抽取: 0.800 (2) ← 从 0 到 0.8
调度: 1.000 (4)
网页上,calc_freight 的参数里现在有了 length_cm: 40, width_cm: 30, height_cm: 30,答案变成 57 元左右,并且会解释"体积重 6kg 大于实重"。
发生了什么
对比第 3 步:那时它在编;第 5 步:它会按按钮但漏参数;现在:参数完整,格式规范。这就是架子 + 微调各自贡献的部分。
还剩的错题也值得看:比如"53 度白酒能不能寄",它可能把 53 和 70 比错了。7B 模型数字比较确实弱——这类题靠多出几道对应练习题解决,见下一步。
第 13 步:加一条你自己的知识和练习题,再训一轮(动手环节)
现在整套流程你都走过了,试着改一点自己的东西。
13a. 加知识(不用训练)
新建 data/kb/09_我的公司规则.md:
# 我的公司规则
## 生鲜冷链
- 冷链件必须使用公司保温箱,每箱加收 15 元冷链费。
- 冷链件仅支持 1000km 以内线路,超出拒收。
- 签收时温度高于 8℃ 可拒签并全额退款。
make kb,然后在网页问"冷链件怎么收费"。不用训练,立刻生效。
13b. 加练习题(教行为)
打开 data/seed/handwritten.jsonl,仿照已有行加一条(整行一个 JSON,注意不要换行):
{"messages":[{"role":"system","content":"占位"},{"role":"user","content":"53度白酒可以寄吗?"},{"role":"assistant","content":"可以寄。53 度在 24%–70% 区间内,规定每件不超过 5L、只能陆运、需防碎包装。70 度以上才禁寄。"}]}
(system 写什么都行,生成脚本会自动替换成标准开场白并注入手册内容。)
再跑一遍第 9–12 步:make data → make train ITERS=200 → 离线验证 → 上线 → make eval。
发生了什么
你刚刚完成了一次完整的迭代循环:发现错题 → 补知识或补练习题 → 重训 → 重测。真实项目里的"行业大模型"就是这个循环转上几十次。
常见错误速查
| 现象 | 原因 | 解法 |
|---|---|---|
页面顶部 LLM ✗ | 模型服务没起 | 终端 A 跑 scripts/serve_mlx.sh 或 ollama serve |
No module named 'logicllm' | 不在项目目录 / 没 uv sync | cd 到项目根目录后 uv sync |
| 下载模型失败 | 访问不了 huggingface | export HF_ENDPOINT=https://hf-mirror.com |
训练 Val loss nan | 样本超长被截断 | --max-seq-length 4096 |
| 训练把电脑卡死 | 内存不够 | train.sh 加 --grad-checkpoint |
| 训练后效果和没训一样 | 便签本没加载 | 做第 11 步的离线对比;确认 config.LLM_ADAPTER 有值 |
| 模型会总结结果但从不调工具 | 练习题没拆前缀 | 确认 make data 输出里有"拆分出工具调用前缀样本" |
| 评测每次分数不一样 | 采样随机性 | 正常;看趋势不看单次 |
| 端口被占 | 别的程序用了 8010/8080 | make serve PORT=8020 |
你现在拥有什么
- 一个跑在自己电脑上、数据不出门的物流助手:网页、接口(兼容 OpenAI 格式,可以接任何聊天前端)。
- 想让别人在线用?训练好的模型已经有现成的发布脚本:
scripts/publish_hf.py(HF Spaces 免费 GPU)和scripts/publish_modelscope.py(ModelScope 创空间),在线演示见文首两个链接。 - 一套可以无限迭代的流程:改手册 → 加工具 → 补练习题 → 训练 → 评测。
- 最重要的:你知道了"行业大模型"的真实构成——知识在手册里,计算在程序里,模型只负责理解和调度。
下一步如果要接真实业务:把 logicllm/tools/waybill.py 里的 SQLite 换成你们的运单系统接口,把 data/kb/ 换成你们的真实制度文件,其余不动。