把 ComfyUI 插件和 C 语言 HTTP 库绑在一起,听着有点混搭。但等你真正在 MacBook 上把 33B 视频模型拉起来,跑通一条能用的工作流,就会知道这个组合有多香。这篇笔记从一个侧面讲清楚:antirez 写的 h3.c 怎么被我塞进 ComfyUI 的自定义节点里,做成本地推理服务的轻量壳,把视频理解模型真正跑在本地。

先说结论:整个过程没有用 GPU 集群,没有云服务,就靠着一台 Apple Silicon MacBook 的统一内存。核心思路是借用 ComfyUI 的节点机制做交互层,用 h3.c 做进程间的本地回调,真正吃显存和算力的活儿交给 llama.cpp 或 MLX 跑的多模态推理进程。下面把设计、踩坑、代码一步步拆开聊。

1. 整体设计与思路拆解

1.1 本事到底要解决什么问题

ComfyUI 本身是个强大的节点式工作流引擎,但它默认只擅长管理生成类模型。视频模型里带理解能力的那批——比如视频问答、视频摘要、帧序列语义标注——往往需要一个独立的推理服务来跑。问题出在桥接:ComfyUI 的节点是 Python 进程,大模型后端多半是独立的本地服务,两者怎么高效通信?

我踩过几个方案。直接在节点里用 Python 的 http.client 轮询,功能能通,但每次请求都要处理连接生命周期,工作流一大就乱。塞一个 FastAPI 进去,等于给 ComfyUI 进程再套个伞,启动慢、权重和管理都变脏。后来看到 antirez 的 h3.c,眼前一亮:一个文件、零依赖的 HTTP 服务器库,编译成动态库后用 Python 的 ctypes 直接调用,刚好适合做本地回环服务。

h3.c 是 Redis 作者 Salvatore Sanfilippo(antirez)写的一个实验性项目。它的 API 极简,没有复杂路由,没有中间件,就是监听一个 TCP 端口、解析 HTTP 请求、回调你注册的 handler。你可以在插件进程里直接把它拉起来,作为模型后端的“接驳站”,负责接收 ComfyUI 工作流发来的视频帧或提示词,转发给真正的推理进程,再把结果原路送回。

1.2 为什么偏偏是 h3.c,而不是别的 HTTP 服务

不是因为它酷,而是因为它贴合“嵌入式”这个使用场景。ComfyUI 插件要的是一个陪伴生命周期极短的轻量服务,不是生产级 Web 服务。h3.c 编译后动态库仅几十 KB,在内存和 CPU 占用上几乎可以忽略。对比一下常规路数:

方案 启动速度 依赖 适合场景
Python http.server 一般 无 极简调试,性能最差
FastAPI + uvicorn 慢 一堆 重接口,复杂业务
Node.js Express 慢 一堆 不需要,和 Python 进程耦合麻烦
h3.c 动态库 极快 只需编译器 本地回环、轻量回调、嵌入式

实际测量下来,h3.c 单线程处理本地 HTTP 请求的延迟比 Python 内建 server 低一个量级。对于视频模型这种动不动几秒一次推理的场景,这个延迟差异不是关键,但它能保证高频率轮询时不把 Python 主线程拖死。另外一个容易被忽略的点是:h3.c 允许你把请求处理函数直接注册成 C 回调,数据在内存里零拷贝传递。我可以在 C 层做帧数据的简单转发,不必每次把字节流反复编码解码。

1.3 整体架构:谁在说话,谁在干活

要理解这个项目,最好的方式是把它拆成三层:

第一层是 ComfyUI 前端节点。用户在工作流里拖一个“视频语义理解”节点,输入一张或多张视频帧,输出一段文字描述。节点本身不加载大模型,它只是一个接线员,把工作流里的数据包装成请求。

第二层是 h3.c 起的本地回调服务。这段代码跑在 ComfyUI 进程内,负责接收节点发来的 HTTP 请求,然后通过进程或 Socket 转发给推理后端。为什么不让节点直接调用推理后端?因为推理后端可能被多个工作流共享,需要一个统一入口;也因为 llama.cpp 的 llama-server 走的是 OpenAI 兼容接口,节点直接调其实也行,但这样做会让节点和具体后端强耦合。加一层回调服务后,换后端只改配置,不用改节点。

