LangChain 系列 2:在 M1 Max 上本地部署 Qwen3-14B MLX 大模型


在开始写 LangChain 代码之前,先要准备一个可以稳定调用的大模型。

如果模型本身还不能正常加载,后面遇到问题时,就很难判断究竟是 LangChain 代码写错了,还是模型、运行环境、网络接口出了问题。因此,本系列先从最底层开始:把模型下载到本地,并确认它可以独立完成一次推理。

本文使用一台配备 64GB 统一内存的 M1 Max Mac,通过 Python 3.12、ModelScope 和 MLX-LM 部署 Qwen3-14B-AWQ-4bit-MLX。这里不讨论训练、微调和生产服务,只完成本地模型下载、资源检查和推理验证。

如果还不清楚 LangChain、LangGraph 和 LangSmith 分别解决什么问题,可以先阅读 LangChain 系列 1《先搞懂 LangChain、LangGraph、LangSmith 是什么》。

整个部署过程可以拆成环境准备、模型下载与检查、加载与推理验证三个阶段:

Qwen3-14B MLX 本地部署流程

这三个阶段不是只追求“命令没有报错”。最终还要确认模型文件完整、MLX-LM 能够加载、模型可以生成中文,并且资源占用在机器可接受的范围内。

1. 先认识模型社区

1.1 Hugging Face

Hugging Face 是常见的开放模型社区。开发者会把模型权重、配置、Tokenizer、数据集、示例代码和模型说明发布到 Hugging Face Hub。

下载一个模型时,拿到的通常不只是一个权重文件,而是一整个模型仓库。后面会看到,模型目录中除了 .safetensors 权重,还包括 config.json、Tokenizer、聊天模板和生成参数等文件。

1.2 魔塔社区 ModelScope

魔塔社区 ModelScope 是国内常用的模型社区和工具链,也提供模型浏览、下载、推理、训练和评测等能力。

本文使用的模型位于:

mlx-community/Qwen3-14B-AWQ-4bit-MLX

选择 ModelScope 不是因为 Hugging Face 不能下载这个模型,而是因为本次课程资料和下载代码都使用 ModelScope,并且在国内网络环境下通常更方便。

2. 为什么选择 Qwen3-14B-AWQ-4bit-MLX

模型 ID 是:

mlx-community/Qwen3-14B-AWQ-4bit-MLX

这个名称包含了本次选型的几个关键信息。

名称 含义
Qwen3 模型系列
14B 参数规模约为 140 亿
AWQ 一种权重量化方式
4bit 模型权重主要按 4bit 保存
MLX 已转换为适合 Apple Silicon 使用的 MLX 格式

选择这个模型主要有三个原因。

第一,MLX 是面向 Apple Silicon 的数组计算框架。对于 M1、M2、M3、M4 系列 Mac,使用 MLX-LM 加载已经转换好的 MLX 模型,安装和推理过程都比较直接。

第二,4bit 量化可以显著降低模型权重占用。14B 模型如果使用 FP16 或 BF16,单是权重的理论体积就接近 28GB;使用 4bit 后,理论体积约为 7GB,更适合本地学习和单用户推理。

第三,14B 在模型能力和本地资源之间比较均衡。更小的模型占用更低,但中文问答、代码解释和后续 Agent 实验能力也会下降;更大的模型可能有更强的能力,但加载、上下文和并发会占用更多统一内存。

3. 部署前估算资源

下载模型之前,需要先判断机器能不能运行它。

课程中使用过 LLaMA Factory 的显存估算工具。没有拿到课程页面中的确切地址时,也可以使用 ApX VRAM Calculator 做同类估算。不过在线计算器只能给出部署前的参考值,最终仍然要看本机运行日志和活动监视器。

3.1 模型权重的粗略计算

最基础的计算公式是:

模型权重大小 ≈ 参数量 × 每个参数占用的字节数

常见精度可以这样估算:

精度 每个参数约占用
FP32 4 字节
FP16 / BF16 2 字节
INT8 1 字节
4bit 0.5 字节

