全栈开发知识库

10 模型微调与私有化部署: 让 Agent 离线跑, 并量出代价

一句话总结: 先用第 9 章的尺子量一遍本地模型 —— 它会给你一个难看的数字, 而这个数字精确指出微调该修的到底是"知识"还是"格式遵循"; 然后从数据库导出训练集、在 Mac 上跑 LoRA、用 Ollama 部署, 最后再量一遍。

本章你能学到什么

  1. 为什么要微调 —— 三个真实动机 (省 token、格式稳、数据不出内网), 以及哪个动机成立哪个不成立
  2. 用 Ollama 三行 .env 切到本地模型, 以及切完立刻用第 9 章的评测量一遍
  3. 一个真实的负面结果: 本地 7B 模型的判分 MAE 是 3.571, 比规则判分器的 0.833 差四倍
  4. 怎么从这个难看的数字定位根因 —— 不是模型不懂 Android, 是它做不到"原样抄回要点文本"
  5. 这个根因为什么恰好是微调最擅长解决的问题 (格式遵循 ≠ 知识注入)
  6. 从 qa_records 表导出两种训练集 (grader 判分 / interviewer 追问), 以及第 8 章埋的伏笔怎么兑现
  7. 数据质量的三条硬规则, 违反任何一条微调只会让模型变笨
  8. dataset_info.json 为什么由脚本生成而不是手抄
  9. 冷启动没数据怎么办 —— seed_traces.py 造管道验证数据, 以及为什么它造的数据不能真拿去训练
  10. 分数分布直方图: 全是满分样本训出来的模型只会打高分
  11. Mac 上用 MLX 跑 LoRA 的完整命令, 以及租卡跑 QLoRA 的成本控制
  12. GGUF 转换 + Ollama Modelfile 部署, 以及"改 .env 三行就能切过去"是怎么在第 1 章就设计好的
  13. 什么情况下不该微调 —— 少于 200 条数据时改 prompt 更划算

一、先别急着微调: 三个动机里只有两个成立

1.1 官方理由

"""把真实面试轨迹导出成微调数据集。

为什么要微调: 云 API 的通用模型不知道"我们的 rubric 长什么样", 每次都要在 prompt 里塞
完整评分规则 + 要点列表, token 贵而且长 prompt 容易漂。微调之后规则进了权重,
prompt 可以砍到只剩题目和回答。
"""

把这段话拆成三个可验证的动机:

动机 能不能量 我们的实测
省 token 能 GRADE_SYSTEM 固定 523 字符, input 平均 254 字符 → 砍掉 system 省 67.3%
格式稳定 能 (第 9 章的尺子) 本章 2.4 节会给出一个非常明确的答案
数据不出内网 不用量, 是硬约束 面试记录含真实候选人回答, 有些公司不许出网

✅ Checkpoint 1: 量一下 prompt 到底有多长。

uv run python - <<'PY'
import json
rows = [json.loads(l) for l in open('finetune/data/grader.jsonl', encoding='utf-8')]
ins = len(rows[0]['instruction'])
inp = [len(r['input']) for r in rows]
print(f"instruction 固定 {ins} 字符")
print(f"input  平均 {sum(inp)//len(inp)} 字符  最短 {min(inp)}  最长 {max(inp)}")
full = ins + sum(inp)/len(inp)
print(f"当前每次判分 prompt = {ins} + {sum(inp)//len(inp)} = {int(full)} 字符")
print(f"微调后砍掉 instruction: 省 {ins/full:.1%}")
PY

真实输出:

instruction 固定 523 字符
input  平均 254 字符  最短 166  最长 386
output 平均 220 字符
当前每次判分 prompt = 523 + 254 = 777 字符
微调后砍掉 instruction: 省 67.3%
样本条数 24

67.3% 是一个真实的数字, 但要看清它的量级: 777 字符大约 500 token, 省下 350 token。按 DeepSeek 的价格, 一万次判分省下来的钱买不了一杯咖啡。

所以"省 token"是一个真实但微弱的动机。 除非你的量级是每天百万次判分, 否则不要拿它当微调的主要理由 —— 这是很多人做微调时算错的第一笔账。

1.2 真正值钱的动机在下一节

省 token 说服力不足, "数据不出内网"是政策约束不需要论证。那剩下"格式稳定"这一条到底成不成立?

第 9 章刚做完的评测体系正好是回答这个问题的工具。先切到本地模型, 量一遍, 再决定要不要微调。

这个顺序非常重要: 不量就微调, 你不知道自己在修什么。


二、先部署再微调: 用 Ollama 三行切到本地模型

2.1 为什么先部署

直觉的顺序是"微调 → 部署"。但正确的顺序是"先部署一个没微调的本地模型, 量一遍, 再决定微调什么"。

理由: 微调是有方向的 —— 你得知道模型哪里不行才能选任务和造数据。没有基线, 微调完你也不知道是不是变好了。

2.2 三行 .env

Ollama 提供 OpenAI 兼容接口, 所以我们的代码一行都不用改:

# .env
LLM_API_KEY=ollama                          # 随便填, Ollama 不校验
LLM_BASE_URL=http://127.0.0.1:11434/v1      # 注意结尾的 /v1
LLM_MODEL=qwen2.5:7b
OFFLINE=false

这三行能生效, 是第 1 章的一个设计在还债:

"""LLM 调用的唯一出口。

把所有模型调用收口在这一个文件, 换模型 (云 API -> 自己微调的模型) 只改 .env, 不动业务代码。
这一条是后面第 09 篇能"三行切换到微调模型"的前提。
"""

app/core/llm.py 里 OpenAI(api_key=..., base_url=...) 的 base_url 是从配置读的。"把所有外部调用收口到一个文件"这个习惯, 在这里换来了零改动切换模型。

✅ Checkpoint 2: 装 Ollama 并确认服务和模型。

# macOS: brew install ollama, 或者从 ollama.com 下 App
ollama --version
ollama pull qwen2.5:7b
ollama list

真实输出:

ollama version is 0.32.5
NAME                       ID              SIZE      MODIFIED
qwen2.5:7b                 845dbda0ea48    4.7 GB    27 hours ago
llama3:latest              365c0bd3c000    4.7 GB    4 days ago
nomic-embed-text:latest    0a109f422b47    274 MB    4 weeks ago
qwen2.5:latest             845dbda0ea48    4.7 GB    4 weeks ago

确认 OpenAI 兼容接口通:

curl -s -X POST http://127.0.0.1:11434/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen2.5:7b","messages":[{"role":"user","content":"只回复 OK 两个字"}],"temperature":0}'

真实输出:

{"id":"chatcmpl-50","object":"chat.completion","created":1787040814,"model":"qwen2.5:7b","system_fingerprint":"fp_ollama","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":35,"completion_tokens":2,"total_tokens":37}}

返回结构和 OpenAI 字字对齐, 包括 usage —— 所以 openai SDK 完全不知道自己在跟本地模型说话。

注意 base_url 结尾必须是 /v1。 漏掉的话 SDK 会去请求 http://127.0.0.1:11434/chat/completions, 得到 404 —— 而 404 的报错信息完全不会提示你少了什么。

2.3 本机是什么配置

sysctl -n machdep.cpu.brand_string
sysctl -n hw.memsize | awk '{print $1/1024/1024/1024" GB"}'

真实输出:

Apple M3
16 GB

Apple Silicon 的统一内存在这里是优势: 16GB 统一内存意味着 GPU 能用到十几 GB, 一张 16GB 显存的独立显卡要贵得多。7B 模型 4-bit 量化后 4.7GB, 跑得动。

✅ Checkpoint 3: 本地判分的延迟。

# 同一道题连判三次, 看延迟
uv run python - <<'PY'
import time
import app.core.llm as L
from app.agent.grader import GRADE_SYSTEM, GRADE_USER
# ... 构造 msgs 见仓库脚本
for i in range(3):
    t0 = time.perf_counter(); L.chat(msgs, temperature=0.0)
    print(f"第 {i+1} 次 {time.perf_counter()-t0:.2f}s")
PY

真实输出:

第 1 次 6.27s
第 2 次 4.88s
第 3 次 4.68s
平均 5.28s   (qwen2.5:7b @ Apple M3 16GB, Ollama)

5.28 秒。 对比一下: 云 API 判分 2~5 秒, 规则判分毫秒级。

本地 7B 在 M3 上和云 API 是同一个量级 —— 这个结论比很多人预期的好。第一次 6.27s 包含模型加载, 后面稳定在 4.7~4.9s。

这也解释了第 8 章为什么一定要做 SSE: 5 秒的判分延迟, 不流式推送用户会以为卡死了。

2.4 关键一步: 用第 9 章的尺子量它

延迟能接受了, 那判分质量呢?

第 9 章那个脚本有 --offline 开关: 不加就是评 LLMGrader。而 LLMGrader 现在指向的是本地的 qwen2.5:7b。一条命令就能量。

uv run python eval/eval_grader.py

第一次跑, 崩了:

ValueError: 3 次都没拿到合法 JSON: Expecting ',' delimiter: line 5 column 20 (char 193)

这不是 bug, 这是第一个发现。 定位一下是哪条样本:

android-lifecycle-001      OK
android-kotlin-001         OK
android-jetpack-001        OK
android-perf-001           OK
android-service-001        OK
android-lifecycle-002      OK
android-perf-003           OK
android-hard-004           FAIL Expecting ',' delimiter: line 5 column 20 (char 193)
---- raw ----
{
  "hit_points": ["一个线程上可以跑很多协程"],
  "missed_points": ["挂起时把线程让出去, 而不是占着线程等待", "挂起恢复靠 CPS 变换出的状态机, 不需要额外线程栈"],
  "wrong_points": ["省在不用为每个任务开线程, 一个线程上可以跑很多协程, 调度是在语言层面做的。"
                   "没有明确指出调度是在语言层面做的,而是模糊地说了调度。",
                   "没提到挂起时把线程让出去,而只是说了一个线程可以跑多个协程"],
  "depth": "surface",
  "comment": "提到了多协程共享线程,但未详细说明机制。"
}
-------------
android-kotlin-002         OK
...

看第 5 行结尾: 第一个字符串后面少了一个逗号。

这是7B 模型的典型失效: 数组里连续两个字符串之间漏掉分隔符。而且这不是随机抖动 —— temperature=0.0 下重试三次都是同一个错误, 所以 chat_json 的重试机制救不了它。

顺带注意: 模型输出里混进了全角逗号 (而是模糊地说了调度,), 说明它对格式的控制是松的。

这一条已经把 1.1 节"格式稳定"那个动机验证成立了: 一个 7B 模型在结构化输出这件事上确实不稳, 而这恰恰是微调最擅长解决的问题 (2.6 节展开)。

先把这一条剔掉, 量剩下 14 条:

✅ Checkpoint 4: 本地 7B 模型的判分一致性。

uv run python eval/eval_grader.py --cases /tmp/cases_14.yaml

真实输出:

