用 h3.c 和 ComfyUI 在 MacBook 上搭建本地大模型工作流
antirez 放出 h3.c 那天我正好在为 MacBook 上跑 33B 视频模型的事发愁。h3.c 是 Redis 作者写的一个极简神经网络实现单文件、纯 C没有 CUDA、没有 PyTorch硬是把一个能生成连贯文本的小模型跑了起来。我当时的第一反应是这东西要是封装成 ComfyUI 插件是不是就能在节点图里拖一个“本地模型生成器”把 MacBook 上跑大模型这件事变成可视化工作流的一环再往下想如果管道打通了那 33B 视频模型是不是也能走同一条路答案是能但中间坑实在不少。这篇笔记就是完整的工程记录写给想在 MacBook 上本地跑大模型、又想用 ComfyUI 做统一操作界面的朋友也写给想快速上手自定义节点开发的 ComfyUI 玩家。1. 项目动机与整体设计思路1.1 h3.c 为什么是个绝佳的“后端样板”h3.c 的代码量大概在一千行上下只依赖标准 C 库。它实现了完整的 transformer 推理链路embedding、多头注意力、LayerNorm、FFN还自己带了一个极简 tokenizer。模型权重最终落成一个二进制文件推理时加载权重、读 prompt、逐 token 往外吐。我选中它不是因为要拿它做生产级应用而是因为它把一个完整的“本地生成模型”压缩到了最小的可运行单元。对封装 ComfyUI 节点来说这简直是最理想的测试对象。我需要验证的事情其实不多编译是否顺利、子进程能不能稳定通信、模型文件怎么管理、超时和崩溃怎么处理。这些事如果直接上一个 33B 的量化视频模型排查起来会非常痛苦。先用 h3.c 把整条链路走通后面换后端只是替换一个可执行文件的事。antirez 这个项目还有个好处它支持训练。虽然训练能力不如 PyTorch 灵活但能在 MacBook 上用很短时间训练出一个小模型对调试节点特别有用。你想测试节点逻辑不用到处找模型文件自己训一个几十 MB 的模型就能跑。1.2 ComfyUI 的自定义节点扩展模型ComfyUI 表面上是画图工具本质是一个“Python 进程 节点注册表”的工作流引擎。每个自定义节点就是一个 Python 类定义输入端口、输出端口和实际执行函数ComfyUI 启动时会自动扫描custom_nodes目录并加载。这种设计有个很实际的好处节点内部可以藏任意复杂的逻辑。我在节点里可以起子进程、读文件、调系统命令只要最终返回正确的输出类型就行。ComfyUI 不会管你背后是 Stable Diffusion 还是 llama.cpp它只关心节点能不能连进图里、数据能不能流转下去。这意味着我不需要为 h3.c 单独写 UI不需要做前端生成按钮直接复用 ComfyUI 自带的“队列执行”就行。用户在节点图里拖入节点、填 prompt、点运行结果就会出现在输出端口上。把 h3.c 塞进这个体系后后续接视频模型时外围框架一点不用动。1.3 MacBook 上跑 33B 模型的路线图先算一笔账。33B 参数量的模型如果用 float16 全精度保存光权重文件就要 66GB 左右普通 MacBook 根本装不下。量化为 Q4_K_M 后大概 19 到 20GBM1/M2/M3 系列的高配机型才能摸到门槛。这里的关键在于苹果的统一内存架构。CPU 和 GPU 共享同一块物理内存模型权重加载到内存里就等于同时进了“显存”不需要像独显那样通过 PCIe 总线搬来搬去。这个特性让 MacBook 跑大模型的体验很特别瓶颈不在显存容量而在内存总带宽。所以我的路线很明确用 llama.cpp 或 MLX 作为后端进程加载量化模型ComfyUI 节点负责传递参数和读取结果。先拿 h3.c 验证节点管道再切到量化后的 33B 视频模型最后把视频帧还原成可预览的内容。每一步都有独立的技术难点下面逐个展开。2. 先把 h3.c 编译跑起来2.1 编译参数对性能的影响h3.c 的编译非常简单在 MacBook 上直接一行命令搞定clang -O3 -marcharmv8.5a h3.c -o h3我没有用默认的gcc而是指定了clang因为 Apple Silicon 上 clang 的性能优化更到位。-marcharmv8.5a这个参数容易被忽略但它能启用 CPU 支持的新指令集对矩阵运算和向量化有明显帮助。实测下来同样的模型和 prompt加了这两个优化参数后生成速度能提升三成以上。编译出来的可执行文件也就几百 KB不依赖任何动态库。这个特性很重要因为这意味着我可以把这个二进制文件直接丢到插件的目录里用户机器上只要有标准 C 库就能跑。2.2 训练和推理的最小闭环h3.c 的使用方式分两个阶段先训练再推理。训练时需要指定数据文件、输出权重路径和一些超参数。大致命令长这样./h3 train -i stories.txt -o model.bin -s 12345 ./h3 generate -m model.bin -p Once upon a time具体参数名可能随源码版本变化以仓库 README 为准。我的建议是第一次调试不要直接用正式数据先把训练文本切成几千行把上下文长度调小几分钟内跑出一版小权重目的是验证节点调用逻辑。等节点跟 h3.c 的通信完全稳定了再拿更高质量的数据训练正式模型。顺便说一句h3.c 生成文本的质量上限不高毕竟模型规模和训练数据都很小。但它胜在足够快、足够简单我在 M1 MacBook 上跑一个小模型生成几百个 token 只要几秒。调试 ComfyUI 节点时这种“快速反馈”比什么都重要。2.3 为什么选子进程而不是 Python 绑定封装 C 库到 Python标准做法是用 ctypes 或 Cython。我刚开始也考虑过这条路但很快放弃了。原因有三个。第一隔离性。c_types 直接加载动态库后如果模型内部崩溃整个 ComfyUI 进程都会被带走。子进程方式下模型崩了最多是那个子进程退出ComfyUI 主程序不受影响。第二内存回收。子进程结束后操作系统会完整回收内存不会出现 Python 进程里部分内存块无法释放的问题。对于需要频繁加载和卸载模型的工作流来说这点很关键。第三后端可替换性。如果我把“调用模型”抽象成“调用一个命令行程序”那 h3.c、llama.cpp、MLX 脚本都可以用同一套接口适配。以后想换模型后端只需要替换可执行文件和参数约定节点代码完全不用动。有人担心子进程通信有性能开销。实际上生成任务是典型的“计算五分钟、通信 1KB”prompt 传进去几秒钟结果传回来几十 KB通信开销可以忽略不计。3. ComfyUI 自定义节点开发实战3.1 插件目录结构与注册机制插件放在ComfyUI/custom_nodes/下面我建了一个h3_comfy目录。最基本的文件结构长这样custom_nodes/ └── h3_comfy/ ├── __init__.py ├── nodes.py └── backend/ └── h3__init__.py必须把节点类暴露给 ComfyUIfrom .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS __all__ [NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS]nodes.py里定义节点类和注册映射。一个非常常见的坑是只定义了节点类但忘了加NODE_DISPLAY_NAME_MAPPINGS或者映射的类名和实际类名不一致。ComfyUI 加载插件时不会报致命错误就是节点列表里找不到你的节点排查起来很费时间。3.2 节点输入输出定义我定义的这个节点叫H3LocalGenerator核心代码框架如下class H3LocalGenerator: classmethod def INPUT_TYPES(cls): return { required: { prompt: (STRING, {multiline: True, default: Once upon a time}), max_tokens: (INT, {default: 256, min: 16, max: 4096}), temperature: (FLOAT, {default: 0.8, min: 0.1, max: 2.0}), model_path: (STRING, {default: models/story_model.bin}), } } RETURN_TYPES (STRING,) FUNCTION generate CATEGORY local models/text def generate(self, prompt, max_tokens, temperature, model_path): # 调用后端 h3返回生成文本 return (text,)RETURN_TYPES定义输出类型必须和generate方法的返回值完全对应。定义的是一个字符串输出函数就返回一个含单个字符串的 tuple这一点写错经常导致莫名其妙的类型错误。3.3 子进程调用的关键细节节点内部的generate方法负责启动 h3 进程并通信。我用了subprocess.Popen而不是os.system主要原因是安全os.system会把字符串交给 shell 解释如果 prompt 里包含特殊字符轻则出错重则命令注入。Popen直接传参数列表彻底绕开 shell。通信约定是prompt 通过标准输入传给 h3生成结果从标准输出读取。伪代码大致是import subprocess def generate(self, prompt, max_tokens, temperature, model_path): cmd [./backend/h3, generate, -m, model_path, --max-tokens, str(max_tokens), --temperature, str(temperature)] proc subprocess.Popen( cmd, stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue, encodingutf-8 ) try: out, err proc.communicate(inputprompt, timeout30) except subprocess.TimeoutExpired: proc.kill() raise RuntimeError(h3 进程超时已强制终止) return (out.strip(),)几个容易踩的细节。第一timeout必须有否则模型卡死时节点会无限挂起整个工作流都停在那里。第二stderr一定要捕获排查问题时没有日志会非常痛苦。第三编码统一用 UTF-8Python 默认编码在不同系统上有差异显式指定更稳妥。3.4 从 h3 到 33B 视频模型的后端抽象等 h3.c 节点跑通后我立刻做了一个后端抽象。定义一个统一的调用协议所有后端接收 prompt、seed、max_tokens 这类通用参数输出文本或文件路径。对于 33B 视频模型节点输入里增加video_size、frame_count、fps等参数。节点本身不关心模型内部怎么扩散、怎么去噪它只负责把参数组装成命令行然后把生成结果返回给工作流。这一步的收益在后期特别明显我在不修改节点 UI 的情况下先后换了 h3.c、llama.cpp、MLX 三个后端。本质上就是替换了内部的命令拼接逻辑ComfyUI 节点的输入输出完全不变。4. MacBook 上 33B 模型的工程适配4.1 先给内存算笔账跑 33B 模型之前先得搞清楚内存预算。下面是不同量化级别的理论占用空间量化格式理论权重占用32GB 内存机器建议Q2_K约 9GB能用但质量下降明显不推荐Q4_K_M约 19-20GB最推荐能留出系统余量Q5_K_M约 24GB可尝试需关闭其他大内存应用Q8_0约 34GB32GB 机器会吃紧适合 48GB 以上表格里只是权重本身的占用。实际加载模型还要算上 KV cache 和推理过程中的临时张量。上下文长度设为 4096 时KV cache 可能额外吃 2 到 6GB。所以我的建议是第一次跑 33B 模型直接用 Q4_K_M先把链路验证通再考虑更高精度的量化。这个计算方式也解释了为什么 8GB 内存的 MacBook 跑 33B 基本没戏。权重都放不下谈优化没有意义。4.2 llama.cpp Metal 的实战参数llama.cpp 是跑量化模型最成熟的后端。在 MacBook 上关键是让所有层都走 Metal GPU 加速./llama-cli -m model-Q4_K_M.gguf -ngl 999 -p Your prompt-ngl 999的意思是“尽可能多地把层 offload 到 GPU”。这里的阈值不是随便设的它决定了权重是留在 CPU 内存里还是跑到 GPU 计算单元上。忘记加这个参数时模型会在 CPU 上硬算速度会慢到让人怀疑人生。加了之后M1 Max 跑 Q4 33B 文本模型生成速度大概在每秒 6 到 8 个 token 的水平。对于视频模型瓶颈不在单次矩阵乘法而是采样步数和 VAE 解码。扩散模型要迭代几十步每步都要完整跑一遍网络这才是耗时大头。所以视频生成的优化方向不是单纯堆 GPU 频率而是减少步数、用更高效的采样器。4.3 MLX 的懒加载优势Apple 自家的 MLX 框架在 Apple Silicon 上表现很惊艳。它支持懒加载和惰性求值某些算子可以按需计算内存峰值比 llama.cpp 还低一些。加载 33B 模型时MLX 能通过内存映射方式读取权重不会出现“先全量载入再转格式”的瞬时峰值。我写了一个简单的mlx_generate.py脚本作为后端。它的工作方式是从标准输入读 JSON 参数加载模型生成结果然后把结果路径写到标准输出。ComfyUI 节点只需要调用这个脚本就能把 MLX 后端无缝接进工作流。MLX 唯一的门槛是它只支持 Apple SiliconIntel Mac 用不了。不过话说回来想在 MacBook 上跑 33B 模型Intel 老机型的内存带宽也确实不够看。4.4 视频生成链路的完整闭环视频模型在 ComfyUI 里的典型链路是文本提示词 → 文本编码 → 视频扩散模型 → VAE 解码 → 帧序列 → ffmpeg 合成 MP4。我在节点里把“等待后端生成结果”实现成一个阻塞步骤。后端进程完成后输出一个 MP4 文件路径节点读取这个路径ComfyUI 就能直接预览。中间环节的错误处理集中在两处一是检查生成文件是否存在二是验证文件大小是否合理。实际跑的时候我建议先用 480p、6 到 8 帧这种小配置验证链路。直接上 720p 长视频一旦中间某步内存爆掉整个工作流崩溃且日志难查。等小配置稳定了再逐步加分辨率。5. 踩坑记录与排查速查表5.1 节点加载失败但 ComfyUI 没报错这是最常见的坑。ComfyUI 启动时会加载 custom_nodes 下的插件但加载失败的提示经常只出现在终端日志里UI 上没有明显提示。排查路径是先看 ComfyUI 启动日志中有没有ImportError或Failed to load之类的字样再检查__init__.py里的导入路径是否正确。另一个容易被忽略的地方节点类如果命名为H3LocalGenerator注册时映射的键名也要一致。ComfyUI 的节点搜索框是按注册名搜索的大小写不匹配直接找不到。5.2 内存爆掉被系统杀掉MacBook 的内存压力高到一定程度系统会直接杀死进程而且不给你讨价还价的机会。排查时用两个命令sysctl hw.memsize memory_pressurememory_pressure会显示系统内存压力的百分比。如果长期徘徊在 80% 以上说明内存快撑不住了。解决顺序是先换更低比特的量化再缩短上下文长度最后关掉浏览器等后台大内存应用。千万不要想着靠 swap 硬撑MacBook 的 swap 写多了 SSD 寿命会受影响。5.3 子进程卡死无响应h3 或 llama.cpp 进程卡死最常见原因是权重文件损坏或模型路径不对。节点里的timeout参数这时就是救命稻草。我设的 30 秒超时超时直接proc.kill()然后把 stderr 内容塞进异常信息抛出来。还有一个隐蔽问题模型文件虽然存在但大小跟预期差太多。比如下载中断导致的残缺文件加载时可能既不报错也不干活就在那里空转。所以我在节点里加了一层“文件大小检查”低于预期就直接报错不让后端进程有机会死等。5.4 视频生成花屏或黑屏这个问题跟 h3.c 无关是接视频模型之后遇到的。排查经验有三条第一VAE 精度问题。某些视频模型的 VAE 在默认 float32 下会异常切到 bf16 或 fp16 就能恢复。第二帧数太少。少于 6 帧时模型很难学到时间维度的连续性容易出现闪跳和花屏。把帧数提到 8 帧以上通常会改善。第三种子问题。固定 seed 做对比测试时如果首帧本身有噪声异常后续所有帧都会受影响。换一个 seed 或改用随机 seed 再试。5.5 发热降频导致的性能衰减MacBook 跑 33B 模型时发热很猛尤其是视频生成那种长时间高负载任务。系统检测到温度过高会自动降频表现就是“生成速度越来越慢”。物理散热是最有效的办法垫高机身、保证通风、插上电源适配器。软件层面能做的有限主要是减少同时运行的其他 Metal 应用把带宽让给推理任务。我整理了一张速查表方便以后遇到问题快速定位问题现象大概率原因解决办法节点列表找不到自定义节点注册映射缺失或类名不匹配检查NODE_DISPLAY_NAME_MAPPINGS和日志启动即崩溃插件导入路径错误看终端的 Python traceback内存压力飙升后进程被杀模型量化级别太高换 Q4_K_M缩短上下文生成停顿在 90% 不动后端子进程卡死检查 timeout 是否会触发看 stderr视频花屏VAE 精度异常或帧数过少VAE 切 bf16帧数至少 8速度越跑越慢系统降频改善散热插电关后台 Metal 应用6. 工程之外的一些实际体会做这个项目最大的感受是先跑通最小闭环的效率远高于一开始就追求完整功能。我第一次让 h3.c 在 ComfyUI 节点里吐出完整句子时只花了一个晚上。那个节点只有最基础的 prompt 输入和纯文本输出没有任何额外功能但正是这个“丑”版本帮我验证了整条管道是通的。之后所有迭代都是在这个骨架上加东西换后端、加参数、接视频解码。如果没有 h3.c 这个极简样例直接上 33B 视频模型我大概率会在环境配置和通信调试上耗掉几天还未必能找到问题出在哪个环节。关于 MacBook 本地跑大模型我的观点是不要被“跑不起来”的传言吓住。量化、内存映射、Metal 加速这三板斧用上33B 模型在配置合适的 MacBook 上确实能跑速度也能接受。但要认清定位本地推理换来的不是速度优势而是隐私可控、断网可用、反复实验不花 API 费用这些实实在在的价值。如果让我给后来者一个建议第一次动手别贪大。先拿 h3.c 这种小模型练手把 ComfyUI 自定义节点的开发流程吃透再上 33B 量化模型。等你能熟练地把任意本地模型封装成节点“能在 MacBook 上跑什么”这件事的限制就会小很多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →