From 0ee327cb9765d3933f871d7b348c287336add097 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 22 Oct 2025 11:51:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=20config=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vitepress/config.mts | 5 +- docs/advanced/configuration.md | 185 ++++++++++ docs/changelog/0.3-release-notes.md | 50 +++ docs/changelog/roadmap.md | 6 +- docs/intro/model-config.md | 169 +++++---- pyproject.toml | 2 + server/routers/chat_router.py | 6 +- src/agents/common/models.py | 11 +- src/config/app.py | 515 +++++++++++++++++----------- src/config/static/models.py | 187 ++++++++++ src/models/chat.py | 11 +- src/models/embed.py | 8 +- src/models/rerank.py | 8 +- 13 files changed, 873 insertions(+), 290 deletions(-) create mode 100644 docs/advanced/configuration.md create mode 100644 src/config/static/models.py diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 29b6aa2b..49caf5cf 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -33,6 +33,7 @@ export default defineConfig({ { text: '高级配置', items: [ + { text: '配置系统详解', link: '/advanced/configuration' }, { text: '文档解析', link: '/advanced/document-processing' }, { text: '智能体', link: '/advanced/agents' }, { text: '品牌自定义', link: '/advanced/branding' }, @@ -42,10 +43,10 @@ export default defineConfig({ { text: '更新日志', items: [ + { text: '版本说明 v0.3', link: '/changelog/0.3-release-notes' }, { text: '路线图', link: '/changelog/roadmap' }, { text: '参与贡献', link: '/changelog/contributing' }, - { text: '常见问题', link: '/changelog/faq' }, - { text: '版本说明 v0.3', link: '/changelog/0.3-release-notes' } + { text: '常见问题', link: '/changelog/faq' } ] } ], diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md new file mode 100644 index 00000000..72e9e679 --- /dev/null +++ b/docs/advanced/configuration.md @@ -0,0 +1,185 @@ +# 配置系统详解 + +## 概述 + +Yuxi-Know 从 v0.3.x 版本开始采用了全新的配置系统,基于 Pydantic BaseModel 和 TOML 格式,提供了类型安全、智能提示和选择性持久化等现代化特性。 + +## 架构设计 + +### 配置层次结构 + +``` +配置系统架构 +├── 默认配置 (代码定义) +│ ├── src/config/static/models.py (模型配置) +│ └── src/config/app.py (应用配置) +├── 用户配置 (TOML 文件) +│ └── saves/config/base.toml (仅保存用户修改) +└── 环境变量 (运行时覆盖) + └── .env 文件 +``` + +### 核心组件 + +#### 1. Config 类 (`src/config/app.py`) + +主配置类,继承自 Pydantic BaseModel,提供: + +- **类型验证**: 自动检查配置项类型 +- **默认值管理**: 内置合理的默认配置 +- **选择性持久化**: 仅保存用户修改的配置项 +- **向后兼容**: 支持旧的字典式访问方式 + +```python +class Config(BaseModel): + # 功能开关 + enable_reranker: bool = Field(default=False, description="是否开启重排序") + enable_content_guard: bool = Field(default=False, description="是否启用内容审查") + + # 模型配置 + default_model: str = Field(default="siliconflow/deepseek-ai/DeepSeek-V3.2-Exp") + embed_model: str = Field(default="siliconflow/BAAI/bge-m3") + + # 运行时状态 (不持久化) + model_provider_status: dict[str, bool] = Field(exclude=True) +``` + +#### 2. 模型配置类 (`src/config/static/models.py`) + +定义了三种类型的模型配置: + +- **ChatModelProvider**: 聊天模型提供商 +- **EmbedModelInfo**: 嵌入模型信息 +- **RerankerInfo**: 重排序模型信息 + +```python +class ChatModelProvider(BaseModel): + name: str = Field(..., description="提供商显示名称") + url: str = Field(..., description="提供商文档或模型列表 URL") + base_url: str = Field(..., description="API 基础 URL") + default: str = Field(..., description="默认模型名称") + env: str = Field(..., description="API Key 环境变量名") + models: list[str] = Field(default_factory=list, description="支持的模型列表") +``` + +添加配置: + + +```python +# 1. 在 DEFAULT_CHAT_MODEL_PROVIDERS 中添加 +"new-provider": ChatModelProvider( + name="新提供商", + url="https://provider.com/docs", + base_url="https://api.provider.com/v1", + default="default-model", + env="NEW_PROVIDER_API_KEY", + models=["model1", "model2"], +), + +# 2. 在 .env 中配置 API Key +# NEW_PROVIDER_API_KEY=your_api_key + +# 3. 重启服务或重新加载配置 +``` + +## 配置管理特性 + +系统只会保存用户修改过的配置项: + +```python +# 用户只修改了 enable_reranker +config.enable_reranker = True +config.save() # 只保存 enable_reranker 到 TOML 文件 + +# TOML 文件内容 +# enable_reranker = true +``` + +### 默认模型配置 (`src/config/static/models.py`) + +包含所有支持的模型提供商的默认配置,开发者可以直接修改此文件添加新的模型: + +```python +DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = { + "siliconflow": ChatModelProvider( + name="SiliconFlow", + url="https://cloud.siliconflow.cn/models", + base_url="https://api.siliconflow.cn/v1", + default="deepseek-ai/DeepSeek-V3.2-Exp", + env="SILICONFLOW_API_KEY", + models=[ + "deepseek-ai/DeepSeek-V3.2-Exp", + "Qwen/Qwen3-235B-A22B-Instruct-2507", + # ... + ], + ), + # 更多提供商... +} +``` + +### 用户配置 (`saves/config/base.toml`) + +只包含用户修改过的配置项,使用 TOML 格式: + +```toml +# 用户只修改了这些配置项 +enable_reranker = true +default_agent_id = "MyCustomAgent" +enable_content_guard = true + +# 模型配置修改 +[model_names.siliconflow] +models = [ + "deepseek-ai/DeepSeek-V3.2-Exp", + "custom-model-name", +] +``` + +## 高级配置 + +### 动态配置更新 + +```python +from src.config import config + +# 更新配置 +config.enable_reranker = True +config.default_agent_id = "CustomAgent" + +# 更新模型列表 +config.model_names["siliconflow"].models.append("new-model") + +# 保存配置 +config.save() + +# 或者只保存特定提供商的模型配置 +config._save_models_to_file("siliconflow") +``` + +### 配置验证 + +```python +# 验证配置 +from src.config import config + +# 检查模型提供商可用性 +for provider, status in config.model_provider_status.items(): + print(f"{provider}: {'✅' if status else '❌'}") + +# 获取可用模型列表 +available_models = config.get_model_choices() +available_embed_models = config.get_embed_model_choices() +available_rerankers = config.get_reranker_choices() +``` + +### 配置导出 + +```python +# 导出完整配置(包含运行时状态) +full_config = config.dump_config() + +# 导出用户配置(仅保存到文件的部分) +user_config = { + field: getattr(config, field) + for field in config._user_modified_fields +} diff --git a/docs/changelog/0.3-release-notes.md b/docs/changelog/0.3-release-notes.md index 0d0ed235..7d8d5fbd 100644 --- a/docs/changelog/0.3-release-notes.md +++ b/docs/changelog/0.3-release-notes.md @@ -18,6 +18,56 @@ Yuxi-Know v0.3 是一个重要的里程碑版本,包含了多项架构重构 cp .env.template .env ``` +### 配置文件管理调整 + +配置系统从 YAML 格式迁移到了基于 Pydantic BaseModel + TOML 的现代化配置系统。 + +| 项目 | v0.2.x | v0.3.x | +|------|--------|--------| +| 默认模型配置 | `src/config/static/models.yaml` | `src/config/static/models.py` | +| 用户配置 | `saves/config/base.yaml` | `saves/config/base.toml` | +| 配置格式 | YAML | Python 代码 + TOML | +| 类型安全 | ❌ 无 | ✅ Pydantic 验证 | +| IDE 支持 | ❌ 基础 | ✅ 完整智能提示 | +| 持久化策略 | 全量保存 | 选择性保存 | + + +示例:迁移自定义模型提供商 + +假设你的旧 `models.yaml` 中有: + +```yaml +# 旧的 models.yaml +MODEL_NAMES: + custom-provider: + name: "My Custom Provider" + base_url: "https://api.custom.com/v1" + default: "custom-model" + env: "CUSTOM_API_KEY" + models: + - "custom-model" + - "another-model" +``` + +需要在新的 `src/config/static/models.py` 中添加: + +```python +# 新的 models.py +DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = { + # ... 现有配置 ... + + "custom-provider": ChatModelProvider( + name="My Custom Provider", + url="https://custom.com/docs", + base_url="https://api.custom.com/v1", + default="custom-model", + env="CUSTOM_API_KEY", + models=["custom-model", "another-model"], + ), +} +``` + + ### 数据库存储架构重构 - **变更内容**: 重新实现了对话管理的存储与管理,不再依赖于 MemorySaver - **影响**: 使用新的存储结构,之前存储的历史记录无法直接迁移 diff --git a/docs/changelog/roadmap.md b/docs/changelog/roadmap.md index 6fdd4acc..bd7a9a55 100644 --- a/docs/changelog/roadmap.md +++ b/docs/changelog/roadmap.md @@ -7,8 +7,7 @@ ## Bugs -- [ ] 当前 ReAct 智能体有消息顺序错乱的 bug,且不会默认调用工具 - +- ## Next @@ -20,7 +19,6 @@ - [ ] 集成智能体评估,首先使用命令行来实现,然后考虑放在 UI 里面展示 - [ ] 开发与生产环境隔离,构建生产镜像 - [ ] 支持 MinerU 2.5 的解析方法 -- [ ] 优化全局配置的管理模型,优化配置管理 ## Later @@ -34,3 +32,5 @@ - [x] 添加测试脚本,覆盖最常见的功能(已覆盖API) - [x] 新建 tasker 模块,用来管理所有的后台任务,UI 上使用侧边栏管理。 - [x] 优化对文档信息的检索展示(检索结果页、详情页) +- [x] 当前 ReAct 智能体有消息顺序错乱的 bug,且不会默认调用工具 +- [x] 优化全局配置的管理模型,优化配置管理 diff --git a/docs/intro/model-config.md b/docs/intro/model-config.md index 1a40e880..e515a847 100644 --- a/docs/intro/model-config.md +++ b/docs/intro/model-config.md @@ -39,7 +39,13 @@ default_model: siliconflow/deepseek-ai/DeepSeek-V3.2-Exp ## 自定义模型供应商 ::: warning -原本网页中的自定义模型已在 `0.3.x` 版本移除,请在 `src/config/static/models.yaml` 中按如下方式配置,并重启服务后选择并使用。此外,这里也推荐一下团队的另外一个小工具 [mvllm (Manage and Route vLLM Servers)](https://github.com/xerrors/mvllm)。 +原本网页中的自定义模型已在 `0.3.x` 版本移除,请在 `src/config/static/models.py` 中按如下方式配置,并重启服务后选择并使用。此外,这里也推荐一下团队的另外一个小工具 [mvllm (Manage and Route vLLM Servers)](https://github.com/xerrors/mvllm)。 +::: + +::: tip 配置系统升级 (v0.3.x) +从 `v0.3.x` 版本开始,模型配置系统已升级为基于 Pydantic BaseModel 的类型安全配置,支持 TOML 格式的用户配置文件。 +- **默认配置**: `src/config/static/models.py` (Python 代码) +- **用户配置**: `saves/config/base.toml` (TOML 格式,仅保存用户修改) ::: 系统理论上兼容任何 OpenAI 兼容的模型服务,包括: @@ -52,52 +58,50 @@ default_model: siliconflow/deepseek-ai/DeepSeek-V3.2-Exp ### 1. 编辑模型配置文件 -**方式一:修改默认配置** -编辑 `src/config/static/models.yaml` 文件 +**方式一:修改默认配置(推荐)** +编辑 `src/config/static/models.py` 文件中的 `DEFAULT_CHAT_MODEL_PROVIDERS` 字典 -**方式二:使用覆盖配置** -创建自定义配置文件并通过环境变量指定: -```bash -# 创建自定义配置文件 -cp src/config/static/models.yaml /path/to/your/custom-models.yaml +在 `src/config/static/models.py` 中添加新的模型供应商: -# 设置环境变量 -export OVERRIDE_DEFAULT_MODELS_CONFIG_WITH=/path/to/your/custom-models.yaml -``` +```python +DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = { + # ... 现有配置 ... -### 2. 添加模型配置 + "custom-provider": ChatModelProvider( + name="自定义提供商", + url="https://your-provider.com/docs", + base_url="https://api.your-provider.com/v1", + default="custom-model-name", + env="CUSTOM_API_KEY_ENV_NAME", + models=[ + "supported-model-name", + "another-model-name", + ], + ), -在配置文件中添加新的模型供应商: + # 本地 Ollama 服务 + "local-ollama": ChatModelProvider( + name="Local Ollama", + url="https://ollama.com", + base_url="http://localhost:11434/v1", + default="llama3.2", + env="NO_API_KEY", # 对于不需要API Key的服务,使用NO_API_KEY + models=["llama3.2", "qwen2.5"], + ), -```yaml -custom-provider-name: - name: custom-provider-name - default: custom-model-name - base_url: "https://api.your-provider.com/v1" - env: CUSTOM_API_KEY_ENV_NAME # 注意:现在是单个环境变量 - models: - - supported-model-name - - another-model-name - -# 本地 Ollama 服务 -local-ollama: - name: Local Ollama - base_url: "http://localhost:11434/v1" - default: llama3.2 - env: NO_API_KEY # 对于不需要API Key的服务,使用NO_API_KEY - models: - - llama3.2 - - qwen2.5 - -# 本地 vLLM 服务 -local-vllm: - name: Local vLLM - base_url: "http://localhost:8000/v1" - default: Qwen/Qwen2.5-7B-Instruct - env: NO_API_KEY - models: - - Qwen/Qwen2.5-7B-Instruct - - Qwen/Qwen2.5-14B-Instruct + # 本地 vLLM 服务 + "local-vllm": ChatModelProvider( + name="Local vLLM", + url="https://docs.vllm.ai", + base_url="http://localhost:8000/v1", + default="Qwen/Qwen2.5-7B-Instruct", + env="NO_API_KEY", + models=[ + "Qwen/Qwen2.5-7B-Instruct", + "Qwen/Qwen2.5-14B-Instruct", + ], + ), +} ``` ### 3. 配置环境变量 @@ -123,21 +127,58 @@ docker compose restart api-dev #### 1. 配置模型信息 -在 `src/config/static/models.yaml` 中或通过覆盖配置文件添加配置: +在 `src/config/static/models.py` 中的默认配置部分添加: -```yaml -EMBED_MODEL_INFO: - vllm/Qwen/Qwen3-Embedding-0.6B: - name: Qwen/Qwen3-Embedding-0.6B - dimension: 1024 - base_url: http://localhost:8000/v1/embeddings - api_key: no_api_key +```python +# 默认嵌入模型配置 +DEFAULT_EMBED_MODELS: dict[str, EmbedModelInfo] = { + # ... 现有配置 ... -RERANKER_LIST: - vllm/BAAI/bge-reranker-v2-m3: - name: BAAI/bge-reranker-v2-m3 - base_url: http://localhost:8000/v1/rerank - api_key: no_api_key + "vllm/Qwen/Qwen3-Embedding-0.6B": EmbedModelInfo( + name="Qwen/Qwen3-Embedding-0.6B", + dimension=1024, + base_url="http://localhost:8000/v1/embeddings", + api_key="no_api_key", + ), +} + +# 默认重排序模型配置 +DEFAULT_RERANKERS: dict[str, RerankerInfo] = { + # ... 现有配置 ... + + "vllm/BAAI/bge-reranker-v2-m3": RerankerInfo( + name="BAAI/bge-reranker-v2-m3", + base_url="http://localhost:8000/v1/rerank", + api_key="no_api_key", + ), +} +``` + +#### 2. 动态配置(可选) + +你也可以通过代码动态添加本地模型: + +```python +from src.config import config +from src.config.static.models import EmbedModelInfo, RerankerInfo + +# 添加本地嵌入模型 +config.embed_model_names["local/embed-model"] = EmbedModelInfo( + name="local-embed-model", + dimension=1024, + base_url="http://localhost:8000/v1/embeddings", + api_key="no_api_key", +) + +# 添加本地重排序模型 +config.reranker_names["local/reranker-model"] = RerankerInfo( + name="local-reranker-model", + base_url="http://localhost:8000/v1/rerank", + api_key="no_api_key", +) + +# 保存配置 +config.save() ``` #### 2. 启动模型服务 @@ -154,20 +195,4 @@ vllm serve BAAI/bge-reranker-v2-m3 \ --task score \ --dtype fp16 \ --port 8000 -``` - -## 常见问题 - -**Q: 如何查看当前可用的模型?** - -在 Web 界面的"设置"页面可以查看所有已配置的模型。 - -**Q: 模型配置不生效?** - -1. 检查环境变量是否正确设置 -2. 确认 API 密钥有效 -3. 重启服务:`docker compose restart api-dev` - -**Q: 如何测试模型连接?** - -在 Web 界面的对话页面选择对应模型进行测试。 +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2003b2f9..2233e678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ dependencies = [ "pymysql>=1.1.0", "tenacity>=8.0.0", "pypinyin>=0.55.0", + "tomli", + "tomli-w", ] [tool.ruff] line-length = 120 # 代码最大行宽 diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index fe5f2c35..4cad7dbc 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -393,9 +393,9 @@ async def get_chat_models(model_provider: str, current_user: User = Depends(get_ @chat.post("/models/update") async def update_chat_models(model_provider: str, model_names: list[str], current_user=Depends(get_admin_user)): """更新指定模型提供商的模型列表 (仅管理员)""" - conf.model_names[model_provider]["models"] = model_names - conf._save_models_to_file() - return {"models": conf.model_names[model_provider]["models"]} + conf.model_names[model_provider].models = model_names + conf._save_models_to_file(model_provider) + return {"models": conf.model_names[model_provider].models} @chat.get("/tools") diff --git a/src/agents/common/models.py b/src/agents/common/models.py index 99622516..4c528819 100644 --- a/src/agents/common/models.py +++ b/src/agents/common/models.py @@ -14,14 +14,17 @@ def load_chat_model(fully_specified_name: str, **kwargs) -> BaseChatModel: """ provider, model = fully_specified_name.split("/", maxsplit=1) - assert provider != "custom", "[弃用] 自定义模型已移除,请在 src/config/static/models.yaml 中配置" + assert provider != "custom", "[弃用] 自定义模型已移除,请在 src/config/static/models.py 中配置" - model_info = config.model_names.get(provider, {}) - env_var = model_info["env"] + model_info = config.model_names.get(provider) + if not model_info: + raise ValueError(f"Unknown model provider: {provider}") + + env_var = model_info.env api_key = os.getenv(env_var, env_var) - base_url = get_docker_safe_url(model_info["base_url"]) + base_url = get_docker_safe_url(model_info.base_url) if provider in ["deepseek", "dashscope"]: from langchain_deepseek import ChatDeepSeek diff --git a/src/config/app.py b/src/config/app.py index f476f226..c8ea3b8b 100644 --- a/src/config/app.py +++ b/src/config/app.py @@ -1,246 +1,371 @@ -import json +""" +应用配置模块 + +使用 Pydantic BaseModel 实现配置管理,支持: +- 从 TOML 文件加载用户配置 +- 仅保存用户修改过的配置项 +- 默认配置定义在代码中 +""" + import os from pathlib import Path +from typing import Any -import yaml +import tomli +import tomli_w +from pydantic import BaseModel, Field, field_validator +from src.config.static.models import ( + DEFAULT_CHAT_MODEL_PROVIDERS, + DEFAULT_EMBED_MODELS, + DEFAULT_RERANKERS, + ChatModelProvider, + EmbedModelInfo, + RerankerInfo, +) from src.utils.logging_config import logger -class SimpleConfig(dict): - def __key(self, key): - return "" if key is None else key # 目前忘记了这里为什么要 lower 了,只能说配置项最好不要有大写的 +class Config(BaseModel): + """应用配置类""" - def __str__(self): - return json.dumps(self) + # ============================================================ + # 基础配置 + # ============================================================ + save_dir: str = Field(default="saves", description="保存目录") + model_dir: str = Field(default="", description="本地模型目录") - def __setattr__(self, key, value): - self[self.__key(key)] = value + # ============================================================ + # 功能开关 + # ============================================================ + enable_reranker: bool = Field(default=False, description="是否开启重排序") + enable_content_guard: bool = Field(default=False, description="是否启用内容审查") + enable_content_guard_llm: bool = Field(default=False, description="是否启用LLM内容审查") + enable_web_search: bool = Field(default=False, description="是否启用网络搜索") - def __getattr__(self, key): - return self.get(self.__key(key)) + # ============================================================ + # 模型配置 + # ============================================================ + default_model: str = Field( + default="siliconflow/deepseek-ai/DeepSeek-V3.2-Exp", + description="默认对话模型", + ) + fast_model: str = Field( + default="siliconflow/THUDM/GLM-4-9B-0414", + description="快速响应模型", + ) + embed_model: str = Field( + default="siliconflow/BAAI/bge-m3", + description="Embedding 模型", + ) + reranker: str = Field( + default="siliconflow/BAAI/bge-reranker-v2-m3", + description="Re-Ranker 模型", + ) + content_guard_llm_model: str = Field( + default="siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507", + description="内容审查LLM模型", + ) - def __getitem__(self, key): - return self.get(self.__key(key)) + # ============================================================ + # 智能体配置 + # ============================================================ + default_agent_id: str = Field(default="", description="默认智能体ID") - def __setitem__(self, key, value): - return super().__setitem__(self.__key(key), value) + # ============================================================ + # 模型信息(只读,不持久化) + # ============================================================ + model_names: dict[str, ChatModelProvider] = Field( + default_factory=lambda: DEFAULT_CHAT_MODEL_PROVIDERS.copy(), + description="聊天模型提供商配置", + exclude=True, + ) + embed_model_names: dict[str, EmbedModelInfo] = Field( + default_factory=lambda: DEFAULT_EMBED_MODELS.copy(), + description="嵌入模型配置", + exclude=True, + ) + reranker_names: dict[str, RerankerInfo] = Field( + default_factory=lambda: DEFAULT_RERANKERS.copy(), + description="重排序模型配置", + exclude=True, + ) - def __dict__(self): - return {k: v for k, v in self.items()} + # ============================================================ + # 运行时状态(不持久化) + # ============================================================ + model_provider_status: dict[str, bool] = Field( + default_factory=dict, + description="模型提供商可用状态", + exclude=True, + ) + valuable_model_provider: list[str] = Field( + default_factory=list, + description="可用的模型提供商列表", + exclude=True, + ) - def update(self, other): - for key, value in other.items(): - self[key] = value + # 内部状态 + _config_file: Path | None = None + _user_modified_fields: set[str] = set() + _modified_providers: set[str] = set() # 记录具体修改的模型提供商 + model_config = {"arbitrary_types_allowed": True, "extra": "allow"} -class Config(SimpleConfig): - def __init__(self): - super().__init__() - self._config_items = {} - self.save_dir = os.getenv("SAVE_DIR", "saves") - self.filename = str(Path(f"{self.save_dir}/config/base.yaml")) - os.makedirs(os.path.dirname(self.filename), exist_ok=True) + def __init__(self, **data): + super().__init__(**data) + self._setup_paths() + self._load_user_config() + self._handle_environment() - self._models_config_path: Path | None = os.getenv("OVERRIDE_DEFAULT_MODELS_CONFIG_WITH") - self._update_models_from_file() + def _setup_paths(self): + """设置配置文件路径""" + self.save_dir = os.getenv("SAVE_DIR", self.save_dir) + self._config_file = Path(self.save_dir) / "config" / "base.toml" + self._config_file.parent.mkdir(parents=True, exist_ok=True) - ### >>> 默认配置 - # 功能选项 - self.add_item("enable_reranker", default=False, des="是否开启重排序") - self.add_item("enable_content_guard", default=False, des="是否启用内容审查") - self.add_item("enable_content_guard_llm", default=False, des="是否启用LLM内容审查") - self.add_item( - "content_guard_llm_model", default="siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507", des="内容审查LLM模型" - ) - # 默认智能体配置 - self.add_item("default_agent_id", default="", des="默认智能体ID") - # 模型配置 - ## 注意这里是模型名,而不是具体的模型路径,默认使用 HuggingFace 的路径 - ## 如果需要自定义本地模型路径,则在 .env 中配置 MODEL_DIR - self.add_item( - "default_model", - default=self._get_default_chat_model_spec(), - des="默认对话模型", - ) - self.add_item( - "fast_model", - default="siliconflow/THUDM/GLM-4-9B-0414", - des="快速响应模型", - ) + def _load_user_config(self): + """从 TOML 文件加载用户配置""" + if not self._config_file or not self._config_file.exists(): + logger.info(f"Config file not found, using defaults: {self._config_file}") + return - self.add_item( - "embed_model", - default="siliconflow/BAAI/bge-m3", - des="Embedding 模型", - choices=list(self.embed_model_names.keys()), - ) - self.add_item( - "reranker", - default="siliconflow/BAAI/bge-reranker-v2-m3", - des="Re-Ranker 模型", - choices=list(self.reranker_names.keys()), - ) # noqa: E501 - ### <<< 默认配置结束 + logger.info(f"Loading config from {self._config_file}") + try: + with open(self._config_file, "rb") as f: + user_config = tomli.load(f) - self.load() - # 清理已废弃的配置项 - self.pop("model_provider", None) - self.pop("model_name", None) - self.handle_self() + # 记录用户修改的字段 + self._user_modified_fields = set(user_config.keys()) - def add_item(self, key, default, des=None, choices=None): - self.__setattr__(key, default) - self._config_items[key] = {"default": default, "des": des, "choices": choices} + # 更新配置 + for key, value in user_config.items(): + if key == "model_names": + # 特殊处理模型配置 + self._load_model_names(value) + elif hasattr(self, key): + setattr(self, key, value) + else: + logger.warning(f"Unknown config key: {key}") - def __dict__(self): - blocklist = [ - "_config_items", - "model_names", - "model_provider_status", - "embed_model_names", - "reranker_names", - "_models_config_path", - ] - return {k: v for k, v in self.items() if k not in blocklist} + except Exception as e: + logger.error(f"Failed to load config from {self._config_file}: {e}") - def _update_models_from_file(self): - """ - 从 models.yaml 或覆盖配置文件中更新 MODEL_NAMES - """ - # 检查是否设置了覆盖配置文件的环境变量 - override_config_path = os.getenv("OVERRIDE_DEFAULT_MODELS_CONFIG_WITH") - - if override_config_path and os.path.exists(override_config_path): - config_file = Path(override_config_path) - logger.info(f"Using override models config from: {override_config_path}") - else: - config_file = Path("src/config/static/models.yaml") - logger.info("Using default models config") - - self._models_config_path = str(config_file) - - with open(self._models_config_path, encoding="utf-8") as f: - _models = yaml.safe_load(f) - - self.model_names = _models["MODEL_NAMES"] - self.embed_model_names = _models["EMBED_MODEL_INFO"] - self.reranker_names = _models["RERANKER_LIST"] - - def _save_models_to_file(self): - """ - 将当前模型配置写回模型配置文件 - """ - if self._models_config_path is None: - self._models_config_path = str(Path("src/config/static/models.yaml")) - - models_payload = { - "MODEL_NAMES": self.model_names, - "EMBED_MODEL_INFO": self.embed_model_names, - "RERANKER_LIST": self.reranker_names, - } - - with open(self._models_config_path, "w", encoding="utf-8") as f: - yaml.safe_dump(models_payload, f, indent=2, allow_unicode=True, sort_keys=False) - - def _get_default_chat_model_spec(self): - """选择一个默认的聊天模型,优先使用 siliconflow 的默认模型""" - preferred_provider = "siliconflow" - provider_info = (self.model_names or {}).get(preferred_provider) - if provider_info: - default_model = provider_info.get("default") - if default_model: - return f"{preferred_provider}/{default_model}" - - for provider, info in (self.model_names or {}).items(): - default_model = info.get("default") - if default_model: - return f"{provider}/{default_model}" - - return "" - - def handle_self(self): - """ - 处理配置 - """ - self.model_dir = os.environ.get("MODEL_DIR", "") + def _load_model_names(self, model_names_data): + """加载用户自定义的模型配置""" + try: + for provider, provider_data in model_names_data.items(): + if provider in self.model_names: + # 更新现有提供商的模型列表 + if "models" in provider_data: + self.model_names[provider].models = provider_data["models"] + else: + # 添加新的提供商 + self.model_names[provider] = ChatModelProvider(**provider_data) + logger.info(f"Loaded custom model configurations for {len(model_names_data)} providers") + except Exception as e: + logger.error(f"Failed to load model names: {e}") + def _handle_environment(self): + """处理环境变量和运行时状态""" + # 处理模型目录 + self.model_dir = os.environ.get("MODEL_DIR", self.model_dir) if self.model_dir: if os.path.exists(self.model_dir): logger.debug( - f"The model directory ({self.model_dir}) " - f"contains the following folders: {os.listdir(self.model_dir)}" + f"Model directory ({self.model_dir}) contains: {os.listdir(self.model_dir)}" ) else: logger.warning( - f"Warning: The model directory ({self.model_dir}) does not exist. " - "If not configured, please ignore it. " - "If configured, please check if the configuration is correct; " - "For example, the mapping in the docker-compose file" + f"Model directory ({self.model_dir}) does not exist. " + "If not configured, please ignore it." ) # 检查模型提供商的环境变量 self.model_provider_status = {} - for provider in self.model_names: - env_var = self.model_names[provider]["env"] - # 如果环境变量名为 NO_API_KEY,则认为总是可用 + for provider, info in self.model_names.items(): + env_var = info.env if env_var == "NO_API_KEY": self.model_provider_status[provider] = True else: self.model_provider_status[provider] = bool(os.getenv(env_var)) + # 检查网络搜索 if os.getenv("TAVILY_API_KEY"): self.enable_web_search = True - self.valuable_model_provider = [k for k, v in self.model_provider_status.items() if v] - assert len(self.valuable_model_provider) > 0, "No model provider available, please check your `.env` file." + # 获取可用的模型提供商 + self.valuable_model_provider = [ + k for k, v in self.model_provider_status.items() if v + ] - def load(self): - """根据传入的文件覆盖掉默认配置""" - logger.info(f"Loading config from {self.filename}") - if self.filename is not None and os.path.exists(self.filename): - if self.filename.endswith(".json"): - with open(self.filename) as f: - content = f.read() - if content: - local_config = json.loads(content) - self.update(local_config) - else: - print(f"{self.filename} is empty.") - - elif self.filename.endswith(".yaml"): - with open(self.filename) as f: - content = f.read() - if content: - local_config = yaml.safe_load(content) - self.update(local_config) - else: - print(f"{self.filename} is empty.") - else: - logger.warning(f"Unknown config file type {self.filename}") + if not self.valuable_model_provider: + raise ValueError( + "No model provider available, please check your `.env` file." + ) def save(self): - logger.info(f"Saving config to {self.filename}") - if self.filename is None: - logger.warning("Config file is not specified, save to default config/base.yaml") - self.filename = os.path.join(self.save_dir, "config", "base.yaml") - os.makedirs(os.path.dirname(self.filename), exist_ok=True) + """保存配置到 TOML 文件(仅保存用户修改的字段)""" + if not self._config_file: + logger.warning("Config file path not set") + return - if self.filename.endswith(".json"): - with open(self.filename, "w+") as f: - json.dump(self.__dict__(), f, indent=4, ensure_ascii=False) - elif self.filename.endswith(".yaml"): - with open(self.filename, "w+") as f: - yaml.dump(self.__dict__(), f, indent=2, allow_unicode=True) - else: - logger.warning(f"Unknown config file type {self.filename}, save as json") - with open(self.filename, "w+") as f: - json.dump(self, f, indent=4) + logger.info(f"Saving config to {self._config_file}") - logger.info(f"Config file {self.filename} saved") + # 获取默认配置 + default_config = Config.model_construct() - def dump_config(self): - return json.loads(str(self)) + # 对比当前配置和默认配置,找出用户修改的字段 + user_modified = {} + for field_name in self.model_fields.keys(): + # 跳过 exclude=True 的字段 + field_info = self.model_fields[field_name] + if field_info.exclude: + continue + + current_value = getattr(self, field_name) + default_value = getattr(default_config, field_name) + + # 如果值不同,说明用户修改了 + if current_value != default_value: + user_modified[field_name] = current_value + + # 写入 TOML 文件 + try: + with open(self._config_file, "wb") as f: + tomli_w.dump(user_modified, f) + logger.info(f"Config saved to {self._config_file}") + except Exception as e: + logger.error(f"Failed to save config to {self._config_file}: {e}") + + def dump_config(self) -> dict[str, Any]: + """导出配置为字典(用于 API 返回)""" + config_dict = self.model_dump( + exclude={ + "model_names", + "embed_model_names", + "reranker_names", + "model_provider_status", + "valuable_model_provider", + } + ) + + # 添加模型信息(转换为字典格式供前端使用) + config_dict["model_names"] = { + provider: info.model_dump() for provider, info in self.model_names.items() + } + config_dict["embed_model_names"] = { + model_id: info.model_dump() for model_id, info in self.embed_model_names.items() + } + config_dict["reranker_names"] = { + model_id: info.model_dump() for model_id, info in self.reranker_names.items() + } + + # 添加运行时状态信息 + config_dict["model_provider_status"] = self.model_provider_status + config_dict["valuable_model_provider"] = self.valuable_model_provider + + fields_info = {} + for field_name, field_info in Config.model_fields.items(): + if not field_info.exclude: # 排除内部字段 + fields_info[field_name] = { + 'des': field_info.description, + 'default': field_info.default, + 'type': field_info.annotation.__name__ if hasattr(field_info.annotation, '__name__') else str(field_info.annotation), + 'exclude': field_info.exclude if hasattr(field_info, 'exclude') else False, + } + config_dict["_config_items"] = fields_info + + return config_dict + + def get_model_choices(self) -> list[str]: + """获取所有可用的聊天模型列表""" + choices = [] + for provider, info in self.model_names.items(): + if self.model_provider_status.get(provider, False): + for model in info.models: + choices.append(f"{provider}/{model}") + return choices + + def get_embed_model_choices(self) -> list[str]: + """获取所有可用的嵌入模型列表""" + return list(self.embed_model_names.keys()) + + def get_reranker_choices(self) -> list[str]: + """获取所有可用的重排序模型列表""" + return list(self.reranker_names.keys()) + + # ============================================================ + # 兼容旧代码的方法 + # ============================================================ + + def __getitem__(self, key: str) -> Any: + """支持字典式访问 config[key]""" + logger.warning("Using deprecated dict-style access for Config. " + "Please use attribute access instead.") + return getattr(self, key, None) + + def __setitem__(self, key: str, value: Any): + """支持字典式赋值 config[key] = value""" + logger.warning("Using deprecated dict-style assignment for Config. " + "Please use attribute access instead.") + setattr(self, key, value) + + def update(self, other: dict): + """批量更新配置(兼容旧代码)""" + for key, value in other.items(): + if hasattr(self, key): + setattr(self, key, value) + else: + logger.warning(f"Unknown config key: {key}") + + def _save_models_to_file(self, provider_name: str = None): + """保存模型配置到主配置文件 + + Args: + provider_name: 如果提供,只保存特定provider的修改;否则保存所有model_names + """ + if not self._config_file: + logger.warning("Config file path not set") + return + + logger.info(f"Saving models config to {self._config_file}") + + try: + # 读取现有配置 + user_config = {} + if self._config_file.exists(): + with open(self._config_file, "rb") as f: + user_config = tomli.load(f) + + # 初始化 model_names 配置(如果不存在) + if "model_names" not in user_config: + user_config["model_names"] = {} + + if provider_name: + # 只保存特定 provider 的修改 + if provider_name in self.model_names: + user_config["model_names"][provider_name] = self.model_names[provider_name].model_dump() + # 记录具体修改的 provider + self._modified_providers.add(provider_name) + logger.info(f"Saved models config for provider: {provider_name}") + else: + # 保存所有 model_names + user_config["model_names"] = { + provider: info.model_dump() + for provider, info in self.model_names.items() + } + # 记录整个 model_names 字段的修改 + self._user_modified_fields.add("model_names") + logger.info("Saved all models config") + + # 写入配置文件 + with open(self._config_file, "wb") as f: + tomli_w.dump(user_config, f) + logger.info(f"Models config saved to {self._config_file}") + except Exception as e: + logger.error(f"Failed to save models config to {self._config_file}: {e}") +# 全局配置实例 config = Config() diff --git a/src/config/static/models.py b/src/config/static/models.py new file mode 100644 index 00000000..c48ff813 --- /dev/null +++ b/src/config/static/models.py @@ -0,0 +1,187 @@ +""" +默认模型配置 + +该文件定义了系统支持的所有默认模型配置,包括: +- 聊天模型(LLM) +- 嵌入模型(Embedding) +- 重排序模型(Reranker) +""" + +from pydantic import BaseModel, Field + + +class ChatModelProvider(BaseModel): + """聊天模型提供商配置""" + + name: str = Field(..., description="提供商显示名称") + url: str = Field(..., description="提供商文档或模型列表 URL") + base_url: str = Field(..., description="API 基础 URL") + default: str = Field(..., description="默认模型名称") + env: str = Field(..., description="API Key 环境变量名") + models: list[str] = Field(default_factory=list, description="支持的模型列表") + + +class EmbedModelInfo(BaseModel): + """嵌入模型配置""" + + name: str = Field(..., description="模型名称") + dimension: int = Field(..., description="向量维度") + base_url: str = Field(..., description="API 基础 URL") + api_key: str = Field(..., description="API Key 或环境变量名") + + +class RerankerInfo(BaseModel): + """重排序模型配置""" + + name: str = Field(..., description="模型名称") + base_url: str = Field(..., description="API 基础 URL") + api_key: str = Field(..., description="API Key 或环境变量名") + + +# ============================================================ +# 默认聊天模型配置 +# ============================================================ + +DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = { + "openai": ChatModelProvider( + name="OpenAI", + url="https://platform.openai.com/docs/models", + base_url="https://api.openai.com/v1", + default="gpt-4o-mini", + env="OPENAI_API_KEY", + models=["gpt-4", "gpt-4o", "gpt-4o-mini"], + ), + "deepseek": ChatModelProvider( + name="DeepSeek", + url="https://platform.deepseek.com/api-docs/zh-cn/pricing", + base_url="https://api.deepseek.com/v1", + default="deepseek-chat", + env="DEEPSEEK_API_KEY", + models=["deepseek-chat", "deepseek-reasoner"], + ), + "zhipu": ChatModelProvider( + name="智谱AI (Zhipu)", + url="https://open.bigmodel.cn/dev/api", + base_url="https://open.bigmodel.cn/api/paas/v4/", + default="glm-4.5-flash", + env="ZHIPUAI_API_KEY", + models=["glm-4.6", "glm-4.5-air", "glm-4.5-flash"], + ), + "siliconflow": ChatModelProvider( + name="SiliconFlow", + url="https://cloud.siliconflow.cn/models", + base_url="https://api.siliconflow.cn/v1", + default="deepseek-ai/DeepSeek-V3.2-Exp", + env="SILICONFLOW_API_KEY", + models=[ + "deepseek-ai/DeepSeek-V3.2-Exp", + "Qwen/Qwen3-235B-A22B-Thinking-2507", + "Qwen/Qwen3-235B-A22B-Instruct-2507", + "moonshotai/Kimi-K2-Instruct-0905", + "zai-org/GLM-4.6", + ], + ), + "together.ai": ChatModelProvider( + name="Together.ai", + url="https://api.together.ai/models", + base_url="https://api.together.xyz/v1/", + default="meta-llama/Llama-3.3-70B-Instruct-Turbo-Free", + env="TOGETHER_API_KEY", + models=["meta-llama/Llama-3.3-70B-Instruct-Turbo-Free"], + ), + "dashscope": ChatModelProvider( + name="阿里百炼 (DashScope)", + url="https://bailian.console.aliyun.com/?switchAgent=10226727&productCode=p_efm#/model-market", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + default="qwen-max-latest", + env="DASHSCOPE_API_KEY", + models=[ + "qwen-max-latest", + "qwen-plus-latest", + "qwen-turbo-latest", + "qwen3-235b-a22b-thinking-2507", + "qwen3-235b-a22b-instruct-2507", + ], + ), + "ark": ChatModelProvider( + name="豆包(Ark)", + url="https://console.volcengine.com/ark/region:ark+cn-beijing/model", + base_url="https://ark.cn-beijing.volces.com/api/v3", + default="doubao-seed-1-6-250615", + env="ARK_API_KEY", + models=[ + "doubao-seed-1-6-250615", + "doubao-seed-1-6-thinking-250715", + "doubao-seed-1-6-flash-250715", + ], + ), + "openrouter": ChatModelProvider( + name="OpenRouter", + url="https://openrouter.ai/models", + base_url="https://openrouter.ai/api/v1", + default="openai/gpt-4o", + env="OPENROUTER_API_KEY", + models=[ + "openai/gpt-4o", + "x-ai/grok-4", + "google/gemini-2.5-pro", + "anthropic/claude-sonnet-4", + ], + ), +} + + +# ============================================================ +# 默认嵌入模型配置 +# ============================================================ + +DEFAULT_EMBED_MODELS: dict[str, EmbedModelInfo] = { + "siliconflow/BAAI/bge-m3": EmbedModelInfo( + name="BAAI/bge-m3", + dimension=1024, + base_url="https://api.siliconflow.cn/v1/embeddings", + api_key="SILICONFLOW_API_KEY", + ), + "siliconflow/Qwen/Qwen3-Embedding-0.6B": EmbedModelInfo( + name="Qwen/Qwen3-Embedding-0.6B", + dimension=1024, + base_url="https://api.siliconflow.cn/v1/embeddings", + api_key="SILICONFLOW_API_KEY", + ), + "vllm/Qwen/Qwen3-Embedding-0.6B": EmbedModelInfo( + name="Qwen3-Embedding-0.6B", + dimension=1024, + base_url="http://localhost:8000/v1/embeddings", + api_key="no_api_key", + ), + "ollama/nomic-embed-text": EmbedModelInfo( + name="nomic-embed-text", + dimension=768, + base_url="http://localhost:11434/api/embed", + api_key="no_api_key", + ), + "ollama/bge-m3": EmbedModelInfo( + name="bge-m3", + dimension=1024, + base_url="http://localhost:11434/api/embed", + api_key="no_api_key", + ), +} + + +# ============================================================ +# 默认重排序模型配置 +# ============================================================ + +DEFAULT_RERANKERS: dict[str, RerankerInfo] = { + "siliconflow/BAAI/bge-reranker-v2-m3": RerankerInfo( + name="BAAI/bge-reranker-v2-m3", + base_url="https://api.siliconflow.cn/v1/rerank", + api_key="SILICONFLOW_API_KEY", + ), + "vllm/BAAI/bge-reranker-v2-m3": RerankerInfo( + name="BAAI/bge-reranker-v2-m3", + base_url="http://localhost:8000/v1/rerank", + api_key="no_api_key", + ), +} diff --git a/src/models/chat.py b/src/models/chat.py index 87fdb5e1..42386eda 100644 --- a/src/models/chat.py +++ b/src/models/chat.py @@ -114,8 +114,11 @@ def select_model(model_provider=None, model_name=None, model_spec=None): assert model_provider, "Model provider not specified" - model_info = config.model_names.get(model_provider, {}) - model_name = model_name or model_info.get("default", "") + model_info = config.model_names.get(model_provider) + if not model_info: + raise ValueError(f"Unknown model provider: {model_provider}") + + model_name = model_name or model_info.default if not model_name: raise ValueError(f"Model name not specified for provider {model_provider}") @@ -128,8 +131,8 @@ def select_model(model_provider=None, model_name=None, model_spec=None): # 其他模型,默认使用OpenAIBase try: model = OpenAIBase( - api_key=os.getenv(model_info["env"]), - base_url=model_info["base_url"], + api_key=os.getenv(model_info.env), + base_url=model_info.base_url, model_name=model_name, ) return model diff --git a/src/models/embed.py b/src/models/embed.py index bef6abad..f442ce09 100644 --- a/src/models/embed.py +++ b/src/models/embed.py @@ -254,10 +254,12 @@ def select_embedding_model(model_id): if provider == "local": raise ValueError("Local embedding model is not supported, please use other embedding models") - elif provider == "ollama": - model = OllamaEmbedding(**config.embed_model_names[model_id]) + # 获取嵌入模型配置并转换为字典 + embed_config = config.embed_model_names[model_id].model_dump() + if provider == "ollama": + model = OllamaEmbedding(**embed_config) else: - model = OtherEmbedding(**config.embed_model_names[model_id]) + model = OtherEmbedding(**embed_config) return model diff --git a/src/models/rerank.py b/src/models/rerank.py index 83cb87a3..b7ec8d0b 100644 --- a/src/models/rerank.py +++ b/src/models/rerank.py @@ -49,7 +49,7 @@ def get_reranker(model_id, **kwargs): assert model_id in support_rerankers, f"Unsupported Reranker: {model_id}, only support {support_rerankers}" model_info = config.reranker_names[model_id] - base_url = model_info["base_url"] - api_key = os.getenv(model_info["api_key"], model_info["api_key"]) - assert api_key, f"{model_info['name']} api_key is required" - return OnlineReranker(model_name=model_info["name"], api_key=api_key, base_url=base_url, **kwargs) + base_url = model_info.base_url + api_key = os.getenv(model_info.api_key, model_info.api_key) + assert api_key, f"{model_info.name} api_key is required" + return OnlineReranker(model_name=model_info.name, api_key=api_key, base_url=base_url, **kwargs)