判分器: LLMGrader   样本 14 条
qid                            人工     机器     误差  depth
android-lifecycle-001        10.0    0.0  -10.0  solid  <-- 偏差大
android-kotlin-001           10.0    0.0  -10.0  solid  <-- 偏差大
android-jetpack-001           7.0    0.0   -7.0  surface  <-- 偏差大
android-perf-001              7.0    0.0   -7.0  surface  <-- 偏差大
android-service-001           6.0    3.0   -3.0  surface
android-lifecycle-002         4.0    4.0   +0.0  surface
android-perf-003              3.0    3.0   +0.0  surface
android-kotlin-002            2.0    0.0   -2.0  surface
android-jetpack-002           3.0    0.0   -3.0  surface
android-hard-003              1.5    0.0   -1.5  surface
android-perf-002              0.0    0.0   +0.0  blank
android-lifecycle-003         0.0    0.0   +0.0  blank
android-hard-001              1.0    0.0   -1.0  surface
android-hard-002             10.0    4.5   -5.5  solid  <-- 偏差大
------------------------------------------------------------
MAE          = 3.571     (平均差几分, 越小越好)
系统性偏差    = -3.571     (正=机器偏松, 负=机器偏严)
误差<=2分占比 = 0.5
顺序一致率    = 0.3626   (91 个样本对)
皮尔逊相关    = 0.2536

参考线: MAE<=1.5 且顺序一致率>=0.85 可以用来做对照实验;
        MAE>2.5 或一致率<0.7 先修判分器, 别急着调模型。
注意: 样本 14 条, 顺序一致率的分辨率是 1/91 = 0.0110, 小于这个的差异是噪声。

这是一个很难看的结果, 而且它比一个漂亮的结果有用得多。

判分器 MAE 顺序一致率 皮尔逊
RuleGrader (80 行关键词加权覆盖) 0.833 0.8 0.9377
本地 qwen2.5:7b 3.571 0.3626 0.2536

一个 80 行、不调任何模型的规则判分器, 在所有三个指标上都完胜一个 7B 大模型。 MAE 好四倍, 一致率好一倍, 相关系数好近四倍。

而且 MAE = 3.571 > 2.5 和 一致率 = 0.3626 < 0.7 同时触发了第 9 章那条参考线的红线: "先修判分器, 别急着调模型"。

如果没有第 9 章那套评测, 我们会怎么做? 大概是跑几场面试, 觉得"嗯本地模型也能用", 然后直接上线 —— 而它给用户打的分几乎是随机的。

这就是评估体系的全部价值: 它把一个会静默上线的灾难变成了一个数字。

2.5 定位根因: 不是不懂 Android, 是抄不回原文

别停在"7B 太小了"这个结论上。 那句话指向不了任何动作。

看第一行: android-lifecycle-001 人工 10.0、机器 0.0, 但 depth 是 solid。

这里有矛盾: 模型认为回答"机制讲清楚了" (solid), 却一个要点都没命中 (0 分)? 挖一下:

=== rubric 原文 ===
'跳转时 A.onPause 先于 B.onCreate'
'A.onStop 发生在 B.onResume 之后, 即 B 完全可见后 A 才停止'
'返回时 B.onPause -> A.onRestart/onStart/onResume -> B.onStop/onDestroy'

=== 模型抄回的 hit_points ===
'从 A 跳到 B 的时候, 先走 A.onPause, 接着 B.onCreate onStart onResume, 等 B 完全可见了 A 才走 onStop,
 也就是 A.onStop 发生在 B.onResume 之后。按返回键回到 A 的时候, 先 B.onPause, 然后 A.onRestart onStart
 onResume, 最后才是 B.onStop 和 B.onDestroy'

逐条是否字字相等:
  False  从 A 跳到 B 的时候, 先走 A.onPause, 接着 B.onCreat

根因找到了。

模型的技术判断完全正确 —— 它确实认出这个回答讲全了三个要点, depth 给 solid 也合理。

但 GRADE_SYSTEM 要求的是 "hit_points": ["原样抄回命中的要点文本"], 而模型把候选人的回答原文抄回来了, 三个要点还合并成了一个字符串。

于是 _score_from_points 这段代码一个都匹配不上:

def _score_from_points(q: Question, hit: list[str], wrong: list[str]) -> float:
    """命中权重占比 -> 0~10 分, 每条错误扣 1.5 分。"""
    hit_set = {h.strip() for h in hit}
    got = sum(p.weight for p in q.rubric if p.point.strip() in hit_set)
    base = 10.0 * got / q.total_weight
    return max(0.0, min(10.0, base - 1.5 * len(wrong)))

p.point.strip() in hit_set 是严格字符串相等。模型抄回的不是 rubric 原文 → got = 0 → 0 分。

所以这 3.571 的 MAE 里, 绝大部分不是"模型不懂 Android", 而是"模型做不到原样抄回一个字符串"。

这个区分是本章最重要的一句话, 因为两种问题的修法完全不同:

问题 修法 微调管用吗
模型不懂 Android 换更大的模型 / 灌领域知识继续预训练 微调帮助有限, 而且贵
模型不遵守输出格式 微调 (指令跟随 + 格式对齐) 这是微调最擅长的

顺带说: 这也暴露了我们自己代码的一个脆弱设计。 用严格字符串相等去匹配模型抄回的文本, 对云端大模型能工作, 对 7B 就完全失效。更稳的做法是让模型返回要点的序号 ("hit": [1, 3]) 而不是原文 —— 一个整数远比一段中文容易被精确复现。这是一个真实的待改项, 本章不改是因为它会让训练数据格式和线上格式脱节 (3.2 节会讲为什么这两者必须字字对齐)。

2.6 结论: 现在微调的理由变得非常清楚

回到 1.1 节那张表, 现在可以填完了:

动机 成立吗 依据
省 token 弱 省 67.3%, 但绝对金额可忽略
格式稳定 强, 已量化 7B 裸模型 MAE 3.571 / 一致率 0.3626, 且根因是格式而非知识
数据不出内网 硬约束 不需要论证

微调要解决的具体问题是: 让 7B 模型学会"原样抄回 rubric 文本"这一个输出习惯。

这是一个格式对齐任务, 不是知识注入任务 —— 而格式对齐恰好是 LoRA 这种小规模微调最擅长、最省资源、最快见效的场景。

先量再修, 你要修的东西会从"模型不行"变成一个具体的、可训练的目标。


三、导出训练数据: 第 8 章埋的伏笔兑现

3.1 数据从哪来

第 8 章设计 qa_records 表时留了一句话: "这张表是第 10 章的伏笔"。现在兑现。

CREATE TABLE IF NOT EXISTS qa_records (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id TEXT, idx INTEGER, qid TEXT, topic TEXT, difficulty INTEGER,
  question TEXT, answer TEXT, score REAL, depth TEXT,
  hit_json TEXT, missed_json TEXT, wrong_json TEXT, comment TEXT,
  followup_depth INTEGER, scored INTEGER, latency_ms INTEGER,
  created_at TEXT
);

每一场真实面试都在往训练集里存样本, 而且存的就是判分任务的输入输出对。 这不是巧合 —— 第 8 章落库时就是按"将来要拿去训练"的格式设计的。

3.2 两种任务, 两个数据集

"""
两种导出格式:
- grader:   评分任务。input = 题目+要点+回答, output = 判定 JSON。这是主力, 直接省 token
- interviewer: 追问任务。input = 题目+回答, output = 下一个追问。锦上添花
"""

grader 是主力, 因为 2.5 节定位到的问题就在这里。interviewer 是锦上添花, 数据量天然少得多 (只有触发了追问的轮次才产生样本)。

def build_grader_sample(rec: dict, rubric: list[dict]) -> dict:
    """一条评分样本。output 严格用和线上一致的 JSON 结构 ——
    训练时的输出格式和推理时期望的格式必须字字对齐, 否则微调白做。"""
    out = {
        "hit_points": json.loads(rec["hit_json"] or "[]"),
        "missed_points": json.loads(rec["missed_json"] or "[]"),
        "wrong_points": json.loads(rec["wrong_json"] or "[]"),
        "depth": rec["depth"] or "surface",
        "comment": rec["comment"] or "",
    }
    return {
        "instruction": GRADE_SYSTEM,
        "input": GRADE_USER.format(
            question=rec["question"], rubric=_rubric_txt(rubric), answer=rec["answer"]
        ),
        "output": json.dumps(out, ensure_ascii=False),
    }

"训练时的输出格式和推理时期望的格式必须字字对齐, 否则微调白做" —— 这句话是整个脚本的第一原则。

注意 instruction=GRADE_SYSTEM 和 input=GRADE_USER.format(...) 直接 import 了线上判分器用的那两个常量:

from app.agent.grader import GRADE_SYSTEM, GRADE_USER

这个 import 是刻意的, 而不是复制粘贴 prompt 文本。

理由: 如果你把 prompt 抄进导出脚本, 那么将来改 grader.py 的 prompt 时, 训练数据和线上 prompt 就悄悄分叉了 —— 模型在一种格式上训练、在另一种格式上被调用, 效果会莫名其妙地差, 而且没有任何报错。

同一份 prompt 只能有一个来源。 这也解释了 2.5 节末尾为什么说"改成返回序号"这个待改项要谨慎: 改了就要同时改 prompt、改 _score_from_points、重新导出数据、重新训练。

json.dumps(out, ensure_ascii=False): ensure_ascii=True (默认) 会把中文变成 中文。训练数据里塞一堆转义序列, 模型学到的就是转义序列 —— 而线上期望的是中文。

rec["hit_json"] or "[]" 处理 NULL: 第 8 章落库时空数组存的是 "[]", 但老数据或异常路径可能是 NULL, json.loads(None) 会崩。

3.3 追问样本: 从相邻两轮里挖出来

def build_followup_sample(parent: dict, child: dict) -> dict:
    return {
        "instruction": FOLLOWUP_SYSTEM,
        "input": f"【面试题】\n{parent['question']}\n\n【候选人的回答】\n{parent['answer']}",
        "output": child["question"],
    }
            # 追问样本: 当前轮是主问题, 下一轮是它的追问
            if (i + 1 < len(turns) and turns[i + 1]["followup_depth"] > t["followup_depth"]
                    and t["answer"].strip()):
                follow_samples.append(build_followup_sample(t, turns[i + 1]))

这个判定条件是三个的与, 每个都必要:

  • i + 1 < len(turns) —— 最后一轮没有下一轮
  • turns[i+1]["followup_depth"] > t["followup_depth"] —— 下一轮的追问深度更大, 才说明它是当前轮的追问, 而不是换了新题
  • t["answer"].strip() —— 空答案产生的追问没有训练价值 (输入是空的)

第二个条件是从第 7 章那个 followup_depth 字段里榨出来的价值。 当初加这个字段是为了 main_count() 统计, 现在它还能重建"哪一轮是哪一轮的追问"这个树形关系 —— 一个整数字段编码了对话结构。

