init: consolidate all ephron.ren PRDs and docs

This commit is contained in:
Ubuntu
2026-05-15 10:39:54 +08:00
parent 9568533314
commit ee8cddf8b8
21 changed files with 6991 additions and 2 deletions

View File

@@ -0,0 +1,511 @@
# 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` 存储 JSONJS 在 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 | 测试连接 APIAJAX | 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` | 保持不变(全局参数) |