Skip to content

Commit 28df491

Browse files
committed
feat(llm): 统一用户模型解析层 + 召回侧改用用户 embedding 模型
将分散在 splitter / markdown_parser / /llm 路由三处的「查配置→解密→建 client→能力校验」 逻辑收敛到 src/core/llm/user_model_resolver.py,消除行为漂移并把 DB 访问收口到一处。 - 新增 aresolve_user_model / build_provider_from_config 统一解析入口;三处调用点接入, 各自保留领域异常以不破坏既有失败码映射 - 召回 dense query 编码改用发起用户的 EMBEDDING 模型(与写入侧同源),缺配置硬失败, 新增错误码 RECALL_EMBEDDING_CONFIG_MISSING,绕过宽松降级 - 清理 ModelFactory 客户端缓存死代码;CacheSyncService 去除对 ModelFactory 的联动 - /llm 路由边界归一 user_id / config_id 为 int(非法值 422),修类型契约漂移 - llm_user_config 增生成列 default_marker + 唯一键 uq_user_default_per_capability, 强制每个 (user_id, provider_type, capability) 至多一条默认且启用配置(migration 0012); 默认配置查询改 order_by(priority).limit(1) 容错,避免脏数据下 MultipleResultsFound 误判 - 同步 docs/api/error_codes.md、docs/api/schemas/mysql.md、scripts/db/init.sql
1 parent 849d4e8 commit 28df491

30 files changed

Lines changed: 830 additions & 440 deletions

docs/api/error_codes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,18 @@ CODE: 中文业务原因;底层详情
146146

147147
| 场景 | 事件 | code |
148148
| --- | --- | --- |
149+
| 发起用户无默认 EMBEDDING 配置(dense 路无法编码 query) | `error` | `RECALL_EMBEDDING_CONFIG_MISSING` |
149150
| 全部召回路失败 / 严格模式失败 | `error` | `RECALL_ALL_SOURCES_FAILED` |
150151
| 召回执行超过 `RECALL_STREAM_TIMEOUT_MS` | `error` | `RECALL_TIMEOUT` |
151152
| 未预期内部异常 | `error` | `RECALL_INTERNAL_ERROR` |
152153

153154
宽松模式下单路失败但仍有成功路时**不是错误**:正常返回 `recall_done`,失败路计入
154155
`failed_sources`。客户端(Java)断连不作为业务错误,Python 停止发送事件并取消召回任务。
155156

157+
例外:dense 召回 query 编码按发起用户的 EMBEDDING 配置解析(与写入侧同源)。用户无默认
158+
EMBEDDING 配置属**必备前置缺失**,走硬失败(`RECALL_EMBEDDING_CONFIG_MISSING`)而非宽松降级——
159+
即便其余路可用也不返回部分结果,避免"读侧系统模型 / 写侧用户模型"向量空间不一致的误召回。
160+
156161
## 6. Chunk Status Values
157162

158163
| Status | 含义 |

docs/api/schemas/mysql.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,15 @@ ORM:[`UserLLMConfigDB`](../../src/models/db_models.py)
9090
| `max_retries` | INT | 最大重试次数,默认 3 |
9191
| `stream_enabled` | BOOLEAN | 是否支持流式输出 |
9292
| `capability` | VARCHAR(32) | `CHAT` / `EMBEDDING` / `RERANK` / `OCR`,默认 `CHAT` |
93+
| `default_marker` | INT,生成列 | `default+active` 时为 `1`,否则 `NULL`,仅用于唯一约束(应用层不写入) |
9394
| `extra_config` | JSON | 扩展配置 |
9495
| `created_at` / `updated_at` | DATETIME | 创建 / 更新时间 |
9596

9697
索引:
9798
- `uk_user_provider_model(user_id, provider_id, model_name)`
9899
- `idx_user_active_default(user_id, is_active, is_default)`
99100
- `idx_user_provider_cap(user_id, provider_type, capability)`
101+
- `uq_user_default_per_capability(user_id, provider_type, capability, default_marker)` — 唯一键。借助生成列 `default_marker`(默认+启用时为 1,否则 NULL)与 MySQL「唯一索引中 NULL 不计重复」语义,保证每个 `(user_id, provider_type, capability)` 至多一条默认且启用的配置;非默认/停用配置不受限(迁移 0012)
100102