第三层是推理进程。我在 MacBook 上用 llama.cpp 跑模型,启动 llama-server ,加载 33B 视频理解模型的量化权重和多模态投影文件。这一步是真正的算力核心,也是内存大户。

整条链路是单向的:ComfyUI 节点 → h3.c 回调服务 → 推理后端 → 返回 JSON → 节点解析 → 工作流继续。这个设计让三个组件都能独立替换,调试时也轻松。

2. 核心细节解析与实操要点

2.1 h3.c 的 API 长什么样

h3.c 的精髓在于 handler 注册。核心流程是:初始化一个 listener,指定端口和最大连接数,然后注册请求处理回调。回调里可以拿到请求路径、请求体、头部信息,填好响应体和状态码即可。下面是一段最简用法(C 层面视角):

#include "h3.h"

void handler(h3_request *req, void *privdata) {
    const char *body = h3_get_body(req);
    h3_response(req, 200, "{\"status\":\"ok\"}");
}

int main(void) {
    h3_server *srv = h3_init(9317, 32);
    h3_set_handler(srv, "/infer", handler, NULL);
    h3_run(srv);
    return 0;
}

我没直接把这套代码编成独立可执行文件,而是编成动态库,让 Python 通过 ctypes 调。这样不必多开一个进程,C 服务和 ComfyUI 节点共享同一个生命周期。

编译动态库的命令很简单:

clang -shared -fPIC -O2 -o libh3bridge.dylib bridge.c

用 clang 是因为 macOS 上自带,不用额外装 GCC。 -fPIC 是位置无关代码,动态库必须。编译好之后,用 otool -L libh3bridge.dylib 检查依赖,正常情况下只会看到系统库。

2.2 ComfyUI 自定义节点的骨架

ComfyUI 的自定义节点本质就是一个 Python 类,放在 custom_nodes/你的插件目录/ 下。目录里必须有 __init__.py ,里面定义 NODE_CLASS_MAPPINGS 和 NODE_DISPLAY_NAME_MAPPINGS 。最简结构如下:

custom_nodes/
└── comfy-video-understanding/
    ├── __init__.py
    ├── nodes.py
    ├── bridge.py
    └── libh3bridge.dylib

nodes.py 里定义一个继承自 ComfyNode 的类(实际上 ComfyUI 不强制继承,只要类里有 INPUT_TYPES 、 RETURN_TYPES 、 FUNCTION 、 CATEGORY 这些类属性就行)。关键点:

  • INPUT_TYPES 返回一个字典,定义输入参数的类型和必填项。要做成“视频帧”输入,可以用 IMAGE 类型,也可以直接接收文件路径字符串。
  • RETURN_TYPES 定义节点输出类型,视频理解模型返回的是文本,所以输出类型用 STRING 。
  • FUNCTION 指明实际执行的类方法名。
  • CATEGORY 决定节点在菜单里的位置,比如 "video/understanding" 。

这里有个细节:ComfyUI 的 IMAGE 类型是 torch.Tensor ,形状是 [batch, height, width, channel] 。你得把它转成推理后端能接受的格式,比如 PNG 字节流。这个转换最容易踩坑,后面细说。

2.3 33B 视频模型在 MacBook 上跑,内存和量化是生死线

33B 级别的视频理解模型,直接上 FP16 权重,光权重就要 66GB,在绝大多数笔记本上没法玩。所以必须量化。Apple Silicon Mac 上走 llama.cpp 的 GGUF 量化是一条成熟路线。

我跑下来,4bit 量化(Q4_K_M)的 33B 模型,权重文件大约 20GB 出头,再加多模态投影文件 mmproj 几 GB,总占用不到 25GB。如果你的 MacBook 是 32GB 统一内存,会很紧张;64GB 版本就能比较从容地留出系统余量。内存带宽反而是更大的约束。视频理解模型处理长序列时,每 token 都要把权重从头到尾扫一遍,内存带宽直接决定生成速度。M 系列 Pro/Max 的带宽能跑到 200GB/s 以上,基本可用;老款 Intel Mac 或 M1 基础款,建议把序列长度压短。

