feat(proxy): T-X13 采纳 ai-model-router 五维评分选模——能力过滤后确定性排序 + 拒绝理由留痕
- routes:能力位硬过滤(T-X6 语义不变)后对存活候选五维评分—— cost_efficiency=1/(1+均价*2)、capability=上下文余量(不满足为 0)、 speed/quality=档位秩比互补(local 快 / premium 优)、reliability 权重占位; 重定向目标从'取首元素'改为'取最高分';降级链按评分降序稳定重排 - 被淘汰候选带人话拒绝理由(vision/tools/上下文窗口三类)写入评分留痕环 (deque 上限 100),/admin/stats 新增 route_scored 段透出(管理面可观测) - 硬过滤语义不变:评分只改变链内顺序,不改变谁能存活 - 新增 tests/test_route_score.py 4 项(拒绝理由/排序偏好/同分决胜/留痕)
This commit is contained in:
+104
-5
@@ -13,7 +13,8 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from collections import deque
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
@@ -237,6 +238,7 @@ def build_proxy_router(cfg: ProxyConfig, pool, settings_provider=None,
|
||||
"margin_milli": s["revenue_milli"] - s["cost_milli"],
|
||||
"by_bucket": s["by_bucket"],
|
||||
"semcache": sem_stats,
|
||||
"route_scored": list(_route_score_events)[-10:][::-1],
|
||||
"today": today,
|
||||
"upstream_failover": upstream_failover_stats()}
|
||||
|
||||
@@ -643,6 +645,86 @@ def _route_sig(needs: Dict[str, Any], model: str, cfg: ProxyConfig) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T-X13(采纳 ai-model-router 五维评分 + 硬过滤拒绝理由设计):能力位硬过滤
|
||||
# (T-X6,语义不变)之后对存活候选做确定性加权评分——改变的是链内顺序而非
|
||||
# 过滤语义;被淘汰候选带人话拒绝理由进评分留痕(管理面可观测)。
|
||||
# ---------------------------------------------------------------------------
|
||||
_ROUTE_SCORE_WEIGHTS = {
|
||||
"cost_efficiency": 0.30, "capability": 0.25, "speed": 0.20,
|
||||
"reliability": 0.15, "quality": 0.10,
|
||||
}
|
||||
_route_score_events: Deque[Dict[str, Any]] = deque(maxlen=100)
|
||||
|
||||
|
||||
def reset_route_score_events() -> None:
|
||||
"""测试用:清空评分留痕。"""
|
||||
_route_score_events.clear()
|
||||
|
||||
|
||||
def _entry_reject_reason(entry: Dict[str, Any], needs: Dict[str, Any]) -> Optional[str]:
|
||||
"""能力位不满足时的人话拒绝理由(满足则 None)。"""
|
||||
cap = entry.get("capabilities") or {}
|
||||
if needs["vision"] and not cap.get("vision", True):
|
||||
return "模型不支持视觉输入(vision)"
|
||||
if needs["tools"] and not cap.get("tools", True):
|
||||
return "模型不支持工具调用(tools)"
|
||||
ctx = int(cap.get("context_window") or 0)
|
||||
if needs["min_context_tokens"] > 0 and 0 < ctx < needs["min_context_tokens"]:
|
||||
return f"上下文窗口 {ctx} 小于需求 {needs['min_context_tokens']}"
|
||||
return None
|
||||
|
||||
|
||||
def _score_entry(entry: Dict[str, Any], needs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""单候选五维评分(各维归一 0-1;确定性)。
|
||||
|
||||
cost_efficiency = 1/(1+均价*2);speed = 1 - 档位秩比(local 最快);
|
||||
quality = 档位秩比(premium 质量最高的启发式);capability = 满足需求时
|
||||
按上下文余量给分、不满足为 0;reliability 预留(单写者模型暂无按模型
|
||||
失败率统计,恒 1.0,权重占位)。
|
||||
"""
|
||||
from gateway.model_pool import TIERS
|
||||
price = max(0.0, (float(entry.get("price_in") or 0)
|
||||
+ float(entry.get("price_out") or 0)) / 2.0)
|
||||
try:
|
||||
rank = TIERS.index(str(entry.get("tier") or ""))
|
||||
except ValueError:
|
||||
rank = max(0, len(TIERS) - 1)
|
||||
span = max(1, len(TIERS) - 1)
|
||||
cap = entry.get("capabilities") or {}
|
||||
ctx = int(cap.get("context_window") or 0)
|
||||
meets = _entry_meets(entry, needs)
|
||||
headroom = 1.0 if ctx <= 0 else min(1.0, ctx / max(1, needs["min_context_tokens"]))
|
||||
dims = {
|
||||
"cost_efficiency": 1.0 / (1.0 + price * 2.0),
|
||||
"capability": headroom if meets else 0.0,
|
||||
"speed": 1.0 - rank / span,
|
||||
"reliability": 1.0,
|
||||
"quality": rank / span,
|
||||
}
|
||||
total = sum(dims[k] * _ROUTE_SCORE_WEIGHTS[k] for k in dims)
|
||||
return {"model": str(entry.get("model") or ""), "tier": entry.get("tier"),
|
||||
"total": round(total, 4),
|
||||
"dims": {k: round(v, 4) for k, v in dims.items()}}
|
||||
|
||||
|
||||
def _rank_candidates(candidates: List[Dict[str, Any]], needs: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""候选按 total 降序(同分按模型名字典序,确定性)。"""
|
||||
return sorted((_score_entry(e, needs) for e in candidates),
|
||||
key=lambda s: (-s["total"], s["model"]))
|
||||
|
||||
|
||||
def _record_route_score_event(request_id: str, ranked: List[Dict[str, Any]],
|
||||
rejected: List[Dict[str, Any]]) -> None:
|
||||
"""评分留痕:入选排序 + 被淘汰候选的拒绝理由(管理面可观测)。"""
|
||||
_route_score_events.append({
|
||||
"ts": int(time.time()), "request_id": request_id,
|
||||
"ranked": ranked[:3],
|
||||
"rejected": rejected,
|
||||
})
|
||||
|
||||
|
||||
def _cached_charge(body: dict, cfg: ProxyConfig, model: str) -> Dict[str, int]:
|
||||
"""缓存命中计费:成本 0,按未命中口径对入/出估 token 收售价(§7)。"""
|
||||
from gateway.proxy.pricing import compute
|
||||
@@ -672,16 +754,26 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
|
||||
needs = _request_needs(body)
|
||||
if not _entry_meets(entry, needs):
|
||||
from gateway.model_pool import filter_by_capabilities
|
||||
all_entries = [e for e in _pool_entries(pool)
|
||||
if e.get("enabled") and e.get("backend") not in ("mock",)
|
||||
and e.get("base_url")]
|
||||
cap_entries = filter_by_capabilities(
|
||||
[e for e in _pool_entries(pool)
|
||||
if e.get("enabled") and e.get("backend") not in ("mock",)
|
||||
and e.get("base_url")],
|
||||
list(all_entries),
|
||||
need_vision=needs["vision"], need_tools=needs["tools"],
|
||||
min_context_tokens=needs["min_context_tokens"])
|
||||
if cap_entries:
|
||||
entry = cap_entries[0]
|
||||
# T-X13:存活候选五维评分,取最高者为重定向目标(不再取首元素)
|
||||
ranked = _rank_candidates(cap_entries, needs)
|
||||
best_model = ranked[0]["model"]
|
||||
entry = next(e for e in cap_entries
|
||||
if str(e.get("model") or "") == best_model)
|
||||
model = str(entry.get("model") or model)
|
||||
body = {**body, "model": model}
|
||||
_record_route_score_event(
|
||||
request_id, ranked,
|
||||
[{"model": str(e.get("model") or ""),
|
||||
"reason": _entry_reject_reason(e, needs) or "评分落选"}
|
||||
for e in all_entries if not _entry_meets(e, needs)])
|
||||
|
||||
# ---- 预算四档(T-X1):接近日上限渐进降档;>100% 仍由 try_hold 硬拒 ----
|
||||
budget_mode = "normal"
|
||||
@@ -769,6 +861,13 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
|
||||
t0 = time.perf_counter()
|
||||
sink: Dict[str, Any] = {}
|
||||
chain = _fallback_chain(pool, entry)
|
||||
# T-X13:降级链按五维评分降序重排(稳定:同分保持原相对序;评分异常回退原链)
|
||||
try:
|
||||
totals = {s["model"]: s["total"] for s in _rank_candidates(chain, needs)}
|
||||
chain = sorted(chain, key=lambda e: (-totals.get(str(e.get("model") or ""), 0.0),
|
||||
str(e.get("model") or "")))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
if is_stream:
|
||||
return await _stream_response(body, entry, sink, headers, client_wants_usage,
|
||||
|
||||
Reference in New Issue
Block a user