多 Provider 图片生成服务 — 架构设计文档

4 阅读11分钟

多 Provider 图片生成服务 — 架构设计文档

脱敏声明: 文档中所有具体域名、API Key、SDK 包名、模型名称均已替换为占位符描述,正式部署时请替换为实际值。 适用场景: 文档生成、图片创作、AI 图像生成等需要调用外部图像 API 的服务。


1. 背景与目标

1.1 为什么需要多 Provider?

在 AI 图像生成应用中,依赖单一模型供应商存在以下风险:

风险类型描述
可用性风险单点故障导致服务不可用,影响用户体验
成本风险高峰时段 API 定价波动影响成本可控性
能力覆盖风险单一模型在特定类型图片(如文字排版、风格一致性)上表现不稳定
网络依赖风险某些 Provider 在特定地区无法访问或响应缓慢

1.2 本服务的设计目标

目标说明
高可用主备 Provider 自动切换,单一节点故障不影响整体服务
成本可控优先使用低单价 Provider,必要时切换高单价兜底
能力互补不同模型各有优势,按场景类型选择最适合的生成器
灵活扩展新增 Provider 只需实现统一接口,无需修改核心调度逻辑
网络兼容自动适配不同 Provider 的网络访问要求(如代理/直连)

2. 整体架构

2.1 架构总览

┌─────────────────────────────────────────────────────────┐
│                    调用入口层                            │
│        文档生成服务 / 任务池 / SSE 流式响应               │
└──────────────────┬──────────────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────────────┐
│              ImageGenerationService                      │
│  ┌─────────────────────────────────────────────────┐   │
│  │  调度策略:Primary / Fallback                     │   │
│  │  · 有本地参考图 → 优先 Primary(支持图片理解)      │   │
│  │  · Primary 重试耗尽 → 自动切换 Fallback           │   │
│  │  · 无本地参考图 + 有远端资产ID → 直接用 Fallback   │   │
│  └─────────────────────────────────────────────────┘   │
└──────────┬──────────────────────────┬──────────────────┘
           │ primary_provider          │ fallback_provider
           ▼                          ▼
┌──────────────────────┐  ┌────────────────────────────────┐
│   PrimaryGenerator   │  │      FallbackGenerator           │
│  ┌────────────────┐  │  │  ┌────────────────────────┐   │
│  │  Provider A     │  │  │  │ Provider B(资产引用) │   │
│  │  · 本地参考图    │  │  │  │ · 资产 ID 列表        │   │
│  │  · base64 上传  │  │  │  │ · 无需本地文件        │   │
│  │  · 流式 SSE     │  │  │  · 异步轮询结果        │   │
│  └────────────────┘  │  │  └────────────────────────┘   │
└──────────────────────┘  └────────────────────────────────┘
           │                          │
           ▼                          ▼
    ┌─────────────┐           ┌─────────────────┐
    │  供应商 A    │           │    供应商 B      │
    │ (海外 API)   │           │ (国内直连 API)   │
    │  VPN 路由    │           │  直连           │
    └─────────────┘           └─────────────────┘

2.2 核心设计思想

  1. 注册表模式:所有 Provider 在模块加载时自动注册,新增 Provider 零改动
  2. 主备降级:Primary 失败自动切换 Fallback,保证服务可用性
  3. 滑动窗口并发池:控制并发数、失败重试、自动入队调度
  4. 网络兼容:不同 Provider 的网络要求(代理/直连)在 Provider 层各自处理

3. 核心组件详解

3.1 Provider 注册中心

文件:src/core/providers/provider_registry.py

采用注册表模式(Registry Pattern),所有 Provider 在模块加载时自动注册到全局字典:

from typing import Dict, Type

PROVIDER_REGISTRY: Dict[str, Type["BaseImageGenerator"]] = {}

def register_provider(name: str, generator_class: Type["BaseImageGenerator"]) -> None:
    """注册 Provider,自动校验接口兼容性"""
    if not issubclass(generator_class, BaseImageGenerator):
        raise TypeError(f"{generator_class.__name__} 必须继承 BaseImageGenerator")
    PROVIDER_REGISTRY[name] = generator_class