101103
### `llm_usage_log` — LLM 调用用量日志
102104

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""enforce one default LLM config per (user, provider_type, capability)
2+
3+
ORM 注释长期承诺「is_default 在 (user_id, provider_type, capability) 范围内唯一」,
4+
但 schema 只有普通 idx_user_provider_cap 索引,从未强制。一旦同一能力下出现两条
5+
is_default=1,默认配置查询(scalar_one_or_none)会抛 MultipleResultsFound,被上层
6+
误判为「读取失败(可重试)」。
7+
8+
参照 0011 的软删判别列思路,新增生成列 default_marker(仅 default+active 时为 1,
9+
否则 NULL),与三元组组成唯一键。MySQL 唯一索引里 NULL 不计重复,因此:
10+
- 每个 (user_id, provider_type, capability) 至多一条 default+active 配置;
11+
- 非默认 / 停用配置(marker=NULL)数量不受限。
12+
13+
注意:应用到已有数据前需确认无重复默认,否则建唯一键会因 Duplicate entry 失败。
14+
15+
Revision ID: 0012
16+
Revises: 0011
17+
Create Date: 2026-06-04
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Sequence, Union
23+
24+
import sqlalchemy as sa
25+
from alembic import op
26+
27+
28+
revision: str = "0012"
29+
down_revision: Union[str, None] = "0011"
30+
branch_labels: Union[str, Sequence[str], None] = None
31+
depends_on: Union[str, Sequence[str], None] = None
32+
33+
34+
def upgrade() -> None:
35+
op.add_column(
36+
"llm_user_config",
37+
sa.Column(
38+
"default_marker",
39+
sa.Integer(),
40+
sa.Computed(
41+
"(CASE WHEN is_default = 1 AND is_active = 1 THEN 1 ELSE NULL END)",
42+
persisted=True,
43+
),
44+
nullable=True,
45+
comment="默认判别生成列:default+active 时为 1,否则 NULL,仅用于唯一约束",
46+
),
47+
)
48+
op.create_unique_constraint(
49+
"uq_user_default_per_capability",
50+
"llm_user_config",
51+
["user_id", "provider_type", "capability", "default_marker"],
52+
)
53+
54+
55+
def downgrade() -> None:
56+
op.drop_constraint(
57+
"uq_user_default_per_capability",
58+
"llm_user_config",
59+
type_="unique",
60+
)
61+
op.drop_column("llm_user_config", "default_marker")

