跳到主内容
快讯直播
AI智模界
教程

Mac M4 上用 CoreML 离线跑 Jev:端侧秒级决策部署

适用场景

现场设备或内网环境不允许把数据发到云端,但又需要一个能"看状态、出动作"的决策模型跑在本地,延迟要求压到秒级以内甚至几十毫秒。Jev 是 APUS 开源的决策类模型,官方仓库提供了 PyTorch 权重与推理脚本;这套方案把它转成 Core ML 的 mlpackage,直接吃 Mac M4 的 Neural Engine,在不联网的前提下跑出一个可观测的决策闭环。

适合三类人:做端侧 Agent 的工程师、要在离线环境做 PoC 的运维、以及手上有 Mac 但不想买推理服务器的小团队。

环境与前置条件

  • 硬件:Apple Silicon 机型(M1 及以上均可,M4 的 Neural Engine 与内存带宽更宽裕)。统一内存建议 16GB 起步;如果 Jev 的权重较大,转模型阶段的内存峰值通常是推理时的 2~3 倍,32GB 会更从容。
  • 磁盘:预留模型权重的 3~5 倍空间。原始权重、trace 中间文件、编译后的 .mlmodelc 会各占一份。
  • 系统:macOS 14 及以上。Core ML 的 mlprogram 格式、算子落点查看能力都和系统版本相关,具体支持矩阵以 Apple 官方文档当前版本为准。
  • 工具链:Xcode(提供 coremlcompiler)、Python 3.10 或 3.11、coremltools、PyTorch、transformers、tokenizers、numpy。版本一律以各项目官方文档当前版本为准。
  • 一次性联网:需要联网把 Jev 权重和 tokenizer 下载到本地。下载完成后全程离线,这是本方案的核心诉求。

分步骤部署

第 0 步:建立可复现的目录和虚拟环境

先固定工作区,后面所有路径都相对它,避免文件散落。

```bash

mkdir -p ~/jev-coreml/{models,traces,out,logs}

cd ~/jev-coreml

python3 -m venv .venv

source .venv/bin/activate

python -m pip install -U pip

pip install -U coremltools torch transformers tokenizers numpy huggingface_hub

pip freeze > requirements.lock

```

requirements.lock 很关键:Core ML 的转换结果对 coremltools 版本敏感,出问题时能靠它回滚。

成功标志:

```bash

python -c "import coremltools as ct, torch; print(ct.__version__, torch.__version__)"

```

能打印出两个版本号即通过。

第 1 步:把权重和 tokenizer 拉到本地

仓库名以 APUS 官方发布页或 README 为准,这里用占位符代替:

```bash

export HF_HOME=~/jev-coreml/models/hf

huggingface-cli download <官方仓库名> --local-dir ~/jev-coreml/models/jev

```

新版 CLI 可能已改名为 hf,具体命令以 Hugging Face 官方文档为准。

下载完成后立刻切到离线模式,后面所有步骤都在这个前提下跑:

```bash

export HF_HUB_OFFLINE=1

export TRANSFORMERS_OFFLINE=1

```

成功标志:~/jev-coreml/models/jev 下能看到 config.json、权重文件和 tokenizer.json 之类的文件。

第 2 步:先在 PyTorch 里把"慢版本"跑通

不要跳过这步。Core ML 报错时的信息量远不如 PyTorch,先在 PyTorch 里确认输入输出形状、确认 tokenizer 行为,后面排查会快很多。

新建 baseline.py

```python

import time, numpy as np, torch

from transformers import AutoModelForCausalLM, AutoAI 词典:Token">Tokenizer

MID = "models/jev"

tok = AutoTokenizer.from_pretrained(MID)

model = AutoModelForCausalLM.from_pretrained(MID, torch_dtype=torch.float32).eval()

S = 128

ids = tok("示例状态:库存 3,订单 12,通道 A 正常", return_tensors="pt").input_ids

ids = torch.nn.functional.pad(ids, (0, max(0, S - ids.shape[1])), value=0)[:, :S].to(torch.int32)

mask = torch.ones_like(ids, dtype=torch.int32)

lat = []

with torch.no_grad():

for _ in range(20):

t0 = time.perf_counter()

out = model(input_ids=ids.long(), attention_mask=mask.long())

lat.append((time.perf_counter() - t0) * 1000)

print("logits:", tuple(out.logits.shape), "p50 ms:", round(float(np.median(lat)), 2))

```