def create_generator(provider: str, **config) -> "BaseImageGenerator":
    """根据名称创建 Provider 实例"""
    if provider not in PROVIDER_REGISTRY:
        available = ", ".join(PROVIDER_REGISTRY.keys())
        raise ValueError(f"Unknown provider: '{provider}'. Available: {available}")
    return PROVIDER_REGISTRY[provider](**config)

扩展新 Provider 的标准流程:

  1. 实现 BaseImageGenerator 抽象类
  2. 在对应模块末尾调用 register_provider("your_provider", YourProviderGenerator)
  3. 在配置文件中添加 Provider 名称即可启用

3.2 统一接口契约

文件:src/core/providers/base_generator.py

所有 Provider 必须继承 BaseImageGenerator 并实现以下方法:

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List

@dataclass
class ImageGenerationResult:
    """统一返回格式"""
    success: bool
    error: Optional[str] = None
    error_code: Optional[str] = None  # 结构化错误码,便于 Fallback 判断
    image_path: Optional[str] = None  # 本地保存路径
    image_data: Optional[bytes] = None  # 原始图片数据
    image_url: Optional[str] = None    # 远程图片 URL
    metadata: Dict[str, Any] = field(default_factory=dict)  # Provider 原始响应

class BaseImageGenerator(ABC):
    """图片生成器抽象基类"""

    @property
    @abstractmethod
    def provider_name(self) -> str:
        """Provider 标识名称"""
        pass

    @property
    @abstractmethod
    def supports_reference_images(self) -> bool:
        """是否支持参考图输入(影响调度策略)"""
        pass

    @property
    @abstractmethod
    def requires_async_mode(self) -> bool:
        """是否需要异步轮询模式"""
        pass

    @abstractmethod
    def generate(
        self,
        prompt: str,
        reference_image_paths: Optional[List[str]] = None,
        reference_asset_ids: Optional[List[str]] = None,
        **kwargs
    ) -> ImageGenerationResult:
        """同步生成"""
        pass

    @abstractmethod
    async def generate_async(
        self,
        prompt: str,
        reference_image_paths: Optional[List[str]] = None,
        reference_asset_ids: Optional[List[str]] = None,
        **kwargs
    ) -> ImageGenerationResult:
        """异步生成(流式/SSE 场景)"""
        pass

    @abstractmethod
    def download_and_save_image(self, url: str, save_path: str) -> bool:
        """下载并保存图片"""
        pass

3.3 Primary / Fallback 调度器

文件:src/core/services/image_generation_service.py

import asyncio
from typing import Optional, List, Dict, Any

class ImageGenerationService:
    """
    统一调度器:自动选择 Primary / Fallback Provider
    """

    def __init__(
        self,
        primary_provider: str,
        fallback_provider: str,
        providers_config: Dict[str, Dict[str, Any]],
    ):
        self.primary = create_provider(primary_provider, providers_config.get(primary_provider, {}))
        self.fallback = create_provider(fallback_provider, providers_config.get(fallback_provider, {}))

    async def generate(
        self,
        prompt: str,
        reference_image_paths: Optional[List[str]] = None,
        reference_asset_ids: Optional[List[str]] = None,
        **kwargs
    ) -> ImageGenerationResult:
        # ── 调度策略 ──
        # 1. 有本地参考图 → 优先 Primary
        if reference_image_paths and self.primary.supports_reference_images:
            result = await self._try_primary(prompt, reference_image_paths, **kwargs)
            if result.success:
                return result
            # 2. Primary 重试耗尽 → 切换 Fallback
            if self._is_retry_exhausted(result):
                if self.fallback.requires_async_mode:
                    return await self._try_fallback(prompt, reference_asset_ids, **kwargs)
                else:
                    return await self._try_fallback(prompt, reference_image_paths, **kwargs)
            return result

        # 3. 无本地参考图 + 有资产ID → 直接走 Fallback
        if reference_asset_ids and self.fallback.requires_async_mode:
            return await self._try_fallback(prompt, reference_asset_ids, **kwargs)

        # 4. 默认走 Primary
        return await self._try_primary(prompt, reference_image_paths, **kwargs)

    async def _try_primary(self, prompt: str, assets, **kwargs) -> ImageGenerationResult:
        # 实际调用 + 重试逻辑(指数退避)
        pass

    async def _try_fallback(self, prompt: str, assets, **kwargs) -> ImageGenerationResult:
        # Fallback 调用
        pass

    def _is_retry_exhausted(self, result: ImageGenerationResult) -> bool:
        """判断是否应该触发 Fallback"""
        # 429 限流、内容审核拒绝、网络超时 → 应切换
        RETRYABLE_CODES = {"RATE_LIMIT", "CONTENT_FILTER", "TIMEOUT", "NETWORK_ERROR"}
        return result.error_code in RETRYABLE_CODES

