零基础跟着做一个物流大模型

在Mac上自己微调一个物流大模型

实战教程:零基础跟着做一个物流大模型

这不是一篇"讲原理"的文章,而是一份跟着敲命令就能做出来的操作手册。 全程在一台苹果芯片的 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/uvhttps://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 kbmake 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。页面顶部勾选"显示过程”。

依次点页面上的示例问题,或自己输入:

  1. 帮我查一下运单 LL2026080258
  2. 没保价的快递丢了能赔多少?
  3. 从武汉出发给长沙、郑州、合肥、南昌送货,怎么排顺序最省路?
  4. 从深圳寄一个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 datamake train ITERS=200 → 离线验证 → 上线 → make eval

发生了什么

你刚刚完成了一次完整的迭代循环:发现错题 → 补知识或补练习题 → 重训 → 重测。真实项目里的"行业大模型"就是这个循环转上几十次。


常见错误速查

现象原因解法
页面顶部 LLM ✗模型服务没起终端 A 跑 scripts/serve_mlx.shollama serve
No module named 'logicllm'不在项目目录 / 没 uv synccd 到项目根目录后 uv sync
下载模型失败访问不了 huggingfaceexport HF_ENDPOINT=https://hf-mirror.com
训练 Val loss nan样本超长被截断--max-seq-length 4096
训练把电脑卡死内存不够train.sh--grad-checkpoint
训练后效果和没训一样便签本没加载做第 11 步的离线对比;确认 config.LLM_ADAPTER 有值
模型会总结结果但从不调工具练习题没拆前缀确认 make data 输出里有"拆分出工具调用前缀样本"
评测每次分数不一样采样随机性正常;看趋势不看单次
端口被占别的程序用了 8010/8080make serve PORT=8020

你现在拥有什么

  • 一个跑在自己电脑上、数据不出门的物流助手:网页、接口(兼容 OpenAI 格式,可以接任何聊天前端)。
  • 想让别人在线用?训练好的模型已经有现成的发布脚本:scripts/publish_hf.py(HF Spaces 免费 GPU)和 scripts/publish_modelscope.py(ModelScope 创空间),在线演示见文首两个链接。
  • 一套可以无限迭代的流程:改手册 → 加工具 → 补练习题 → 训练 → 评测
  • 最重要的:你知道了"行业大模型"的真实构成——知识在手册里,计算在程序里,模型只负责理解和调度

下一步如果要接真实业务:把 logicllm/tools/waybill.py 里的 SQLite 换成你们的运单系统接口,把 data/kb/ 换成你们的真实制度文件,其余不动。