From d086c4f7d2e171eeceaee50944173246492eb35b Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 8 Sep 2026 17:20:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(govern):=20=E8=83=BD=E5=8A=9B=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E8=A7=84=E5=88=99i2i=E2=86=92t2i/r2v=E2=86=92t2v(2026?= =?UTF-8?q?-09-08=E7=94=A8=E6=88=B7=E5=AE=9A=E5=A4=BA,=E4=B8=8Er2v?= =?UTF-8?q?=E2=86=92t2v=E5=90=8C=E4=BE=8B)=E2=80=94=E2=80=94selection?= =?UTF-8?q?=E5=8A=A0CAP=5FDEGRADE=E4=BA=8B=E5=AE=9E=E6=BA=90+degrade=5Fexp?= =?UTF-8?q?and,models=5Fcatalog=E5=80=99=E9=80=89=E6=B1=A0=E5=90=AB?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E6=BA=90=E6=A8=A1=E5=9E=8B,=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E5=A4=87=E9=93=BE/owner=E5=AE=B9=E9=94=99=E9=93=BE?= =?UTF-8?q?=E5=90=8C=E8=83=BD=E5=8A=9B=E9=93=81=E5=BE=8B=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E5=90=88=E6=B3=95=E9=99=8D=E7=BA=A7=E6=BA=90,auto=5Fselect?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=A1=A5=E9=99=8D=E7=BA=A7=E8=A7=84?= =?UTF-8?q?=E5=88=99;=E5=8D=95=E5=90=91=E9=99=8D=E7=BA=A7=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E6=B0=B8=E8=BF=9C=E9=9D=9E=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_llm/gateway.py | 20 ++++++++++++++++---- pipeline_llm/selection.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/pipeline_llm/gateway.py b/pipeline_llm/gateway.py index 15664ad..262af00 100644 --- a/pipeline_llm/gateway.py +++ b/pipeline_llm/gateway.py @@ -201,10 +201,18 @@ async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''): # sqlor IN 列表必须展开占位符(传 list 会崩) ph = ','.join('${b%d}$' % i for i in range(len(backups))) bparams = {'b%d' % i: b for i, b in enumerate(backups)} + # 能力降级(2026-09-08):备链允许同能力或合法降级源能力 + # (t2i→i2i、t2v→r2v);跨能力(如 t2t)仍永远非法 + from .selection import CAP_DEGRADE_SOURCE + _alt = CAP_DEGRADE_SOURCE.get(want_cap, '') + if _alt: + ph += ',${altcap}$' + bparams['altcap'] = _alt bparams['cap'] = want_cap - brecs = await sor.sqlExe( - "SELECT id FROM llm_model WHERE status='active' AND id IN (%s) " - "AND COALESCE(NULLIF(capability,''),'t2t')=${cap}$" % ph, bparams) + _sql = ("SELECT id FROM llm_model WHERE status='active' AND id IN (%s) " + "AND COALESCE(NULLIF(capability,''),'t2t') IN (${cap}$" + + (",${altcap}$" if _alt else "") + ")") % ph + brecs = await sor.sqlExe(_sql, bparams) await sor.sqlExe("COMMIT", {}) for br in (brecs or []): bid = getattr(br, 'id', '') @@ -542,10 +550,14 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '', models0 = [m for m in models0 if _owner_allowed(m.get('org_id'), org_id)] # 同能力铁律(2026-09-07):显式指定模型时 owner 容错链也只许 # 同能力替代——要图给文的静默回退在任何一层都不合法。 + # 能力降级(2026-09-08):合法降级源能力(t2i→i2i、t2v→r2v)除外。 if model_name and models0: + from .selection import CAP_DEGRADE_SOURCE _want = (models0[0].get('capability') or 't2t').strip().lower() + _alt = CAP_DEGRADE_SOURCE.get(_want, '') models0 = [m for m in models0 - if (m.get('capability') or 't2t').strip().lower() == _want] + if (m.get('capability') or 't2t').strip().lower() + in (_want, _alt)] for m in models0: ok, cand = await _pick_candidate(sor, m, pref0) if ok and isinstance(cand, tuple): diff --git a/pipeline_llm/selection.py b/pipeline_llm/selection.py index 1e65d53..d83e5aa 100644 --- a/pipeline_llm/selection.py +++ b/pipeline_llm/selection.py @@ -23,6 +23,26 @@ logger = logging.getLogger("pipeline_llm.selection") # 会话 agent 模型下拉只列这些;embedding/rerank/图视频生成等形态各有专属入口。 CHAT_CAPS = ('t2t', 'i2t', 'm2t') +# 能力降级表(2026-09-08 用户定夺,与 r2v→t2v 同例):请求能力无可用模型时, +# 允许降级用「超集能力」模型执行任务。方向 = 降级目标模型的输入契约覆盖 +# 请求能力: +# i2i(图像→图像,接受文本提示+可选图)可执行 t2i(文本→图像)任务 +# r2v(参考媒体→视频,接受文本+参考媒体)可执行 t2v(文本→视频)任务 +# 单向降级:t2i 模型不能接 i2i(缺源图)、t2v 不能接 r2v——反向永远非法。 +CAP_DEGRADE = {'t2i': 'i2i', 't2v': 'r2v'} +# 反向索引:降级源能力 → 请求能力(门禁契约换算用:i2i 模型无图输入时按 t2i 契约校验) +CAP_DEGRADE_SOURCE = {v: k for k, v in CAP_DEGRADE.items()} + + +def degrade_expand(caps): + """能力白名单 + 其合法降级源能力(候选池扩展用)。无映射时原样返回。""" + out = [str(c).strip().lower() for c in (caps or ()) if str(c or '').strip()] + for c in list(out): + d = CAP_DEGRADE.get(c) + if d and d not in out: + out.append(d) + return tuple(out) + def _norm_caps(capabilities): """capabilities 参数归一:'chat' 哨兵 → CHAT_CAPS;逗号串 → tuple; @@ -178,6 +198,10 @@ async def models_catalog(org_id, capabilities=(), limit=50): """ db, dbname = _get_sor() caps = _norm_caps(capabilities) + # 能力降级(2026-09-08):候选池含请求能力的合法降级源模型 + # (t2i→i2i、t2v→r2v),无可用 t2i 模型时 i2i 模型可承接文生图任务 + if caps: + caps = degrade_expand(caps) async with db.sqlorContext(dbname) as sor: sql = ("SELECT m.id, m.name, m.vendor_model_id, m.capability, m.description, " "m.org_id, v.name AS vendor_name " @@ -249,7 +273,10 @@ async def auto_select_model(org_id, user_input, user_id='', capabilities='chat') '(格式:模型名 [能力类型] 供应商:描述):\n%s\n\n' '能力类型含义:t2t=文本对话 i2t=图像理解 m2t=多媒体理解 t2i=文生图 ' 'i2v=图生视频 t2v=文生视频 r2v=参考生视频 tts=语音合成 asr=语音识别 ' - 'embedding=向量化 rerank=重排序\n\n' + 'embedding=向量化 rerank=重排序\n' + '能力降级规则:无 t2i 模型时 i2i(图生图)模型可承接文生图任务;' + '无 t2v 模型时 r2v(参考生视频)模型可承接文生视频任务;' + '反向(t2i 接图生图、t2v 接参考生视频)非法。\n\n' '用户任务:\n%s\n\n' '请根据任务的内容领域、语言、复杂度,从目录中选出最适合完成该任务的模型' '(只能选目录中的模型名,按适合度从高到低排序,最多 3 个)。'