3.4 并发任务池

文件:src/core/services/image_task_pool.py

采用滑动窗口模式,保证容器中始终有 N 个并发任务运行:

import asyncio
from typing import List, Callable, Any, Coroutine

class ImageGenerationTaskPool:
    """
    并发任务池(滑动窗口)
    - 控制最大并发数,避免 Provider 限流
    - 失败自动重试,自动入队
    """

    def __init__(self, max_workers: int = 5, max_retries: int = 2):
        self.max_workers = max_workers
        self.max_retries = max_retries
        self.running: int = 0
        self.pending_queue: asyncio.Queue = asyncio.Queue()
        self.retry_counts: Dict[int, int] = {}

    async def submit_batch(
        self,
        tasks: List[Coroutine],
        on_progress: Callable[[int, int, Any], None] = None,
    ) -> List[Any]:
        """
        提交一批任务,自动控制并发
        Args:
            tasks: 任务列表
            on_progress: 进度回调 (idx, total, result)
        """
        results = [None] * len(tasks)
        for idx, task in enumerate(tasks):
            await self.pending_queue.put((idx, task))

        async def worker():
            while True:
                try:
                    idx, task_coro = self.pending_queue.get_nowait()
                except asyncio.QueueEmpty:
                    break

                self.running += 1
                try:
                    result = await task_coro
                    results[idx] = result

                    if not self._is_success(result) and self.retry_counts.get(idx, 0) < self.max_retries:
                        self.retry_counts[idx] = self.retry_counts.get(idx, 0) + 1
                        await self.pending_queue.put((idx, task_coro))

                    if on_progress:
                        await on_progress(idx, len(tasks), result)
                finally:
                    self.running -= 1
                    self.pending_queue.task_done()

        workers = [asyncio.create_task(worker()) for _ in range(self.max_workers)]
        await asyncio.gather(*workers)
        return results

    def _is_success(self, result: Any) -> bool:
        return getattr(result, "success", False)

3.5 提示词管理器(按场景类型)

文件:src/core/prompts/prompt_manager.py

每个 Provider 维护独立的 YAML 提示词目录,按场景类型精准匹配:

yaml_prompts/
  provider_a/                    ← Provider A 专用提示词
    封面.yaml
    背景页.yaml
    内容页.yaml
    数据图表.yaml
    ...
  provider_b/                    ← Provider B 专用提示词
    封面.yaml
    背景页.yaml
    内容页.yaml
    ...

使用方式:

class PromptManager:
    def __init__(self, prompts_dir: str = "yaml_prompts"):
        self.prompts_dir = prompts_dir
        self._cache: Dict[str, Dict[str, str]] = {}

    def get_prompt(self, provider: str, page_type: str) -> str:
        cache_key = f"{provider}:{page_type}"
        if cache_key not in self._cache:
            path = Path(self.prompts_dir) / provider / f"{page_type}.yaml"
            self._cache[cache_key] = yaml.safe_load(path.read_text())
        return self._cache[cache_key]["prompt"]

4. 调度决策树

