# 本地推理、微调与训练边界：先选对解决问题的层

## 用途、程度与前置

看到开源模型和微调教程时，很容易把“自己训练一个模型”当成 AI 全栈必经之路。对于偏前端交付，更重要的是判断问题需要改提示、接数据、换推理方式还是训练。本章 L1 要求是能做有依据的选择，理解本地模型接口和资源限制；不要求训练基础大模型。

前置是模型调用、评测、Python 和向量概念。完整脚本默认只构造请求并输出，不调用网络；只有显式 --run 才访问本机 Ollama 服务。真正推理需要自行安装 Ollama、下载模型并具有足够内存或显存。教材不声称已下载模型、测过你的硬件速度或完成训练。

## 先分清五类动作

托管 API 由供应商运行模型，你管理调用合同、数据、成本与应用逻辑。本地推理是下载已有权重并运行，不会因为在自己电脑上运行就自动学习新知识。RAG 在回答前检索外部材料，把当前可访问证据提供给模型，适合更新频繁、需要引用和权限控制的知识。

微调使用训练数据调整模型参数或适配器，常用于更稳定的任务格式、风格、领域行为或特定分类表现。它不等于可靠导入一个随时更新的事实数据库。今天改了价格、订单状态或公司政策，应该更新数据库或知识源并在推理时检索，而不是期待微调立即、精确且可删除地记住所有事实。

预训练通常是从大规模语料学习通用表示，涉及大量数据、计算、训练系统和长期评估。对多数应用团队，它与交付一个稳定的业务功能不是同一规模的工作。先做一个可测的应用基线，再决定是否值得投入训练。

可以用故障类型决定改哪一层：不知道最新条款，补检索；格式不稳定，先强化结构化输出与校验；调用错工具，改工具设计和路由；权限错误，修执行器；小模型在重复任务上表现不足且有高质量标注，再评估微调。不要用训练掩盖缺失的工程边界。

## 本地推理的收益与成本

本地推理可能帮助离线工作、控制数据边界和避免按次调用费用，但它仍有设备、电力、运维和工程成本。模型许可证、训练数据政策、运行库遥测与更新路径也需要核对。数据不发给外部模型不代表整个应用完全离线：下载器、插件、工具调用和日志出口仍可能联网。

资源不能只看模型文件大小。推理还需要运行时开销和 KV cache，后者随上下文长度、并发和模型结构变化。更长上下文和更多并发常常比短提示单请求占用更多内存。量化减少权重存储与部分计算压力，但可能损失质量，速度收益取决于硬件、内核和量化格式，不能一概保证。

选模型时先以语言、任务、工具能力、输出格式、上下文和许可证过滤，再在真实设备上测首字延迟、生成速度、总时长和并发。小模型启动快不代表能满足复杂推理；大模型离线评分好也可能因交互延迟不适合产品。评测仍是决定依据。

