本文完整记录了一个AI工具平台从0到1的技术实现过程,包含架构设计、代码实现、踩坑记录。适合有Python Web开发基础的读者参考。
技术栈 :FastAPI + Vue 3 + SQLite + Redis + Celery(简化版)
部署环境:腾讯云轻量服务器(2核2G)+ 本地Worker(4060Ti)
目录
- 项目背景与需求分析
- 整体架构设计
- 云端部署:2核2G服务器方案
- [后端实现:FastAPI + SQLite](#后端实现:FastAPI + SQLite)
- [前端实现:Vue 3 + Vite](#前端实现:Vue 3 + Vite)
- Worker设计:插件化任务引擎
- 实战案例:成语卡片生成全流程
- 关键技术决策与踩坑记录
- 部署与运维
- 总结与后续规划
一、项目背景与需求分析
1.1 初始状态
项目开始前,我已经有一系列本地运行的AI工具脚本:
| 工具 | 技术栈 | 功能 |
|---|---|---|
| PPT生成 | OpenAI API + python-pptx | 根据主题生成PPT大纲并输出.pptx |
| 知识卡片 | Playwright + Chromium | HTML模板渲染为图片 |
| 成语卡片 | Pillow + ComfyUI | 生成带插图的成语教育卡片 |
| AI生图 | ComfyUI本地部署 | 水彩风格插图生成 |
核心问题:这些脚本只能在本地运行,依赖环境复杂(Python 3.12、Playwright、ComfyUI、字体文件等),无法提供给其他用户使用。
1.2 需求拆解
经过分析,确定核心需求:
- Web化:用户通过浏览器访问,无需本地安装任何依赖
- 异步执行:AI计算任务耗时长,需要异步处理
- 低成本:利用现有硬件(本地4060Ti),避免高额的GPU云服务器费用
- 可扩展:支持新增任务类型(插件化)
1.3 技术选型
| 组件 | 选型 | 理由 |
|---|---|---|
| 后端框架 | FastAPI | 异步支持好,自动生成API文档 |
| 前端框架 | Vue 3 + Vite | 轻量,打包后可直接部署 |
| 数据库 | SQLite | MVP阶段零配置,单文件易备份 |
| 缓存 | Redis | 积分缓存、Worker心跳检测 |
| 反向代理 | Nginx | HTTPS、静态文件、API转发 |
| Worker框架 | 自研(Python threading) | 轻量,HTTP轮询替代Celery |
二、整体架构设计
2.1 架构图
#mermaid-svg-5rYJl8VOMi1Jpe2F{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5rYJl8VOMi1Jpe2F .error-icon{fill:#552222;}#mermaid-svg-5rYJl8VOMi1Jpe2F .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5rYJl8VOMi1Jpe2F .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .marker.cross{stroke:#333333;}#mermaid-svg-5rYJl8VOMi1Jpe2F svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5rYJl8VOMi1Jpe2F p{margin:0;}#mermaid-svg-5rYJl8VOMi1Jpe2F .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .cluster-label text{fill:#333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .cluster-label span{color:#333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .cluster-label span p{background-color:transparent;}#mermaid-svg-5rYJl8VOMi1Jpe2F .label text,#mermaid-svg-5rYJl8VOMi1Jpe2F span{fill:#333;color:#333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .node rect,#mermaid-svg-5rYJl8VOMi1Jpe2F .node circle,#mermaid-svg-5rYJl8VOMi1Jpe2F .node ellipse,#mermaid-svg-5rYJl8VOMi1Jpe2F .node polygon,#mermaid-svg-5rYJl8VOMi1Jpe2F .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5rYJl8VOMi1Jpe2F .rough-node .label text,#mermaid-svg-5rYJl8VOMi1Jpe2F .node .label text,#mermaid-svg-5rYJl8VOMi1Jpe2F .image-shape .label,#mermaid-svg-5rYJl8VOMi1Jpe2F .icon-shape .label{text-anchor:middle;}#mermaid-svg-5rYJl8VOMi1Jpe2F .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5rYJl8VOMi1Jpe2F .rough-node .label,#mermaid-svg-5rYJl8VOMi1Jpe2F .node .label,#mermaid-svg-5rYJl8VOMi1Jpe2F .image-shape .label,#mermaid-svg-5rYJl8VOMi1Jpe2F .icon-shape .label{text-align:center;}#mermaid-svg-5rYJl8VOMi1Jpe2F .node.clickable{cursor:pointer;}#mermaid-svg-5rYJl8VOMi1Jpe2F .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .arrowheadPath{fill:#333333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5rYJl8VOMi1Jpe2F .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5rYJl8VOMi1Jpe2F .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5rYJl8VOMi1Jpe2F .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5rYJl8VOMi1Jpe2F .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5rYJl8VOMi1Jpe2F .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5rYJl8VOMi1Jpe2F .cluster text{fill:#333;}#mermaid-svg-5rYJl8VOMi1Jpe2F .cluster span{color:#333;}#mermaid-svg-5rYJl8VOMi1Jpe2F div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5rYJl8VOMi1Jpe2F .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5rYJl8VOMi1Jpe2F rect.text{fill:none;stroke-width:0;}#mermaid-svg-5rYJl8VOMi1Jpe2F .icon-shape,#mermaid-svg-5rYJl8VOMi1Jpe2F .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5rYJl8VOMi1Jpe2F .icon-shape p,#mermaid-svg-5rYJl8VOMi1Jpe2F .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5rYJl8VOMi1Jpe2F .icon-shape .label rect,#mermaid-svg-5rYJl8VOMi1Jpe2F .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5rYJl8VOMi1Jpe2F .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5rYJl8VOMi1Jpe2F .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5rYJl8VOMi1Jpe2F :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 本地 Worker(家用电脑)
云端服务器(2核2G,¥68/月)
用户端
HTTPS
HTTPS 轮询(Worker 主动拉取)
SCP 上传结果
SCP 上传结果
用户浏览器
打开网页 → 选任务 → 提交
Nginx
反向代理 + 静态文件
FastAPI
后端 API
SQLite + Redis
任务队列 + 存储
TaskExecutor 主循环
轮询 claim → 分发插件 → 多线程执行
IdiomPlugin
LLM 生成 + Pillow 渲染 + SCP 上传
PPTPlugin
LLM 生成大纲 + python-pptx
本地环境
Python 3.12 + ComfyUI + Playwright + 4060Ti
2.2 数据流转
用户提交任务
↓
云端创建Task记录(PENDING)
↓
Worker轮询拉取任务(claim)
↓
Worker执行任务(调用插件)
↓
Worker上报进度(progress)
↓
Worker上传结果(SCP)
↓
Worker上报完成(complete)
↓
用户下载结果
2.3 为什么不用Celery?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Celery + Redis | 成熟方案,实时性好 | Worker必须与Broker在同一网络 | 云端统一部署 |
| HTTP轮询(采用) | 穿透NAT,部署灵活 | 有几秒延迟 | Worker在NAT后面 |
关键决策:Worker运行在本地家用电脑,没有公网IP,无法使用Celery的Push模式。HTTP轮询是Worker主动往外连,天然穿透NAT。
三、云端部署:2核2G服务器方案
3.1 服务器配置
云服务器 :腾讯云轻量服务器
配置 :2核2G,¥68/月
系统:Ubuntu 20.04 LTS
已安装软件 :

- Nginx(反向代理 + 静态文件)
- FastAPI(Uvicorn)
- SQLite(数据存储)
- Redis(缓存 + 心跳)
重要原则:云端不运行任何AI计算任务,只做任务调度和结果存储。
3.2 Nginx配置
nginx
server {
listen 80;
server_name www.chunlei.fun;
# 前端静态文件
location / {
root /var/www/badianxia/frontend/dist;
try_files $uri $uri/ /index.html;
}
# 后端API转发
location /taskapi/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 任务结果文件
location /files/ {
alias /var/www/badianxia/backend/storage/results/;
}
}
关键点:
try_files $uri $uri/ /index.html:支持Vue SPA的前端的路由/taskapi/:API路由转发到FastAPI(端口8000)/files/:任务结果文件直接通过Nginx serve,不需要经过FastAPI
四、后端实现:FastAPI + SQLite
4.1 项目结构
backend/
├── app/
│ ├── main.py # FastAPI入口
│ ├── api/v1/ # API路由
│ │ ├── tasks.py # 任务相关
│ │ ├── users.py # 用户相关
│ │ └── nodes.py # Worker节点相关
│ ├── models/ # ORM模型
│ │ ├── task.py
│ │ ├── user.py
│ │ └── node.py
│ ├── services/ # 业务逻辑
│ │ ├── task_service.py
│ │ ├── user_service.py
│ │ └── node_service.py
│ └── core/ # 核心配置
│ ├── config.py
│ ├── database.py
│ ├── auth.py
│ └── logging_setup.py
├── data/tasks.db # SQLite数据库文件
└── requirements.txt
4.2 核心模型定义
Task模型(简化版):
python
# backend/app/models/task.py
from sqlalchemy import Column, Integer, String, Float, DateTime
from backend.app.core.database import Base
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True)
task_id = Column(String(36), unique=True)
user_id = Column(Integer)
task_type = Column(String(50)) # idiom / ppt
status = Column(String(20)) # PENDING / CLAIMED / PROCESSING / SUCCESS / FAILED
progress = Column(Float, default=0.0)
result = Column(String(2000)) # JSON字符串
created_at = Column(DateTime)
updated_at = Column(DateTime)
Node模型(Worker节点):
python
# backend/app/models/node.py
class Node(Base):
__tablename__ = "nodes"
id = Column(Integer, primary_key=True)
node_id = Column(String(36), unique=True)
name = Column(String(100))
secret = Column(String(64)) # SHA256哈希值
status = Column(String(20)) # ONLINE / OFFLINE
last_heartbeat = Column(DateTime)
4.3 API路由实现
任务提交接口:
python
# backend/app/api/v1/tasks.py
from fastapi import APIRouter, Depends
from backend.app.services.task_service import TaskService
router = APIRouter()
@router.post("/tasks")
async def create_task(
task_data: TaskCreateRequest,
current_user = Depends(get_current_user)
):
"""提交新任务(扣除积分)"""
# 1. 检查积分余额
if current_user.credits_balance < task_data.cost:
raise HTTPException(400, "积分不足")
# 2. 创建任务记录
task = await TaskService.create_task(
user_id=current_user.id,
task_type=task_data.task_type,
params=task_data.params
)
# 3. 扣除积分
await UserService.deduct_credits(
user_id=current_user.id,
amount=task_data.cost
)
return {"task_id": task.task_id, "status": "PENDING"}
Worker认领任务接口:
python
@router.get("/nodes/tasks/claim")
async def claim_task(
node: Node = Depends(get_node_by_secret)
):
"""Worker拉取待执行任务"""
# 1. 查找PENDING状态的任务
task = await TaskService.find_pending_task(node.capabilities)
if not task:
return {"message": "no task"}
# 2. 更新任务状态为CLAIMED
task.status = "CLAIMED"
task.node_id = node.node_id
await task.save()
return task.to_dict()
4.4 数据库配置(SQLite)
python
# backend/app/core/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.app.core.config import settings
# SQLite连接(启用WAL模式提升并发性能)
engine = create_engine(
settings.DATABASE_URL,
connect_args={
"check_same_thread": False, # FastAPI异步场景需要
"timeout": 30, # 锁等待超时
}
)
# 启用WAL模式(提升读写并发)
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
为什么用SQLite?
- MVP阶段用户量少,SQLite性能足够
- 零配置,单文件,备份方便
- 后续迁移到PostgreSQL只需要改连接字符串
五、前端实现:Vue 3 + Vite
5.1 项目结构
frontend/
├── src/
│ ├── App.vue # 主框架
│ ├── components/
│ │ ├── LandingPage.vue # 首页
│ │ ├── TaskSubmit.vue # 任务提交
│ │ ├── TaskList.vue # 任务列表
│ │ ├── TaskDetail.vue # 任务详情
│ │ └── AuthModal.vue # 登录/注册弹窗
│ ├── router/index.js # 路由配置
│ ├── store/index.js # Vuex状态管理
│ └── api/ # API调用封装
│ ├── tasks.js
│ └── users.js
├── package.json
└── vite.config.js
5.2 任务详情页(核心代码)
vue
<!-- frontend/src/components/TaskDetail.vue -->
<template>
<div class="task-detail" v-if="task">
<!-- 进度条 -->
<div class="progress-section" v-if="isProcessing">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: task.progress + '%' }"></div>
</div>
<div class="progress-text">{{ task.progress }}% - {{ task.progress_message }}</div>
</div>
<!-- 成语卡片展示 -->
<div v-if="task.task_type === 'idiom' && task.result" class="idiom-result">
<img :src="idiomImageUrl" alt="成语卡片" class="idiom-image" />
<a :href="idiomImageUrl" download class="download-btn">下载卡片</a>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useTaskStore } from '@/store/tasks'
const props = defineProps(['taskId'])
const taskStore = useTaskStore()
const task = computed(() => taskStore.getTask(props.taskId))
const idiomImageUrl = computed(() => {
if (!task.value?.result) return ''
const result = JSON.parse(task.value.result)
return result.download_urls?.[0] || ''
})
// 轮询刷新(每2秒)
let timer = null
onMounted(() => {
timer = setInterval(() => {
if (task.value?.status in ['PENDING', 'PROCESSING']) {
taskStore.fetchTask(props.taskId)
}
}, 2000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
5.3 API调用封装
javascript
// frontend/src/api/tasks.js
import axios from 'axios'
const api = axios.create({
baseURL: '/taskapi/v1',
timeout: 10000
})
// 请求拦截器:自动带JWT token
api.interceptors.request.use(config => {
const token = localStorage.getItem('jwt_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export const tasksApi = {
// 提交任务
createTask(data) {
return api.post('/tasks', data)
},
// 获取任务列表
getTasks(params) {
return api.get('/tasks', { params })
},
// 获取任务详情
getTask(taskId) {
return api.get(`/tasks/${taskId}`)
}
}
六、Worker设计:插件化任务引擎
6.1 整体架构
workers/
├── worker-idiom/ # 成语卡片Worker
│ ├── worker.py # 入口
│ ├── plugins/
│ │ └── idiom_plugin.py # 成语卡片处理插件
│ ├── worker_core/ # 通用Worker框架
│ │ ├── config.py
│ │ ├── logging_setup.py
│ │ ├── state_store.py
│ │ ├── node_client.py # HTTP客户端
│ │ ├── plugin_base.py # 插件基类
│ │ └── task_executor.py # 任务调度器
│ └── .env
│
└── worker-ppt/ # PPT Worker(同结构)
6.2 插件基类设计
python
# workers/worker-idiom/worker_core/plugin_base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class TaskContext:
"""任务执行上下文"""
task_id: str
params: dict
report_progress: callable # 上报进度回调函数
class TaskPlugin(ABC):
"""任务插件基类"""
name: str = "base-plugin"
task_type: str = ""
default_config: dict = {}
@abstractmethod
def execute(self, ctx: TaskContext) -> dict:
"""
执行任务,返回结果dict
Args:
ctx: 任务上下文,包含task_id、params、report_progress
Returns:
dict: {
"download_urls": ["https://..."],
"file_path": "/path/to/result",
...
}
"""
pass
6.3 任务调度器核心逻辑
python
# workers/worker-idiom/worker_core/task_executor.py
import threading
import time
class TaskExecutor:
def __init__(self, node_client, plugin_registry, config):
self.node_client = node_client
self.plugin_registry = plugin_registry
self.config = config
self.active_threads = []
def start(self):
"""启动主循环"""
while True:
try:
# 1. 心跳保活
self.node_client.heartbeat()
# 2. 清理已完成的线程
self.active_threads = [
t for t in self.active_threads if t.is_alive()
]
# 3. 拉取新任务
if len(self.active_threads) < self.config.max_concurrent:
task_data = self.node_client.claim_task()
if task_data and task_data.get('task_id'):
self._execute_task_async(task_data)
# 4. 等待下次轮询
time.sleep(self.config.claim_interval)
except Exception as e:
logging.error(f"Executor error: {e}")
time.sleep(5)
def _execute_task_async(self, task_data):
"""异步执行任务"""
def run():
task_id = task_data['task_id']
task_type = task_data['task_type']
try:
# 1. 查找插件
plugin = self.plugin_registry.get_plugin(task_type)
if not plugin:
raise Exception(f"No plugin for {task_type}")
# 2. 创建上下文
ctx = TaskContext(
task_id=task_id,
params=task_data['params'],
report_progress=lambda p, msg: self.node_client.report_progress(task_id, p, msg)
)
# 3. 执行插件
result = plugin.execute(ctx)
# 4. 上报完成
self.node_client.complete_task(task_id, result)
except Exception as e:
logging.error(f"Task {task_id} failed: {e}")
self.node_client.fail_task(task_id, str(e))
# 启动新线程
thread = threading.Thread(target=run, daemon=True)
thread.start()
self.active_threads.append(thread)
6.4 Worker启动流程
python
# workers/worker-idiom/worker.py
import os
from dotenv import load_dotenv
from worker_core.config import WorkerConfig
from worker_core.node_client import NodeClient
from worker_core.plugin_base import PluginRegistry
from worker_core.task_executor import TaskExecutor
from plugins.idiom_plugin import IdiomPlugin
def main():
# 1. 加载配置
load_dotenv()
config = WorkerConfig.from_env()
# 2. 注册插件
registry = PluginRegistry()
registry.register(IdiomPlugin())
# 3. 初始化Node客户端
client = NodeClient(config)
client.register_node() # 首次运行注册,后续读取本地state
# 4. 启动执行器
executor = TaskExecutor(client, registry, config)
executor.start()
if __name__ == '__main__':
main()
七、实战案例:成语卡片生成全流程
7.1 用户输入
用户在网页输入成语名:"画蛇添足",点击提交。
7.2 后端处理
python
# 后端创建任务记录
task = Task(
task_id=str(uuid.uuid4()),
user_id=1,
task_type='idiom',
status='PENDING',
params='{"idiom": "画蛇添足"}',
cost=10 # 消耗10积分
)
db.add(task)
db.commit()
7.3 Worker拉取任务
ComfyUI 大模型API 云端服务器 本地Worker ComfyUI 大模型API 云端服务器 本地Worker #mermaid-svg-msLqz82cppEBOYAt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-msLqz82cppEBOYAt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-msLqz82cppEBOYAt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-msLqz82cppEBOYAt .error-icon{fill:#552222;}#mermaid-svg-msLqz82cppEBOYAt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-msLqz82cppEBOYAt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-msLqz82cppEBOYAt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-msLqz82cppEBOYAt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-msLqz82cppEBOYAt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-msLqz82cppEBOYAt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-msLqz82cppEBOYAt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-msLqz82cppEBOYAt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-msLqz82cppEBOYAt .marker.cross{stroke:#333333;}#mermaid-svg-msLqz82cppEBOYAt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-msLqz82cppEBOYAt p{margin:0;}#mermaid-svg-msLqz82cppEBOYAt .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-msLqz82cppEBOYAt text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-msLqz82cppEBOYAt .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-msLqz82cppEBOYAt .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-msLqz82cppEBOYAt .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-msLqz82cppEBOYAt .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-msLqz82cppEBOYAt #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-msLqz82cppEBOYAt .sequenceNumber{fill:white;}#mermaid-svg-msLqz82cppEBOYAt #sequencenumber{fill:#333;}#mermaid-svg-msLqz82cppEBOYAt #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-msLqz82cppEBOYAt .messageText{fill:#333;stroke:none;}#mermaid-svg-msLqz82cppEBOYAt .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-msLqz82cppEBOYAt .labelText,#mermaid-svg-msLqz82cppEBOYAt .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-msLqz82cppEBOYAt .loopText,#mermaid-svg-msLqz82cppEBOYAt .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-msLqz82cppEBOYAt .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-msLqz82cppEBOYAt .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-msLqz82cppEBOYAt .noteText,#mermaid-svg-msLqz82cppEBOYAt .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-msLqz82cppEBOYAt .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-msLqz82cppEBOYAt .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-msLqz82cppEBOYAt .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-msLqz82cppEBOYAt .actorPopupMenu{position:absolute;}#mermaid-svg-msLqz82cppEBOYAt .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-msLqz82cppEBOYAt .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-msLqz82cppEBOYAt .actor-man circle,#mermaid-svg-msLqz82cppEBOYAt line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-msLqz82cppEBOYAt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /nodes/tasks/claim {task_id: "xxx", idiom: "画蛇添足"} POST progress 5%"正在解析成语" 生成成语教育内容 {idiom, pinyin, explanation, story, tip} POST progress 20% 生成水彩插图 插图图片 POST progress 60% SCP上传card.png POST progress 90% POST complete
7.4 插件执行细节
python
# workers/worker-idiom/plugins/idiom_plugin.py
class IdiomPlugin(TaskPlugin):
name = "idiom-plugin"
task_type = "idiom"
def execute(self, ctx: TaskContext) -> dict:
# 1. 调用LLM生成成语数据
ctx.report_progress(5, "正在调用大模型生成内容")
idiom_data = self._generate_idiom_data(ctx.params['idiom'])
ctx.report_progress(20, "成语内容生成完成")
# 2. 生成插图(ComfyUI)
ctx.report_progress(30, "正在生成AI插图")
illustration = self._generate_illustration(idiom_data['idiom'])
ctx.report_progress(60, "插图生成完成")
# 3. 渲染卡片(Pillow)
ctx.report_progress(70, "正在渲染卡片")
card_path = self._render_card(idiom_data, illustration)
ctx.report_progress(90, "卡片渲染完成")
# 4. 上传到云主机
download_url = self._upload_to_server(card_path)
ctx.report_progress(100, "上传完成")
return {
"download_urls": [download_url],
"image_path": card_path
}
def _generate_idiom_data(self, idiom: str) -> dict:
"""调用LLM生成成语教育内容"""
prompt = f"""
你是一个面向6-12岁儿童的成语教育专家。
请为成语"{idiom}"生成以下内容,返回JSON格式:
- pinyin: 拼音
- explanation: 释义(适合儿童理解)
- story: 成语故事(200字以内)
- tip: 家长提示(如何使用这个成语)
"""
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": prompt}]
)
return json.loads(response.choices[0].message.content)
def _render_card(self, idiom_data: dict, illustration: Image) -> str:
"""使用Pillow渲染成语卡片"""
from playwright_gen_image.app.idiom.core import generate_idiom_card
output_path = f"/tmp/{idiom_data['idiom']}.png"
generate_idiom_card(
output_path=output_path,
idiom=idiom_data['idiom'],
pinyin=idiom_data['pinyin'],
explanation=idiom_data['explanation'],
story=idiom_data['story'],
tip=idiom_data['tip'],
illustration=illustration
)
return output_path
7.5 结果展示
任务完成后,用户在前端看到:

点击下载按钮,直接下载生成的成语卡片图片。
八、关键技术决策与踩坑记录
8.1 踩坑1:多虚拟环境文件不同步
问题描述:
修改了playwright_gen_image库的代码,只在hermes-agent的venv里改了,但Worker进程用的是py312环境。结果Worker跑起来报错:
TypeError: generate_idiom_card() got an unexpected keyword argument 'illustration_provider'
原因分析:
- 多个Python虚拟环境(venv/miniconda)同时存在
- 修改代码后只在其中一个环境生效
- Worker启动时加载的是另一个环境的包
解决方案:
-
修改源码后,必须重新打包
bashcd E:\aidata\skills\playwright-gen-image python -m build -
在所有环境重新安装
bashpip install dist\playwright_gen_image-0.1.2-py3-none-any.whl --force-reinstall
经验教训:
多个venv环境下,必须从源码构建wheel统一分发,不能只改某个venv里的文件。
8.2 踩坑2:Worker心跳超时导致节点离线
问题描述:
Worker启动后如果网络波动,心跳请求失败几次,云端就把节点标记为OFFLINE。之后Worker再claim任务就会被拒绝。
解决方案:
-
Worker端:心跳失败重试逻辑
python# worker_core/node_client.py def heartbeat(self): max_retries = 3 for i in range(max_retries): try: resp = requests.post( f"{self.config.base_url}/nodes/heartbeat", headers=self._auth_headers(), timeout=5 ) if resp.status_code == 200: return except Exception as e: logging.warning(f"Heartbeat failed ({i+1}/{max_retries}): {e}") time.sleep(2) -
云端:定期清理过期节点
python# backend/app/services/node_service.py def cleanup_stale_nodes(): """清理超过30秒没心跳的节点""" threshold = datetime.now() - timedelta(seconds=30) stale_nodes = Node.query.filter( Node.last_heartbeat < threshold, Node.status == 'ONLINE' ).all() for node in stale_nodes: node.status = 'OFFLINE' db.commit()
8.3 踩坑3:ComfyUI插图出现文字
问题描述:
AI生图服务偶尔会在插图里画出文字,这在成语卡片里很不合适。
解决方案:
-
Prompt增强:
负面提示词增加: text, words, letters, characters, watermark, signature, Chinese characters, pinyin, 文字, 字, 拼音 -
检测机制(NumPy网格分析):
pythondef detect_text_in_image(image: Image) -> float: """ 检测图片中的文字嫌疑分数 返回0-1之间的分数,越接近1表示越可能有文字 """ import numpy as np img_array = np.array(image.convert('L')) # 转灰度 # 使用Sobel算子检测边缘 sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]]) # 计算梯度幅值 grad_x = convolve2d(img_array, sobel_x, mode='same') grad_y = convolve2d(img_array, sobel_y, mode='same') gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2) # 文字通常表现为高频边缘,计算高频区域占比 high_freq_ratio = np.sum(gradient_magnitude > 128) / gradient_magnitude.size return min(high_freq_ratio * 10, 1.0) # 归一化到0-1
九、部署与运维
9.1 后端启动脚本
python
# backend/manage.py(简化版)
import subprocess
import psutil
def kill_port(port: int):
"""杀死占用指定端口的进程"""
for conn in psutil.net_connections():
if conn.laddr.port == port and conn.status == 'LISTEN':
pid = conn.pid
if pid:
print(f"Killing process {pid} on port {port}")
subprocess.run(['taskkill', '/F', '/T', '/PID', str(pid)])
time.sleep(2)
def start_backend():
"""启动FastAPI后端"""
kill_port(8000) # 先杀旧进程
cmd = [
'uvicorn',
'app.main:app',
'--host', '0.0.0.0',
'--port', '8000',
'--reload'
]
subprocess.Popen(cmd, cwd='backend')
print("Backend started on http://0.0.0.0:8000")
if __name__ == '__main__':
start_backend()
9.2 Worker启动脚本(Windows)
batch
:: workers/worker-idiom/start.bat
@echo off
cd /d %~dp0
:: 激活conda环境
call conda activate py312
:: 启动Worker
python worker.py
pause
9.3 日志管理
python
# backend/app/core/logging_setup.py
import logging
from logging.handlers import RotatingFileHandler
def setup_logging():
"""配置滚动日志"""
handler = RotatingFileHandler(
'logs/app.log',
maxBytes=10*1024*1024, # 10MB
backupCount=10
)
formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
)
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)
十、总结与后续规划
10.1 项目成果
| 指标 | 数值 |
|---|---|
| 后端代码量 | ~2000行 |
| Worker框架代码量 | ~500行 |
| 前端代码量 | ~1500行 |
| 云端服务器成本 | ¥68/月 |
| 支持的AI任务类型 | 2种(成语卡片、PPT生成) |
10.2 核心设计亮点
- HTTP轮询替代消息队列:解决Worker在NAT后面的连接问题
- SCP + Nginx静态替代对象存储:省成本、省复杂度
- 插件化Worker框架:新增任务类型只需要写一个插件类
- 云端极轻量:2核2G足够,月费¥68
10.3 后续规划
短期(已排期)
- 支持更多任务类型(知识卡片、图片生成)
- 结果预览优化(PPT在线预览、图片画廊)
- WebSocket实时进度推送(替代轮询)
中期
- Agent模式:用户输入自然语言,Agent自动拆解为多个任务
- 多Worker节点:支持多台机器同时执行不同任务类型
- 数据库迁移到PostgreSQL(支持高并发)
长期
- 开源Worker框架(plugin_base + task_executor独立为pip包)
- 其他开发者可以写自己的插件接入平台
如果这篇文章对你有帮助,欢迎点赞 + 收藏 + 关注 👇
我会持续更新这个项目的开发进展和技术细节。