因此,Qwen3-14B 4bit 的理论权重大小约为:

14B × 0.5 byte ≈ 7GB

3.2 运行内存不等于模型文件大小

7GB 只是权重的粗略下限。模型运行时还需要考虑:

资源 作用
量化元数据 保存 scale、bias 等量化信息
KV Cache 保存上下文计算结果,上下文越长,占用越高
Tokenizer 负责文本编码和解码
MLX 与 Python 运行时 加载代码、张量和中间计算结果
batch 与并发 同时处理的请求越多,占用越高
macOS 系统占用 系统进程与模型共享统一内存

Apple Silicon 使用统一内存,因此不能简单地把 64GB 全部当成模型可用“显存”。操作系统、应用程序、模型权重和运行时缓存都使用同一块内存。

统一内存的组成可以这样理解:

本地推理的统一内存组成

图中各项不是固定比例。上下文越长、batch 越大、并发请求越多,KV Cache 和运行时开销通常越高,因此目录大小只能用于部署前粗估,不能替代运行时观察。

本次模型目录实测为 7.8G,MLX-LM 生成日志中的 Peak memory 为 8.522 GB。这个指标反映 MLX 运行过程中的峰值内存,不等于整台 Mac 的总统一内存占用;判断系统压力时还要结合活动监视器和 vm_stat。对 M1 Max 64GB 来说,本次单用户推理有较充足的余量,但这不代表可以无限增加上下文和并发。

4. 项目目录

学习代码统一放在:

~/Documents/git/blog/source/_posts/llm_learning

第一阶段目录结构如下:

llm_learning/
├── .venv/
├── model -> /Users/bianhn/Documents/git/llm/qwen3/model
├── requirements.txt
└── langchain/
    └── p02_local_models/
        ├── 01_download_model.py
        ├── 02_test_generate.py
        ├── README.md
        └── requirements.txt

代码位于 langchain/p02_local_models/,模型入口放在项目根目录的 model/。后续 LangChain、LangGraph 和 LangSmith 的代码都可以复用同一份模型,不需要每个阶段复制一份 7.8GB 的权重。

本机的 model 是一个符号链接:

llm_learning/model -> /Users/bianhn/Documents/git/llm/qwen3/model

这表示代码访问的是 llm_learning/model,模型文件实际仍保存在 /Users/bianhn/Documents/git/llm/qwen3/model。如果是第一次部署,没有这个符号链接也没关系,下载脚本会直接创建 llm_learning/model/ 目录。

5. 使用 Python 3.12 创建虚拟环境

进入项目目录:

cd ~/Documents/git/blog/source/_posts/llm_learning

明确使用 Python 3.12 创建同名虚拟环境:

python3.12 \
  -m venv --prompt llm_learning .venv

激活虚拟环境:

source .venv/bin/activate

检查 Python 版本和解释器位置:

python -V
which python

本机输出:

Python 3.12.11
/Users/bianhn/Documents/git/blog/source/_posts/llm_learning/.venv/bin/python

必须检查 which python。只看终端前面的环境名称并不可靠,尤其是在 Conda 的 (base) 环境中再次激活 venv 时,要以解释器实际路径为准。

6. 安装并锁定依赖

先升级 pip:

python -m pip install -U pip

第一阶段的 requirements.txt 内容如下:

mlx-lm==0.28.3
mlx==0.29.3
modelscope==1.31.0
transformers==4.57.1

安装依赖:

python -m pip install \
  -r langchain/p02_local_models/requirements.txt

安装完成后,使用包元数据检查真实版本:

python - <<'PY'
from importlib.metadata import version
import platform
import sys

# 检查当前 Python,避免依赖被装到其他环境。
print("python", platform.python_version())
print("executable", sys.executable)
# mlx 没有稳定的 __version__ 属性,使用包元数据读取版本。
for package in ["mlx", "mlx-lm", "modelscope", "transformers"]:
    print(package, version(package))
PY

本机输出:

