From aa9db718e6c3073fea2e9656f010672b67283ee4 Mon Sep 17 00:00:00 2001 From: tzt <14718231+flying-travel@user.noreply.gitee.com> Date: Sat, 19 Sep 2026 10:15:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20T-X13=20=E9=87=87=E7=BA=B3=20ai-?= =?UTF-8?q?model-router=20=E4=BA=94=E7=BB=B4=E8=AF=84=E5=88=86=E9=80=89?= =?UTF-8?q?=E6=A8=A1=E2=80=94=E2=80=94=E8=83=BD=E5=8A=9B=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E5=90=8E=E7=A1=AE=E5=AE=9A=E6=80=A7=E6=8E=92=E5=BA=8F=20+=20?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E7=90=86=E7=94=B1=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 项(拒绝理由/排序偏好/同分决胜/留痕) --- gateway/proxy/routes.py | 109 ++++++++++++++++++++++++++++++++++++-- tests/test_route_score.py | 62 ++++++++++++++++++++++ 2 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 tests/test_route_score.py diff --git a/gateway/proxy/routes.py b/gateway/proxy/routes.py index 98a4ec5..531ab7c 100644 --- a/gateway/proxy/routes.py +++ b/gateway/proxy/routes.py @@ -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, diff --git a/tests/test_route_score.py b/tests/test_route_score.py new file mode 100644 index 0000000..e604981 --- /dev/null +++ b/tests/test_route_score.py @@ -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