用h3.c为ComfyUI打造轻量本地视频理解推理服务
把 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 节点在启动时会自动探测后端是否活着。如果没探测到,节点会直接给出友好提示,而不是等推理超时了才报错。这个思路适合任何方案,给自己的桥接服务加个心跳接口,调试时你会感谢当初的自己。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)