python 3.12.11
executable /Users/bianhn/Documents/git/blog/source/_posts/llm_learning/.venv/bin/python
mlx 0.29.3
mlx-lm 0.28.3
modelscope 1.31.0
transformers 4.57.1

第一篇只安装本地推理需要的组件。LangChain、LangGraph 等依赖放到后续阶段安装,这样出现问题时更容易判断是模型环境还是应用框架造成的。

7. 使用 ModelScope 下载模型

下载脚本是 langchain/p02_local_models/01_download_model.py:

from pathlib import Path
import shutil
import sys
import time

from modelscope import snapshot_download


# 固定模型 ID,避免后续脚本误下载到原始 FP16/BF16 或其他量化版本。
MODEL_ID = "mlx-community/Qwen3-14B-AWQ-4bit-MLX"
MODEL_REVISION = "67a0161a28eb07263a862fa6b3a63fcd84b55c14"
# 当前脚本位于 llm_learning/langchain/p02_local_models/ 下。
# parents[2] 就是 llm_learning 项目根目录,模型统一放在根目录的 model/。
ROOT_DIR = Path(__file__).resolve().parents[2]
MODEL_DIR = ROOT_DIR / "model"

# 4bit 权重约 7-8GB,但下载临时文件、缓存和日志也要占空间。
# 这里预留 25GB,磁盘不足时提前失败,比下载到一半报错更容易排查。
MIN_FREE_GB = 25


def free_gb(path: Path) -> float:
    """返回指定目录所在磁盘的剩余空间,单位为 GiB。"""
    usage = shutil.disk_usage(path)
    return usage.free / 1024**3


def main() -> int:
    # 模型放在项目根目录,后续 LangChain、LangGraph 等代码都可以共用这一份模型。
    ROOT_DIR.mkdir(parents=True, exist_ok=True)
    MODEL_DIR.mkdir(parents=True, exist_ok=True)

    available = free_gb(ROOT_DIR)
    print(f"Target directory: {ROOT_DIR}")
    print(f"Model directory:  {MODEL_DIR}")
    print(f"Model ID:         {MODEL_ID}")
    print(f"Model revision:   {MODEL_REVISION}")
    print(f"Free disk:        {available:.2f} GiB")

    if available < MIN_FREE_GB:
        print(
            f"ERROR: free disk is lower than {MIN_FREE_GB} GiB. "
            "Clean disk space before downloading the model.",
            file=sys.stderr,
        )
        return 2

    started = time.time()

    # snapshot_download 会把模型仓库文件下载到本地目录。
    # 如果中途网络中断,保留同一个 MODEL_DIR 后重新运行,通常可以复用已下载内容。
    model_dir = snapshot_download(
        model_id=MODEL_ID,
        revision=MODEL_REVISION,
        local_dir=str(MODEL_DIR),
    )
    elapsed = time.time() - started

    print(f"Downloaded to:    {model_dir}")
    print(f"Elapsed seconds:  {elapsed:.1f}")
    print("Top-level files:")

    # 下载后列出顶层文件,便于确认 safetensors、tokenizer、config 是否齐全。
    for item in sorted(MODEL_DIR.iterdir()):
        print(f"  {item.name}")
    return 0


if __name__ == "__main__":
    # 用退出码表达下载结果,方便 shell、日志和后续自动化判断是否成功。
    raise SystemExit(main())

运行脚本:

cd ~/Documents/git/blog/source/_posts/llm_learning
source .venv/bin/activate
python langchain/p02_local_models/01_download_model.py

初次下载时,ModelScope SDK 共下载 15 个顶层文件,其中最耗时的是两个 .safetensors 权重分片。本次初次下载日志为:

Downloading: 100%|██████████| 15/15 [49:12<00:00, 196.86s/file]
Downloaded to:    /Users/bianhn/Documents/git/llm/qwen3/model
Elapsed seconds:  2956.4

初次下载约 49 分钟。实际时间会受到网络速度、ModelScope 服务状态和磁盘写入速度影响,不能把这个数据当成固定值。