这里有一个“别死磕精度”的忠告:33B 模型在 4bit 下,视频理解任务的回答质量依然能打,但长视频的细节会有损失。我在实测里发现,5bit 量化(Q5_K_M)对比度、时间戳定位能力明显更稳,代价是内存多占 4~5GB。如果你的机器内存充足,建议上 Q5。

推理后端我用的是 llama.cpp 的 llama-server ,因为它自带 OpenAI 兼容接口,代码里只需配置 base_url 和 api_key ,一行都不用改推理逻辑。启动命令如下:

llama-server \
  -m Qwen2.5-VL-33B-Q4_K_M.gguf \
  --mmproj Qwen2.5-VL-33B-mmproj-f16.gguf \
  --host 127.0.0.1 \
  --port 8082 \
  --ctx-size 8192

加载模型时多模态部分用的是 --mmproj ,这个是 llama.cpp 对视觉语言模型的标准做法。如果模型是纯视频理解而非视觉,同样道理,只是输入从单帧变成多帧组合。32GB 内存的话, --ctx-size 建议压到 4096,否则容易触发系统级内存压缩,速度断崖式下跌。

2.4 插件和 h3.c 怎么协作:连接机制的细节

h3.c 回调服务和插件进程是同一个 Python 进程,这意味着如果你直接在 h3.c 回调里做重活,会阻塞 ComfyUI 的节点执行线程。我采用的方式是:h3.c 回调只做两件事——接收请求、把请求体写进一个线程安全队列,然后立刻返回“已接收”。真正调用推理后端的动作放在另一个后台线程里,完成后单独回调节点。这样工作流不会被卡死。

在 Python 侧,我用 threading.Thread 起一个后台循环,从队列里取请求,再交给 httpx 请求 llama-server。由于 llama.cpp 的接口是标准的 /v1/chat/completions ,我可以把视频帧转成 base64 塞进消息内容里,也能直接把多帧拼成视频输入。

这里有一个关键决策:视频帧怎么传给后端?两种方案:一是逐帧传 base64,简单但 token 消耗巨大;二是本地抽帧,只抽关键帧,把帧路径传给后端,让后端用 mmproj 自己处理。我最终用了后者,因为视频理解模型内部有自己的处理逻辑,给它原始帧序列比给它一张拼接图靠谱得多。抽帧策略我在后面 3.3 节展开。

3. 实操过程与核心环节实现

3.1 编译 C 动态库,以及验证是否可用

先把 C 桥接层写出来。我不会展示完整的 h3.c 源码,那个可以去 antirez 仓库看,这里只说明我的封装层。桥接层暴露三个函数给 Python:

  • bridge_start(port, max_conns) :初始化服务并启动后台监听线程。
  • bridge_register_handler(path) :绑定某个路径到 Python 侧的回调。
  • bridge_stop() :停止服务。

C 代码里已经封好了从 Python 回调到 C 的跳转。ctypes 加载动态库后用 CFUNCTYPE 传递回调指针,这一步最麻烦的是回调签名要对上,否则段错误。实际示例:

import ctypes

lib = ctypes.CDLL("./libh3bridge.dylib")

# 定义回调类型:接收请求体指针、长度,返回响应字符串
CALLBACK = ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int)

@CALLBACK
def python_handler(body, length):
    data = body[:length]
    # 解析 JSON,调用推理后端
    result = infer(data)
    return result.encode("utf-8")

lib.bridge_register_handler(b"/infer", python_handler)
lib.bridge_start(9317, 64)

编译前记得在 C 代码里声明回调为 extern ,并且确保 Python 侧持有回调对象的引用——不然 Python 垃圾回收器把回调对象回收了,C 层还在用,必崩。这是我踩过最坑的一次段错误。

