给自媒体团队写了个画布式 AI 工作流,500 任务/分钟,pgvector 真香
朋友前两天找我吐槽:他做口播号,电脑里装了 ChatGPT、Midjourney、Kling、HeyGen、飞瓜五个工具,每条视频要在工具间复制粘贴七八次,一天出三条已经是体力极限。
我说我们刚好做了一个东西,把这五件事塞进同一块画布。配置一次、批量跑、结果回流,4 核 8G 服务器就能跑。他试了两周,给我发了一条消息:"一天 12 条,停不下来。"
这篇文章把我们这套东西的架构、踩过的坑、关键性能数据完整地讲一遍。
TL;DR(给赶时间的同学)
- 项目叫 三千AI智能体,是一个面向自媒体/MCN 的私有化 AI 工作台
- 技术栈:NestJS + Nuxt 4 + PostgreSQL 17.6(pgvector + zhparser)+ Redis 8.2.2 BullMQ + Docker Compose
- 核心:画布式工作流引擎,20 个节点串起脚本 → 配图 → 配音 → 数字人合成
- 单机 4C8G 实测:200 流式连接 / 500 任务每分钟队列吞吐 / 1000–2000 日活承载
- 项目地址:github.com/2961799660/…,演示:ai1.zijie.lol
一、为什么不用现成的 LangGraph / Dify / n8n?
这是被问得最多的问题,先回答:
| 工具 | 为什么没直接用 |
|---|---|
| Dify | 偏对话/RAG 编排,画布交互弱,二开成本高 |
| n8n | 通用自动化,对图片/视频长任务调度不友好 |
| LangGraph | 偏后端 DAG,没有给运营人员看的可视化画布 |
| Coze | 不能私有化,企业客户卡死在这一条 |
我们的核心需求是:运营/剪辑能直接拖拽、长任务(5min+ 视频生成)异步可恢复、完全私有化。三条画死了,于是自己写。
二、整体架构图
flowchart LR
User((用户)) -->|HTTPS| Nginx
Nginx -->|3000| Nuxt[Nuxt 4 SSR/SPA<br/>画布前端]
Nginx -->|4090| API[NestJS API<br/>BullMQ 生产者]
API --> PG[(PostgreSQL 17.6<br/>pgvector + zhparser)]
API --> Redis[(Redis 8.2.2)]
Redis --> Worker[BullMQ Worker<br/>长任务消费]
Worker -->|HTTP| Models[模型 API 矩阵<br/>GPT/Claude/Kling/Seedream...]
Worker --> PG
Worker -->|WebSocket| Nuxt
一台 4C8G 全跑得动,PostgreSQL 同时承担业务库 / 向量库 / 中文全文检索三件事,省掉 Milvus + ES 两个组件,资源占用减少 60% 。
三、画布工作流引擎:核心抽象只有三层
第一版我们差点掉进过度设计的坑,写了 8 层抽象,后来推倒重来。最终的核心抽象只有三层:
// 1. 节点定义(运行时拿到的执行单元)
interface CanvasNode {
id: string
type: NodeType // 'image_gen' | 'lip_sync' | ...
config: Record<string, any>
status: NodeStatus // 'pending' | 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled'
inputs: NodePort[]
outputs: NodePort[]
}
// 2. 边(DAG 依赖关系)
interface CanvasEdge {
from: { nodeId: string; portId: string }
to: { nodeId: string; portId: string }
}
// 3. 视图层(坐标/样式,跟业务彻底解耦)
interface CanvasView {
nodeId: string
x: number; y: number
width: number; height: number
collapsed: boolean
}
血泪教训 1:视图层一定要跟业务分开存。我们最早把 x/y 坐标塞在 config 里,结果有一次产品要做"自动布局",改一个字段全表迁移,加班加到吐。
血泪教训 2:节点状态机要写成 6 态枚举,不要用 running: boolean。后来 BullMQ 重试、用户取消、Worker OOM 三种场景一上来,布尔值彻底不够用。
四、20 个内置节点是怎么组织的?
┌────────────────────────────────────────────┐
│ 🎨 图像 (9) │
│ 图像生成/局部重绘/画面扩展/超分/白底图 │
│ 自动蒙版/局部替换/写实化/图像融合 │
├────────────────────────────────────────────┤
│ 🎬 视频 (6) │
│ 文生视频/视频剪辑/视频合成 │
│ 分镜串联/首尾帧/去水印 │
├────────────────────────────────────────────┤
│ 👤 数字人 (2) │
│ 口型同步/虚拟数字人生成 │
├────────────────────────────────────────────┤
│ 🔧 其他 (3) │
│ 音频处理/文本处理/音频处理 │
└────────────────────────────────────────────┘
每个节点抽象成一个 BullMQ Job:
// nodes/image-gen.processor.ts
@Processor('image-gen')
export class ImageGenProcessor {
@Process()
async handle(job: Job<ImageGenPayload>) {
const { prompt, model, size } = job.data
// 1. 调模型
const result = await this.modelHub.invoke(model, { prompt, size })
// 2. 写对象存储
const url = await this.oss.upload(result.buffer)
// 3. 写数据库 + 触发下游
await this.nodeService.markSucceeded(job.data.nodeId, { url })
await this.eventBus.emit('node.completed', { nodeId: job.data.nodeId })
}
}
关键设计:节点之间不直接调用对方,全部通过 node.completed 事件触发,DAG 引擎拿到事件后判断下游是否所有依赖都满足,再 enqueue。这样任意中间节点重试都不会污染上下游。
五、ZIP 模式 vs CROSS 模式
这是被运营同学疯狂表扬的两个模式:
ZIP 模式(一对一批量)
prompts: [A, B, C]
sizes: [1024, 768, 512]
=> 跑 3 个任务: (A, 1024), (B, 768), (C, 512)
CROSS 模式(参数交叉)
prompts: [A, B, C]
sizes: [1024, 768]
=> 跑 6 个任务: (A,1024)(A,768)(B,1024)(B,768)(C,1024)(C,768)
实现关键:参数级别的笛卡尔积要在调度器里做,不能让前端拼,否则 6×6×6 = 216 个任务前端连发会被 Nginx 限流。
六、为什么选 PostgreSQL 一把梭?
很多人第一反应是 "向量库就该用 Milvus / Qdrant"。我们一开始也这么想的,跑了两周以后果断切回 pgvector,原因:
| 维度 | pgvector | 独立向量库 |
|---|---|---|
| 事务一致性 | 业务表和向量表同库事务 | 双写一致性自己维护 |
| 运维成本 | 1 个服务 | 2 个服务 |
| 中文搜索 | 配 zhparser 直接用 | 还要再上 ES |
| 百万级查询性能 | 50–80ms(HNSW 索引) | 30–50ms |
| 小团队选哪个 | ✅ pgvector | ❌ |
向量量级到亿级再考虑独立向量库。我们当前生产环境最大单库 800w 向量,pgvector + HNSW 索引 P95 < 100ms,完全够用。
七、BullMQ 长任务调度的 5 个坑
视频生成动辄 3–5 分钟,BullMQ 的默认配置一个都不能用,全踩过:
-
stalledInterval默认 30s,长任务直接被判 stalled- 改成
stalledInterval: 0,禁用 stalled 检测,自己心跳
- 改成
-
Job 超时默认 30s
- 长任务必须显式
timeout: 600_000(10min)
- 长任务必须显式
-
Redis
maxmemory-policy不能用allkeys-lru- 任务会被驱逐,必须用
noeviction
- 任务会被驱逐,必须用
-
Worker 并发数别贪心
- 每个 Worker
concurrency: 2,超过容易 OOM
- 每个 Worker
-
失败重试要带
backoffattempts: 3, backoff: { type: 'exponential', delay: 5000 }
八、多模型聚合层怎么写最省事
不是 if model === 'gpt' else if ...,那种写法到第 5 个模型就崩溃。我们用的是 Provider Strategy:
interface ModelProvider {
invoke(payload: InvokePayload): Promise<ModelResult>
stream(payload: InvokePayload): AsyncIterable<ModelChunk>
costPerCall(payload: InvokePayload): number
}
@Injectable()
export class ModelHub {
constructor(private providers: Map<string, ModelProvider>) {}
async invoke(modelName: string, payload: InvokePayload) {
const provider = this.providers.get(modelName)
if (!provider) throw new ModelNotFoundError(modelName)
// 自动失败回退
try {
return await provider.invoke(payload)
} catch (err) {
const fallback = this.getFallback(modelName)
if (fallback) return fallback.invoke(payload)
throw err
}
}
}
当前接入:
| 类型 | 已接入模型 |
|---|---|
| 💬 LLM | GPT、Claude、Gemini、Kimi、深度求索、智谱 GLM、通义千问、文心一言、xAI Grok |
| 🖼️ 文生图 | 闪绘、精绘、可灵 omni-image、豆包 Seedream 5.0 |
| 🎥 文生视频 | 创影、Kling V3、Kling 动作控制、豆包 Seedance 2.0 Pro / 2.0 / 1.x Lite |
真香点:管理后台可以按算力消耗配置 —— 1 次 GPT-4 = 5 算力、1 次豆包 = 1 算力,给 SaaS 玩法留了口子。
九、MCP 协议接入:把外部工具搬进对话框
MCP(Model Context Protocol)算是最近半年开发者圈最值得跟的协议。我们已经接入:
- 飞书文档
- Notion
- EXA 搜索
- 抖音 / 小红书数据
- 视频文案提取
接入第三方 MCP Server 的代码不到 30 行:
const mcp = new McpClient({ endpoint: 'https://your-mcp.example.com' })
await mcp.connect()
const tools = await mcp.listTools() // 自动注册到对话工具池
十、性能数据(生产实测)
| 指标 | 数值 | 说明 |
|---|---|---|
| 单机并发对话 | 200+ 流式 | SSE 长连接 |
| 画布节点队列吞吐 | 500 任务/分钟 | 短任务(文本类) |
| 数字人合成 | 60s 视频 ≈ 3–5 分钟 | 取决于模型方 |
| pgvector P95 | < 100ms | 800w 向量 + HNSW |
| 单机日活承载 | 1000–2000 | 4C8G 配置 |
| 部署上线 | 10 分钟 | docker compose up -d |
十一、最小化部署清单
# 服务器:4C / 8G / 40G / Linux 或 macOS
git clone <release-bundle>
cp .env.example .env
# 填好域名、数据库密码、模型 Key
docker compose up -d
# 进容器启用 pgvector / zhparser
docker exec -it sanqian-pg psql -U postgres -d sanqian
> CREATE EXTENSION IF NOT EXISTS vector;
> CREATE EXTENSION IF NOT EXISTS zhparser;
> CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
Nginx 关键配置(别忘了开 SSE / WebSocket) :
location /api/ {
proxy_pass http://127.0.0.1:4090;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # SSE 必须
proxy_read_timeout 600s; # 长任务必须
}
location /ws {
proxy_pass http://127.0.0.1:4090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
十二、写在最后
这套东西现在用法大致三类人:
- 自媒体团队 —— 一天稳定出 10+ 条
- MCN 机构 —— 多账号矩阵 + IP 档案 + 数据中台
- AI SaaS 创业者 —— 买源码改 LOGO 自己卖
最后放链接,欢迎来拍砖:
- 🐙 项目地址:github.com/2961799660/…
- 🚀 在线演示:ai1.zijie.lol
- 🪞 Gitee 镜像:gitee.com/a2961799660…
如果你也在做画布式工作流 / 长任务调度 / 多模型聚合相关的事情,评论区聊,我们也想看看别人的方案有没有更优雅的写法。
喜欢这种"我做了个 X"实战拆解的,点赞 + 收藏让算法看见,下一篇我打算写画布前端怎么用 Vue Flow 撑起 200+ 节点不卡,缺关注。