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 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
|
||||||
|
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(
|
cap_entries = filter_by_capabilities(
|
||||||
[e for e in _pool_entries(pool)
|
list(all_entries),
|
||||||
if e.get("enabled") and e.get("backend") not in ("mock",)
|
|
||||||
and e.get("base_url")],
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user