验证动态库是否正常,可以直接写一个小 Python 脚本,调用 bridge_start 然后 curl http://127.0.0.1:9317/infer ,看到响应后再把服务关掉。这一步跑通,再接入 ComfyUI。

3.2 插件节点代码:从 IMAGE 到 HTTP 请求的完整链路

节点代码的核心是这个函数:

def run_inference(self, images, prompt, max_tokens):
    frames = self.prepare_frames(images)  # 抽帧并编码为 JPEG/PNG
    payload = {
        "frames": frames,
        "prompt": prompt,
        "max_tokens": max_tokens
    }
    # 通过本地回环服务转发
    resp = bridge_request("/infer", json.dumps(payload))
    return (resp["text"],)

prepare_frames 这一步很关键。从 ComfyUI 拿到的 images 是 tensor,需要从 GPU 或 CPU 内存里转成 numpy 数组,再转成 PNG 字节流。我用了 PIL.Image.fromarray ,把每帧缩放到一个固定尺寸(比如 448x448),避免过大的帧把上下文窗口撑爆。视频模型通常不在乎分辨率,更在乎时序一致性,所以抽帧间隔要合理。

节点里还必须处理并发安全。ComfyUI 允许并行执行节点,我加了一个 threading.Lock 保护桥接请求,避免两个节点同时写同一连接导致数据交叉。

3.3 让后端真正跑起来:抽帧策略和上下文窗口的匹配

视频理解模型处理视频,本质上是把多帧图通过 mmproj 映射成连续 token。假设每帧被编码成 256 个 token,一个 20 帧的视频片段就是 5120 个 token,这还没算提示词。如果模型上下文窗口只有 8192,那留给回答的空间就很少。所以我抽帧的策略是:

  • 视频少于 10 秒:每 1 秒抽 1 帧。
  • 视频 10~30 秒:每 2~3 秒抽 1 帧,总量控制在 15 帧以内。
  • 视频超过 1 分钟:先取前 30 秒做密集抽样,后面的内容抽关键转场,总量最多不超过 24 帧。

这个策略不是我拍脑袋定的,而是反复测试后总结的。视频模型对时间戳定位比较敏感,如果帧太少,它会分不清事件先后;帧太多,回答质量会被上下文稀释。24 帧是一个比较稳的甜点:token 占比合理,模型也能感知大致时间线。

后端返回的 JSON 里除了文字内容,我还会塞一个 usage 字段,里面有 prompt_tokens 和 completion_tokens 。用这个来微调抽帧密度,形成闭环:如果 prompt_tokens 超过上下文的 70%,自动降低帧数;如果过低,说明模型没看够,就加大帧数。这个自动调节逻辑我用了几周,效果比固定值好很多。

3.4 端到端工作流:从拖节点到跑通一条完整链路

实际使用时分三步。

第一步,把视频文件放到工作流里。可以用 ComfyUI 自带的 LoadVideo 节点(社区有实现),也可以直接用 LoadImage 加载某几帧关键图。如果用的是视频理解而不是纯视觉理解,建议走视频路径,因为帧顺序本身就是信息的一部分。

第二步,拖入我写的 VideoSemanticUnderstand 节点,把视频输入和它连起来。右侧可以设置提示词前缀,比如“分析这段视频中人物的动线、场景变化和关键事件,按时间顺序输出”。节点里的人工提示词对结果影响巨大,我建议写成结构化描述模板,而不是自然语言长句。

第三步,把输出用 SaveText 节点存到本地,或者连到文本展示节点预览。如果后面还有下游生成任务,比如根据总结生成分镜脚本,就把 STRING 输出直接接给支持文本输入的节点。

一条完整工作流的节点连接大概是:视频加载 → 抽帧 → 理解节点 → 文本保存。整体跑一次,33B 模型在 MacBook 上处理 30 秒视频,耗时大概 2~4 分钟,其中抽帧不到 5 秒,大头全在推理。

4. 常见问题与排查技巧实录

4.1 端口被占用:h3.c 起不来,ComfyUI 报错

我用 h3.c 默认监听 9317 端口,有次只杀掉了 llama-server,忘了停掉旧插件进程,导致新实例绑定失败。排查方法很老套:

