一、代码(2 文件 + 2 工具脚本注记)
* app/service/model_gateway.py:DatabaseModelEndpointResolver.resolve() 加配置守卫
—— required == "embedding" 且 len(matched) > 1 时 logger.warning(只告警、不改行为)。
多个 embedding 端点会让索引向量与查询向量可能来自不同模型(维度同为 1024、不报错),
COSINE 相似度整体失真,表现为"越答越差"的哑故障。顺手删掉重复的 return endpoints(死代码)。
* tests/unit/service/test_model_gateway.py:新增 2 条单测(多端点告警且返回顺序不变 / 单端点静默)。
* tools/configure_embedding_endpoint.py:加「已废弃,勿重跑」标注 —— 它写的是
qwen-embedding / qwen3.7-text-embedding-flash,与现役端点 knowledge-embedding-qwen-v3 /
text-embedding-v3 不一致,重跑会凭空多出一个 embedding 端点。
* tools/build_knowledge_chunks.py:删掉与新口径冲突的注释「不泄露档位与门槛」,
改为「registered 的依据是权益明细而非门槛;门槛属公开宣传口径」。
二、文档(8 份;D-1 选乙 + D-3 统一为 18)
* D2.4 v1.6 → v1.7:§4.4 + 附录B 更正「门槛金额不再单独构成 registered 的理由」
(public 的 FAQ-0014 已完整给出五档门槛、FAQ-0050 含钻石门槛);
HNW-004—HNW-007 保持 registered,依据收窄为"各层级权益明细";HNW-* 档位不动(分区键)。
* D3.1 v2.5 → v2.6:§5.3 加索引口径落地注(覆盖 §2.5 决策表 / FR-CS-007 / 排期 T4)
+ 补「字段表同属初稿」(实库 18 字段全 NOT NULL、doc_id 主键、无 metadata JSON)。
* D3.2 v1.2 → v1.6:§4.1 加同口径注 + 版本位追平(顶栏 v1.1 / doc-meta v1.2 落后于自身记录 v1.5)。
* D2.2 v2.6 → v2.7:§1.4.2 域 B 加注(TopK / 阈值 / 度量 / 集合选择均未变 ⇒ 不影响验收)。
* D3.7:§3 难例口径统一 —— 难例 32 条(改写 8 + 口语 16 + 多轮 4 + 禁忌 4)为定义式总数,
M-2b 分母 = 其中带期望证据家族的 18 条;并补正 §3 初稿表格条数(以 cases_46.json 为准)。
* D1.1 v1.8 → v1.9:新增 §28;四处版本位同步;顺带修正两处历史遗留
(D2.4 版本位长期停在 v1.3、D2.2 日期列停在 2026-09-17)。
* D1.6:新增 §4.47(含自我失误留痕)。
* D2.1 v6.33 → v6.34:新增本轮修订要点段。
三、实测门口(本机)
* tests/unit/service/test_model_gateway.py:10 passed
* pytest -q -p no:cacheprovider(全量,跑前已停 Worker):1917 passed / 3 skipped / 0 failed
* tools/check_authoritative_docs.py:54 文档无编号冲突(exit 0)
* _consistency.py:失效锚点 0、交叉引用全 ✅(exit 0)
* _fe_boundary_http.py(重建件):12/12 符合预期
* 服务已重启:/internal/health/ready 三依赖全绿(mysql / redis / milvus)
四、如实留痕(自我失误)
本轮清理临时文件时删除判据过宽,误删 _consistency.py(已原样恢复)、
_legacy_customer_service.py(已按 f72a545 逐字节重建,40,554 字节)、
_fe_boundary_http.py(原件不可恢复,已按既有判据重建并实跑 12/12)与若干历史轮次原始日志。
详见 D1.6 §4.47 五。
351 lines
15 KiB
Python
351 lines
15 KiB
Python
import logging
|
||
import os
|
||
from collections.abc import Mapping
|
||
from dataclasses import dataclass
|
||
from typing import Any, Protocol, cast
|
||
|
||
import httpx
|
||
from sqlalchemy import select
|
||
|
||
from app.core.errors import (
|
||
DependencyUnavailableError,
|
||
RecoverableAgentError,
|
||
UpstreamTimeoutError,
|
||
)
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.configuration import ModelEndpointConfig
|
||
|
||
|
||
class ModelGateway(Protocol):
|
||
async def generate(self, *, endpoint_code: str, prompt: str, timeout_ms: int) -> str: ...
|
||
|
||
async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]: ...
|
||
|
||
|
||
class EndpointSettings(Protocol):
|
||
endpoint_code: str
|
||
base_url: str
|
||
model_name: str
|
||
secret_ref: str
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class EnvironmentSecretResolver:
|
||
def resolve(self, secret_ref: str) -> str:
|
||
if not secret_ref.startswith("env:"):
|
||
raise RecoverableAgentError("模型密钥必须使用 env: 引用")
|
||
name = secret_ref.removeprefix("env:")
|
||
value = os.getenv(name)
|
||
if not value:
|
||
raise RecoverableAgentError("模型密钥未配置")
|
||
return value
|
||
|
||
|
||
#: `task_type` → 端点必须具备的能力标签。
|
||
#:
|
||
#: 取值来自全仓实际调用点与**现库端点实际声明的能力**(实测:
|
||
#: `embedding-primary` = `["embedding"]`、`chat-primary` = `["chat"]`):
|
||
#: `embedding`(向量化:`bootstrap.get_memory_embedding_service`、`knowledge_vector_worker`)、
|
||
#: `chat`(生成/复述合并:`customer_service`)、
|
||
#: `intent_classification`(`BaseAgent.classify_intent`)、
|
||
#: `memory_extraction` / `text_generation`(结构化文本生成)。
|
||
#:
|
||
#: ⚠️ `intent_classification` 必须映射到 `chat`,**不能**映射成同名的
|
||
#: `intent_classification`:现库没有任何端点声明该能力,同名映射会筛出空集,
|
||
#: 依赖"回退到全部端点"才不至于失败——那是把配置缺陷掩盖成巧合。
|
||
#: 未列入的 `task_type` 不做过滤(无法判断该要哪种能力),但会**告警留痕**(见 `resolve`)。
|
||
REQUIRED_CAPABILITY_BY_TASK_TYPE: dict[str, str] = {
|
||
"embedding": "embedding",
|
||
"chat": "chat",
|
||
"intent_classification": "chat",
|
||
"memory_extraction": "text_generation",
|
||
# 风控的几处 task_type:它们要的都是"能生成文本"的端点,与 memory_extraction 同理。
|
||
# 不登记就会落到"未映射 → 返回全部端点"的分支,而能否选到文本端点就取决于
|
||
# `model_endpoint_config` 的**行顺序**——实测风控能跑通,仅仅因为 deepseek-flash(id=3)
|
||
# 恰好排在 qwen-embedding(id=5) 前面。这种"靠数据顺序才对"的隐式依赖必须消掉。
|
||
"risk_agent_chat": "text_generation",
|
||
"risk_analysis": "text_generation",
|
||
"risk_script": "text_generation",
|
||
"risk_summary": "text_generation",
|
||
"daily_report_suggestion": "text_generation",
|
||
"text_generation": "text_generation",
|
||
}
|
||
|
||
|
||
class OpenAICompatibleGateway:
|
||
"""供应商无关的 Chat Completions Adapter;密钥只从 secret_ref 解析。"""
|
||
|
||
def __init__(
|
||
self,
|
||
endpoints: Mapping[str, EndpointSettings],
|
||
*,
|
||
secret_resolver: EnvironmentSecretResolver | None = None,
|
||
client: httpx.AsyncClient | None = None,
|
||
) -> None:
|
||
self.endpoints = endpoints
|
||
self.secret_resolver = secret_resolver or EnvironmentSecretResolver()
|
||
self.client = client
|
||
self._owns_client = client is None
|
||
|
||
async def generate(self, *, endpoint_code: str, prompt: str, timeout_ms: int) -> str:
|
||
endpoint = self.endpoints.get(endpoint_code)
|
||
if endpoint is None:
|
||
raise RecoverableAgentError("模型端点未注册")
|
||
token = self.secret_resolver.resolve(endpoint.secret_ref)
|
||
url = endpoint.base_url.rstrip("/") + "/chat/completions"
|
||
payload = {
|
||
"model": endpoint.model_name,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": 0,
|
||
}
|
||
client = self.client or httpx.AsyncClient()
|
||
try:
|
||
response = await client.post(
|
||
url,
|
||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||
json=payload,
|
||
timeout=httpx.Timeout(timeout_ms / 1000),
|
||
)
|
||
response.raise_for_status()
|
||
body: Any = response.json()
|
||
content = body.get("choices", [{}])[0].get("message", {}).get("content")
|
||
if not isinstance(content, str) or not content.strip():
|
||
raise DependencyUnavailableError("模型响应缺少文本")
|
||
return content
|
||
except httpx.TimeoutException as exc:
|
||
raise UpstreamTimeoutError("模型端点调用超时") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise DependencyUnavailableError("模型端点调用失败") from exc
|
||
finally:
|
||
if self._owns_client:
|
||
await client.aclose()
|
||
|
||
async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]:
|
||
"""OpenAI-compatible Embeddings Adapter;密钥同样只从 secret_ref 解析。"""
|
||
endpoint = self.endpoints.get(endpoint_code)
|
||
if endpoint is None:
|
||
raise RecoverableAgentError("模型端点未注册")
|
||
token = self.secret_resolver.resolve(endpoint.secret_ref)
|
||
url = endpoint.base_url.rstrip("/") + "/embeddings"
|
||
payload = {"model": endpoint.model_name, "input": text}
|
||
client = self.client or httpx.AsyncClient()
|
||
try:
|
||
response = await client.post(
|
||
url,
|
||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||
json=payload,
|
||
timeout=httpx.Timeout(timeout_ms / 1000),
|
||
)
|
||
response.raise_for_status()
|
||
body: Any = response.json()
|
||
vector = _first_embedding(body)
|
||
if vector is None:
|
||
raise DependencyUnavailableError("向量响应缺少 embedding")
|
||
return vector
|
||
except httpx.TimeoutException as exc:
|
||
raise UpstreamTimeoutError("向量端点调用超时") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise DependencyUnavailableError("向量端点调用失败") from exc
|
||
finally:
|
||
if self._owns_client:
|
||
await client.aclose()
|
||
|
||
|
||
def _first_embedding(body: Any) -> list[float] | None:
|
||
"""取 OpenAI-compatible 响应的首条向量;结构不符时返回 None,由调用方失败关闭。"""
|
||
data = body.get("data") if isinstance(body, dict) else None
|
||
if not isinstance(data, list) or not data:
|
||
return None
|
||
entry = data[0]
|
||
raw = entry.get("embedding") if isinstance(entry, dict) else None
|
||
if not isinstance(raw, list) or not raw:
|
||
return None
|
||
try:
|
||
return [float(value) for value in raw]
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
class DatabaseModelGateway:
|
||
"""从当前数据库端点配置动态构造已批准的 OpenAI-compatible Adapter。"""
|
||
|
||
async def _endpoint(self, endpoint_code: str) -> ModelEndpointConfig:
|
||
async with SessionFactory() as session:
|
||
endpoint = await session.scalar(select(ModelEndpointConfig).where(
|
||
ModelEndpointConfig.endpoint_code == endpoint_code,
|
||
ModelEndpointConfig.status == "active",
|
||
))
|
||
if endpoint is None:
|
||
raise RecoverableAgentError("模型端点未注册或未激活")
|
||
return endpoint
|
||
|
||
async def generate(self, *, endpoint_code: str, prompt: str, timeout_ms: int) -> str:
|
||
endpoint = await self._endpoint(endpoint_code)
|
||
adapter = OpenAICompatibleGateway(
|
||
{endpoint.endpoint_code: cast(EndpointSettings, endpoint)}
|
||
)
|
||
return await adapter.generate(
|
||
endpoint_code=endpoint.endpoint_code, prompt=prompt, timeout_ms=timeout_ms
|
||
)
|
||
|
||
async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]:
|
||
endpoint = await self._endpoint(endpoint_code)
|
||
adapter = OpenAICompatibleGateway(
|
||
{endpoint.endpoint_code: cast(EndpointSettings, endpoint)}
|
||
)
|
||
return await adapter.embed(
|
||
endpoint_code=endpoint.endpoint_code, text=text, timeout_ms=timeout_ms
|
||
)
|
||
|
||
|
||
#: 历史别名,指向上面的唯一映射表。保留名字是为了不破坏既有引用;
|
||
#: **两个名字不要各存一份内容**——那正是"修复被后续合并悄悄回退"的成因。
|
||
TASK_CAPABILITY = REQUIRED_CAPABILITY_BY_TASK_TYPE
|
||
|
||
|
||
class DatabaseModelEndpointResolver:
|
||
"""按 `task_type` 筛选当前已激活的模型端点。
|
||
|
||
修复的缺陷:原实现 `del agent_type, task_type` 后返回**全部** active 端点,而
|
||
`ModelDispatchService` 的 `generate`/`embed` 走的是**不同上游路径**
|
||
(`/chat/completions` 与 `/embeddings`),且只按顺序尝试前 `max(1, max_attempts)`
|
||
(默认 2)个。两者叠加的后果是「能不能选到支持该任务的端点」取决于端点在表里的顺序——
|
||
实测表现为每次 embedding 都先拿文本生成端点失败一次再落到真正的向量端点,
|
||
端点一多就会**耗尽尝试次数**直接失败。
|
||
|
||
筛选策略保持保守:只在确实筛到端点时收窄;未映射的 task_type、以及「一个都没声明
|
||
该能力」的配置缺口,都退回全部端点,让故障表现为**调用失败**而不是解析为空。
|
||
"""
|
||
|
||
async def resolve(self, *, agent_type: str, task_type: str) -> list[ModelEndpointConfig]:
|
||
del agent_type # 按 agent_type 分配端点的路由仍由发布配置与 ModelRouterService 扩展。
|
||
required = REQUIRED_CAPABILITY_BY_TASK_TYPE.get(task_type)
|
||
async with SessionFactory() as session:
|
||
endpoints = list(await session.scalars(select(ModelEndpointConfig).where(
|
||
ModelEndpointConfig.status == "active"
|
||
)))
|
||
if required is None:
|
||
# 未登记的 task_type 仍退回全部端点(保守策略:让故障表现为调用失败、
|
||
# 而不是解析为空),但必须留下痕迹。静默退回会让"选端点靠表行顺序"这类问题
|
||
# 在下游以"偶发调用失败"的形式冒出来,极难定位。
|
||
logger.warning(
|
||
"模型端点筛选:task_type=%r 未登记能力映射,退回全部 active 端点;"
|
||
"请在 REQUIRED_CAPABILITY_BY_TASK_TYPE 中补上它对应的能力",
|
||
task_type,
|
||
)
|
||
return endpoints
|
||
matched = [
|
||
endpoint for endpoint in endpoints
|
||
if isinstance(endpoint.capabilities, list) and required in endpoint.capabilities
|
||
]
|
||
# `capabilities` 为 NULL/空 的端点绝不会被 `matched` 选中(不能裸奔到错误的网关方法上);
|
||
# 但整批都没声明该能力时退回全部端点,避免把这个配置缺口伪装成"没有可用端点"。
|
||
if required == "embedding" and len(matched) > 1:
|
||
# 配置守卫:声明朝 `embedding` 能力的 active 端点应**恰好 1 个**。
|
||
# 后端会按顺序只试前 `max(1, max_attempts)`(默认 2)个端点,端点一多就会被截断;
|
||
# 更隐蔽的是模型混用——Milvus 集合里向量维度固定 1024,若两个端点背后的模型不同,
|
||
# 写入向量与查询向量就不在同一个空间,`COSINE` 相似度会整体失真:
|
||
# **不报错,只是越答越差**(这类故障最难定位)。这里只告警、不改行为。
|
||
logger.warning(
|
||
"模型端点筛选:active 端点中有 %d 个声明 embedding 能力(%s);"
|
||
"向量化端点应恰好 1 个——多端点会让索引向量与查询向量可能来自不同模型,"
|
||
"相似度整体失真且不报错。请停用多余端点或去掉其 embedding 能力声明",
|
||
len(matched),
|
||
", ".join(str(getattr(e, "endpoint_code", "?")) for e in matched),
|
||
)
|
||
return matched or endpoints
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ModelExecution:
|
||
endpoint_code: str
|
||
text: str
|
||
attempts: int
|
||
degraded: bool = False
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ModelEmbedding:
|
||
endpoint_code: str
|
||
vector: list[float]
|
||
attempts: int
|
||
degraded: bool = False
|
||
|
||
|
||
class ModelDispatchService:
|
||
"""Executes only router-approved endpoints and falls back in declared order."""
|
||
|
||
def __init__(self, gateway: ModelGateway) -> None:
|
||
self.gateway = gateway
|
||
|
||
async def generate(
|
||
self,
|
||
endpoints: list[Any],
|
||
prompt: str,
|
||
*,
|
||
max_attempts: int = 2,
|
||
) -> ModelExecution:
|
||
last_error: Exception | None = None
|
||
attempts = 0
|
||
for endpoint in endpoints[: max(1, max_attempts)]:
|
||
attempts += 1
|
||
try:
|
||
text = await self.gateway.generate(
|
||
endpoint_code=endpoint.endpoint_code,
|
||
prompt=prompt,
|
||
timeout_ms=endpoint.timeout_ms,
|
||
)
|
||
return ModelExecution(endpoint.endpoint_code, text, attempts, attempts > 1)
|
||
except Exception as exc:
|
||
last_error = exc
|
||
raise RecoverableAgentError("所有已批准模型端点调用失败") from last_error
|
||
|
||
async def embed(
|
||
self, endpoints: list[Any], text: str, *, max_attempts: int = 2
|
||
) -> ModelEmbedding:
|
||
"""与文本生成同族的受控降级:按声明顺序尝试,成功即返回并标记是否降级。"""
|
||
last_error: Exception | None = None
|
||
attempts = 0
|
||
for endpoint in endpoints[: max(1, max_attempts)]:
|
||
attempts += 1
|
||
try:
|
||
vector = await self.gateway.embed(
|
||
endpoint_code=endpoint.endpoint_code,
|
||
text=text,
|
||
timeout_ms=endpoint.timeout_ms,
|
||
)
|
||
return ModelEmbedding(endpoint.endpoint_code, vector, attempts, attempts > 1)
|
||
except Exception as exc:
|
||
last_error = exc
|
||
raise RecoverableAgentError("所有已批准向量端点调用失败") from last_error
|
||
|
||
|
||
class ModelGenerationService:
|
||
"""业务 Agent 的唯一模型生成入口。路由结果必须先由 ModelRouterService 给出。"""
|
||
|
||
def __init__(self, dispatch: ModelDispatchService) -> None:
|
||
self.dispatch = dispatch
|
||
|
||
async def generate(
|
||
self, endpoints: list[Any], prompt: str, *, max_attempts: int = 2
|
||
) -> ModelExecution:
|
||
if not endpoints:
|
||
raise RecoverableAgentError("没有可用的已批准模型端点")
|
||
return await self.dispatch.generate(endpoints, prompt, max_attempts=max_attempts)
|
||
|
||
|
||
class ModelEmbeddingService:
|
||
"""文本向量化入口(记忆语义召回等只读用途),与生成同样必须先经过端点解析。"""
|
||
|
||
def __init__(self, dispatch: ModelDispatchService) -> None:
|
||
self.dispatch = dispatch
|
||
|
||
async def embed(
|
||
self, endpoints: list[Any], text: str, *, max_attempts: int = 2
|
||
) -> ModelEmbedding:
|
||
if not endpoints:
|
||
raise RecoverableAgentError("没有可用的已批准向量端点")
|
||
return await self.dispatch.embed(endpoints, text, max_attempts=max_attempts)
|