# RFC: 供应商平台化架构设计 ## 背景 ### 当前问题 1. **硬编码适配器**: `gemini`, `flux`, `minimax` 写死在代码中 2. **新供应商需改代码**: 接入 nanobanana 等新供应商需要修改 `provider_router.py` 3. **无法动态切换**: 供应商故障时需要重启服务 4. **缺乏监控**: 不知道哪个供应商更快、更便宜、更稳定 ### 目标 - **零代码接入**: 通过后台配置即可接入新供应商 - **动态切换**: 运行时切换供应商,无需重启 - **智能路由**: 基于成本、延迟、成功率自动选择最优供应商 - **可观测性**: 供应商健康状态、成本、性能一目了然 --- ## 架构设计 ### 1. 整体架构 ``` ┌─────────────────────────────────────────────────────────────┐ │ Admin Dashboard │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │ │ 供应商管理 │ │ 健康监控 │ │ 成本分析 │ │ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Provider Router │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ 路由策略: Priority → Weight → Health → Cost │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ ┌───────────┬───────────┬───────────┬───────────────────┐ │ │ │ Adapter │ Adapter │ Adapter │ Adapter │ │ │ │ Registry │ Factory │ Health │ Metrics │ │ │ └───────────┴───────────┴───────────┴───────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ Text Adapters │ │ Image Adapters│ │ TTS Adapters │ ├───────────────┤ ├───────────────┤ ├───────────────┤ │ • Gemini │ │ • Flux │ │ • Minimax │ │ • OpenAI │ │ • Nanobanana │ │ • ElevenLabs │ │ • Claude │ │ • DALL-E │ │ • Azure TTS │ │ • Qwen │ │ • Midjourney │ │ • Google TTS │ └───────────────┘ └───────────────┘ └───────────────┘ ``` ### 2. 核心组件 #### 2.1 Adapter 接口定义 ```python # 统一适配器接口 from abc import ABC, abstractmethod from typing import TypeVar, Generic from pydantic import BaseModel T = TypeVar("T") class AdapterConfig(BaseModel): """适配器配置基类""" api_key: str api_base: str | None = None model: str | None = None timeout_ms: int = 60000 max_retries: int = 3 class BaseAdapter(ABC, Generic[T]): """适配器基类""" # 适配器元信息 adapter_type: str # text / image / tts adapter_name: str # gemini / flux / minimax def __init__(self, config: AdapterConfig): self.config = config @abstractmethod async def execute(self, **kwargs) -> T: """执行适配器逻辑""" pass @abstractmethod async def health_check(self) -> bool: """健康检查""" pass @property @abstractmethod def estimated_cost(self) -> float: """预估单次调用成本 (USD)""" pass ``` #### 2.2 适配器注册表 ```python # 适配器注册表 - 支持动态注册 class AdapterRegistry: """适配器注册表""" _adapters: dict[str, type[BaseAdapter]] = {} @classmethod def register(cls, adapter_type: str, adapter_name: str): """装饰器: 注册适配器""" def decorator(adapter_class: type[BaseAdapter]): key = f"{adapter_type}:{adapter_name}" cls._adapters[key] = adapter_class return adapter_class return decorator @classmethod def get(cls, adapter_type: str, adapter_name: str) -> type[BaseAdapter] | None: key = f"{adapter_type}:{adapter_name}" return cls._adapters.get(key) @classmethod def list_adapters(cls, adapter_type: str | None = None) -> list[str]: """列出所有已注册的适配器""" if adapter_type: return [k for k in cls._adapters if k.startswith(f"{adapter_type}:")] return list(cls._adapters.keys()) ``` #### 2.3 适配器实现示例 ```python # 图像适配器示例: Nanobanana @AdapterRegistry.register("image", "nanobanana") class NanobananapAdapter(BaseAdapter[str]): adapter_type = "image" adapter_name = "nanobanana" async def execute(self, prompt: str, **kwargs) -> str: """生成图片,返回 URL""" async with httpx.AsyncClient(timeout=self.config.timeout_ms / 1000) as client: response = await client.post( f"{self.config.api_base}/generate", json={"prompt": prompt, "model": self.config.model}, headers={"Authorization": f"Bearer {self.config.api_key}"}, ) response.raise_for_status() return response.json()["image_url"] async def health_check(self) -> bool: # 简单的健康检查 try: async with httpx.AsyncClient(timeout=5) as client: response = await client.get(f"{self.config.api_base}/health") return response.status_code == 200 except Exception: return False @property def estimated_cost(self) -> float: return 0.02 # $0.02 per image ``` #### 2.4 智能路由器 ```python class ProviderRouter: """智能供应商路由器""" def __init__(self, db: AsyncSession): self.db = db self._health_cache: dict[str, tuple[bool, float]] = {} # adapter_key -> (healthy, last_check) async def route( self, provider_type: str, strategy: str = "priority", # priority / cost / latency / round_robin **kwargs ): """路由到最优供应商""" providers = await self._get_enabled_providers(provider_type) if not providers: raise ValueError(f"No {provider_type} providers configured") # 按策略排序 sorted_providers = self._sort_by_strategy(providers, strategy) errors = [] for provider in sorted_providers: # 检查健康状态 if not await self._is_healthy(provider): continue try: adapter = self._create_adapter(provider) result = await adapter.execute(**kwargs) # 记录成功指标 await self._record_metrics(provider, success=True) return result except Exception as e: errors.append(f"{provider.name}: {e}") await self._record_metrics(provider, success=False, error=str(e)) continue raise ValueError(f"All providers failed: {' | '.join(errors)}") def _sort_by_strategy(self, providers: list[Provider], strategy: str) -> list[Provider]: if strategy == "priority": return sorted(providers, key=lambda p: (-p.priority, -p.weight)) elif strategy == "cost": return sorted(providers, key=lambda p: self._get_estimated_cost(p)) elif strategy == "latency": return sorted(providers, key=lambda p: self._get_avg_latency(p)) else: return providers ``` ### 3. 数据模型扩展 ```sql -- 供应商表 (已有,需扩展) ALTER TABLE providers ADD COLUMN api_key_ref VARCHAR(100); -- 密钥引用 (从 secrets 表获取) ALTER TABLE providers ADD COLUMN request_schema JSONB; -- 请求参数 schema ALTER TABLE providers ADD COLUMN response_parser VARCHAR(200); -- 响应解析规则 -- 供应商指标表 (新增) CREATE TABLE provider_metrics ( id SERIAL PRIMARY KEY, provider_id VARCHAR(36) REFERENCES providers(id), timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(), success BOOLEAN, latency_ms INTEGER, cost_usd DECIMAL(10, 6), error_message TEXT, request_id VARCHAR(100) ); -- 供应商健康状态表 (新增) CREATE TABLE provider_health ( provider_id VARCHAR(36) PRIMARY KEY REFERENCES providers(id), is_healthy BOOLEAN DEFAULT TRUE, last_check TIMESTAMP WITH TIME ZONE, consecutive_failures INTEGER DEFAULT 0, last_error TEXT ); -- 密钥管理表 (新增) CREATE TABLE provider_secrets ( id VARCHAR(36) PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, encrypted_value TEXT NOT NULL, -- 加密存储 created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); ``` ### 4. Admin Dashboard 功能 #### 4.1 供应商管理 - 供应商列表 (启用/禁用/删除) - 新增供应商 (选择适配器类型 + 配置参数) - 编辑供应商 (修改优先级/权重/超时等) - 测试连接 (验证 API Key 有效性) #### 4.2 健康监控 - 实时健康状态 (绿/黄/红) - 成功率趋势图 - 延迟分布图 - 故障告警配置 #### 4.3 成本分析 - 按供应商统计调用量 - 按供应商统计成本 - 成本趋势图 - 预算告警 #### 4.4 A/B 测试 - 创建实验 (供应商 A vs B) - 流量分配 (50/50 或自定义) - 效果对比 (成功率/延迟/成本) --- ## 实现路径 ### 阶段 1: 适配器抽象 (基础) - ✅ 已完成 | 任务 | 状态 | 文件 | |------|------|------| | 定义 `BaseAdapter` 接口 | ✅ | `services/adapters/base.py` | | 实现 `AdapterRegistry` 注册表 | ✅ | `services/adapters/registry.py` | | 重构 GeminiAdapter | ✅ | `services/adapters/text/gemini.py` | | 重构 FluxAdapter | ✅ | `services/adapters/image/flux.py` | | 重构 MinimaxAdapter | ✅ | `services/adapters/tts/minimax.py` | | 重构 `ProviderRouter` 使用新接口 | ✅ | `services/provider_router.py` | ### 阶段 2: 新供应商接入 (扩展) - 待开始 1. 实现 Nanobanana 适配器 2. 实现 OpenAI/Claude 文本适配器 3. 实现 ElevenLabs TTS 适配器 4. 验证零代码接入流程 ### 阶段 3: 监控与分析 (可观测) - 待开始 1. 实现指标收集 2. 实现健康检查 3. 实现成本追踪 4. Admin Dashboard 开发 ### 阶段 4: 智能路由 (优化) - 待开始 1. 实现多种路由策略 2. 实现自动故障转移 3. 实现 A/B 测试框架 --- ## 并行执行与容错设计 ### 问题 当前串行流程存在两个问题: 1. **等待时间长**: 故事(3-5s) → 封面(5-10s) → 音频(3-5s) = 总计 11-20s 2. **单点失败**: 某一步502/超时导致整个流程失败 ### 方案 1: 并行执行 ```python async def generate_story_full(keywords: list[str]) -> StoryResult: # Step 1: 故事生成(必须先完成,后续依赖它) story = await generate_story_content(keywords) # Step 2: 图片和音频并行执行 image_task = asyncio.create_task(generate_image(story.summary)) audio_task = asyncio.create_task(text_to_speech(story.content)) # 等待两者完成,互不阻塞 image_result, audio_result = await asyncio.gather( image_task, audio_task, return_exceptions=True # 一个���败不影响另一个 ) return StoryResult( story=story, image_url=image_result if not isinstance(image_result, Exception) else None, audio_url=audio_result if not isinstance(audio_result, Exception) else None, errors={ "image": str(image_result) if isinstance(image_result, Exception) else None, "audio": str(audio_result) if isinstance(audio_result, Exception) else None, } ) ``` **时间对比:** ``` 串行: 3s + 8s + 4s = 15s 并行: 3s + max(8s, 4s) = 11s (节省 27%) ``` ### 方案 2: 部分成功处理 **核心原则: 部分成功 > 全部失败** ```python @dataclass class StoryResult: story: Story # 核心,必须成功 image_url: str | None = None # 增强,可降级 audio_url: str | None = None # 增强,可降级 errors: dict[str, str] = field(default_factory=dict) @property def is_complete(self) -> bool: return self.image_url is not None and self.audio_url is not None @property def failed_components(self) -> list[str]: return [k for k, v in self.errors.items() if v is not None] ``` **降级策略:** | 组件 | 失败时降级方案 | 用户体验 | |------|---------------|---------| | 故事 | 无降级,整体失败 | 显示错误,提示重试 | | 封面 | 使用默认封面图 | 显示占位图 + "重新生成"按钮 | | 音频 | 不生成音频 | 隐藏播放按钮 + "生成语音"按钮 | ### 方案 3: 流式返回 (SSE) **为什么用 SSE:** - 用户无需等待全部完成 - 每完成一步立即展示 - 比 WebSocket 简单,HTTP 兼容性好 **后端实现:** ```python from fastapi import APIRouter from sse_starlette.sse import EventSourceResponse router = APIRouter() @router.post("/api/generate/stream") async def generate_story_stream( request: GenerateRequest, current_user: User = Depends(get_current_user), ): async def event_generator(): # 1. 立即返回任务ID story_id = str(uuid.uuid4()) yield {"event": "started", "data": json.dumps({"story_id": story_id})} # 2. 生成故事 try: story = await generate_story_content(request.keywords) yield {"event": "story_ready", "data": json.dumps({ "title": story.title, "content": story.content, })} except Exception as e: yield {"event": "story_failed", "data": json.dumps({"error": str(e)})} return # 3. 并行生成图片和音频 async def gen_image(): try: url = await generate_image(story.summary) yield {"event": "image_ready", "data": json.dumps({"image_url": url})} except Exception as e: yield {"event": "image_failed", "data": json.dumps({"error": str(e)})} async def gen_audio(): try: url = await text_to_speech(story.content) yield {"event": "audio_ready", "data": json.dumps({"audio_url": url})} except Exception as e: yield {"event": "audio_failed", "data": json.dumps({"error": str(e)})} # 并行执行,逐个yield结果 tasks = [gen_image(), gen_audio()] for coro in asyncio.as_completed([t.__anext__() for t in tasks]): result = await coro yield result yield {"event": "complete", "data": json.dumps({"story_id": story_id})} return EventSourceResponse(event_generator()) ``` **前端实现:** ```typescript const eventSource = new EventSource('/api/generate/stream', { method: 'POST', body: JSON.stringify({ keywords }), }); eventSource.addEventListener('started', (e) => { const { story_id } = JSON.parse(e.data); showLoading('正在创作故事...'); }); eventSource.addEventListener('story_ready', (e) => { const { title, content } = JSON.parse(e.data); renderStory(title, content); showLoading('正在生成封面和语音...'); }); eventSource.addEventListener('image_ready', (e) => { const { image_url } = JSON.parse(e.data); renderCover(image_url); }); eventSource.addEventListener('image_failed', (e) => { showRetryButton('image'); }); eventSource.addEventListener('audio_ready', (e) => { const { audio_url } = JSON.parse(e.data); enablePlayButton(audio_url); }); eventSource.addEventListener('complete', () => { eventSource.close(); hideLoading(); }); ``` **用户体验时间线:** ``` 0s → 显示"正在创作..." 3s → 故事文本渲染,显示"正在生成封面和语音..." 3-7s → 音频就绪,播放按钮可用 3-11s → 封面就绪,图片显示 11s → 完成 ``` ### 方案 4: 断点续传 (可选) 适用于网络不稳定场景,支持刷新页面后继续: ```python class StoryWorkflowState(Base): __tablename__ = "story_workflow_states" story_id: Mapped[str] = mapped_column(String(36), primary_key=True) status: Mapped[str] = mapped_column(String(20)) # pending/story_done/image_done/audio_done/complete story_content: Mapped[str | None] = mapped_column(Text) image_url: Mapped[str | None] = mapped_column(String(500)) audio_url: Mapped[str | None] = mapped_column(String(500)) last_error: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, onupdate=datetime.utcnow) async def resume_workflow(story_id: str) -> StoryResult: state = await get_workflow_state(story_id) if state.status == "story_done": # 从图片+音频生成继续 return await generate_image_and_audio(state) elif state.status == "image_done": # 只需要生成音频 return await generate_audio_only(state) elif state.status == "audio_done": # 只需要生成图片 return await generate_image_only(state) else: return StoryResult.from_state(state) ``` ### 推荐实现顺序 | 优先级 | 方案 | 收益 | 复杂度 | 状态 | |--------|------|------|--------|------| | P0 | 并行执行 | 节省 27% 时间 | 低 | ✅ 已完成 | | P0 | 部分成功 | 提升容错性 | 低 | ✅ 已完成 | | P1 | SSE 流式返回 | 体验大幅提升 | 中 | 待开始 | | P2 | 断点续传 | 极端场景保障 | 高 | 待开始 | **P0 实现详情:** - 新增 API: `POST /api/generate/full` - 文件: `api/stories.py:113-189` - 响应模型: `FullStoryResponse` (含 `errors` 字段标识失败组件) --- ## 待决策清单 > **使用说明**: 在每个决策的 `[ ]` 中填入你的选择(如 `[x]` 或 `[B]`),确认后删除未选中的选项。 --- ### 决策 1: 适配器配置存储 **问题**: 适配器的配置信息(API地址、模型名、超时等)存在哪里? | 选项 | 方案 | 优点 | 缺点 | |------|------|------|------| | [ ] A | 全部存数据库 | 完全动态,运行时可改 | 需要管理界面,初始化复杂 | | [ ] B | 代码定义 + DB配置 | 平衡,核心逻辑在代码,参数可调 | 新适配器仍需改代码 | | [ ] C | 配置文件 (YAML/JSON) | 简单,版本控制友好 | 改配置需重启 | **推荐**: B(代码定义适配器类,DB存储启用状态/优先级/API Key引用) --- ### 决策 2: 密钥管理 **问题**: API Key 等敏感信息如何存储? | 选项 | 方案 | 优点 | 缺点 | |------|------|------|------| | [ ] A | 环境变量 | 简单,当前方式 | 多供应商时env膨胀,改key需重启 | | [ ] B | 数据库加密存储 | 动态管理,支持多key | 需要加密方案,安全风险 | | [ ] C | 外部密钥服务 (Vault/AWS Secrets) | 企业级安全 | 复杂,增加依赖 | **推荐**: A(当前阶段),后期可迁移到B --- ### 决策 3: 图像供应商优先级 **问题**: 接入多个图像供应商后,默认使用哪个? | 选项 | 供应商 | 特点 | 预估成本 | |------|--------|------|----------| | [ ] 1 | Nanobanana | 新兴,据说效果好 | 待调研 | | [ ] 2 | Flux (当前) | 稳定,已接入 | ~$0.03/张 | | [ ] 3 | DALL-E 3 | OpenAI出品,质量高 | ~$0.04/张 | | [ ] 4 | Midjourney | 艺术风格强 | API受限 | **推荐**: 先调研Nanobanana,效果好则替换Flux --- ### 决策 4: 文本供应商优先级 **问题**: 故事生成使用哪个LLM? | 选项 | 供应商 | 特点 | 预估成本 | |------|--------|------|----------| | [ ] 1 | Gemini (当前) | 免费额度大,中文好 | 免费/低成本 | | [ ] 2 | OpenAI GPT-4o | 质量稳定 | ~$0.01/1K tokens | | [ ] 3 | Claude | 创意写作强 | ~$0.015/1K tokens | | [ ] 4 | Qwen (通义千问) | 国内,中文优化 | 待调研 | **推荐**: Gemini为主,OpenAI备用 --- ### 决策 5: TTS供应商优先级 **问题**: 语音合成使用哪个服务? | 选项 | 供应商 | 特点 | 预估成本 | |------|--------|------|----------| | [ ] 1 | Minimax (当前) | 中文效果好,已接入 | ~$0.01/1K字符 | | [ ] 2 | ElevenLabs | 英文最佳,多语言 | ~$0.03/1K字符 | | [ ] 3 | Azure TTS | 稳定,多语言 | ~$0.016/1K字符 | | [ ] 4 | Google TTS | 便宜 | ~$0.004/1K字符 | **推荐**: Minimax为主(中文场景) --- ### 决策 6: Admin Dashboard 技术栈 **问题**: 供应商管理后台用什么技术? | 选项 | 方案 | 优点 | 缺点 | |------|------|------|------| | [ ] A | 复用 Vue 前端 | 技术栈统一,复用组件 | 需要自己写UI | | [ ] B | React Admin | 成熟的Admin框架 | 引入新技术栈 | | [ ] C | 现成方案 (AdminJS/Retool) | 开发快 | 定制性差,可能收费 | **推荐**: A(在现有Vue项目中加 `/admin` 路由) --- ### 决策 7: Phase 2 功能优先级 **问题**: 体验增强阶段先做哪个功能? | 选项 | 功能 | 用户价值 | 开发复杂度 | |------|------|----------|------------| | [ ] 1 | 故事编辑 | 高(用户可修改AI内容) | 中 | | [ ] 2 | 角色定制 | 高(孩子成为主角) | 低 | | [ ] 3 | 故事分享 | 高(增长引擎) | 中 | | [ ] 4 | 故事续写 | 中(延长使用时长) | 中 | **推荐**: 2 → 1 → 3 → 4(角色定制最快出效果) --- ### 决策 8: 并行与容错实现顺序 **问题**: 并行执行、部分成功、SSE、断点续传,先做哪些? | 选项 | 方案 | 说明 | |------|------|------| | [ ] A | P0先做 | 先实现并行+部分成功,快速见效 | | [ ] B | P0+P1一起 | 并行+部分成功+SSE,体验完整 | | [ ] C | 只做SSE | 跳过简单方案,直接上流式 | **推荐**: A(先P0,验证后再做SSE) --- ## 确认后删除此区块 确认所有决策后,可以删除未选中的选项,保留最终方案作为实现依据。