3.4 三条数据质量硬规则

"""
数据质量的三条硬规则 (违反任何一条, 微调只会让模型变笨):
1. 只导出人工复核过的样本 —— 用机器判分结果训机器, 等于把错误固化进权重
2. 去重: 同一道题同一个回答只留一条, 重复样本会让模型过拟合到那几条
3. 分布均衡: 高分/中分/低分样本比例不能太偏, 全是满分样本训出来的模型只会打高分
"""

规则一是最容易违反的, 而且我们的脚本目前就在违反它。

qa_records 里的 hit_json / depth / comment 全都是机器判的。拿它们当训练目标, 等于让模型学习自己 (或者另一个模型) 的错误 —— 这叫模型崩塌 (model collapse), 效果是错误被固化进权重然后放大。

所以生产上这一步必须有人工复核环节: 导出后人工过一遍, 改掉判错的, 删掉可疑的。脚本没有实现这个环节, 因为它是一个人工流程 (通常是一个标注平台或者一个 Excel)。这是本项目一个明确的未完成部分, 面试时应该主动说出来。

规则二: 去重。

def dedup_key(s: dict) -> str:
    return hashlib.md5((s["input"] + s["output"]).encode()).hexdigest()

按 input + output 的 MD5 去重。为什么不只按 input? 因为同一个输入可能有两条不同的标注 (一条是机器判的, 一条是人工改过的) —— 那种情况应该保留人工那条, 而不是随便丢一条。按两者的组合去重, 至少不会静默丢掉信息。

去重在我们的数据上很重要: seed_traces.py 造的数据里, "满分档"的答案是 rubric 原文拼的, 不同会话如果选到同一道题就会产生完全相同的样本。

规则三: 分布均衡, 而且要可视化。

def score_bucket(score: float | None) -> str:
    if score is None:
        return "none"
    if score >= 8.0:
        return "高分(8-10)"
    if score >= 5.0:
        return "中分(5-8)"
    if score >= 2.0:
        return "低分(2-5)"
    return "零分(0-2)"
    print("分数分布 (全是高分样本训出来的模型只会打高分):")
    for b in ["高分(8-10)", "中分(5-8)", "低分(2-5)", "零分(0-2)"]:
        n = buckets[b]
        pct = n / sum(buckets.values()) if buckets else 0
        print(f"  {b:12s} {n:4d}  {'#' * int(pct * 40)} {pct:.1%}")

'#' * int(pct * 40) 那个 ASCII 直方图值得学。 一列数字要读、要比较; 一个直方图一眼就能看出偏斜。三行代码换来的可读性提升非常划算。

为什么分布偏斜会毁掉微调: 如果训练集 90% 是满分样本, 模型学到的最优策略就是"永远说全部命中" —— 在训练集上损失很低, 在真实场景里完全没用。这个失效模式不会在训练 loss 曲线上表现出来, 只会在评测时暴露 (所以第 9 章那套尺子在这里又是必需的)。

3.5 冷启动: 没有真实数据怎么办

"""造一批面试轨迹进库, 用来演示微调数据导出。

真实项目里这些数据来自用户, 但第一天上线是没有数据的 —— 冷启动阶段用这个脚本
把黄金集的 rubric 按不同完整度拆成"满分/良好/及格/差/白卷"五档答案灌进去,
先把导出链路和数据分布检查跑通, 等真实数据来了直接换掉。

注意: 这样造出来的数据只能验证管道, 不能真拿去微调 ——
答案是 rubric 原文拼的, 模型学到的是"抄要点", 不是"判断回答"。
"""

"注意"那两行是这个脚本最重要的部分。

看它是怎么造答案的:

def answers_for(q: Question) -> dict[str, str]:
    """按完整度造五档答案。追问轮的 Question 没有 rubric, 直接返回空表, 由调用方兜底。"""
    pts = [p.point for p in q.rubric]
    if not pts:
        return {}
    return {
        "满分": "。".join(pts) + "。",
        "良好": "。".join(pts[:-1] or pts) + "。",
        "及格": pts[0] + "。",
        "差": "这个我记得大概, 好像跟生命周期和线程有点关系, 具体记不清了。",
        "白卷": "",
    }

"满分"档答案就是 rubric 要点原文拼起来的。

拿这个训练会发生什么? 模型学到的规律是"如果回答里的句子和要点长得一样就命中" —— 这是一个字符串匹配任务, 不是语义判断任务。 训出来的模型在真实的、用自己的话回答的候选人身上会彻底失效。

这个 pts[:-1] or pts 也值得看一眼: 如果 rubric 只有一个要点, pts[:-1] 是空列表, "。".join([]) 是空串 —— "良好"档会变成白卷。or pts 兜住了这个边界。

所以这个脚本的定位是"管道验证", 不是"数据生成"。 它的价值是: 在真实数据到来之前, 让你能把导出链路、去重、分布检查全部跑通并调试好。

这个区分非常重要, 而且是一个常见的自欺陷阱: 用 LLM 造一批数据、导出、微调、发现指标变好了 —— 因为你的评测集也是同一批 LLM 造的。造的数据只能验证管道, 评估必须用人工标注的数据。

✅ Checkpoint 5: 灌一批管道验证数据。

uv run python scripts/seed_traces.py

真实输出:

[满分] 会话 8c614f94b1c5  6 轮  均分 10.0  水平 高级
[良好] 会话 c96fed21968a  9 轮  均分 6.83  水平 中级
[及格] 会话 bf2396ed35dd  8 轮  均分 3.33  水平 待加强
[差] 会话 9824770a0ad7  6 轮  均分 0.0  水平 待加强
[白卷] 会话 8e6ad0f395ac  6 轮  均分 0.0  水平 待加强

库统计: {'sessions': 5, 'qa_records': 35, 'avg_score': 4.03}

这份输出顺带把第 7 章的两个设计验证了一遍:

  • 五档答案得到了五个不同的均分 (10.0 / 6.83 / 3.33 / 0.0 / 0.0) —— 判分器的单调性在端到端链路上成立
  • "良好"档跑了 9 轮而"满分"档只跑了 6 轮 —— 第 7 章那个"答得好就不追问、答得半懂才追问"的策略真的在工作。满分档全部 expert 直接换题, 良好档触发了 3 次追问

"差"档和"白卷"档均分都是 0.0 是规则判分器的特性 (关键词一个没中就是 0), 和 2.4 节看到的是同一个现象。

3.6 导出

uv run python finetune/export_traces.py --task both

✅ Checkpoint 6: 真实导出结果。

grader       原始   24 去重后   24 -> /tmp/ck10_out/grader.jsonl
interviewer  原始    5 去重后    5 -> /tmp/ck10_out/interviewer.jsonl

会话 5 场, 跳过空答案 6 条
分数分布 (全是高分样本训出来的模型只会打高分):
  高分(8-10)        7  ########### 29.2%
  中分(5-8)         5  ######## 20.8%
  低分(2-5)         6  ########## 25.0%
  零分(0-2)         6  ########## 25.0%

合计 29 条。经验值: QLoRA 微调 7B 模型, 500 条能看出效果,
2000 条以上比较稳。低于 200 条不要微调 —— 改 prompt 更划算。
当前 29 条 < 200, 建议先攒数据 (多跑几场真人面试), 或者用 LLM 造一批再人工筛。
LLaMA-Factory 注册文件 -> /tmp/ck10_out/dataset_info.json

读这份输出:

一, 分布相当均衡 (29.2% / 20.8% / 25.0% / 25.0%)。 这是 seed_traces.py 按五档造数据的直接结果 —— 说明"按档位造"这个设计对分布是有效的。真实数据大概率会偏 (真人不会均匀地答对答错), 那时候要靠采样调平。

二, 跳过空答案 6 条。 白卷档的答案是空串, if t["answer"].strip() 把它们挡掉了。空答案不能进训练集: 输入是空的, 模型学不到任何映射, 只会学到"看到空就输出全 missed"这个可以用一行代码实现的规则。

三, grader 24 条 vs interviewer 5 条, 差了将近 5 倍。

原因在 3.3 节: 追问样本只在"下一轮 followup_depth 更大"时才产生, 而 35 条 qa_records 里只有 5 次触发了追问。这就是为什么 interviewer 任务被定位成"锦上添花" —— 它的数据天然稀缺。

四, 合计 29 条 < 200, 脚本直接建议不要微调。

    total = n_g + n_f
    print(f"\n合计 {total} 条。经验值: QLoRA 微调 7B 模型, 500 条能看出效果,")
    print("2000 条以上比较稳。低于 200 条不要微调 —— 改 prompt 更划算。")
    if total < 200:
        print(f"当前 {total} 条 < 200, 建议先攒数据 (多跑几场真人面试), 或者用 LLM 造一批再人工筛。")

"低于 200 条不要微调 —— 改 prompt 更划算"是本章第二重要的一句话。

微调的成本不只是训练那半小时: 造数据、清数据、调超参、评测、部署、维护两套模型 —— 加起来是几天。而 200 条以下的数据量, 花两小时改 prompt (比如把"原样抄回要点文本"改成"回答要点序号") 大概率能拿到更好的效果。

一个脚本主动劝你不要用它, 比一个吹自己多有用的脚本可信。

3.7 看一条真实样本

✅ Checkpoint 7: grader.jsonl 第一条长什么样。

--- instruction (523 字符) ---
你是一位严格但公正的 Android 技术面试官, 正在给候选人的回答评分。

评分规则:
1. 逐条检查下面给出的每一个评分要点, 判断候选人的回答是否讲到了
2. 只看技术内容是否正确, 不看表达是否流畅, 不因为用词和参考答案不同就判为没讲到
3. 如果候选人说了明确错误的技术内容, 记入 wrong_points, 这比漏讲严重
4. 不要因为候选人多讲了要点之外的正确内容而扣分

只输出一个 JSON 对象, 字段如下:
{
  "hit_points": ["原样抄回命中的要点文本"],
  ...
}
...

--- input (386 字符) ---
【面试题】
从 Activity A 跳到 B, 再按返回键回到 A, 两个 Activity 的生命周期回调顺序分别是什么?

【评分要点】
1. (3分) 跳转时 A.onPause 先于 B.onCreate
2. (3分) A.onStop 发生在 B.onResume 之后, 即 B 完全可见后 A 才停止
3. (4分) 返回时 B.onPause -> A.onRestart/onStart/onResume -> B.onStop/onDestroy

【候选人的回答】
跳转时 A.onPause 先于 B.onCreate。A.onStop 发生在 B.onResume 之后, 即 B 完全可见后 A 才停止。返回时 B.onPause -> A.onRestart/onStart/onResume -> B.onStop/onDestroy。

--- output (257 字符) ---
{"hit_points": ["跳转时 A.onPause 先于 B.onCreate", "A.onStop 发生在 B.onResume 之后, 即 B 完全可见后 A 才停止", "返回时 B.onPause -> A.onRestart/onStart/onResume -> B.onStop/onDestroy"], "missed_points": [], "wrong_points": [], "depth": "expert", "comment": "[规则判分] 命中 3/3 个要点"}