成功标志:打印出 logits 形状,并且 p50 延迟是一个能接受的数字(这里只是基准,不必和 Core ML 比)。

注意这里刻意把序列长度固定成 128。固定形状是后面能不能吃上 Neural Engine 的第一决定因素。

第 3 步:导出 traced 图

Core ML 转换吃的是 TorchScript trace,不是 HuggingFace 的 generate()。要把动态控制流全部去掉,把 past_key_values 这类缓存变成显式的输入输出(如果 Jev 是分类/回归式决策头,没有 KV cache,就跳过缓存部分,只导一张图)。

```python

import torch, numpy as np

from transformers import AutoModelForCausalLM

S = 128

model = AutoModelForCausalLM.from_pretrained("models/jev", torch_dtype=torch.float32).eval()

class Wrap(torch.nn.Module):

def __init__(self, m):

super().__init__()

self.m = m

def forward(self, input_ids, attention_mask):

return self.m(input_ids=input_ids, attention_mask=attention_mask).logits

wrapped = Wrap(model).eval()

ex_ids = torch.zeros((1, S), dtype=torch.int32)

ex_mask = torch.ones((1, S), dtype=torch.int32)

with torch.no_grad():

traced = torch.jit.trace(wrapped, (ex_ids, ex_mask), strict=False)

traced.save("traces/jev.pt")

print("traced ok")

```

成功标志:traces/jev.pt 生成,且体积和模型量级相符。

第 4 步:转成 Core ML 的 mlpackage

```python

import numpy as np, coremltools as ct

S = 128

traced = __import__("torch").jit.load("traces/jev.pt")

mlmodel = ct.convert(

traced,

inputs=[

ct.TensorType(name="input_ids", shape=(1, S), dtype=np.int32),

ct.TensorType(name="attention_mask", shape=(1, S), dtype=np.int32),

],

outputs=[ct.TensorType(name="logits")],

convert_to="mlprogram",

minimum_deployment_target=ct.target.macOS14, # 可选 target 以当前 coremltools 文档为准

compute_precision=ct.precision.FLOAT16,

)

mlmodel.save("out/jev.mlpackage")

print("saved")

```

几个要点:

  • minimum_deployment_target 不要盲目拉高。设得比本机系统还新,加载时会直接编译失败。设低一点换兼容性,设高一点换新算子,按实际机型选。
  • FLOAT16 通常能显著提速且精度损失对决策任务可接受,但要靠第 5 步的一致性校验来确认。
  • 如果转换报某个算子不支持,见"常见报错"第 3 条。

成功标志:out/jev.mlpackage 生成,且控制台没有 warning 级别的算子回退提示。

第 5 步:加载模型,跑一次预测

```python

import numpy as np, coremltools as ct, time

m = ct.models.MLModel("out/jev.mlpackage", compute_units=ct.ComputeUnit.CPU_AND_NE)

print(m.input_description)

print(m.output_description)

ids = np.zeros((1, 128), dtype=np.int32)

mask = np.ones((1, 128), dtype=np.int32)

out = m.predict({"input_ids": ids, "attention_mask": mask})

print({k: v.shape for k, v in out.items()})

```

ComputeUnit.CPU_AND_NE 表示允许调度 CPU 和 Neural Engine。也可以试 ALL(含 GPU)对比耗时,不同模型的最优组合不一样,以实测为准。

另外可以先用命令行编译成 .mlmodelc,用 Xcode 之外的流程调用:

```bash

xcrun coremlcompiler compile out/jev.mlpackage out/

ls out/ | grep mlmodelc

```

成功标志:打印出输入输出描述,且能拿到 logits 数组。

第 6 步:写端侧离线决策闭环

闭环由五段组成:采集状态 → 拼提示词 → 本地 tokenize → Core ML 推理 → 解析动作并执行。关键是所有缓冲区预分配、形状全程不变,否则每次调用都在重新分配内存。

