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:
tzt
2026-09-19 10:15:21 +08:00
parent b2b7a64cb3
commit aa9db718e6
2 changed files with 166 additions and 5 deletions
+104 -5
View File
@@ -13,7 +13,8 @@ import asyncio
import json import json
import time import time
import uuid 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 import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse 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"], "margin_milli": s["revenue_milli"] - s["cost_milli"],
"by_bucket": s["by_bucket"], "by_bucket": s["by_bucket"],
"semcache": sem_stats, "semcache": sem_stats,
"route_scored": list(_route_score_events)[-10:][::-1],
"today": today, "today": today,
"upstream_failover": upstream_failover_stats()} "upstream_failover": upstream_failover_stats()}
@@ -643,6 +645,86 @@ def _route_sig(needs: Dict[str, Any], model: str, cfg: ProxyConfig) -> str:
return "" 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]: def _cached_charge(body: dict, cfg: ProxyConfig, model: str) -> Dict[str, int]:
"""缓存命中计费:成本 0,按未命中口径对入/出估 token 收售价(§7)。""" """缓存命中计费:成本 0,按未命中口径对入/出估 token 收售价(§7)。"""
from gateway.proxy.pricing import compute 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) needs = _request_needs(body)
if not _entry_meets(entry, needs): if not _entry_meets(entry, needs):
from gateway.model_pool import filter_by_capabilities from gateway.model_pool import filter_by_capabilities
cap_entries = filter_by_capabilities( all_entries = [e for e in _pool_entries(pool)
[e for e in _pool_entries(pool)
if e.get("enabled") and e.get("backend") not in ("mock",) if e.get("enabled") and e.get("backend") not in ("mock",)
and e.get("base_url")], and e.get("base_url")]
cap_entries = filter_by_capabilities(
list(all_entries),
need_vision=needs["vision"], need_tools=needs["tools"], need_vision=needs["vision"], need_tools=needs["tools"],
min_context_tokens=needs["min_context_tokens"]) min_context_tokens=needs["min_context_tokens"])
if cap_entries: 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) model = str(entry.get("model") or model)
body = {**body, "model": 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 硬拒 ---- # ---- 预算四档(T-X1):接近日上限渐进降档;>100% 仍由 try_hold 硬拒 ----
budget_mode = "normal" 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() t0 = time.perf_counter()
sink: Dict[str, Any] = {} sink: Dict[str, Any] = {}
chain = _fallback_chain(pool, entry) 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: try:
if is_stream: if is_stream:
return await _stream_response(body, entry, sink, headers, client_wants_usage, return await _stream_response(body, entry, sink, headers, client_wants_usage,
+62
View File
@@ -0,0 +1,62 @@
"""五维评分选模与拒绝理由留痕(T-X13,采纳 ai-model-router scoring 设计)。"""
import gateway.proxy.routes as R
from gateway.proxy.routes import (_entry_reject_reason, _rank_candidates,
_record_route_score_event,
reset_route_score_events)
def _entry(model, tier="budget", price_in=1.0, price_out=2.0,
vision=True, tools=True, ctx=0):
return {"id": model, "model": model, "enabled": True,
"backend": "openai", "base_url": "http://127.0.0.1:9/v1",
"tier": tier, "price_in": price_in, "price_out": price_out,
"capabilities": {"vision": vision, "tools": tools,
"context_window": ctx}}
_NEEDS = {"vision": True, "tools": False, "min_context_tokens": 1000}
def test_reject_reasons_are_human_readable():
"""vision/tools/上下文三类不满足均有对应人话理由;满足则 None。"""
assert "vision" in (_entry_reject_reason(_entry("a", vision=False), _NEEDS) or "")
need_tools = {"vision": False, "tools": True, "min_context_tokens": 1000}
assert "tools" in (_entry_reject_reason(_entry("a", tools=False), need_tools) or "")
small = _entry("a", ctx=512)
assert "512" in (_entry_reject_reason(small, _NEEDS) or "")
assert _entry_reject_reason(_entry("a", ctx=8192), _NEEDS) is None
def test_rank_prefers_capable_and_cheap():
"""不满足需求者 capability=0 沉底;便宜者靠成本效率维胜出。"""
good_cheap = _entry("cheap", tier="budget", price_in=0.1, price_out=0.2, ctx=8192)
good_pricey = _entry("pricey", tier="premium", price_in=8.0, price_out=8.0, ctx=8192)
ranked = _rank_candidates([good_pricey, good_cheap], _NEEDS)
assert ranked[0]["model"] == "cheap"
assert ranked[0]["dims"]["capability"] > 0
assert ranked[0]["dims"]["cost_efficiency"] > ranked[1]["dims"]["cost_efficiency"]
# premium 的质量维更高(启发式)
assert ranked[1]["dims"]["quality"] > ranked[0]["dims"]["quality"]
def test_rank_tie_break_deterministic():
"""同配置同分按模型名字典序。"""
a, b = _entry("zeta"), _entry("alpha")
ranked = _rank_candidates([a, b], _NEEDS)
assert [s["model"] for s in ranked] == ["alpha", "zeta"]
def test_score_event_records_rejections():
"""评分留痕:入选排序 + 被淘汰候选的拒绝理由;reset 清空。"""
reset_route_score_events()
ranked = _rank_candidates([_entry("good", ctx=8192)], _NEEDS)
rejected = [{"model": "bad", "reason": "模型不支持视觉输入(vision"}]
_record_route_score_event("px-test", ranked, rejected)
_record_route_score_event("px-test-2", ranked, rejected)
assert len(R._route_score_events) == 2
ev = R._route_score_events[-1]
assert ev["request_id"] == "px-test-2"
assert ev["ranked"][0]["model"] == "good"
assert ev["rejected"][0]["reason"].startswith("模型不支持")
reset_route_score_events()
assert len(R._route_score_events) == 0