这一条样本把 3.5 节的警告演示得非常清楚:

【候选人的回答】 和 【评分要点】 几乎字字相同 —— 因为它就是 rubric 原文拼的。模型在这条样本上学到的是"复制粘贴", 不是"语义判断"。

而且 comment 是 "[规则判分] 命中 3/3 个要点" —— 这是 RuleGrader 生成的机器文案。拿它当训练目标, 模型会学着输出"[规则判分]"这个前缀。这正是规则一"只导出人工复核过的样本"要拦住的东西。

这两处一起说明: 这份数据只能验证管道。 而这个结论不是靠猜的, 是打开数据看出来的 —— 微调之前一定要打开自己的训练数据逐条读几遍, 这是投入产出比最高的一步。

再看追问样本:

--- instruction ---
你是一位资深 Android 面试官。根据候选人的回答, 提出一个能问出深度的追问。
要求: 只针对回答里含糊或缺失的地方追问, 一句话, 不要复述候选人的话, 不要给答案。

--- input ---
【面试题】
匿名 Handler 为什么会导致 Activity 泄漏? 怎么解决?

【候选人的回答】
非静态内部类/匿名类隐式持有外部 Activity 引用。未执行的 Message 在 MessageQueue 里持有 Handler, Handler 持有 Activity。

--- output ---
除了 Handler, 还有哪些高频泄漏场景?

这条的质量明显比 grader 样本高。 因为 output 是人写的 —— 它来自 golden.yaml 里预设的 followups 字段, 是第 3 章手工写进评测集的。

这是一个有意思的反差: interviewer 数据量少 (5 条) 但质量高 (人写的), grader 数据量多 (24 条) 但质量低 (机器判的)。数据量和数据质量往往是反相关的, 因为高质量意味着人工介入。

3.8 dataset_info.json 为什么由脚本生成

    # LLaMA-Factory 要的注册文件。写在这里而不是让人手抄, 少一个环节出错
    info = {
        "android_grader": {"file_name": "grader.jsonl",
                           "columns": {"prompt": "instruction", "query": "input", "response": "output"}},
        "android_interviewer": {"file_name": "interviewer.jsonl",
                                "columns": {"prompt": "instruction", "query": "input", "response": "output"}},
    }

生成的文件:

{
  "android_grader": {
    "file_name": "grader.jsonl",
    "columns": {
      "prompt": "instruction",
      "query": "input",
      "response": "output"
    }
  },
  "android_interviewer": {
    "file_name": "interviewer.jsonl",
    "columns": { "prompt": "instruction", "query": "input", "response": "output" }
  }
}

"写在这里而不是让人手抄, 少一个环节出错" —— LLaMA-Factory 需要这个注册文件才能认识你的数据集, 而它的字段映射 (prompt/query/response → instruction/input/output) 是最容易抄错的东西。

抄错的症状极其难查: 训练能正常跑完、loss 会下降 (因为数据结构合法), 但模型学的是错的映射 —— 比如把 input 当成了要输出的内容。你会白烧一次训练。

任何"两个地方必须一致"的配置, 都应该由一个地方生成另一个。


四、在 Mac 上真的跑一次 LoRA

这一节是全书唯一一次"训练模型"。请先降低预期: 我们只有 29 条数据 (第 3.6 节自己都说了"低于 200 条不要微调"), 用的是 0.5B 的小模型。我们跑这一次, 不是为了得到一个能用的模型, 是为了让你亲手摸过训练这条链路, 并且亲眼看到"数据不够时微调到底会发生什么"。

结论我先说, 你带着这个结论看后面的输出会更清楚:

LoRA 修好了格式, 没修好正确性。

4.1 为什么 Mac 上要用 MLX 而不是 PyTorch

Mac 没有 N 卡, 所以 CUDA 生态 (bitsandbytes、flash-attn、大部分 QLoRA 脚本) 直接不可用。Apple Silicon 上的对应方案是 MLX —— 苹果自家的机器学习框架, 直接跑在 M 系列芯片的 GPU 上。

Apple Silicon 在这件事上有一个真实优势: 统一内存 (Unified Memory)。PC 上显存和内存是分开的, 一张 8GB 显存的卡, 模型加载超过 8GB 就 OOM, 哪怕你有 64GB 内存。Mac 的内存是 CPU 和 GPU 共用的 —— 16GB 的 M3, GPU 可以吃掉其中 12GB 以上。

这就是为什么一台 16GB 的 Mac 能微调 0.5B~7B 的模型, 而一张 8GB 显存的老显卡反而更容易卡住。

# 装 mlx-lm (只在 Apple Silicon 上可用)
uv pip install mlx-lm

✅ Checkpoint 8: 确认 MLX 认到了 GPU

python -c "import mlx.core as mx, mlx_lm; print(mlx_lm.__version__); print(mx.default_device())"

真实输出:

0.31.3
Device(gpu, 0)

Device(gpu, 0) 是这一步唯一要看的东西。如果它打印 Device(cpu, 0), 说明你在 Intel Mac 上, 后面的训练会慢到不可用 —— 直接跳到第五节租卡。

4.2 把导出的数据换成 MLX 要的格式

第三节导出的是 instruction / input / output 三段式 —— 那是 LLaMA-Factory 的格式 (第五节租卡时用)。MLX 要的是另一种: 对话格式。