scripts/db/init.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,13 @@ CREATE TABLE IF NOT EXISTS llm_user_config (
6868
max_retries INT DEFAULT 3 COMMENT '最大重试次数',
6969
stream_enabled BOOLEAN DEFAULT TRUE COMMENT '是否支持流式输出',
7070
capability VARCHAR(32) NOT NULL DEFAULT 'CHAT' COMMENT '专用能力标识:CHAT/EMBEDDING/RERANK/OCR',
71+
default_marker INT GENERATED ALWAYS AS (CASE WHEN is_default = 1 AND is_active = 1 THEN 1 ELSE NULL END) STORED COMMENT '默认判别生成列:default+active 时为 1,否则 NULL,仅用于唯一约束',
7172
extra_config JSON COMMENT '扩展配置',
7273
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
7374
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
7475

7576
UNIQUE KEY uk_user_provider_model (user_id, provider_id, model_name),
77+
UNIQUE KEY uq_user_default_per_capability (user_id, provider_type, capability, default_marker),
7678
INDEX idx_user_active_default (user_id, is_active, is_default),
7779
INDEX idx_user_provider_cap (user_id, provider_type, capability)
7880
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 AUTO_INCREMENT=10000 COMMENT '用户级 LLM 配置表';

src/api/internal_auth.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
CODE_ALL_SOURCES_FAILED = "RECALL_ALL_SOURCES_FAILED"
3131
CODE_TIMEOUT = "RECALL_TIMEOUT"
3232
CODE_INTERNAL_ERROR = "RECALL_INTERNAL_ERROR"
33+
# 发起用户无默认 EMBEDDING 配置:dense 召回无法编码 query,整请求硬失败。
34+
CODE_EMBEDDING_CONFIG_MISSING = "RECALL_EMBEDDING_CONFIG_MISSING"
3335

3436

3537
class RecallApiError(Exception):

src/api/recall_pipeline_provider.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,14 @@ def _build_sparse_retriever() -> Retriever:
4343

4444

4545
def _build_dense_retriever() -> Retriever:
46+
# dense 召回 query 编码按发起用户的 EMBEDDING 配置解析(与写入侧 index_chunks 同源):
47+
# 注入 aresolve_user_chunk_embedding_pipeline,facade.search_dense_chunks 据 user_id 解析。
48+
from src.core.splitter.factory import aresolve_user_chunk_embedding_pipeline
49+
4650
return DenseRetriever(
47-
backend=compose_vector_storage_facade(),
51+
backend=compose_vector_storage_facade(
52+
query_embedding_resolver=aresolve_user_chunk_embedding_pipeline,
53+
),
4854
score_threshold=settings.DENSE_RETRIEVAL_SCORE_THRESHOLD,
4955
)
5056

src/api/routes/llm.py

Lines changed: 62 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,58 @@
88
from pydantic import BaseModel, Field
99
from sqlalchemy.ext.asyncio import AsyncSession
1010

11-
from src.core.llm.response import (
12-
APIResponse,
13-
GenerateResult,
14-
EmbeddingResult,
15-
RerankResult,
16-
StreamChunk,
17-
UsageInfo,
18-
)
19-
from src.services.config_reader_service import ConfigReaderService
20-
from src.core.llm.factory import ModelFactory
11+
from src.core.llm.response import APIResponse
12+
from src.core.llm.base_provider import BaseProvider
13+
from src.core.llm.exceptions import UserModelConfigMissingError
14+
from src.core.llm.user_model_resolver import aresolve_user_model
2115
from src.database import get_db
2216

2317
router = APIRouter(prefix="/api/v1/llm", tags=["llm"])
2418

25-
# 依赖注入
26-
model_factory = ModelFactory()
19+
20+
def _coerce_int(value: str, field: str) -> int:
21+
"""把请求边界传入的 ID 字符串归一成 int,非法值 → 422。
22+
23+
``user_id``(来自 ``X-User-Id`` Header)与 ``config_id``(来自请求体)在路由层是
24+
字符串,但下游 resolver / ConfigReaderService / ``BigInteger`` 主键都按 int 契约。
25+
在此显式转换并校验,避免把弱类型一路下沉到 SQL 靠驱动隐式转换。
26+
"""
27+
try:
28+
return int(value)
29+
except (TypeError, ValueError) as exc:
30+
raise HTTPException(status_code=422, detail=f"invalid {field}") from exc
31+
32+
33+
async def _resolve_provider(
34+
db: AsyncSession,
35+
user_id: str,
36+
capability: str,
37+
*,
38+
config_id: Optional[str] = None,
39+
override_model: Optional[str] = None,
40+
) -> BaseProvider:
41+
"""按用户解析指定能力的 Provider,未命中(含系统兜底)→ 404。
42+
43+
统一走 :func:`aresolve_user_model`(``/llm`` 路由保留系统兜底):config_id 指定优先,
44+
否则取用户该能力默认配置,仍无则系统环境兜底;都没有抛 ``UserModelConfigMissingError``
45+
在此翻成 404,保持原有对外行为。``user_id`` / ``config_id`` 在边界归一成 int。
46+
"""
47+
uid = _coerce_int(user_id, "X-User-Id")
48+
cid = _coerce_int(config_id, "config_id") if config_id is not None else None
49+
try:
50+
resolved = await aresolve_user_model(
51+
user_id=uid,
52+
capability=capability,
53+
config_id=cid,
54+
allow_system_fallback=True,
55+
override_model=override_model,
56+
db=db,
57+
)
58+
except UserModelConfigMissingError as exc:
59+
raise HTTPException(
60+
status_code=404, detail=f"{capability} configuration not found"
61+
) from exc
62+
return resolved.provider
2763

2864

2965
# ============ 请求模型 ============
@@ -81,32 +117,9 @@ async def generate_text(
81117
APIResponse[GenerateResult]
82118
"""
83119
try:
84-
config_service = ConfigReaderService(db)
85-
86-
# 获取用户配置
87-
if request.config_id:
88-
config = await config_service.get_user_config_by_id(x_user_id, request.config_id)
89-
else:
90-
config = await config_service.get_user_default_config_by_capability(x_user_id, "CHAT")
91-
92-
if not config:
93-
config = config_service.get_system_fallback_config_by_capability("CHAT")
94-
95-
if not config:
96-
raise HTTPException(status_code=404, detail="LLM CHAT configuration not found")
97-
98-
# 获取 Provider
99-
provider_type = config.get("provider_type", "openai")
100-
if config.get("is_system_fallback"):
101-
api_key = config.get("api_key", "")
102-
else:
103-
api_key = await config_service.decrypt_api_key(config.get("api_key", ""))
104-
105-
client = model_factory.create_client(
106-
provider_type=provider_type,
107-
api_key=api_key,
108-
api_base_url=config.get("custom_api_base_url"),
109-
model_name=request.model or config.get("model_name"),
120+
client = await _resolve_provider(
121+
db, x_user_id, "CHAT",
122+
config_id=request.config_id, override_model=request.model,
110123
)
111124

112125
# 调用生成
@@ -147,30 +160,9 @@ async def generate_text_stream(
147160
from fastapi.responses import StreamingResponse
148161

149162
try:
150-
config_service = ConfigReaderService(db)
151-
152-
if request.config_id:
153-
config = await config_service.get_user_config_by_id(x_user_id, request.config_id)
154-
else:
155-
config = await config_service.get_user_default_config_by_capability(x_user_id, "CHAT")
156-
157-
if not config:
158-
config = config_service.get_system_fallback_config_by_capability("CHAT")
159-
160-
if not config:
161-
raise HTTPException(status_code=404, detail="LLM CHAT configuration not found")
162-
163-
provider_type = config.get("provider_type", "openai")
164-
if config.get("is_system_fallback"):
165-
api_key = config.get("api_key", "")
166-
else:
167-
api_key = await config_service.decrypt_api_key(config.get("api_key", ""))
168-
169-
client = model_factory.create_client(
170-
provider_type=provider_type,
171-
api_key=api_key,
172-
api_base_url=config.get("custom_api_base_url"),
173-
model_name=request.model or config.get("model_name"),
163+
client = await _resolve_provider(
164+
db, x_user_id, "CHAT",
165+
config_id=request.config_id, override_model=request.model,
174166
)
175167

176168
async def event_generator():
@@ -206,30 +198,9 @@ async def embed_text(
206198
APIResponse[EmbeddingResult]
207199
"""
208200
try:
209-
config_service = ConfigReaderService(db)
210-
211-
if request.config_id:
212-
config = await config_service.get_user_config_by_id(x_user_id, request.config_id)
213-
else:
214-
config = await config_service.get_user_default_config_by_capability(x_user_id, "EMBEDDING")
215-
216-
if not config:
217-
config = config_service.get_system_fallback_config_by_capability("EMBEDDING")
218-
219-
if not config:
220-
raise HTTPException(status_code=404, detail="Embedding configuration not found")
221-
222-
provider_type = config.get("provider_type", "openai")
223-
if config.get("is_system_fallback"):
224-
api_key = config.get("api_key", "")
225-
else:
226-
api_key = await config_service.decrypt_api_key(config.get("api_key", ""))
227-
228-
client = model_factory.create_client(
229-
provider_type=provider_type,
230-
api_key=api_key,
231-
api_base_url=config.get("custom_api_base_url"),
232-
model_name=request.model or config.get("model_name"),
201+
client = await _resolve_provider(
202+
db, x_user_id, "EMBEDDING",
203+
config_id=request.config_id, override_model=request.model,
233204
)
234205

235206
result = await client.embed(texts=request.input, model=request.model)
@@ -258,30 +229,9 @@ async def rerank_documents(
258229
APIResponse[RerankResult]
259230
"""
260231
try:
261-
config_service = ConfigReaderService(db)
262-
263-
if request.config_id:
264-
config = await config_service.get_user_config_by_id(x_user_id, request.config_id)
265-
else:
266-
config = await config_service.get_user_default_config_by_capability(x_user_id, "RERANK")
267-
268-
if not config:
269-
config = config_service.get_system_fallback_config_by_capability("RERANK")
270-
271-
if not config:
272-
raise HTTPException(status_code=404, detail="Rerank configuration not found")
273-
274-
provider_type = config.get("provider_type", "openai")
275-
if config.get("is_system_fallback"):
276-
api_key = config.get("api_key", "")
277-
else:
278-
api_key = await config_service.decrypt_api_key(config.get("api_key", ""))
279-
280-
client = model_factory.create_client(
281-
provider_type=provider_type,
282-
api_key=api_key,
283-
api_base_url=config.get("custom_api_base_url"),
284-
model_name=request.model or config.get("model_name"),
232+
client = await _resolve_provider(
233+
db, x_user_id, "RERANK",
234+
config_id=request.config_id, override_model=request.model,
285235
)
286236

287237
result = await client.rerank(
@@ -315,30 +265,8 @@ async def extract_text_from_image(
315265
APIResponse[dict]
316266
"""
317267
try:
318-
config_service = ConfigReaderService(db)
319-
320-
if request.config_id:
321-
config = await config_service.get_user_config_by_id(x_user_id, request.config_id)
322-
else:
323-
config = await config_service.get_user_default_config_by_capability(x_user_id, "OCR")
324-
325-
if not config:
326-
config = config_service.get_system_fallback_config_by_capability("OCR")
327-
328-
if not config:
329-
raise HTTPException(status_code=404, detail="OCR configuration not found")
330-
331-
provider_type = config.get("provider_type", "openai")
332-
if config.get("is_system_fallback"):
333-
api_key = config.get("api_key", "")
334-
else:
335-
api_key = await config_service.decrypt_api_key(config.get("api_key", ""))
336-
337-
client = model_factory.create_client(
338-
provider_type=provider_type,
339-
api_key=api_key,
340-
api_base_url=config.get("custom_api_base_url"),
341-
model_name=request.model or config.get("model_name"),
268+
client = await _resolve_provider(
269+
db, x_user_id, "OCR", config_id=request.config_id,
342270
)
343271

344272
result = await client.extract_text(

0 commit comments

Comments
 (0)