```python

import json, time, numpy as np, coremltools as ct

from transformers import AutoTokenizer

S = 128

tok = AutoTokenizer.from_pretrained("models/jev")

model = ct.models.MLModel("out/jev.mlpackage", compute_units=ct.ComputeUnit.CPU_AND_NE)

ID_BUF = np.zeros((1, S), dtype=np.int32)

MASK_BUF = np.ones((1, S), dtype=np.int32)

log = open("logs/decisions.jsonl", "a")

def encode(state_text: str):

ids = tok(state_text).input_ids[:S]

ID_BUF[0, :] = 0

ID_BUF[0, :len(ids)] = ids

return ID_BUF, MASK_BUF

def decide(state):

t0 = time.perf_counter()

out = model.predict({"input_ids": ID_BUF, "attention_mask": MASK_BUF})

ms = (time.perf_counter() - t0) * 1000

action = int(np.argmax(out["logits"][0, -1]))

log.write(json.dumps({"action": action, "latency_ms": round(ms, 2)}) + "\n")

log.flush()

return action, ms

预热:前几次调用包含编译和缓存初始化,不计入统计

for _ in range(5):

decide("warmup")

lat = []

N = 200

for i in range(N):

ids, mask = encode(f"步 {i}:队列 {i % 7},负载 {i % 13}")

_, ms = decide(f"步 {i}")

lat.append(ms)

lat = np.array(lat)

print(f"p50={np.percentile(lat,50):.2f}ms p95={np.percentile(lat,95):.2f}ms "

f"qps={1000/lat.mean():.1f}")

```

关于"每秒 45 次决策":把它当成一条目标线,而不是承诺值。45 次/秒意味着平均单次预算约 22 毫秒。能不能达到取决于模型规模、序列长度、是否真的命中了 Neural Engine。达不到时按这个顺序调:缩短固定序列长度 → 确认 fp16 生效 → 查看算子落点 → 缩小模型。

验证部署是否成功

按下面五项逐条过,全过才算闭环成立。

1. 环境可用

```bash

python -c "import coremltools as ct; print(ct.__version__)"

```

有版本号输出。

2. 模型能加载并推理

```bash

python -c "

import coremltools as ct, numpy as np

m = ct.models.MLModel('out/jev.mlpackage')

o = m.predict({'input_ids': np.zeros((1,128),np.int32), 'attention_mask': np.ones((1,128),np.int32)})

print({k: v.shape for k,v in o.items()})

"

```

能打印出预期的输出形状。

3. 数值一致性

用同一段输入分别跑 PyTorch 和 Core ML,比较输出的最大绝对误差。fp16 下通常在 1e-2 量级以内,具体容忍度按任务定。这个校验必须做,否则量化后的模型可能仍然"能跑",只是决策全错。

4. 算子落点

在 Xcode 里打开 mlpackage,用 Product > Performance Report 查看各算子分配到 CPU / GPU / Neural Engine 的情况。如果大量算子在 CPU,速度上不去就不奇怪了。macOS 14 及以上还可以用 MLComputePlan 在代码里查询落点。

5. 断网跑闭环

关掉 Wi-Fi 和有线网络,带 HF_HUB_OFFLINE=1 跑第 6 步脚本 5 分钟,同时另开一个终端:

```bash

lsof -i -P | grep -i python

```

不应该看到任何对外的网络连接。同时观察输出的 p50、p95、qps 是否稳定。p95 远高于 p50 往往是系统在降频或有其他进程抢资源,不一定是模型的问题。

常见报错与解决

报错 1:ModuleNotFoundError: No module named 'coremltools'

→ 原因:虚拟环境没激活,或者包装到了系统 Python 上。

```bash

cd ~/jev-coreml && source .venv/bin/activate

pip install -U coremltools

python -c "import coremltools; print(coremltools.__file__)"

```

确认路径在 .venv 下面。

报错 2:OSError: We couldn't connect to 'https://huggingface.co'LocalEntryNotFoundError

→ 原因:已经开了离线变量,但本地缓存不完整(缺 tokenizer 的某个附加文件很常见)。

```bash

unset HF_HUB_OFFLINE TRANSFORMERS_OFFLINE

huggingface-cli download <官方仓库名> --local-dir ~/jev-coreml/models/jev

export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1

```

