# LLM 多提供商配置管理 重构 PRD > **版本**: v1.0 > **日期**: 2026-05-09 > **状态**: 📝 待评审 --- ## 一、背景与动机 ### 1.1 现状分析 当前 `/admin/settings` 页面采用**按调用协议(Anthropic / OpenAI)硬编码**的方式管理 LLM 配置: | 层 | 文件 | 现状 | |---|---|---| | 存储 | settings.py | 12 个 `llm.*` key,按协议前缀分组 | | 服务 | settings.py | `get_llm_config()` 硬编码两套,`get_active_provider_config()` 用 if/else 分支 | | 调用 | llm.py | `chat_completion()` 按 `config["provider"]` 分发到 `_call_anthropic` / `_call_openai` | | 路由 | admin.py | POST 接收 12 个独立 Form 字段 | | 前端 | settings.html | 两张固定的 provider-card,点击切换 | ### 1.2 缺失的能力 | 场景 | 当前行为 | 期望行为 | |------|----------|----------| | 临时切换到另一个提供商 | 手动改 URL + Key + 模型名(3 个字段) | 一键切换 | | 切换回来 | 再手动改 3 个字段 | 一键切换 | | 某提供商同时支持两种协议 | 无法在一个配置下管理 | 同一提供商下可混合不同协议的模型 | | 添加新的提供商 | 只能在 Anthropic 或 OpenAI 二选一 | 自定义添加任意数量的提供商 | ### 1.3 用户核心诉求 > "我配置了提供商 A 的模型,想暂时换用提供商 B,目前只能改 URL 和 Key,改了之后模型名称又对不上了。切回来又要再改一次。" 本质:**从"单套配置"变成"多套配置方案"的管理方式。** --- ## 二、功能定义 ### 2.1 功能描述 将 LLM 设置从"按协议管理两套固定配置"重构为"按提供商管理多套自定义配置方案"。 核心变化: - **提供商(Profile)**:用户自定义的配置方案,包含名称、URL、API Key - **模型**:每个提供商下可配置多个模型,每个模型指定调用协议(anthropic / openai) - **全局参数**:temperature、max_tokens、timeout 等保持全局,不随提供商切换 ### 2.2 用户故事 1. 作为管理员,我想保存多套 LLM 提供商配置,这样我可以快速在不同提供商之间切换,而不用每次手动改多个字段 2. 作为管理员,我想给每个模型指定调用协议(Anthropic / OpenAI 兼容),这样同一个提供商下可以混合使用不同协议的模型 3. 作为管理员,我想保留现有的配置数据,升级后自动迁移,不需要重新配置 ### 2.3 交互设计 #### 页面布局 ``` ┌─────────────────────────────────────────────────────────────────┐ │ LLM 设置 [返回管理] │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─ 提供商列表 ────┐ ┌─ 编辑区 ──────────────────────────────┐ │ │ │ │ │ │ │ │ │ 🔵 我的 Claude │ │ 名称: [我的 Claude ] │ │ │ │ ○ DeepSeek │ │ Base URL: [https://api.anthropic...] │ │ │ │ ○ 本地 Ollama │ │ API Key: [sk-ant-...] [👁] [测试] │ │ │ │ │ │ │ │ │ │ [+ 新建提供商] │ │ ┌─ 模型列表 ─────────────────────┐ │ │ │ │ │ │ │ claude-sonnet-4 Claude 4 Sonnet │ │ │ │ │ │ │ │ 协议: [anthropic ▾] │ │ │ │ │ │ │ │ claude-opus-4 Claude 4 Opus │ │ │ │ │ │ │ │ 协议: [anthropic ▾] │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ [+ 添加模型] [删除当前] │ │ │ │ │ │ │ └──────────────────────────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ [保存提供商] [删除提供商] │ │ │ └─────────────────┘ └───────────────────────────────────────┘ │ │ │ │ ┌─ 全局参数 ──────────────────────────────────────────────────┐ │ │ │ Temperature: [0.7] Max Tokens: [8000] Timeout: [120s] │ │ │ │ 速率限制: [10] 次/分钟 [60] 次/小时 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ [保存全局参数] │ └─────────────────────────────────────────────────────────────────┘ ``` #### 交互流程 1. **切换提供商**:点击左侧列表中的提供商卡片 → 右侧编辑区显示该提供商的配置 2. **新建提供商**:点击 `[+ 新建提供商]` → 创建空配置 → 自动选中 → 右侧可编辑 3. **删除提供商**:点击 `[删除提供商]` → 确认弹窗 → 删除并切换到列表第一项(不允许删除最后一个) 4. **添加模型**:在模型列表点击 `[+ 添加模型]` → 新增一行,协议默认 openai 5. **删除模型**:点击模型行的 `×` 按钮 6. **测试连接**:使用当前编辑区的 URL + Key + 第一个模型发起测试请求 7. **保存**:提供商配置和全局参数分开保存,各自有独立的保存按钮 ### 2.4 API 设计 #### GET /admin/settings 返回设置页面,传入: - `profiles`: 所有配置方案列表 - `active_profile_id`: 当前激活的方案 ID - `global_config`: 全局参数(temperature, max_tokens, timeout, rate_limit) #### POST /admin/settings/save-profiles 保存提供商配置。 **请求体**(Form): ``` csrf_token: string active_profile_id: string # 当前激活的方案 ID profiles_json: string # JSON 序列化的方案列表 ``` **profiles_json 结构**: ```json [ { "id": "prof_1715234567_abc", "name": "我的 Claude", "base_url": "https://api.anthropic.com", "api_key": "sk-ant-...", "models": [ { "id": "claude-sonnet-4-20250514", "alias": "Claude 4 Sonnet", "protocol": "anthropic" }, { "id": "gpt-4o", "alias": "GPT-4o", "protocol": "openai" } ], "default_model_index": 0 } ] ``` **校验规则**: - `name` 必填,最长 50 字符 - `base_url` 必填,必须以 `http://` 或 `https://` 开头 - `api_key` 可选(某些本地模型不需要) - `models` 至少有一个模型 - 每个模型的 `id` 必填,`protocol` 必须是 `anthropic` 或 `openai` - `profiles` 不能为空(至少保留一个方案) **成功响应**:302 重定向到 `/admin/settings?success=1` **错误响应**:302 重定向到 `/admin/settings?error={message}` #### POST /admin/settings/save-global 保存全局参数。 **请求体**(Form): ``` csrf_token: string temperature: float (0-2) max_output_tokens: int (256-200000) request_timeout: int (10-600) rate_limit_per_minute: int (1-1000) rate_limit_per_hour: int (1-10000) ``` #### POST /admin/settings/test-connection(可选增强) 测试提供商连接。 **请求体**(JSON): ```json { "base_url": "https://api.anthropic.com", "api_key": "sk-ant-...", "model_id": "claude-sonnet-4-20250514", "protocol": "anthropic" } ``` **响应**: ```json // 成功 {"success": true, "model": "claude-sonnet-4-20250514", "latency_ms": 1200} // 失败 {"success": false, "error": "Invalid API key"} ``` ### 2.5 数据模型 **不新建表**,继续使用现有的 `settings` key-value 表。 #### 存储结构 | Key | Value | 说明 | |-----|-------|------| | `llm.active_profile_id` | `"prof_xxx"` | 当前激活的方案 ID | | `llm.profiles` | `JSON array` | 所有方案的 JSON 序列化 | | `llm.temperature` | `"0.7"` | 全局默认 temperature | | `llm.max_output_tokens` | `"8000"` | 全局默认最大输出 tokens | | `llm.request_timeout` | `"120"` | 全局默认请求超时(秒) | | `llm.rate_limit_per_minute` | `"10"` | 每分钟速率限制 | | `llm.rate_limit_per_hour` | `"60"` | 每小时速率限制 | #### Profile 数据结构 ```json { "id": "prof_1715234567_abc", "name": "我的 Claude", "base_url": "https://api.anthropic.com", "api_key": "sk-ant-...", "models": [ { "id": "claude-sonnet-4-20250514", "alias": "Claude 4 Sonnet", "protocol": "anthropic" } ], "default_model_index": 0 } ``` 字段说明: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | id | string | 自动生成 | 格式 `prof_{timestamp}_{random}` | | name | string | ✅ | 用户自定义名称,最长 50 字符 | | base_url | string | ✅ | API 端点地址 | | api_key | string | ❌ | API 密钥(本地模型可为空) | | models | array | ✅ | 模型列表,至少 1 个 | | models[].id | string | ✅ | 模型 ID,用于 API 调用 | | models[].alias | string | ❌ | 显示别名 | | models[].protocol | string | ✅ | `"anthropic"` 或 `"openai"` | | default_model_index | int | ❌ | 默认模型的索引,默认 0 | #### 向后兼容迁移 在 `init_db()` 中检测旧 key 存在时自动转换: ```python def _migrate_llm_profiles(cursor): """将旧的按协议存储的配置迁移到新的 profile 格式""" cursor.execute( "SELECT key, value FROM settings " "WHERE key LIKE 'llm.anthropic_%' OR key LIKE 'llm.openai_%' " "OR key = 'llm.active_provider'" ) old = {row["key"]: row["value"] for row in cursor.fetchall()} if not old: return # 已迁移或全新安装 # 检查是否已经迁移过 cursor.execute("SELECT 1 FROM settings WHERE key = 'llm.profiles'") if cursor.fetchone(): return profiles = [] import time, json # 迁移 Anthropic 配置 if old.get("llm.anthropic_api_key") or old.get("llm.anthropic_base_url"): models = json.loads(old.get("llm.anthropic_models_json", "[]")) profiles.append({ "id": f"prof_{int(time.time())}_anthropic", "name": "Anthropic", "base_url": old.get("llm.anthropic_base_url", "https://api.anthropic.com"), "api_key": old.get("llm.anthropic_api_key", ""), "models": [{"id": m["id"], "alias": m.get("alias", ""), "protocol": "anthropic"} for m in models] or [ {"id": "claude-sonnet-4-20250514", "alias": "Claude 4 Sonnet", "protocol": "anthropic"} ], "default_model_index": 0, }) # 迁移 OpenAI 配置 if old.get("llm.openai_api_key") or old.get("llm.openai_base_url"): models = json.loads(old.get("llm.openai_models_json", "[]")) profiles.append({ "id": f"prof_{int(time.time())}_openai", "name": "OpenAI 兼容", "base_url": old.get("llm.openai_base_url", "https://api.openai.com/v1"), "api_key": old.get("llm.openai_api_key", ""), "models": [{"id": m["id"], "alias": m.get("alias", ""), "protocol": "openai"} for m in models] or [ {"id": "gpt-4o", "alias": "GPT-4o", "protocol": "openai"} ], "default_model_index": 0, }) if not profiles: return # 确定激活的方案 active_provider = old.get("llm.active_provider", "anthropic") active_profile = next( (p for p in profiles if any(m["protocol"] == active_provider for m in p["models"])), profiles[0] ) # 写入新格式 cursor.execute( "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))", ("llm.profiles", json.dumps(profiles, ensure_ascii=False)) ) cursor.execute( "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))", ("llm.active_profile_id", active_profile["id"]) ) # 清理旧 key old_keys = [ "llm.active_provider", "llm.anthropic_base_url", "llm.anthropic_api_key", "llm.anthropic_models_json", "llm.openai_base_url", "llm.openai_api_key", "llm.openai_models_json", ] for key in old_keys: cursor.execute("DELETE FROM settings WHERE key = ?", (key,)) ``` ### 2.6 安全与限制 | 项目 | 策略 | |------|------| | 认证 | 需登录 + `prompt.entry.view_admin` 权限查看,`prompt.entry.edit_any` 权限修改 | | CSRF | 所有 POST 请求验证 CSRF token | | API Key 存储 | 明文存储在 SQLite(与现有行为一致,后续可考虑加密) | | 速率限制 | POST 接口 20 次/分钟(与现有行为一致) | | 输入校验 | URL 格式、temperature 范围、token 范围等 | | 最少方案数 | 不允许删除最后一个方案,至少保留一个 | --- ## 三、技术方案 ### 3.1 架构变更 ``` 改动前: settings.html → POST 12个字段 → admin.py → update_settings(12个key) → get_llm_config() 硬编码两套 → get_active_provider_config() if/else → llm.py 按 provider 分发 改动后: settings.html → POST profiles_json + global_params → admin.py → save_profiles() / save_global_params() → settings.py → get_active_profile() 动态获取 → get_active_provider_config() 从 profile 构建 → llm.py 不变(仍按 config["provider"] 分发) ``` ### 3.2 文件改动清单 | 文件 | 改动 | 说明 | |------|------|------| | `prompt/src/services/settings.py` | **中** | 新增 `get_all_profiles()`, `get_active_profile()`, `save_profiles()`;重构 `get_active_provider_config()`;保留 `get_llm_config()` 只返回全局参数 | | `prompt/src/services/db.py` | **小** | `init_db()` 末尾加迁移函数调用 | | `prompt/src/routes/admin.py` | **中** | settings 路由改为接收 `profiles_json`;新增 test-connection 路由 | | `prompt/templates/admin/settings.html` | **大** | 完全重写:左侧列表 + 右侧编辑区 + 模型列表动态增删 | | `prompt/src/services/llm.py` | **不动** | 只看 `config["provider"]`,不感知 profile 概念 | | `prompt/src/services/rate_limiter.py` | **不动** | 读全局 rate_limit | ### 3.3 settings.py 核心函数 ```python import json import time import secrets def get_all_profiles() -> list[dict]: """获取所有配置方案""" raw = get_setting("llm.profiles") or "[]" try: profiles = json.loads(raw) return profiles if isinstance(profiles, list) else [] except json.JSONDecodeError: return [] def get_active_profile() -> dict | None: """获取当前激活的配置方案""" profiles = get_all_profiles() if not profiles: return None active_id = get_setting("llm.active_profile_id") return next((p for p in profiles if p["id"] == active_id), profiles[0]) def save_profiles(profiles: list[dict], active_id: str) -> bool: """保存所有配置方案""" return update_setting("llm.profiles", json.dumps(profiles, ensure_ascii=False)) \ and update_setting("llm.active_profile_id", active_id) def generate_profile_id() -> str: """生成方案 ID""" ts = int(time.time()) rand = secrets.token_hex(4) return f"prof_{ts}_{rand}" def get_active_provider_config() -> dict: """从当前 profile 构建调用配置(返回格式不变,llm.py 无需改动)""" profile = get_active_profile() if not profile: raise LLMError("没有配置任何 LLM 提供商", "config_error") models = profile.get("models", []) default_idx = profile.get("default_model_index", 0) # 获取全局参数 global_config = get_llm_config() return { "provider": models[default_idx]["protocol"] if models and default_idx < len(models) else "openai", "base_url": profile["base_url"], "api_key": profile.get("api_key", ""), "default_model": models[default_idx]["id"] if models and default_idx < len(models) else "", "available_models": [m["id"] for m in models], "temperature": global_config["temperature"], "max_output_tokens": global_config["max_output_tokens"], "request_timeout": global_config["request_timeout"], } ``` ### 3.4 前端实现要点 1. **提供商列表**:用 JS 动态渲染,点击切换编辑目标 2. **模型列表**:每个模型行包含 model_id、alias、protocol 下拉框 3. **表单提交**:用隐藏字段 `profiles_json` 存储 JSON,JS 在 submit 前序列化 4. **测试连接**:AJAX 请求,显示成功/失败结果 5. **新建/删除**:纯前端操作,保存时一起提交 --- ## 四、优先级与排期 | 阶段 | 内容 | 预计时间 | 依赖 | |------|------|----------|------| | P0 | settings.py:新增 profile CRUD 函数 | 0.5h | 无 | | P0 | db.py:迁移逻辑 | 0.5h | P0 settings.py | | P0 | admin.py:重写 settings 路由 | 1h | P0 settings.py | | P0 | settings.html:重写前端 | 3h | P0 admin.py | | P1 | 测试连接 API(AJAX) | 0.5h | P0 | | P1 | 迁移测试 + 功能测试 | 1h | P0 | | **总计** | | **6.5h** | | --- ## 五、技术风险与决策点 ### 5.1 决策记录 | 决策点 | 选项 | 选择 | 理由 | |--------|------|------|------| | 存储方式 | A: JSON 单字段 / B: 新建表 | A | 不改表结构,迁移简单,对这个项目规模足够 | | 参数粒度 | A: 全局 / B: 方案级 / C: 模型级 | A | 大多数用户切换提供商时不需要改参数,减少复杂度 | | 协议位置 | A: 方案级 / B: 模型级 | B | 支持同一提供商下混合不同协议的模型 | | 速率限制 | A: 全局 / B: 方案级 | A | 安全措施,不应随提供商切换 | | llm.py | A: 改 / B: 不改 | B | `get_active_provider_config()` 返回格式不变,隔离变更 | ### 5.2 技术风险 | 风险 | 影响 | 缓解措施 | |------|------|----------| | 迁移逻辑丢失旧数据 | 🔴 高 | 迁移前备份,迁移函数幂等(INSERT OR IGNORE) | | JSON 过大(100+ 方案) | 🟢 低 | 实际场景不可能超过 10 个方案 | | 并发编辑竞态 | 🟢 低 | 单管理员场景,SQLite 写锁保护 | | 前端 JSON 序列化错误 | 🟡 中 | 提交前校验 JSON 结构,错误时阻止提交并提示 | --- ## 六、附录 ### A. 相关文件清单 | 文件 | 作用 | |------|------| | `prompt/src/services/settings.py` | 设置服务,核心改动 | | `prompt/src/services/db.py` | 数据库初始化,加迁移 | | `prompt/src/services/llm.py` | LLM 调用层,不改动 | | `prompt/src/services/rate_limiter.py` | 速率限制,不改动 | | `prompt/src/routes/admin.py` | 管理路由,改动 | | `prompt/templates/admin/settings.html` | 设置页面,重写 | | `prompt/src/config.py` | 配置,不改动 | ### B. 旧配置迁移映射 | 旧 Key | 迁移到 | |--------|--------| | `llm.active_provider` | `llm.active_profile_id`(通过匹配 protocol) | | `llm.anthropic_base_url` | profiles[0].base_url | | `llm.anthropic_api_key` | profiles[0].api_key | | `llm.anthropic_models_json` | profiles[0].models(每个 model 加 protocol="anthropic") | | `llm.openai_base_url` | profiles[1].base_url | | `llm.openai_api_key` | profiles[1].api_key | | `llm.openai_models_json` | profiles[1].models(每个 model 加 protocol="openai") | | `llm.temperature` | 保持不变(全局参数) | | `llm.max_output_tokens` | 保持不变(全局参数) | | `llm.request_timeout` | 保持不变(全局参数) | | `llm.rate_limit_per_minute` | 保持不变(全局参数) | | `llm.rate_limit_per_hour` | 保持不变(全局参数) |