{"messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]}

为什么两个工具的格式不一样, 而且都合理?

  • 三段式是"任务描述 + 输入 + 输出", 更贴近传统的指令微调数据集 (Alpaca 格式), 好人工审阅。
  • 对话格式是模型推理时真正看到的东西 —— system/user/assistant 三个角色, 由 tokenizer 的 chat template 拼成最终字符串。

MLX 选对话格式的好处是: 训练时喂进去的字符串和推理时 apply_chat_template 生成的字符串是同一个函数产出的, 不会出现"训练用了 A 模板、推理用了 B 模板"这种能跑通但学不到东西的错位。这正是第 3.2 节强调的"字字对齐"在工具层面的体现。

转换脚本 (放在 finetune/to_mlx.py):

"""把 export_traces.py 的三段式数据转成 MLX 的对话格式, 并切分训练/验证/测试集。

MLX 要求目录下有 train.jsonl / valid.jsonl, 可选 test.jsonl。
切分用固定 seed, 保证每次跑出来的划分一样 —— 否则你没法比较两次训练。
"""
import json, random, sys
from pathlib import Path

src = Path(sys.argv[1] if len(sys.argv) > 1 else "finetune/data/grader.jsonl")
dst = Path(sys.argv[2] if len(sys.argv) > 2 else "/tmp/mlx_data")
dst.mkdir(parents=True, exist_ok=True)

rows = []
for line in src.read_text(encoding="utf-8").splitlines():
    if not line.strip():
        continue
    d = json.loads(line)
    rows.append({"messages": [
        {"role": "system", "content": d["instruction"]},
        {"role": "user", "content": d["input"]},
        {"role": "assistant", "content": d["output"]},
    ]})

random.seed(0)          # 固定划分, 可复现
random.shuffle(rows)
n = len(rows)
n_tr, n_va = int(n * 0.7), int(n * 0.15)
parts = {"train": rows[:n_tr], "valid": rows[n_tr:n_tr + n_va], "test": rows[n_tr + n_va:]}

for name, part in parts.items():
    with (dst / f"{name}.jsonl").open("w", encoding="utf-8") as f:
        for r in part:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")
    print(f"{name}: {len(part)}")

✅ Checkpoint 9: 切出三份数据

python finetune/to_mlx.py finetune/data/grader.jsonl /tmp/mlx_data

真实输出:

train: 16
valid: 3
test: 5

16 / 3 / 5。请把这三个数字记住, 它们是后面所有结论的前提:

  • train 16 条 —— 少得离谱。第 3.6 节的门槛是 200 条, 我们差了一个数量级。
  • valid 3 条 —— 验证集只有 3 条, 意味着 val loss 的分辨率极低, 它降下去不能当成"学会了"的证据 (回想第 9 章反复讲的样本量与分辨率)。
  • test 5 条 —— 留出来做前后对比, 训练时模型完全没见过。

为什么明知不够还要切验证集? 因为流程要走通。真实项目里你有 2000 条, 切法一模一样, 只是每个数字乘 100。现在把管道跑对, 数据到位那天你只需要换一个文件路径。

4.3 一条命令开始训练

mlx_lm lora \
  --model mlx-community/Qwen2.5-0.5B-Instruct-4bit \
  --train \
  --data /tmp/mlx_data \
  --fine-tune-type lora \
  --num-layers 8 \
  --batch-size 1 \
  --iters 60 \
  --learning-rate 1e-4 \
  --steps-per-report 10 \
  --steps-per-eval 30 \
  --max-seq-length 1024 \
  --adapter-path /tmp/mlx_adapter \
  --seed 0

每个参数为什么这么填:

参数 取值 理由
--model Qwen2.5-0.5B-Instruct-4bit 0.5B + 4bit 量化, 16GB Mac 上训练只占 1.7GB。故意选小的 —— 这一步的目的是验证链路, 不是产出模型
--fine-tune-type lora 只训练插进去的低秩矩阵, 原模型权重冻结
--num-layers 8 只给最后 8 层加 LoRA。层数越多能学的越多, 也越容易在小数据上过拟合
--batch-size 1 数据只有 16 条, 再大就凑不出一个 batch
--iters 60 16 条数据跑 60 步 ≈ 看了将近 4 遍
--learning-rate 1e-4 LoRA 的常用区间 (1e-5 ~ 2e-4)。全参微调要小一个数量级
--max-seq-length 1024 第 1.1 节量过: 我们的样本满打满算 777 字符, 1024 token 够用。设大了白占内存
--seed 0 可复现。不固定 seed 你没法判断"这次改好了"是改动生效还是随机波动

✅ Checkpoint 10: 训练日志

真实输出 (删掉了下载进度条):

Loading pretrained model
Loading datasets
Training
Trainable parameters: 0.297% (1.466M/494.033M)
Starting training..., iters: 60
Calculating loss...: 100%|██████████| 3/3
Iter 1: Val loss 3.331, Val took 1.049s
Iter 10: Train loss 1.066, Learning Rate 1.000e-04, It/sec 1.051, Tokens/sec 551.065, Trained Tokens 5243, Peak mem 1.711 GB
Iter 20: Train loss 0.502, Learning Rate 1.000e-04, It/sec 1.958, Tokens/sec 1047.592, Trained Tokens 10596, Peak mem 1.711 GB
Iter 30: Val loss 0.091, Val took 0.324s
Iter 30: Train loss 0.207, Learning Rate 1.000e-04, It/sec 1.925, Tokens/sec 1017.443, Trained Tokens 15864, Peak mem 1.711 GB
Iter 40: Train loss 0.076, Learning Rate 1.000e-04, It/sec 1.911, Tokens/sec 1004.836, Trained Tokens 20990, Peak mem 1.711 GB
Iter 50: Train loss 0.075, Learning Rate 1.000e-04, It/sec 1.949, Tokens/sec 1032.077, Trained Tokens 26371, Peak mem 1.711 GB
Iter 60: Val loss 0.040, Val took 0.325s
Iter 60: Train loss 0.073, Learning Rate 1.000e-04, It/sec 1.925, Tokens/sec 1021.782, Trained Tokens 31688, Peak mem 1.711 GB
Saved final weights to /tmp/mlx_adapter/adapters.safetensors.

读这份日志有四个点。

第一, Trainable parameters: 0.297% (1.466M/494.033M) —— 这一行就是 LoRA 的全部定义。 4.94 亿个参数里, 我们只动了 146 万个 (0.297%)。剩下 99.7% 冻结不动。这是为什么一台 Mac 能干这件事 —— 全参微调要为每个参数存梯度和优化器状态, 显存开销大约是参数量的 4 倍以上。

第二, Peak mem 1.711 GB —— 比想象中小得多。 16GB 的机器峰值只用了 1.7GB。这告诉你: 在 Mac 上真正的瓶颈通常不是内存, 是时间。约 1000 tokens/sec, 我们 3 万 token 跑了半分钟; 换成 7B 模型 + 2000 条数据, 就是几个小时。

第三, Tokens/sec 从 551 涨到 1047 —— 不是变快了, 是第一次报告里含了编译和预热时间。看稳定值 (1000 左右), 别看第一个数。

第四, 也是最重要的: Val loss 3.331 → 0.091 → 0.040 这条曲线是过拟合, 不是成功。

训练 loss 从 1.066 掉到 0.073, 验证 loss 从 3.331 掉到 0.040 —— 教科书上"漂亮的收敛"长这样, 但把它放到我们的实际条件里:

  • 训练集 16 条, 跑了 60 步, 模型把每条样本看了近 4 遍;
  • 验证集 3 条 —— 第 9 章讲过的分辨率问题: 3 条样本的 loss 只能反映"这 3 条背下来了没有";
  • val loss 0.040 意味着模型对验证集几乎零困惑 —— 一个真正在学习泛化能力的模型不会这么低。这个数字低到反常, 它说明模型在背答案。

"loss 下降"永远不能当成微调成功的证据。loss 只说明模型在拟合你给的字符串, 不说明它拟合的是对的东西 (第 3.8 节讲字段映射抄错时也是同一个道理: 映射错了 loss 照样降)。

唯一能证明微调有效的, 是拿模型没见过的数据去测, 并且用第 9 章那套指标去量。下面就做这件事。

4.4 微调前 vs 微调后: 同一道题, 同一个模型

对比脚本很短, 关键是同一个 prompt、同一个 temp=0.0、只差一个 adapter_path:

from mlx_lm import load, generate
from mlx_lm.sample_utils import make_sampler

rows = [json.loads(l) for l in open("/tmp/mlx_data/test.jsonl", encoding="utf-8")]
m = rows[0]["messages"]

for tag, adapter in [("微调前", None), ("微调后", "/tmp/mlx_adapter")]:
    model, tok = load("mlx-community/Qwen2.5-0.5B-Instruct-4bit", adapter_path=adapter)
    p = tok.apply_chat_template(m[:2], add_generation_prompt=True)   # 只喂 system+user
    out = generate(model, tok, prompt=p, max_tokens=320,
                   sampler=make_sampler(temp=0.0), verbose=False)
    print(f"=== {tag} ===\n{out}")

两个必须做对的细节:

  • m[:2] —— 只喂 system 和 user, 把 assistant 那条 (标准答案) 切掉。如果连标准答案一起喂进去, 模型就是在照抄, 你测的是复读机不是模型。
  • temp=0.0 —— 温度归零, 保证两次差异来自 adapter, 不来自采样随机性。这和第 9 章"固定 seed 才能做对照实验"是同一条原则。

✅ Checkpoint 11: 逐字对比

测试集第 0 条 (模型训练时完全没见过) 的输入:

【面试题】
手机横过来之后我页面上填的东西全没了, 有什么官方组件能解决这个问题?

【评分要点】
1. (3分) 横竖屏切换属于配置变更, 会销毁重建宿主
2. (4分) 用 ViewModel, 它由 ViewModelStore 在配置变更时保留
3. (3分) 但进程被杀救不了, 那种情况要 SavedStateHandle

【候选人的回答】
横竖屏切换属于配置变更, 会销毁重建宿主。

标准答案 (人类看一眼就知道: 只答对了第 1 点, 命中 1 个要点, 深度 surface):

{"hit_points": ["横竖屏切换属于配置变更, 会销毁重建宿主"], "missed_points": ["用 ViewModel, 它由 ViewModelStore 在配置变更时保留", "但进程被杀救不了, 那种情况要 SavedStateHandle"], "wrong_points": [], "depth": "surface", "comment": "[规则判分] 命中 1/3 个要点"}

微调前的真实输出:

```json
{
  "hit_points": ["横竖屏切换属于配置变更, 会销毁重建宿主。"],
  "missed_points": ["横竖屏切换属于配置变更, 会销毁重建宿主。"],
  "wrong_points": ["横竖屏切换属于配置变更, 会销毁重建宿主。"],
  "depth": "surface",
  "comment": "示例代码或示例问题, 不能简单地答出问题的类型"
}
```

微调后的真实输出:

{"hit_points": [], "missed_points": ["横竖屏切换属于配置变更, 会销毁重建宿主"], "wrong_points": [], "depth": "blank", "comment": "[规则判分] 命中 0/3 个要点"}

把两份输出摆在一起逐项看:

观察项 微调前 微调后 变好了吗
```json 围栏 有 没有 ✅ 修好了
同一句话同时塞进 hit / missed / wrong 是 (三个字段一模一样) 否 ✅ 修好了
抄回的文本是不是 rubric 原文 不是 (带上了候选人答案末尾的句号) 是 (与要点原文字字相同) ✅ 修好了
comment 格式 胡言乱语 [规则判分] 命中 N/3 个要点 ✅ 学到了模板
命中判断 hit=1 (蒙对了) hit=0 (错) ❌ 没修好
depth surface (对) blank (错) ❌ 没修好

这就是那句结论的实证: LoRA 修好了格式, 没修好正确性。

微调前那份输出最荒唐的地方不是围栏, 是它把同一句话同时判成了"命中"、"遗漏"和"错误" —— 一个 0.5B 模型根本没理解这三个字段是互斥的。它的 hit_points 恰好包含了正确的那句话, 所以 _score_from_points 走完流程会给出 hit=1, 看起来是对的, 但那是巧合 —— 它给所有三个字段填了同样的内容, 蒙中一个不算本事。

微调后的模型学会了字段互斥, 学会了原样抄 rubric (末尾句号都去掉了), 学会了 comment 模板 —— 全都是格式层面的东西。但它把"命中"判成了"遗漏", 于是分数从 3 分掉到 0 分, depth 也跟着从 surface 变成 blank。

4.5 一条样本不算数据: 把 5 条测试集都量一遍

第 9 章反复讲的事情在这里也成立 —— 一条样本能讲故事, 不能下结论。所以把 test.jsonl 全部 5 条都跑一遍, 统计五个格式指标:

FIELDS = {"hit_points", "missed_points", "wrong_points", "depth", "comment"}
DEPTHS = {"blank", "surface", "solid", "expert"}

for tag, adapter in [("微调前", None), ("微调后", "/tmp/mlx_adapter")]:
    model, tok = load("mlx-community/Qwen2.5-0.5B-Instruct-4bit", adapter_path=adapter)
    n_fence = n_json = n_fields = n_depth = n_verbatim = 0
    for r in rows:
        m = r["messages"]
        out = generate(model, tok, prompt=tok.apply_chat_template(m[:2], add_generation_prompt=True),
                       max_tokens=320, sampler=make_sampler(temp=0.0), verbose=False)
        if "```" in out:
            n_fence += 1
        try:
            d = extract_json(out)      # 复用 app/core/llm.py 里那个容错解析
            n_json += 1
        except Exception:
            continue
        if FIELDS <= set(d):
            n_fields += 1
        if d.get("depth") in DEPTHS:
            n_depth += 1
        gold = json.loads(m[2]["content"])
        rub = [x.strip() for x in gold["hit_points"] + gold["missed_points"]]
        got = [x.strip() for x in d.get("hit_points", []) + d.get("missed_points", [])]
        if got and all(g in rub for g in got):   # 抄回的每一句都必须是 rubric 原文
            n_verbatim += 1
    print(f"{tag}: 带围栏 {n_fence}/{len(rows)} ... 原样抄回 {n_verbatim}/{len(rows)}")

注意这里复用了 app/core/llm.py 的 extract_json —— 不重新写一个宽松的解析器。因为生产环境用的就是它, 用别的解析器测出来的"可解析率"跟线上无关。

✅ Checkpoint 12: 五项格式指标

真实输出:

微调前: 带围栏 5/5  JSON可解析 5/5  字段齐全 5/5  depth合法 5/5  原样抄回 0/5
微调后: 带围栏 0/5  JSON可解析 5/5  字段齐全 5/5  depth合法 5/5  原样抄回 3/5

两个真实的变化, 一个必须承认的局限。

变化一: 围栏 5/5 → 0/5。这是 LoRA 最擅长的事 —— 输出的外壳长什么样, 一学就会。

变化二: 原样抄回 0/5 → 3/5。这是本章最关键的一个数字。第 2.5 节诊断出的根因就是"模型没有原样抄回 rubric 文本, 导致 _score_from_points 的 p.point.strip() in hit_set 一个都匹配不上, 分数恒为 0"。微调后 5 条里有 3 条抄对了 —— 这个具体的失败模式确实被 LoRA 缓解了。

局限: JSON可解析 5/5、字段齐全 5/5、depth合法 5/5 三项前后都是满分, 这三个指标在这里什么都没测出来。

原因是样本量: 5 条太少, 而基座模型本来就能输出合法 JSON (第 2.4 节 14 条里也只有 1 条崩)。指标撞到天花板时, 它不是"证明微调没用", 也不是"证明微调有用" —— 它是没有分辨率。要区分 96% 和 99% 的可解析率, 你需要几百条测试样本, 不是 5 条。这跟第 9 章那句"3 场对局的分辨率是 0.3333, 只能当哨兵"是同一件事。

4.6 为什么正确性没修好 —— 三个原因, 全都是我们自己写在前面的

这个结果不是意外, 它精确命中了第三节自己列出的三条警告:

原因 我们的实际情况 前面哪一节警告过
模型太小 0.5B。判断"这句话是否覆盖了那个要点"需要语义理解能力, 0.5B 只够学格式 4.3 节参数表: "故意选小的 —— 目的是验证链路"
数据太少 train 16 条 §3.6 "低于 200 条不要微调 —— 改 prompt 更划算"
训练目标本身就是机器判的 grader.jsonl 是 seed_traces.py 用 RuleGrader 造出来的, 不是人工标注 §3.4 硬规则 1: "用机器判分结果训机器, 等于把错误固化进权重"

第三条最致命, 而且是我们明知故犯的。第 3.4 节写着"只导出人工复核过的样本", 而我们的 export_traces.py 目前没有这个过滤 —— 当时就标成了"待改项"。于是训练目标里带着 RuleGrader 自己的偏差 (第 9 章量过: MAE 0.833, 系统性偏差 -0.833, 也就是一致偏严), 模型忠实地学到了这个"偏严", 表现出来就是把该算命中的判成了遗漏。

微调不会创造能力, 它只会放大你数据里已有的倾向。你的标注偏严, 微调出来的模型就偏严, 而且比标注更偏 —— 因为它连你偶尔的宽松都当噪声抹掉了。

4.7 那这一节到底赚到了什么

一次跑不出可用模型的训练, 价值在四件事上:

  1. 链路全通了。导出 → 转格式 → 切分 → 训练 → 加载 adapter → 对比推理, 每一环都真的跑过。数据到位那天, 你改的只有 --model 和 --iters。
  2. 确认了 LoRA 的能力边界。格式 (围栏、字段互斥、原样抄回、comment 模板) 可以修; 语义判断力不行。这条边界决定了你以后该不该微调。
  3. 拿到了硬件基线。0.5B / 16 条 / 60 步 = 半分钟, Peak mem 1.7GB, ~1000 tokens/sec。要估 7B + 2000 条要多久, 从这个基线往上乘就行。
  4. 第 3.4 节那条"待改项"从纸面变成了后果。你现在有一份能拿出去讲的证据: 训练数据的质量问题会以什么形式出现在模型行为里。

如果只能记一句: 微调前先把评估做出来 (第 9 章), 微调后用同一套指标再量一遍 —— 否则你永远说不清那次训练是赚了还是亏了。


五、数据够了以后: 租一张卡跑 QLoRA

⚠️ 这一节我没有真的跑过。全书所有 Checkpoint 都是真实执行的输出, 这一节例外 —— 它需要一张 N 卡, 而我们的环境是 Mac。所以这里给的是参数配置和成本算法, 不是 Checkpoint。等你数据攒到 200 条以上真的去租卡时, 请把每一步的输出自己核对一遍, 不要相信任何没跑过的命令 (包括这一节的)。

5.1 什么时候才该租卡

三个条件同时满足:

  1. 人工复核过的数据 ≥ 200 条 (第 3.6 节的门槛), 且分数分布不偏。
  2. 改 prompt 已经试过并且不够了。这是最容易被跳过的一步, 也是最省钱的一步。
  3. 你有一个能量出差异的评估 —— 第 9 章那套。没有它, 你花钱买不到任何可验证的东西。

一张 4090 大约 2 元/小时, 7B 模型 + 2000 条数据 + 3 个 epoch 大概 2~4 小时, 也就是十块钱。真正贵的从来不是卡, 是你为了准备数据花掉的时间, 和没有评估时反复瞎调烧掉的卡时。

5.2 QLoRA 和 LoRA 差在哪

LoRA QLoRA
基座权重精度 16 位 4 位量化
显存占用 (7B) 约 16GB+ 约 6~8GB
训练的是什么 插入的低秩矩阵 同样是低秩矩阵 (16 位)
代价 —— 量化有精度损失, 且反量化增加计算, 稍慢

QLoRA = 把冻结的基座压成 4 位, 省下来的显存用来跑更大的模型。第 4.3 节我们用的 Qwen2.5-0.5B-Instruct-4bit 已经是 4bit 基座 + LoRA, 本质上就是 QLoRA, 只是模型小到无需强调。

5.3 LLaMA-Factory 的配置

第 3.8 节脚本生成的 dataset_info.json 就是给它用的:

{
  "android_grader": {
    "file_name": "grader.jsonl",
    "columns": {"prompt": "instruction", "query": "input", "response": "output"}
  },
  "android_interviewer": {
    "file_name": "interviewer.jsonl",
    "columns": {"prompt": "instruction", "query": "input", "response": "output"}
  }
}

训练配置 (finetune/qlora.yaml):

model_name_or_path: Qwen/Qwen2.5-7B-Instruct
stage: sft
do_train: true
finetuning_type: lora
lora_target: all            # 所有线性层都加 LoRA, 比只加 q/v 效果好
lora_rank: 16
lora_alpha: 32
quantization_bit: 4        # 这一行让 LoRA 变成 QLoRA

dataset: android_grader,android_interviewer
dataset_dir: finetune/data
template: qwen              # 必须和基座模型匹配, 填错等于用错聊天格式
cutoff_len: 1024            # 第 1.1 节量过 777 字符, 1024 够
overwrite_cache: true

output_dir: saves/qwen7b-android
per_device_train_batch_size: 2
gradient_accumulation_steps: 8   # 等效 batch = 2 x 8 = 16
learning_rate: 1.0e-4
num_train_epochs: 3.0
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true
logging_steps: 10
save_steps: 100

val_size: 0.1
per_device_eval_batch_size: 2
eval_strategy: steps
eval_steps: 100
llamafactory-cli train finetune/qlora.yaml

三个最容易踩的地方:

  • template: qwen 填错是静默失败。它决定 system/user/assistant 怎么拼成字符串。填成 llama3 训练照样跑完、loss 照样降, 但模型学的是错的对话格式, 推理时全乱。和第 3.8 节字段映射抄错是同一类 bug: 能跑、能降 loss、结果没用。
  • num_train_epochs: 3 不要往上加。小数据集上 epoch 越多越背答案 —— 第 4.3 节我们 16 条跑 4 遍, val loss 掉到 0.040, 那就是背下来的样子。
  • val_size: 0.1 必须留。没有验证集你就只能看 train loss, 而 train loss 永远会降。

5.4 成本控制的三条实操建议

  1. 先用 1% 数据跑 10 步, 确认数据能加载、格式没错、显存够, 再开正式训练。十块钱的训练不值得省这三分钟, 但一次配置写错白跑四小时值得。
  2. 按小时计费的卡, 训练完立刻导出 adapter 并下载, 然后关机。adapter 文件通常只有几十 MB。
  3. 一次只改一个变量。同时改了学习率和 epoch 数, 结果变好了你也不知道是哪个起的作用 —— 又得重跑。

六、把微调结果部署回去: 合并权重 → GGUF → Ollama

训练产出的是一个 adapter (几十 MB 的旁挂补丁), 而 Ollama 要的是一个完整模型。中间要过三道:

adapter (LoRA 补丁)  --fuse-->  完整模型 (safetensors)  --转换-->  GGUF  --Modelfile-->  Ollama

6.1 fuse: 把补丁焊回基座

mlx_lm fuse \
  --model mlx-community/Qwen2.5-0.5B-Instruct-4bit \
  --adapter-path /tmp/mlx_adapter \
  --save-path /tmp/mlx_fused

fuse 做的事情就是数学上的一步加法: LoRA 的低秩矩阵乘出来加到原权重上, 之后就没有"基座 + 补丁"的概念了, 只有一个模型。

为什么要 fuse 而不是一直挂着 adapter?

  • 挂着 adapter 需要推理框架支持 (MLX 支持, --adapter-path 就是), 但 Ollama / llama.cpp 这类部署框架只认单一模型文件。
  • 合并后推理少一次矩阵加法, 快一点。
  • 代价: 失去了灵活性。合并后你不能再换 adapter, 也不能同时挂多个。所以: 实验阶段挂着, 上线前 fuse。

✅ Checkpoint 13: fuse 的产物

ls -la /tmp/mlx_fused

真实输出:

-rw-r--r--@  1 c  wheel        245 Aug 18 16:45 README.md
-rw-r--r--@  1 c  wheel       2509 Aug 18 16:45 chat_template.jinja
-rw-r--r--@  1 c  wheel        863 Aug 18 16:45 config.json
-rw-r--r--@  1 c  wheel  278064766 Aug 18 16:45 model.safetensors
-rw-r--r--@  1 c  wheel      44248 Aug 18 16:45 model.safetensors.index.json
-rw-r--r--@  1 c  wheel   11421892 Aug 18 16:45 tokenizer.json
-rw-r--r--@  1 c  wheel        729 Aug 18 16:45 tokenizer_config.json

两个值得注意的文件:

  • model.safetensors 278 MB —— 完整权重。对比一下 adapter 只有几 MB, 这就是"0.297% 可训练参数"的另一面: 你训练的东西很小, 但部署的东西是整个模型。
  • chat_template.jinja 和 tokenizer_config.json 一起被复制过来了 —— 这不是附赠品, 是必需品。模型学到的是"在某种拼接格式下该输出什么", 换掉模板等于换掉了输入分布。第 5.3 节 template: qwen 填错会静默失败, 根因就在这个文件。fuse 时它被自动带过来, 是在替你避免那个坑。

✅ Checkpoint 14: fuse 之后必须验一次, 因为它会变

fuse 是数学上的加法, 直觉上"合并前后输出应该一样"。实测不一样。

用第 4.4 节完全相同的输入 (测试集第 0 条)、完全相同的 temp=0.0, 分别跑 fuse 后的模型和挂 adapter 的模型, 各跑两遍:

fused-2nd   {"hit_points": ["横竖屏切换属于配置变更, 会销毁重建宿主"], "missed_points": ["横竖屏切换属于配置变更, 会销毁重建宿主"], "wrong_points": [], "depth": "blank", "comment": "[规则判分] 命中 0/3 个要点"}
adapter-2nd {"hit_points": [], "missed_points": ["横竖屏切换属于配置变更, 会销毁重建宿主"], "wrong_points": [], "depth": "blank", "comment": "[规则判分] 命中 0/3 个要点"}

两遍结果各自完全一致 (所以不是采样随机性), 但两者之间不一致: fuse 后的模型把那句话同时放进了 hit_points 和 missed_points —— 第 4.4 节表格里"字段互斥"那条刚修好的格式, 在 fuse 之后退化了一部分。

原因是量化。基座是 4bit 量化的, LoRA 权重是 16 位的。合并时 16 位的增量被加到 4bit 权重上, 结果必须重新量化回 4bit —— 这一步有精度损失, 而且损失落在哪个权重上不可控。模型本来就在"命中/遗漏"这个判断上摇摇欲坠 (第 4.6 节: 0.5B + 16 条数据), 一点扰动就足以把它推过去。

这条经验的实际价值比它看起来大:

结论
fuse 不是无损操作 在量化基座上合并 LoRA 必然重新量化, 输出会变
所以 评估必须在你真正要部署的那个产物上重跑一遍, 不能拿挂 adapter 时的评估结果去代表上线后的表现
想减少漂移 用 16 位基座 fuse, 合并完再量化一次, 而不是"在 4bit 上加了再压回 4bit"
本来就脆的模型 会被 fuse 推倒。模型足够稳时 fuse 的影响可以忽略 —— 但"足够稳"要靠量, 不能靠假设

这也是第九章那套评估体系的又一次兑现: 每一次改变产物形态 (换模型、fuse、量化、换部署框架) 都是一次需要重新测量的变更。

6.2 转 GGUF 并注册到 Ollama

⚠️ 这一小节我没有执行成功, 所以没有 Checkpoint。转换脚本来自 llama.cpp 仓库, 在本次环境里没有获得运行许可。命令和参数按官方文档给出, 请你自己跑一遍并核对输出, 不要把它当成已验证的结论。第五节也是同样的性质。

Ollama 底层是 llama.cpp, 它要的模型格式叫 GGUF —— 一个把权重、tokenizer、chat template、元数据全部打包进单个文件的格式。这就是为什么 ollama run 一条命令就能跑起来: 一个文件里什么都有。

# 1) 拿到转换脚本 (来自 llama.cpp 仓库)
git clone --depth 1 https://github.com/ggml-org/llama.cpp.git /tmp/llamacpp
uv pip install gguf sentencepiece

# 2) safetensors -> GGUF
python /tmp/llamacpp/convert_hf_to_gguf.py /tmp/mlx_fused \
  --outfile /tmp/android-grader.gguf \
  --outtype f16

# 3) 量化 (可选, 需要编译 llama.cpp 的 llama-quantize)
#    Q4_K_M 是通用推荐档: 体积约为 f16 的 30%, 质量损失可接受
llama-quantize /tmp/android-grader.gguf /tmp/android-grader-q4.gguf Q4_K_M

关于第 3 步的量化, 请把第 6.1 节 Checkpoint 14 的教训带过来: 我们的 fused 模型已经经历过一次"4bit → 加 LoRA → 重新量化"的精度损失, 再压一次是第二次损失。0.5B 这种小模型经不起, 直接用 f16 (278MB, 不大)。7B 以上再考虑 Q4_K_M。

注册进 Ollama 要写一个 Modelfile (finetune/Modelfile):

FROM /tmp/android-grader.gguf

# 必须和训练时的 chat template 一致 —— 这是最容易错的地方
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
{{ .Response }}<|im_end|>
"""

PARAMETER stop "<|im_end|>"
PARAMETER temperature 0.0     # 判分要可复现, 温度必须为 0
PARAMETER num_ctx 2048
ollama create android-grader -f finetune/Modelfile
ollama list        # 应该能看到 android-grader

TEMPLATE 那一段是本节唯一真正危险的地方。它必须和训练时 tokenizer 拼出来的格式字字一致。Qwen 系列用的是 <|im_start|>role\n...<|im_end|> 这套 ChatML 标记, 上面就是它。填错不会报错, 模型只是开始胡说 —— 因为它收到的输入分布和训练时完全不同。

怎么核对? 拿 fuse 产物里的 chat_template.jinja (第 6.1 节 Checkpoint 13 那个文件) 对照着看, 或者直接:

from mlx_lm import load
_, tok = load("/tmp/mlx_fused")
print(tok.apply_chat_template([{"role":"system","content":"S"},{"role":"user","content":"U"}],
                              add_generation_prompt=True, tokenize=False))

打印出来的字符串就是训练时模型看到的真实格式, 照着它写 TEMPLATE。不要凭记忆写。


七、切过去只改三行, 但必须重新量一遍

7.1 三行 .env

模型注册进 Ollama 之后, 整个应用要切过去用它, 改动量是三行配置, 零行代码:

LLM_API_KEY=ollama                          # Ollama 不校验, 但 openai 客户端要求非空
LLM_BASE_URL=http://127.0.0.1:11434/v1      # 结尾必须是 /v1
LLM_MODEL=android-grader                    # 第 6.2 节 ollama create 的那个名字

这就是第 1 章那个决定 —— "把所有模型调用收口在 app/core/llm.py 这一个文件" —— 在第 10 章收到的回报。从 DeepSeek 换到本地 7B、再换到自己微调的模型, 业务代码一行没动。

如果当初把 openai.OpenAI(...) 散落在 grader、interviewer、judge、CLI 四个地方, 现在要改的就是四处, 而且总会漏掉一处 —— 漏掉的那处不报错, 只是继续偷偷调用云端 API, 直到某天你发现"数据不出内网"这个合规要求根本没满足。

7.2 切完立刻用第 9 章那套量一遍

# 判分器层: 15 条人工标注, 四个指标
python -m eval.eval_grader --cases eval/grader_cases.yaml --golden eval/golden.yaml

# 检索层不受影响 (没换 embedding), 但换过 embedding 就必须重跑
python -m eval.eval_retrieval --golden eval/golden.yaml

拿到的数字要和已有基线并排放:

判分器 MAE 顺序一致率 皮尔逊 结论
RuleGrader (80 行关键词加权覆盖) 0.833 0.800 0.9377 目前最好
云端 deepseek-chat 见第 9 章基线 可用
本地 qwen2.5:7b (未微调) 3.571 0.3626 0.2536 ❌ 两条红线都爆
本地微调版 你自己测 ?

红线 (第 9 章定的): MAE > 2.5 或 顺序一致率 < 0.7, 不许上线。

按第 4.6 节的三个原因, 用 16 条机器判分数据训出来的 0.5B 模型几乎不可能过这条线 —— 它连"命中/遗漏"都判反了。所以本章的真实结论是:

目前线上继续用 RuleGrader + 云端模型。微调这条路已经打通, 但要等数据攒够 200 条人工复核样本。

这是一个负面结论, 而它恰恰是本章最有价值的产出。如果没有第 9 章那套评估, 你会怎样? 你会看到 val loss 从 3.331 掉到 0.040, 看到输出的 JSON 变干净了, 然后满怀信心地把一个把"命中"判成"遗漏"的模型推上线。

7.3 一张表: 什么时候用哪个模型

场景 判分器 面试官 为什么
本地开发 / CI RuleGrader (--offline) 固定题库 免费、确定、快。第 9 章讲过 CI 只能跑前两层
线上默认 云端 deepseek-chat 云端 质量最好, 成本可接受
内网合规场景 本地模型 (必须先过红线) 本地 数据不出内网是硬约束, 质量退让
演示 / 断网 RuleGrader 固定题库 不依赖任何网络

同一套代码能覆盖这四种场景, 靠的是 .env 里的三行加一个 OFFLINE 开关。


八、踩坑与专家提示

1. LLM_BASE_URL 结尾漏了 /v1 → 404, 而且报错完全看不出原因。 Ollama 的 OpenAI 兼容层挂在 /v1 前缀下。写成 http://127.0.0.1:11434 会得到一个跟模型、跟配置都无关的 404。这是本章最高频的坑。

2. LLM_API_KEY 留空 → openai 客户端在构造时就抛异常。 Ollama 不校验 key, 但官方 SDK 要求非空。随便填 ollama 即可。注意这不代表可以在生产环境省掉鉴权 —— 那是第 8 章的 API_KEYS, 两件不同的事。

3. "loss 下降"不是微调成功的证据。 第 4.3 节实测: 16 条数据、3 条验证集, val loss 3.331 → 0.040。这不是学会了, 是背下来了。唯一的证据是在没见过的数据上用第 9 章那套指标测出来的差异。

4. 用机器判分结果当训练目标, 等于把判分器的偏差焊进模型权重。 第 3.4 节硬规则 1, 第 4.6 节实测后果。RuleGrader 的系统性偏差是 -0.833 (偏严), 微调出来的模型比它更严 —— 把该算命中的判成了遗漏。这种错误改不回来, 只能重新标数据重新训。

5. 200 条是门槛, 不是建议。 低于 200 条, 改 prompt 的收益一定大于微调。export_traces.py 自己会打印这句劝退语 —— 一个主动劝你别用它的脚本, 比一个鼓励你用它的脚本更值得信任。

6. fuse 不是无损操作。 第 6.1 节 Checkpoint 14 实测: 在 4bit 基座上合并 16 位 LoRA 需要重新量化, 同一输入、temp=0.0、多次运行结果稳定, 但 fuse 前后不一致 —— 刚修好的"字段互斥"退化了。每次改变部署产物形态都要重测。

7. TEMPLATE / template: 填错是静默失败中最贵的一种。 第 5.3 节 (LLaMA-Factory 的 template: qwen) 和第 6.2 节 (Modelfile 的 TEMPLATE) 是同一个坑的两个位置。训练能跑完、loss 会降、模型能加载, 只是输出乱。核对方法: 打印 tok.apply_chat_template(..., tokenize=False), 照着抄, 不要凭记忆写。

8. dataset_info.json 的字段映射抄错, 会白烧一整次训练。 第 3.8 节: 映射错了数据结构依然合法, loss 依然下降, 模型学的是错的映射。所以那个文件由脚本生成, 不手抄。任何"两个地方必须一致"的配置都该这样处理。

9. json.dumps 忘了 ensure_ascii=False → 模型学会输出 中文。 训练数据里是转义序列, 生产环境期望中文, 两边对不上。而且这个错误在训练日志里完全看不出来。

10. 去重要按 md5(input + output), 不能只按 input。 只按 input 去重, 会把"同一道题被人工修正过的那一条"当成重复丢掉 —— 你精心修正的那条数据被静默删除了。

11. Mac 上真正的瓶颈是时间, 不是内存。 第 4.3 节实测 Peak mem 只有 1.711 GB, 16GB 机器绰绰有余。但 ~1000 tokens/sec, 换 7B + 2000 条就是几小时。先按这个基线估算再决定是本地跑还是租卡。

12. 统一内存是 Apple Silicon 的真实优势, 别拿显存的直觉套过来。 16GB M3 的 GPU 能用 12GB 以上; 而一张 8GB 显存的独显, 哪怕主机有 64GB 内存也只能用 8GB。

13. temp=0.0 是做前后对比的前提。 不固定温度, 你分不清差异来自 adapter 还是采样随机性。同理: --seed 0、random.seed(0) 切数据集。第 9 章的"做对照实验先固定随机源"在这里同样适用。

14. 对比推理时只喂 messages[:2]。 把 assistant 那条 (标准答案) 一起喂进去, 测的是复读机不是模型。这个错误的症状是"效果好得不真实"。

15. 评估脚本崩在某一条数据上, 先别改评估脚本。 第 2.4 节: ValueError: 3 次都没拿到合法 JSON 是第一个发现, 不是 bug —— 它说明本地 7B 在特定输入上产不出合法 JSON。加 try/except 跳过去, 你就把最重要的信号消音了。

16. 挂 adapter 用于实验, fuse 用于上线。 挂着可以随时换、可以同时比多个; fuse 之后快一点、能进 GGUF, 但失去灵活性且有精度损失。别在实验阶段过早 fuse。

17. 微调不创造能力, 只放大数据里已有的倾向。 本章最贵的一条。格式可以教 (第 4.5 节实测: 围栏 5/5 → 0/5, 原样抄回 0/5 → 3/5), 语义判断力教不出来。"模型答得不准"通常不是微调能解决的问题, "模型格式不稳"通常是。

18. 指标撞天花板时它没有分辨率, 既不能证明有用也不能证明没用。 第 4.5 节: JSON可解析 5/5、字段齐全 5/5、depth合法 5/5 前后都满分。5 条样本区分不出 96% 和 99%。看到"全对"先问样本量, 再问结论。


九、面试视角

Q1: 什么情况下该微调, 什么情况下不该?

该微调的判据只有三条: 格式不稳 (输出结构反复出错, prompt 里写了也不听)、数据不能出内网 (合规硬约束)、省 token 且量足够大。不该微调的情况更常见: 模型答得不准、缺少某个领域知识、想让它更聪明 —— 这些微调都解决不了。

我在这个项目里实测过: LoRA 把 ```json 围栏从 5/5 降到 0/5, 把"原样抄回 rubric 文本"从 0/5 提到 3/5, 但把"这句话是否命中要点"判反了。格式层面立竿见影, 语义层面纹丝不动。

顺序是: 先改 prompt → 再改检索 → 最后才考虑微调。低于 200 条人工复核数据, 改 prompt 的性价比一定更高。

Q2: LoRA 为什么省显存? QLoRA 又省在哪?

LoRA 冻结原权重, 只训练插进去的低秩矩阵。我这次实测是 0.297% (1.466M/494.033M) —— 4.94 亿参数只动 146 万。省的不只是这 146 万本身, 更是优化器状态和梯度: 全参微调要为每个参数存这些, 开销约为参数量的 4 倍以上。

QLoRA 再往前一步: 把冻结的基座量化到 4 位, 7B 模型从 16GB+ 压到 6~8GB。代价是量化精度损失和反量化的额外计算。

Q3: 怎么判断一次微调是成功的?

不看 loss。我这次 val loss 从 3.331 掉到 0.040 —— 看起来完美, 实际是 16 条训练数据、3 条验证集上的过拟合。

判断标准是: 在训练时没见过的数据上, 用微调前就已经建好的评估体系测出可信的差异。具体是三步: 微调前先跑一遍拿基线 → 微调 → 用同一套指标同一批数据再跑一遍。我们的红线是 MAE > 2.5 或 顺序一致率 < 0.7 不许上线。

关键是评估体系必须在微调之前就存在。事后补的评估会不自觉地朝着"证明我的微调有效"的方向设计。

Q4: 训练数据从哪来? 有什么坑?

来自线上真实轨迹 —— 我们在第 8 章落库时就存了 qa_records, 包含题目、答案、判分结果。导出脚本从这张表构造样本。

三条硬规则: 只导人工复核过的、按 md5(input+output) 去重而不是只按 input、分数分布要均衡。

第一条最容易违反, 后果也最重。我这次故意违反了它 (用 RuleGrader 的判分结果当训练目标), 结果模型学到了 RuleGrader 的系统性偏差 (-0.833, 偏严) 并且放大了它, 把该算命中的判成了遗漏。用机器的输出训机器, 等于把错误固化进权重, 这在文献里叫模型崩塌。

Q5: 本地部署一个模型, 要改多少业务代码?

零行。改三行 .env: LLM_API_KEY、LLM_BASE_URL (指向 Ollama 的 /v1)、LLM_MODEL。

这是因为项目第一章就定下"所有模型调用收口在 app/core/llm.py 一个文件"。如果 openai.OpenAI(...) 散落在四个模块里, 现在要改四处, 而漏掉的那处不会报错, 只会继续偷偷调用云端 —— 直到你发现合规要求根本没满足。

这类"看起来是过度设计的收口", 回报都是在换供应商那天一次性到账的。

Q6: adapter 和完整模型的区别? 什么时候需要合并?

adapter 是几十 MB 的旁挂补丁, 需要推理框架支持才能加载; 合并 (fuse) 后是一个完整模型, 任何框架都认。

实验阶段挂着 —— 能随时换、能同时对比多个 adapter。上线前才 fuse, 因为 Ollama / llama.cpp 只认单一模型文件。

有一个反直觉的点我实测到了: fuse 不是无损的。在 4bit 量化基座上合并 16 位 LoRA, 结果要重新量化回 4bit, 有精度损失。同一输入、温度 0、多次运行各自稳定, 但 fuse 前后输出不同 —— 我这次 fuse 后刚修好的"字段互斥"就退化了。所以评估必须在真正要部署的那个产物上重跑。

Q7: 为什么一个 80 行的关键词匹配器能打赢 7B 大模型?

因为任务不匹配。我们的判分器架构是"模型只负责判断哪些要点命中, 分数由代码根据要点权重算" —— 模型的活是原样抄回命中的要点文本, 然后代码做严格字符串匹配。

本地 7B 的知识判断其实是对的 (depth 判成 solid, 没错), 但它把候选人的原话抄回来了, 而不是 rubric 原文, 于是严格匹配一个都没中, 分数恒为 0。MAE 3.571 vs RuleGrader 的 0.833。

这个案例的真正教训是: 模型能力和任务要求之间的错配, 靠换更大的模型解决不了, 靠改接口约定 (比如让模型返回要点编号而不是文本) 一行就解决了。在动手微调之前, 先问一遍"是不是我的接口设计得太苛刻了"。


十、划重点

  1. 微调的三个动机里只有两个立得住: 格式稳定 (强)、数据不出内网 (硬约束)。省 token 是真的但太弱 —— 实测省 67.3% 的 prompt 长度, 换成钱买不了一杯咖啡。
  2. 先部署, 后微调。没有基线你不知道该修什么。
  3. Ollama 的 OpenAI 兼容层让本地部署变成改三行 .env —— 前提是第 1 章把模型调用收口在了一个文件里。
  4. LLM_BASE_URL 结尾必须是 /v1。漏了会得到一个跟配置无关的 404。
  5. 本地 qwen2.5:7b 判分实测 MAE 3.571 / 一致率 0.3626, 输给 80 行的 RuleGrader (0.833 / 0.800)。两条红线都爆。
  6. 那次失败的根因是格式不是知识 —— 模型 depth 判对了, 但抄回的是候选人原话而不是 rubric 原文, 严格匹配全落空。这正是 LoRA 能修的那一类问题。
  7. 评估脚本崩在某条数据上是发现, 不是 bug。加 try/except 跳过去就把信号消音了。
  8. 训练数据三条硬规则: 只导人工复核过的、按 md5(input+output) 去重、分数分布均衡。违反任何一条, 微调只会让模型变笨。
  9. 用机器判分结果训机器 = 把判分器的偏差焊进权重。实测: RuleGrader 偏严 -0.833, 微调后的模型更严, 把命中判成遗漏。
  10. ensure_ascii=False, 否则模型学的是 \uXXXX 而生产环境要中文。
  11. dataset_info.json 由脚本生成, 不手抄。字段映射抄错 → 训练正常、loss 下降、学的是错映射 → 白烧一次训练。任何"两个地方必须一致"的配置都该由一个地方生成另一个。
  12. 低于 200 条不要微调, 改 prompt 更划算。导出脚本会自己打印这句劝退语。
  13. seed_traces.py 造的数据只能验证管道, 不能真拿去微调 —— 它的满分答案是 rubric 拼接, 学到的是字符串复制。
  14. Apple Silicon 的统一内存是真优势: 16GB M3 的 GPU 能吃 12GB+; 实测 LoRA Peak mem 仅 1.711 GB。Mac 上的瓶颈是时间 (~1000 tokens/sec), 不是内存。
  15. Trainable parameters: 0.297% (1.466M/494.033M) 这一行就是 LoRA 的定义。
  16. val loss 3.331 → 0.040 是过拟合不是成功。16 条训练 + 3 条验证, loss 低到反常说明在背答案。loss 永远不能当成微调有效的证据。
  17. LoRA 修好了格式没修好正确性 (实测): 围栏 5/5 → 0/5, 原样抄回 0/5 → 3/5, 但 hit_points 从蒙对变成判错, depth 从 surface 变 blank。
  18. 三个原因全是自己写在前面的: 模型太小 (0.5B)、数据太少 (16 条)、训练目标是机器判的。没有一个是意外。
  19. 指标撞天花板 = 没有分辨率。5 条样本上"全对"既不能证明有用也不能证明没用。看到满分先问样本量。
  20. temp=0.0 + 固定 seed 是做前后对比的前提, 否则分不清差异来自改动还是随机性。对比推理时只喂 messages[:2], 带上标准答案测的是复读机。
  21. fuse 不是无损操作 (实测): 4bit 基座上合并 16 位 LoRA 要重新量化, fuse 前后输出不同, 刚修好的格式会退化。每次改变部署产物形态都要重新测量。
  22. 实验阶段挂 adapter, 上线前才 fuse。挂着能换能比, fuse 后快一点能进 GGUF 但失去灵活性。
  23. chat template 填错是最贵的静默失败, 在 LLaMA-Factory 的 template: 和 Modelfile 的 TEMPLATE 两处都会遇到。核对方法: 打印 apply_chat_template(..., tokenize=False) 照着抄。
  24. 微调不创造能力, 只放大数据里已有的倾向。格式能教, 语义判断力教不出来。
  25. 本章的结论是负面的 —— 而这正是它的价值。线上继续用 RuleGrader + 云端模型, 微调链路已打通, 等 200 条人工数据。没有第 9 章的评估, 你会看着漂亮的 loss 曲线把一个判反了的模型推上线。
  26. 模型能力和任务要求的错配, 换更大的模型解决不了, 改接口约定一行就解决。动手微调前先问: 是不是我的接口设计得太苛刻了。

十一、下一章预告

模型这条线到这里告一段落: 云端能用、本地能跑、微调链路打通、什么时候该用哪个也想清楚了。

但现在整个项目还只能在你自己这台 Mac 上跑 —— 换台机器要重装 Python、重灌语料、重建向量库, 而且 .env 里的密钥全靠手抄。

第 11 章讲 Docker 与上线, 要解决的就是这件事:

  • 多阶段构建: 为什么构建镜像要分两段, 以及怎么让最终镜像不带编译工具链
  • 依赖层缓存: 改一行业务代码不该重装一遍依赖, COPY 的顺序决定了这件事
  • docker-compose.yml: 应用 + 数据卷一把起, 以及为什么 ChromaDB 和 SQLite 必须挂 volume (不挂就是每次重启清空数据)
  • Nginx 反代与 SSE: 默认配置会把流式输出缓冲住, 用户看到的是"卡半天然后一次性刷出来" —— 这是最典型的上线才暴露的问题
  • 健康检查与优雅停机: /healthz 在编排里到底怎么被用
  • 上线清单: 鉴权、限流、日志、密钥管理, 以及上线前必须跑一遍的验证脚本

第 8 章我们写好了 /healthz, 第 11 章它终于有了用处。

本页由 Markdown 自动渲染 · 共 628 篇笔记