适用场景
现场设备或内网环境不允许把数据发到云端,但又需要一个能"看状态、出动作"的决策模型跑在本地,延迟要求压到秒级以内甚至几十毫秒。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 → 去掉一切 if、for 依赖输入张量的分支 → 用第 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 更重要。
