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

20 KiB
Raw Blame History

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 结构

[
  {
    "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 必须是 anthropicopenai
  • 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

{
  "base_url": "https://api.anthropic.com",
  "api_key": "sk-ant-...",
  "model_id": "claude-sonnet-4-20250514",
  "protocol": "anthropic"
}

响应

// 成功
{"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 数据结构

{
  "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 存在时自动转换:

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 核心函数

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 保持不变(全局参数)