用户请求
   │
   ├── 有本地参考图路径?
   │      │
   │      ├─ 是 ──→ 检查 Primary 是否支持参考图
   │      │          │
   │      │          ├─ 支持 ──→ 调用 Primary
   │      │          │          ├─ 成功 ──→ 返回结果
   │      │          │          ├─ 限流/超时/审核拒绝 ──→ 切换 Fallback
   │      │          │          └─ 其他错误 ──→ 返回错误
   │      │          │
   │      │          └─ 不支持 ──→ 直接调用 Fallback
   │      │
   │      └─ 否
   │              │
   │              ├── 有远端资产 ID 列表?
   │              │      │
   │              │      ├─ 是 ──→ 调用 Fallback(支持资产引用)
   │              │      └─ 否 ──→ 调用 Primary(无参考图模式)
   │              │
   └── (任务池层处理并发、重试、进度推送)

5. Provider 配置说明

5.1 Provider A(Primary)

适用于:需要图片理解/风格迁移、支持本地参考图输入的场景。

配置项说明
API 端点https://api.provider-a.example/v1/chat/completions
鉴权方式Bearer Token(通过环境变量注入)
参考图输入本地文件路径 → base64 压缩后内嵌请求体
输出格式流式 SSE,内含 base64 编码图片数据
重试策略429 限流 / 内容审核拒绝:指数退避,最多 3 次

5.2 Provider B(Fallback)

适用于:国内直连、成本较低、支持资产 ID 引用的场景。

配置项说明
API 端点国内直连域名(无需代理)
鉴权方式云服务商 SecretId / SecretKey(通过配置文件注入)
参考图输入预上传后获得的资产 ID 列表(0~3 个)
输出格式异步任务 → 轮询获取结果 → 下载图片 URL
轮询策略每 3 秒轮询一次,最多 120 次(约 6 分钟超时)

6. 如何接入新的 Provider

以接入一个新 Provider 为例,完整步骤如下:

6.1 实现 Provider 类

文件:src/core/providers/generator_openai.py

from .base_generator import BaseImageGenerator, ImageGenerationResult, register_provider
from .network_helper import download_image_bytes
import requests, base64, os

class OpenAIImageGenerator(BaseImageGenerator):

    def __init__(self, api_key: str, base_url: str = "https://api.openai.com", **kwargs):
        self.api_key = api_key
        self.base_url = base_url
        self.max_retries = kwargs.get("max_retries", 3)

    @property
    def provider_name(self) -> str:
        return "openai"

    @property
    def supports_reference_images(self) -> bool:
        return True  # 支持图片参考

    @property
    def requires_async_mode(self) -> bool:
        return False  # 同步返回

    def _prepare_reference_images(self, paths: List[str]) -> List[Dict]:
        """将本地图片转为 base64 格式"""
        images = []
        for path in paths:
            with open(path, "rb") as f:
                data = base64.b64encode(f.read()).decode()
                images.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{data}"}})
        return images

    async def generate_async(
        self,
        prompt: str,
        reference_image_paths: List[str] = None,
        **kwargs
    ) -> ImageGenerationResult:
        try:
            headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
            body = {"prompt": prompt, "n": 1, "size": "1024x1024"}

            if reference_image_paths:
                body["images"] = self._prepare_reference_images(reference_image_paths)

            resp = requests.post(
                f"{self.base_url}/v1/images/generations",
                headers=headers, json=body, timeout=60
            )

            if resp.status_code == 200:
                image_url = resp.json()["data"][0]["url"]
                image_data = download_image_bytes(image_url)
                return ImageGenerationResult(success=True, image_data=image_data, metadata=resp.json())
            else:
                return ImageGenerationResult(
                    success=False,
                    error=resp.text,
                    error_code=self._classify_error(resp.status_code)
                )
        except Exception as e:
            return ImageGenerationResult(success=False, error=str(e), error_code="NETWORK_ERROR")

    def generate(self, prompt: str, **kwargs) -> ImageGenerationResult:
        return asyncio.run(self.generate_async(prompt, **kwargs))

    def download_and_save_image(self, url: str, save_path: str) -> bool:
        data = download_image_bytes(url)
        if data:
            os.makedirs(os.path.dirname(save_path), exist_ok=True)
            with open(save_path, "wb") as f:
                f.write(data)
            return True
        return False

    def _classify_error(self, status_code: int) -> str:
        mapping = {429: "RATE_LIMIT", 400: "BAD_REQUEST", 401: "AUTH_ERROR", 500: "SERVER_ERROR"}
        return mapping.get(status_code, "UNKNOWN_ERROR")