8. 模型目录中的 15 个文件

下载完成后,可以先确认顶层文件数量:

find -L model -maxdepth 1 -type f | wc -l

本机输出:

15

这 15 个文件的内容和作用如下。

文件 实测大小 内容与作用
.gitattributes 2.2K Git LFS 和仓库文件属性配置。它不参与推理,但记录了模型仓库中的大文件应如何管理。
README.md 830B 模型仓库说明,通常记录模型来源、转换方式和基础用法。它不是推理必需文件。
added_tokens.json 707B 额外 token 与 token id 的映射,包括聊天、thinking 和工具调用可能使用的特殊 token。
chat_template.jinja 4.6K 聊天模板。它负责把 system、user、assistant 消息转换成模型真正接收的 prompt。
config.json 80K 模型主体配置,包括网络结构、层数、注意力头、上下文长度和量化参数等。
configuration.json 73B ModelScope 平台使用的任务和框架元信息。
generation_config.json 237B 默认生成参数,例如 temperature、top_p、top_k 和结束 token。
merges.txt 1.6M BPE Tokenizer 的子词合并规则,与 vocab.json 一起决定文本怎样被切成 token。
model-00001-of-00002.safetensors 5.0G 第一个模型权重分片,保存量化后的模型张量。
model-00002-of-00002.safetensors 2.8G 第二个模型权重分片。两个分片合在一起才是完整权重。
model.safetensors.index.json 84K 权重索引,记录每个张量位于哪个 .safetensors 分片中。
special_tokens_map.json 614B 定义结束符、填充符、聊天边界等特殊 token 的语义。
tokenizer.json 11M Tokenizer 的完整序列化文件,负责把文本编码为 token,并把输出 token 解码为文本。
tokenizer_config.json 5.3K Tokenizer 配置,记录特殊 token、解码规则和聊天模板相关设置。
vocab.json 2.6M Tokenizer 词表,记录 token 字符串与 token id 的对应关系。

可以把它们简单分成四类:

类型 主要文件 作用
模型权重 model-*.safetensors 保存模型参数
模型结构 config.json、权重索引 告诉 MLX-LM 怎样构建并加载模型
文本处理 tokenizer*、vocab.json、merges.txt 在文本和 token 之间转换
对话与生成 chat_template.jinja、generation_config.json 组织聊天格式和默认生成参数

只看到两个大权重文件并不代表模型目录完整。缺少配置、索引或 Tokenizer 文件时,模型仍然可能无法加载。

9. 使用 MLX-LM 测试模型

测试脚本是 langchain/p02_local_models/02_test_generate.py:

from pathlib import Path
import subprocess
import time

from mlx_lm import generate, load


# 当前脚本位于 llm_learning/langchain/p02_local_models/ 下。
# 使用项目根目录的 model/,不再依赖旧的 p02_local_models/model 路径。
ROOT_DIR = Path(__file__).resolve().parents[2]
MODEL_DIR = ROOT_DIR / "model"


def run(command: list[str]) -> str:
    """执行系统命令并返回输出,这里只用于记录 du/vm_stat 等资源信息。"""
    result = subprocess.run(command, check=False, text=True, capture_output=True)
    return (result.stdout or result.stderr).strip()