报错 3:RuntimeError: PyTorch convert function for op 'xxx' not implemented

→ 原因:trace 图里出现了 Core ML 当前版本不支持的算子。

```bash

先确认到底是哪个算子

python -c "

import coremltools as ct

ct.converters.mil.frontend.torch.test.test_torch_ops # 无实际作用,仅示意

"

python - <<'PY'

import torch

t = torch.jit.load("traces/jev.pt")

for n in t.graph.nodes():

print(n.kind(), n)

PY

```

拿到算子名后,用 coremltools 官方支持算子列表(以官方文档当前版本为准)核对。常规做法有三种:把该算子替换成等价组合、把 minimum_deployment_target 提高到支持它的版本、把这段计算挪到 Mac 侧的预处理代码里,不让它进 Core ML 图。

报错 4:Error compiling model / 模型版本不受支持

→ 原因:minimum_deployment_target 设得比本机 macOS 版本还新。

```bash

sw_vers

```

看实际系统版本,重转时把 target 降到不高于系统版本的值再试。

报错 5:Killed: 9 或进程被系统强制结束

→ 原因:内存不足。转模型阶段要同时持有 PyTorch 权重、trace 图和 Core ML 图,峰值远高于推理阶段。

```bash

缩短示例序列长度、改用更小的模型规格重转;转换前关掉占内存的应用

S=64 python convert.py

```

16GB 内存的机器转较大的模型时,务必留出余量。

报错 6:KeyError: 'input_ids' 或输入形状不匹配

→ 原因:调用时用的 key 或 shape 和转换时定义的不一致。

```bash

python -c "

import coremltools as ct

m = ct.models.MLModel('out/jev.mlpackage')

print(m.input_description)

print(m.get_spec().description)

"

```

以打印出的名字和形状为准去改调用代码。

报错 7:xcrun: error: unable to find utility "coremlcompiler"

→ 原因:只装了 Command Line Tools,没装完整 Xcode。

```bash

xcode-select -p

sudo xcode-select -s /Applications/Xcode.app

```

装好 Xcode 后用 xcrun -f coremlcompiler 确认能找到。

报错 8:qps 只有个位数,明显没吃到 Neural Engine

→ 原因:图里有动态形状、依赖输入的控制流,或者大块 fp32 算子。

检查顺序:确认输入 shape 是固定常量 → 确认 compute_precision=ct.precision.FLOAT16 → 去掉一切 iffor 依赖输入张量的分支 → 用第 4 项验证方法看算子落点。

后续维护

备份。 需要归档的是四样东西:Jev 原始权重、tokenizer 文件、转换脚本、requirements.lock。Core ML 的 .mlpackage 能重新生成,不必单独备份,但生成脚本必须留着。加一个校验和清单方便比对:

```bash

shasum -a 256 ~/jev-coreml/out/jev.mlpackage/**/* > ~/jev-coreml/logs/model.sha256

```

升级。 模型、coremltools、macOS 三者任一升级,都要重跑"数值一致性 + 性能基线"这两项回归,不能只看它能不能加载。建议先在小样本回归集上比对 PyTorch 与 Core ML 的输出,确认动作序列一致,再考虑切到正式流程。升级期间保留旧的 mlpackage 和对应的 lock 文件,随时能回滚。

日志。 决策日志建议用 JSONL,每条至少记录:时间戳、输入摘要、输出动作、延迟毫秒数、compute unit。第 6 步的 decisions.jsonl 已经给了最简版本。需要系统级日志时:

```bash

log stream --predicate 'process == "python"' --level info

```

监控。 盯四个指标:p50 与 p95 延迟、qps、超时降级次数、单次决策的能耗趋势。持续高频推理会让机器降频,p95 慢慢抬高是正常现象,可以在闭环里加一个节流上限,比如两次决策之间至少间隔若干毫秒,用可接受的一点延迟换稳定性。

降级策略。 端侧闭环一定要有兜底:推理超时或输出越界时,立刻切到规则引擎或返回安全动作。离线环境的优势是没有网络抖动,但内存压力、降频、模型版本不匹配都可能让单次推理卡住,兜底逻辑比刷高 qps 更重要。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。