Ollama 提供本地 HTTP API，生成接口可以流式返回；本章设 stream 为 false，便于完整解析 JSON。keep_alive 控制请求后模型保留在内存的时长，影响冷启动和资源占用；它不是请求超时。响应可包含 prompt_eval_count、eval_count 以及纳秒计的 duration 字段。[生成 API](https://docs.ollama.com/api/generate)、[流式协议](https://docs.ollama.com/api/streaming)

## 完整示例：默认只预览的本地 API 探针

保存为 local_probe.py，Python 3.12 以上，无第三方依赖。它的默认执行可以离线验收；--run 的输出依赖真实服务，不能预先保证具体生成文本。

```python
# local_probe.py
import argparse
import json
import sys
import urllib.error
import urllib.request

def main() -> int:
    parser = argparse.ArgumentParser(description="预览或调用本机 Ollama")
    parser.add_argument("--model", default="qwen3:0.6b")
    parser.add_argument("--run", action="store_true", help="实际调用本机服务")
    args = parser.parse_args()
    if not args.model.strip():
        parser.error("模型名称不能为空")
    payload = {
        "model": args.model,
        "prompt": "请用一句中文介绍你能做什么。",
        "stream": False,
        "keep_alive": "2m",
        "options": {"temperature": 0, "num_predict": 80}
    }
    if not args.run:
        print(json.dumps({"mode": "preview", "request": payload}, ensure_ascii=False))
        return 0
    request = urllib.request.Request(
        "http://127.0.0.1:11434/api/generate",
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST"
    )
    try:
        with urllib.request.urlopen(request, timeout=120) as response:
            result = json.load(response)
        if result.get("error"):
            raise ValueError(str(result["error"]))
        if not isinstance(result.get("response"), str):
            raise ValueError("响应缺少文本字段")
    except urllib.error.HTTPError as error:
        print(f"HTTP 错误：{error.code}，检查服务和模型是否存在", file=sys.stderr)
        return 2
    except (urllib.error.URLError, TimeoutError, ValueError) as error:
        print(f"调用失败：{error}", file=sys.stderr)
        return 2
    print(json.dumps({
        "text": result["response"],
        "output_tokens": result.get("eval_count"),
        "generation_seconds": (
            result["eval_duration"] / 1_000_000_000
            if isinstance(result.get("eval_duration"), (int, float)) else None
        )
    }, ensure_ascii=False))
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
```

先运行无网络预览：

```bash
python local_probe.py
```

预期得到 mode 为 preview 的 JSON，包含 model、prompt、stream、keep_alive 和 options，没有任何生成结果。想实际调用时，先按 [Ollama 官方安装入口](https://docs.ollama.com/) 安装适合系统的版本，再执行下面命令；下载模型会占用网络与磁盘，操作前查看当前模型页与设备资源。[示例模型](https://ollama.com/library/qwen3:0.6b)

```bash
ollama pull qwen3:0.6b
ollama list
python local_probe.py --run
```

服务未自动启动时另开终端执行 ollama serve；已启动时不要重复占用端口。成功后会得到 text、output_tokens 和 generation_seconds，文本与耗时按实际运行变化。模型可能在输出预算内尚未形成理想回答，这也是需要评估的产品行为。temperature 为零不承诺跨版本、硬件和配置绝对确定。

逐段解析：命令行开关把构造请求与执行清晰分开；URL 固定回环地址，避免示例意外发往外部地址；stream:false 对应单个 JSON 响应，若改成流式就必须按返回协议逐段解析，不能继续 json.load 一次读完。timeout 是客户端网络等待设置，不等于严格的整个 Agent 任务总时限。错误码 2 让脚本能进入自动化检查。

本地 API 不应未经鉴权直接暴露公网。需要团队共享时，由受控后端或网关提供身份、速率与预算限制；桌面本地运行不意味着任何局域网客户端都应有无限推理权限。

## 微调什么时候值得做

微调前至少准备：可测的现有基线、目标失败模式、足够一致且有权使用的数据、独立验证与测试集，以及发布和回退方案。先确认提示与检索不能以更低成本解决问题，再做小规模实验。数据不是越多越好，互相矛盾的标签和低质量答案会让训练方向混乱。

LoRA 冻结基础模型权重，训练较小的低秩更新矩阵，降低可训练参数量。rank r 控制适配器规模；target_modules 指定作用模块；lora_alpha 影响更新缩放。具体模块名依模型架构而定，不能把另一个模型的配置直接复制过去。[PEFT LoRA](https://huggingface.co/docs/peft/main/conceptual_guides/lora)

训练参数还包括学习率、批量大小、梯度累积、序列长度和训练轮数。它们会影响稳定性、内存和过拟合。训练损失下降只说明在训练目标上拟合改善，不证明业务质量提高；必须在未参与训练的样本上比较格式、事实、安全、延迟和成本。适配器还依赖基础模型及 tokenizer 版本，发布时应成组记录。

训练数据进入模型后，不容易像数据库一行记录那样精确撤销。涉及个人信息、删除义务或快速变更知识时，要考虑数据治理与可维护性，不能承诺微调能够实现精确记忆或精确遗忘。对多数应用起步，维护高质量评测集通常比立刻训练更有收益。

## 常见错误与排障

连接被拒绝先查服务是否启动、监听端口与本机地址；模型不存在先用 ollama list 核对名字；内存不足先降低并发、上下文或选择更小模型，不要无限重试。第一次慢后续快，拆分加载时间与生成时间，别只公布热启动速度。

微调后训练集很好、真实请求更差，检查数据泄漏、重复、风格偏置和遗忘。回退到基线，先确认评测可复现；不要用更多轮训练作为默认修复。检索事实错误则优先检查来源与时间，不要指望微调自动更新数据库。

## 应用工程师需要理解到哪一层

模型训练并不是一个必须补齐的身份标签。应用工程师首先要能定义任务、取得正确数据、建立可靠调用、处理权限和评测质量。只有这些环节已经清楚，训练实验的结果才有可解释性。没有评测集时，很难知道微调是改进了行为，还是只让几条演示回答更像预期。

理解模型工作方式仍然有帮助。推理是使用已有参数计算输出；训练是在数据和目标函数指导下更新参数；微调在已有模型基础上进行较小范围的适应。这些过程共享一些计算组件，但资源、数据、失败模式和发布方式不同。能够运行一个推理脚本，不代表已经掌握训练系统；反过来，能够训练一个适配器，也不自动解决产品可靠性。

对前端背景，可以把基础模型理解为一个能力丰富但行为具有统计性的底层组件，提示和工具接口是调用合同，检索提供运行时数据，微调改变组件在某些任务上的行为倾向。这只是帮助定位责任的类比，模型并不是可以通过单个配置保证每种输入确定输出的普通 UI 组件。

先建立决策顺序：业务事实缺失先接数据，格式不稳定先加结构约束，检索不准先看表示和分块，权限问题修执行器，延迟问题拆分测量。只有确认失败模式来自模型任务能力或稳定风格，并且有适合训练的数据时，再比较微调的收益与维护成本。这个顺序能避免用最昂贵的手段修最普通的工程问题。

## 本地推理的内存来自哪些部分

模型权重只是内存的一部分。推理还需要中间张量、运行时缓冲、分词器和上下文缓存。自回归生成会复用已处理 token 的键和值，这通常称为 KV cache；它随序列长度、并发和模型结构增长。某些架构和缓存策略不同，所以不能只看参数规模就推断需要多少显存。

量化减少权重表示精度，例如用更少位数存储近似值，但文件还包含量化元数据和未量化部分。理论“参数数乘位数”只是粗略下界，不是最终模型文件大小，更不是进程总内存。量化模型的质量、速度和支持程度需要在目标硬件与运行库中实测。

上下文长度不只是允许输入多少文字，也影响预填充计算和缓存占用。长文档一次塞入上下文可能增加首字等待，即使最后只输出一句话。并发多个长请求会放大这种成本。检索、分段和摘要可以减少输入，但它们也可能丢信息，需要在业务评测中比较，而不是只为了节省内存直接截断。

## 第二个完整例子：理解资源估计的组成

保存 memory_estimate.py，Python 3.12，仅标准库。数字是假想架构参数，不对应某个已验证模型，也不能据此承诺某台设备能够运行。程序分别计算理论权重位存储和一种常见全长 KV 缓存布局。

```python
# memory_estimate.py
def estimate(parameters, bits_per_weight, layers, kv_heads,
             head_dimension, tokens, batch=1, cache_bytes=2):
    values = [parameters, bits_per_weight, layers, kv_heads,
              head_dimension, tokens, batch, cache_bytes]
    if any(type(value) is not int or value <= 0 for value in values):
        raise ValueError("所有参数必须是正整数")
    weight_bytes = parameters * bits_per_weight / 8
    # 2 分别代表 Key 与 Value；这里使用 KV 头数，不是任意注意力头数
    kv_bytes = 2 * layers * kv_heads * head_dimension * tokens * batch * cache_bytes
    return {
        "theoretical_weight_gib": round(weight_bytes / 1024 ** 3, 3),
        "estimated_kv_gib": round(kv_bytes / 1024 ** 3, 3)
    }

for tokens in (4096, 8192):
    print(tokens, estimate(
        parameters=7_000_000_000, bits_per_weight=4,
        layers=32, kv_heads=8, head_dimension=128, tokens=tokens
    ))
```

执行 python memory_estimate.py，理论权重约三点二六 GiB；上下文四千零九十六时 KV 约零点五 GiB，翻倍后约一 GiB。结果没有包含运行时、中间激活、量化元数据和其他缓冲，所以不能把两项相加当成保证可运行的内存需求。

改变 batch 为二，缓存估计也翻倍。这个实验帮助理解为什么单请求演示很顺畅，多用户并发却可能内存不足。实际推理服务会采用不同缓存分配、批处理和卸载策略，观察真实进程与设备指标后才能确定容量。单位也要分清：十进制 GB 与二进制 GiB 并不相同。

## 本地 API 的请求参数应该怎样读

原探针把 stream 设为 false，因为生成接口默认 stream 为 true。若保留默认，响应通常是连续的部分结果，不能按单个完整 JSON 解析。stream 决定传输方式，不决定模型是否只生成一个句子。前端流式展示还需要处理完成、失败和取消状态，不能看到第一段文字就标记任务成功。

keep_alive 控制请求后模型驻留时长，零可用于请求后立即卸载；它与网络 timeout、任务总时限完全不同。短驻留减少空闲内存占用，但频繁重新加载会增加冷启动。配置应根据共享设备、调用间隔与模型切换情况选择，不存在所有应用都合适的一个数字。

options 中生成预算、采样与上下文相关设置会影响结果和资源，实际支持与默认值可能受模型和运行时版本影响。教材采用显式配置并记录版本，避免依赖未说明的安装默认。temperature 为零有助于减少采样随机性，但不能保证跨硬件、内核和版本完全一致；更不能把低温度当事实校验。

响应中的 load_duration、prompt_eval_duration 和 eval_duration 分别帮助理解加载、输入处理和生成耗时；它们不一定覆盖用户感知的全部等待，还可能存在排队、网络和前端渲染。统计 token 每秒时应清楚使用的是生成阶段还是总耗时，不能把冷启动排除后宣称所有用户都会同样快。

## 微调究竟在优化什么

监督微调通常让模型在给定输入下更倾向生成训练示例中的目标输出。它学习的是统计模式，不是把每条记录存入可精确查询和删除的表。若训练示例互相矛盾，模型会在不一致目标之间适应；若只包含理想简单输入，复杂真实请求仍可能失败。

训练损失衡量模型对训练目标的拟合情况。损失下降可能来自学会有用规则，也可能来自记忆重复样本。验证集帮助观察未参与更新的数据表现，测试集用于最终比较。训练集和测试集若包含同一会话的改写，结果会被污染，不能据此判断泛化能力。

LoRA 将某些权重更新表示成较小矩阵的组合，基础权重保持冻结，训练的参数量因此减少。rank 控制这类更新的容量，但更高并不自动更好；target_modules 决定更新放在哪些层，必须与模型结构对应。不同方法可能采用不同缩放公式，所以实验要记录配置，而不是只写“用了 LoRA”。

学习率控制每次更新的幅度，过大可能破坏已有能力，过小可能难以在预算内形成有效适应。批量、梯度累积与序列长度会影响每次更新看到的数据和内存；训练轮数增加会重复利用同一批数据，也可能加重过拟合。不要把网上某组超参数当成适用于所有模型和任务的默认答案。

应用工程师不必立即自己实现训练循环，但需要读懂数据格式、损失目标、评测曲线和制品依赖。训练服务帮你执行计算，不会替你判断数据是否有权使用、标签是否正确或结果是否值得发布。把这些决策留给明确的业务评测与治理流程，才是可维护的路线。

## 第三个完整例子：先检查训练数据的一致性

保存 training_data_check.py，Python 3.12，仅标准库。它检查一个自定义的教学分类数据格式，不是某供应商的微调上传格式。真实接入时需要再映射到对应平台或训练框架合同。

```python
# training_data_check.py
import hashlib

def fingerprint(text):
    return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()

def validate(training, validation):
    labels = {}
    for row in training:
        if not row["input"].strip() or row["output"] not in {"refund", "other"}:
            raise ValueError("训练样本无效")
        key = fingerprint(row["input"])
        if key in labels and labels[key] != row["output"]:
            raise ValueError("相同输入存在冲突标签")
        labels[key] = row["output"]
    leaked = [row["id"] for row in validation if fingerprint(row["input"]) in labels]
    if leaked:
        raise ValueError("验证集包含已见输入：" + ",".join(leaked))
    return {"training_unique_inputs": len(labels), "validation_rows": len(validation)}

train = [
    {"id": "a", "input": "我要退款", "output": "refund"},
    {"id": "b", "input": "查询物流", "output": "other"}
]
valid = [{"id": "c", "input": "钱能退回来吗", "output": "refund"}]
print(validate(train, valid))
try:
    validate(train, [{"id": "d", "input": " 我要退款 ", "output": "refund"}])
except ValueError as error:
    print(error)
```

运行后第一行得到两个唯一训练输入和一条验证记录，第二行指出 d 已出现在训练输入中。把第二条训练内容改成“我要退款”却保留 other 标签，会报告冲突。它只检测规范化后的精确重复，不能检测语义改写或同一用户群泄漏，后者还需要业务组和来源检查。

这个例子强调训练准备先于 GPU 使用。重复和矛盾标签不解决，增加计算只会更快拟合有问题的数据。训练数据还应覆盖信息不足、拒答、异常输入和边界案例，避免模型只学会对所有请求给出自信答案。

## 训练后的发布与回退

适配器通常依赖具体基础模型、tokenizer、模板和运行库。只保存一个适配器文件而不记录依赖，很难在另一台机器上复现。发布清单应包含基础模型标识、适配器版本、训练数据版本、关键配置与评测报告；量化和合并适配器后也要重新验证最终部署制品。

评测不仅比较目标任务提升，还要检查通用能力、安全拒绝、输出格式、延迟和内存是否退化。一个分类任务提升可能伴随其他任务下降；如果应用只需要该分类能力，可以把适配模型放在明确路由中，而不是替换所有任务的默认模型。

上线可以先限于可控流量并保留基线回退。模型结果若影响写操作，执行器规则仍然独立存在，不能因为微调后“更听话”就删除权限和确认。监控出现分布变化时，先检查数据、提示、检索与版本，不要把再训练当作唯一维护方式。

## 综合练习：写一份可反驳的技术选择说明

选择一个实际需求，分别说明为什么使用托管模型、本地推理、检索或微调。说明必须包含失败证据、候选方案、验证方法和放弃条件。例如“本地摘要在目标设备首字太慢且质量无收益”就是可观察的放弃条件，而“我觉得开源更好”不是。

<details><summary>参考实现思路</summary>

可以把比较结果保存成普通 JSON，并用下面独立脚本检查是否具备决策所需字段。保存 decision_record.py 并执行 python decision_record.py；它不替你做技术决策，只验证说明没有遗漏关键证据入口。

```python
record = {
    "problem": "离线整理内部会议文本",
    "candidate": "local-inference",
    "baseline": "现有人工流程",
    "evaluation_dataset": "meeting-summary-v1",
    "quality_gate": "关键行动项无虚构",
    "latency_gate": "按目标设备实测后确定",
    "rollback": "保留原文本并回到人工整理",
}
required = {"problem", "candidate", "baseline", "evaluation_dataset",
            "quality_gate", "latency_gate", "rollback"}
if set(record) != required or any(not value.strip() for value in record.values()):
    raise ValueError("决策记录不完整")
print("决策记录结构完整；内容仍需实际评测支持")
```

不要把“按实测确定”留到正式发布报告。练习阶段可以标明待验证，交付阶段必须填写实际测量方法、数据与结论。模型、设备和训练服务本章均未真实运行，离线脚本只证明资源公式、请求构造与数据检查机制。

</details>


## 练习、提示与参考解答

练习：为“每天变化的库存问答”“固定分类标签”“离线文档摘要”分别选择技术方案，说明为什么。运行预览并尝试空模型参数，确认失败；有设备条件时再测冷启动和热启动。

提示：先写目标问题与验收标准，再讨论技术名称。

<details><summary>参考答案</summary>

库存问答优先实时数据库工具或带时效与权限的检索；固定分类先做规则、提示和模型基线，有高质量数据且收益明确再评估微调；离线摘要可以评测本地推理，但需核对模型许可、设备资源与摘要质量。空模型参数触发 argparse 错误。冷启动与热启动分别记录，不把预览模式输出当作推理验证。

</details>

## 可验证验收与自测

验收无网络预览、空名称拒绝以及选择方案的理由。真实模型推理、设备性能和任何训练都须另行执行并报告实际结果。能够写出基础模型、适配器、数据、评测与配置的版本清单。

1. **本地推理会自动学到新知识吗？** 不会，它运行既有权重，更新事实通常需要外部数据或检索。
2. **LoRA 的作用是什么？** 通过训练较小的适配参数调整行为，减少可训练参数量，但仍需资源、数据和评测。
3. **训练损失降低就能发布吗？** 不能，必须看独立业务评测与运行成本，并确认安全和兼容性没有退化。