Files
ephron-ren-prd/prd-llm-profile-management.md

512 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | 保持不变(全局参数) |