实战:从本地脚本到Web SaaS平台(FastAPI + Vue3 + Worker插件化)

本文完整记录了一个AI工具平台从0到1的技术实现过程,包含架构设计、代码实现、踩坑记录。适合有Python Web开发基础的读者参考。

技术栈 :FastAPI + Vue 3 + SQLite + Redis + Celery(简化版)

部署环境:腾讯云轻量服务器(2核2G)+ 本地Worker(4060Ti)


目录

  1. 项目背景与需求分析
  2. 整体架构设计
  3. 云端部署:2核2G服务器方案
  4. [后端实现:FastAPI + SQLite](#后端实现:FastAPI + SQLite)
  5. [前端实现:Vue 3 + Vite](#前端实现:Vue 3 + Vite)
  6. Worker设计:插件化任务引擎
  7. 实战案例:成语卡片生成全流程
  8. 关键技术决策与踩坑记录
  9. 部署与运维
  10. 总结与后续规划

一、项目背景与需求分析

1.1 初始状态

项目开始前,我已经有一系列本地运行的AI工具脚本:

工具 技术栈 功能
PPT生成 OpenAI API + python-pptx 根据主题生成PPT大纲并输出.pptx
知识卡片 Playwright + Chromium HTML模板渲染为图片
成语卡片 Pillow + ComfyUI 生成带插图的成语教育卡片
AI生图 ComfyUI本地部署 水彩风格插图生成

核心问题:这些脚本只能在本地运行,依赖环境复杂(Python 3.12、Playwright、ComfyUI、字体文件等),无法提供给其他用户使用。

1.2 需求拆解

经过分析,确定核心需求:

  1. Web化:用户通过浏览器访问,无需本地安装任何依赖
  2. 异步执行:AI计算任务耗时长,需要异步处理
  3. 低成本:利用现有硬件(本地4060Ti),避免高额的GPU云服务器费用
  4. 可扩展:支持新增任务类型(插件化)

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/;
    }
}

关键点

  1. try_files $uri $uri/ /index.html:支持Vue SPA的前端的路由
  2. /taskapi/:API路由转发到FastAPI(端口8000)
  3. /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?

  1. MVP阶段用户量少,SQLite性能足够
  2. 零配置,单文件,备份方便
  3. 后续迁移到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启动时加载的是另一个环境的包

解决方案

  1. 修改源码后,必须重新打包

    bash 复制代码
    cd E:\aidata\skills\playwright-gen-image
    python -m build
  2. 在所有环境重新安装

    bash 复制代码
    pip 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任务就会被拒绝。

解决方案

  1. 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)
  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生图服务偶尔会在插图里画出文字,这在成语卡片里很不合适。

解决方案

  1. Prompt增强

    复制代码
    负面提示词增加:
    text, words, letters, characters, watermark, signature, 
    Chinese characters, pinyin, 文字, 字, 拼音
  2. 检测机制(NumPy网格分析):

    python 复制代码
    def 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 核心设计亮点

  1. HTTP轮询替代消息队列:解决Worker在NAT后面的连接问题
  2. SCP + Nginx静态替代对象存储:省成本、省复杂度
  3. 插件化Worker框架:新增任务类型只需要写一个插件类
  4. 云端极轻量:2核2G足够,月费¥68

10.3 后续规划

短期(已排期)
  • 支持更多任务类型(知识卡片、图片生成)
  • 结果预览优化(PPT在线预览、图片画廊)
  • WebSocket实时进度推送(替代轮询)
中期
  • Agent模式:用户输入自然语言,Agent自动拆解为多个任务
  • 多Worker节点:支持多台机器同时执行不同任务类型
  • 数据库迁移到PostgreSQL(支持高并发)
长期
  • 开源Worker框架(plugin_base + task_executor独立为pip包)
  • 其他开发者可以写自己的插件接入平台

如果这篇文章对你有帮助,欢迎点赞 + 收藏 + 关注 👇

我会持续更新这个项目的开发进展和技术细节。