def main() -> None:
    if not MODEL_DIR.exists():
        raise FileNotFoundError(f"Model directory does not exist: {MODEL_DIR}")

    model_size_path = MODEL_DIR.resolve() if MODEL_DIR.is_symlink() else MODEL_DIR

    # 先记录模型目录大小和加载前内存状态,方便和生成后的资源占用对比。
    print(f"Model directory: {MODEL_DIR}")
    if MODEL_DIR.is_symlink():
        print(f"Model target: {model_size_path}")
    print(f"Model size: {run(['du', '-sh', str(model_size_path)])}")
    print("vm_stat before loading:")
    print(run(["vm_stat"]))

    started = time.time()

    # load 会读取 MLX 权重和 tokenizer;如果模型文件不完整,通常会在这里暴露问题。
    model, tokenizer = load(str(MODEL_DIR))
    load_elapsed = time.time() - started
    print(f"Model load elapsed seconds: {load_elapsed:.1f}")

    messages = [
        {"role": "system", "content": "你是一个本地运行的中文技术助手。"},
        {"role": "user", "content": "请用三句话说明什么是大语言模型本地部署。"},
    ]

    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,

        # Qwen3 默认可能先输出 <think> 思考过程。
        # 连通性测试更关心模型能否快速返回答案,所以这里关闭 thinking。
        enable_thinking=False,
    )

    generate_started = time.time()
    response = generate(
        model,
        tokenizer,
        prompt=prompt,

        # 这里限制输出长度,避免测试脚本因为长回答占用过多时间和内存。
        max_tokens=256,

        # verbose=True 会打印 token 数、生成速度和 peak memory,是本次验证的关键指标。
        verbose=True,
    )
    generate_elapsed = time.time() - generate_started

    print("\nGenerated response:")
    print(response)
    print(f"\nGenerate elapsed seconds: {generate_elapsed:.1f}")
    print("vm_stat after generation:")
    print(run(["vm_stat"]))


if __name__ == "__main__":
    main()

运行测试:

cd ~/Documents/git/blog/source/_posts/llm_learning
source .venv/bin/activate
python langchain/p02_local_models/02_test_generate.py

本次重新运行后的关键输出:

Model directory: /Users/bianhn/Documents/git/blog/source/_posts/llm_learning/model
Model target: /Users/bianhn/Documents/git/llm/qwen3/model
Model size: 7.8G  /Users/bianhn/Documents/git/llm/qwen3/model
Model load elapsed seconds: 4.3
Prompt: 38 tokens, 30.039 tokens-per-sec
Generation: 64 tokens, 33.326 tokens-per-sec
Peak memory: 8.522 GB
Generate elapsed seconds: 4.8

模型实际回答:

大语言模型本地部署是指将大型语言模型安装在本地服务器或个人计算机上,而非依赖云端服务。这种方式可以提高数据处理的隐私性和响应速度,同时减少对外部网络的依赖。本地部署通常需要较高的硬件配置,如高性能GPU或TPU来支持模型的运行。

回答中关于 GPU、TPU 的表述比较笼统。本文实际使用的是 Apple Silicon 的 GPU、统一内存和 MLX,并不是 CUDA GPU。不过这次测试的目的不是评估回答质量,而是确认模型文件完整、MLX-LM 可以加载模型,并且模型能够生成中文内容。

到这里可以确认:模型目录可访问、15 个文件可以被正确读取、MLX-LM 能够加载权重,Tokenizer 和聊天模板也能正常工作。

加载耗时和生成速度会随着机器负载、系统缓存、提示词长度和输出长度变化,因此文章记录的是本次实测值,不是性能保证。

10. 怎样观察资源占用

10.1 检查模型文件大小

项目使用符号链接,所以检查真实目标目录:

du -sh /Users/bianhn/Documents/git/llm/qwen3/model

输出:

7.8G    /Users/bianhn/Documents/git/llm/qwen3/model

10.2 查看系统内存

可以在另一个终端执行:

vm_stat
top -o mem
ps aux | grep '[p]ython'

也可以直接打开 macOS 的“活动监视器”,观察:

  1. 内存压力是否变成黄色或红色。
  2. Python 进程占用了多少内存。
  3. Swap 使用量是否持续增加。
  4. 生成结束后内存是否能够回收。

vm_stat 中的 Swapins、Swapouts 是系统启动以来的累计计数,不能只看到非零值就判断本次推理正在大量使用 Swap。判断当前状态时,应结合活动监视器中的内存压力和 Swap 使用量。

11. 本次遇到的问题

11.1 系统 python3 不是 Python 3.12

第一次直接执行:

python3 -m venv .venv

创建出来的是 Python 3.9 环境,并出现了 urllib3 与 LibreSSL 相关警告。