lsof -i :9317
kill -9 <PID>

这个问题的根因是插件进程退出并不一定回收端口。如果你反复调试插件代码,最好在插件启动前强制清理端口。我在 bridge_start 里加了个参数,先关闭旧监听再建新监听,能省掉不少烦心事。

4.2 ctypes 段错误,Python 进程直接崩

这是最严重的问题,通常发生在回调注册后。原因是 CFUNCTYPE 回调对象被 Python GC 回收了。Python 侧必须要用一个全局变量持有回调:

global _callback_holder
_callback_holder = python_handler

另一个原因是 C 侧回调函数里访问了未初始化的内存。h3.c 的请求体不保证以 \0 结尾,所以我的回调签名里必须传入长度,C 层在调用 Python 回调时把这个长度也传过去,Python 侧用 body[:length] 切片。不要直接 body.decode("utf-8") ,会概率性读越界。

4.3 内存不足:MacBook 卡成幻灯片

现象是模型加载时提示内存压力,系统开始疯狂交换。我最初的教训是把 --ctx-size 设成 16384,结果 33B 的 KV cache 直接暴涨到近 10GB,加上权重 20GB 出头,把 32GB 内存挤爆。后面收敛到 4096 就舒服多了。

另一个优化点是把 --mlock 打开。llama.cpp 的 --mlock 会把 模型权重锁在物理内存里,避免被交换出去。虽然代价是启动慢一点,但对于 20GB 以上的模型来说,锁住内存反而保证了推理稳定。如果开机后上述操作后内存还是吃紧,就把量化等级从 Q5 降到 Q4,或者把上下文再砍一半。

4.4 视频理解结果特别差,像在胡编

如果模型返回的内容和视频内容对不上,90% 的情况不是模型问题,而是输入帧质量问题。帧被缩得太小、JPEG 压缩率太高、或者抽帧间隔太密导致重复帧太多,都会让模型“看不清”。我的建议是统一走 PNG 格式,保留更多纹理细节;缩放尺寸别低于 336 像素;抽帧后先去重——用简单的感知哈希对比相邻帧,重复的直接扔掉。

另外注意,提示词里要明确告诉模型“你是视频理解模型,描述的是我看到的一系列帧,按时间顺序排列”。不然模型容易把单帧图误当成静态图来描述。

4.5 ComfyUI 不识别插件:节点列表里没有

先检查目录结构。 custom_nodes/ 下每个插件必须是独立目录,而且 __init__.py 里要正确导出 NODE_CLASS_MAPPINGS 。常见错误是忘记添加 NODE_DISPLAY_NAME_MAPPINGS ,虽然不影响运行,但节点菜单里会显示类名,很难看。

如果插件依赖了额外 Python 包,比如 httpx ,ComfyUI 默认的 Python 环境可能没有装。用 pip install httpx 装到 ComfyUI 运行环境里,别装到系统环境。macOS 上如果你用虚拟环境跑 ComfyUI,记得先 source 激活再安装。

5. 这个方案还能怎么玩

h3.c 的好处是“小”,所以同样一套封装思路完全可以复用到别的模型上。换成一个 LLM 做视频摘要生成中间步骤,只需要改注册的回调函数,不用动 C 代码。换成一个音频模型做帧级语音识别,也只是改 prepare_frames 为 prepare_audio ,桥接层完全复用。

我个人在实际使用中最满意的一点是:整个插件没有任何外部重型依赖,不依赖 Docker,不依赖云 API,不依赖额外运行时。你要复现,只需要有 clang、Python 3.10+、ComfyUI 和对应的 GGUF 模型文件。对比那些动不动要求你装 CUDA、配虚拟环境的方案,这条本地链路清爽太多。

再分享一个小技巧:h3.c 里可以注册多个 handler,我把 /health 也注册了,ComfyUI 节点在启动时会自动探测后端是否活着。如果没探测到,节点会直接给出友好提示,而不是等推理超时了才报错。这个思路适合任何方案,给自己的桥接服务加个心跳接口,调试时你会感谢当初的自己。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