# 注册到注册中心
register_provider("openai", OpenAIImageGenerator)

6.2 添加配置文件

# config/image_providers.yaml

providers:
  openai:
    enabled: true
    api_key: ${OPENAI_API_KEY}
    base_url: "https://api.openai.com"
    max_retries: 3
    timeout: 60

  siliconflow:  # Fallback Provider
    enabled: true
    api_key: ${SILICONFLOW_API_KEY}
    base_url: "https://api.siliconflow.cn/v1"
    max_retries: 3
    timeout: 120

6.3 启用 Provider

# 在应用初始化时
from src.core.providers import create_generator

primary = create_generator("openai", config={"api_key": os.getenv("OPENAI_API_KEY")})
fallback = create_generator("siliconflow", config={"api_key": os.getenv("SILICONFLOW_API_KEY")})

service = ImageGenerationService(primary=primary, fallback=fallback)

7. 网络路由策略

流量类型路由方式说明
Provider A(海外 API)VPN / 代理海外 endpoint,需配置出口代理
Provider B(国内 API)直连国内云服务,直接访问
结果图片下载直连CDN / 云存储,均为国内域名

推荐实现: 在系统代理层(如 Clash Verge TUN 模式)按域名规则分流,业务代码无感知。详见:多网络环境隔离方案


8. 目录结构

src/
├── core/
│   ├── providers/
│   │   ├── provider_registry.py       ← Provider 注册中心
│   │   ├── base_generator.py           ← 统一接口契约(抽象基类)
│   │   ├── generator_provider_a.py     ← Provider A 实现(示例)
│   │   ├── generator_provider_b.py     ← Provider B 实现(示例)
│   │   ├── generator_openai.py         ← OpenAI Provider 示例
│   │   └── network_helper.py           ← 网络请求辅助工具
│   └── services/
│       ├── image_generation_service.py ← 调度器(Primary/Fallback)
│       └── image_task_pool.py           ← 并发任务池
├── prompts/
│   └── page_type_manager.py            ← 提示词管理器
└── config/
    └── image_providers.yaml             ← Provider 配置文件

9. 已知限制与改进方向

9.1 当前版本已知限制

编号限制描述改进建议状态
L1全局单例无法重置,多租户/测试场景串台会话级实例工厂 + TTL 清理✅ 已修复
L2Fallback 触发条件依赖字符串关键字,随时失效结构化 ErrorCode 枚举✅ 已修复
L3提示词与 Provider 强耦合多级兜底链 + 自动降级日志✅ 已修复
L4部分 Provider 接受语义尺寸(如"2K"),不支持精确像素映射在调度层统一归一化为像素尺寸待处理

9.2 可选增强方向

增强项说明
指标采集接入 Prometheus,采集每个 Provider 的成功率、平均耗时、成本
熔断器当某个 Provider 连续失败 N 次后,自动熔断降级,不再尝试
灰度切换支持按用户、按场景类型选择 Provider,逐步放量
成本优化增加用量统计,高峰期自动切换到更经济的 Provider
多级兜底从 Primary → Fallback1 → Fallback2,支持更长的降级链

10. 安全注意事项

  1. 密钥不硬编码:所有 API Key / Secret 通过环境变量注入,不提交到代码仓库
  2. 文件路径校验reference_image_paths 必须为已存在的本地文件,防止路径穿越攻击
  3. 资产 ID 隔离:资产 ID 仅来自可信数据源(数据库/Redis),不接受用户直接传入
  4. 错误信息脱敏:向上层暴露的错误信息不含内部路径、堆栈、Key 信息

11. 部署检查清单

  • 所有 Provider 的 API Key / Secret 已正确注入环境变量
  • 海外 Provider 的 VPN 代理已启动并验证连通性
  • 国内 Provider 的云端配额充足,未达上限
  • 参考图预上传流程已验证可用
  • max_workers 根据服务器 CPU/内存调整(建议 CPU 核数 / 2)
  • 任务池并发数与 Provider 侧 Rate Limit 匹配
  • 图片输出目录已创建且有写权限
  • 域名分流规则已配置(详见网络隔离方案)