解决方法是删除错误环境,然后明确使用 Python 3.12:

rm -rf .venv
python3.12 \
  -m venv --prompt llm_learning .venv
source .venv/bin/activate
python -V
which python

11.2 未固定版本导致依赖组合发生变化

直接执行不带版本约束的安装命令,会随着软件仓库更新而得到不同依赖组合。为了让本文环境可以复现,应使用项目锁定并经过实测的固定版本:

python -m pip install --force-reinstall \
  'mlx-lm==0.28.3' \
  'mlx==0.29.3' \
  'modelscope==1.31.0' \
  'transformers==4.57.1'

因此,后续复现应优先使用仓库中的 requirements.txt,不要无条件升级所有依赖。

11.3 ModelScope 大文件下载中断

下载两个权重分片时出现过网络中断和 SSL 抖动:

IncompleteRead(...), will retry
SSLError(1, '[SSL: WRONG_VERSION_NUMBER] wrong version number'), will retry

处理时不要删除已经下载的 model/,也不要临时更换下载目录。先让 ModelScope SDK 自动重试;如果脚本最终退出,再使用相同模型 ID 和相同目录重新运行,避免重新下载已有文件。

11.4 Qwen3 的 thinking 占满输出长度

第一次测试没有关闭 thinking,模型先生成较长的 。当 max_tokens=256 时,思考内容可能占满输出长度,真正回答反而被截断。

本文只是做连通性测试,因此在聊天模板中设置:

enable_thinking=False

这不会改变模型文件,只影响本次 prompt 的聊天模板。后续确实需要观察推理过程时,可以再开启 thinking,并相应提高 max_tokens。

11.5 移动项目后找不到模型

模型文件很大,不适合随着每个学习目录复制。项目通过根目录的 model 符号链接复用原来的权重:

ls -ld model

如果链接失效,可以重新创建:

rm model
ln -s /Users/bianhn/Documents/git/llm/qwen3/model model

如果不是符号链接,而是正常的 model/ 目录,不要执行 rm model。先用 ls -ld model 确认第一个字符是 l,再处理链接。

11.6 mx.metal.device_info 弃用警告

运行时可能看到:

mx.metal.device_info is deprecated and will be removed in a future version.
Use mx.device_info instead.

这是 MLX-LM 内部调用产生的弃用警告,不会阻止当前模型加载和生成。只要后面仍然有正常回答、生成速度和峰值内存日志,就不需要为了这个警告修改业务代码。

12. 小结

这次部署完成了四件事:

  1. 使用 Python 3.12 创建独立的 llm_learning 虚拟环境。
  2. 使用 ModelScope 下载 Qwen3-14B-AWQ-4bit-MLX 的 15 个模型文件。
  3. 使用 MLX-LM 直接加载本地模型并生成中文回答。
  4. 记录模型大小、加载时间、生成速度、峰值内存和实际报错处理过程。

对于 M1 Max 64GB,14B MLX 4bit 模型能够在保留较多系统余量的情况下完成本地单用户推理。这里验证的是“模型本身可以工作”。下一篇再把它启动为 OpenAI 兼容服务,分别使用 OpenAI Python SDK 和 LangChain 进行调用。

13. 最终部署结果

完成环境安装、模型下载和推理测试后,将本次实际部署数据整理如下。

项目 实测结果
机器 Apple M1 Max,64GB 统一内存
系统 macOS 15,arm64
Python 3.12.11
模型 mlx-community/Qwen3-14B-AWQ-4bit-MLX
模型格式 MLX,AWQ 4bit 量化
模型目录大小 7.8G
模型文件数量 15 个顶层文件
初次下载耗时 2956.4 秒,约 49 分钟
MLX-LM 0.28.3
MLX 0.29.3
ModelScope 1.31.0
transformers 4.57.1
本次复测加载耗时 4.3 秒
本次复测生成速度 33.326 tokens/s
本次复测 MLX-LM Peak memory 8.522 GB

文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录