From 5a1a567720a6693000bfbed17d7d6c2d9e09fad6 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Mon, 7 Sep 2026 18:38:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E5=90=8C=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=A4=87=E9=93=BE=E9=93=81=E5=BE=8B=E2=80=94=E2=80=94=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=8C=87=E5=AE=9A=E6=A8=A1=E5=9E=8B=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=97=B6=E7=A6=81=E6=AD=A2=E8=B7=A8=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E9=A1=BA=E5=BB=B6(2026-09-07=20qwen-image-pl?= =?UTF-8?q?us=E5=AE=9E=E6=B5=8B:=E6=8C=87=E5=AE=9At2i=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=97=A0key=E8=B4=A6=E5=8F=B7=E2=86=92=E6=97=A7=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E8=A1=A5t2t=E5=A4=87=E9=93=BE=E2=86=92=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E8=BF=94=E5=9B=9Edeepseek=E6=96=87=E5=AD=97=E8=BF=98?= =?UTF-8?q?=E6=8A=A5ok:true,=E8=A6=81=E5=9B=BE=E5=BE=97=E6=96=87=E5=BD=93?= =?UTF-8?q?=E6=88=90=E5=8A=9F);=5Fmodel=5Fchain=E6=8C=87=E5=AE=9A=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8F=AA=E8=A1=A5=E5=90=8C=E8=83=BD=E5=8A=9B=E5=A4=87?= =?UTF-8?q?=E9=93=BE+owner=E5=AE=B9=E9=94=99=E9=93=BE=E5=90=8C=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E8=BF=87=E6=BB=A4,=E6=97=A0=E5=90=8C=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=9B=BF=E4=BB=A3=E5=88=99=E6=8A=A5=E5=8F=AF=E8=A1=8C?= =?UTF-8?q?=E5=8A=A8=E9=94=99=E8=AF=AF(=E8=BF=9D=E5=8F=8D=E7=A6=81?= =?UTF-8?q?=E6=AD=A2=E9=9D=99=E9=BB=98=E5=9B=9E=E9=80=80=E9=93=81=E5=BE=8B?= =?UTF-8?q?,=E4=B8=94=E6=AF=92=E5=AE=B3invoke=5Fmodel=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=80=89=E5=9E=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_llm/gateway.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/pipeline_llm/gateway.py b/pipeline_llm/gateway.py index ea6be1c..15664ad 100644 --- a/pipeline_llm/gateway.py +++ b/pipeline_llm/gateway.py @@ -169,24 +169,47 @@ async def _org_policy(sor, org_id: str): async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''): """解析模型链 [主, 备...],返回 llm_model 记录 dict 列表。 - - 调用方指定 model_name:在 llm_model 找到 → [它] + 策略备链补充;找不到 → 空(走旧表兜底) + - 调用方指定 model_name:在 llm_model 找到 → [它] + **同能力**备链补充; + 找不到 → 空(走旧表兜底) - 未指定:策略主模型 + 备链;无策略 → 空 - purpose='utility'(辅助任务:分类/选择/摘要):链 = [辅助模型] + 主 + 备。 显式指定 model_name 时忽略 purpose(显式优先);未配辅助模型时等同普通链。 辅助模型不可用(账号/端点全挂)由 govern_resolve 的逐模型尝试顺延到主模型, 与主备容错同一机制,无新增降级路径。 + + ⚠️ 同能力备链铁律(2026-09-07 qwen-image-plus 实测根因):显式指定 t2i + 模型不可用时,旧逻辑把策略备链(清一色 t2t 对话模型)补进链里 → 静默 + 顺延到 deepseek 返回**文字**还报 ok:true——要图得文当成功,违反 + 「治理真实失败禁止静默回退」,且直接毒害 invoke_model 自动选型 + (选了生图模型却拿到文本)。跨能力替换永远非法;同能力替代 + (另一个文生图模型)才是合法容错。指定模型无同能力替代时链只剩 + 它自己,挑不到候选 → govern_resolve 报可行动错误(消息带最后原因)。 """ chain_ids = [] primary, utility, backups, pref = await _org_policy(sor, org_id) if model_name: recs = await sor.sqlExe( - "SELECT id FROM llm_model WHERE status='active' AND (name=${n}$ OR vendor_model_id=${n}$) LIMIT 1", + "SELECT id, capability FROM llm_model WHERE status='active' " + "AND (name=${n}$ OR vendor_model_id=${n}$) LIMIT 1", {"n": model_name}) await sor.sqlExe("COMMIT", {}) if not recs: return [], pref chain_ids.append(getattr(recs[0], 'id', '')) - chain_ids.extend([b for b in backups if b not in chain_ids]) + want_cap = (getattr(recs[0], 'capability', '') or 't2t').strip().lower() + if backups: + # 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)} + 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) + await sor.sqlExe("COMMIT", {}) + for br in (brecs or []): + bid = getattr(br, 'id', '') + if bid and bid not in chain_ids: + chain_ids.append(bid) else: if purpose == 'utility' and utility: chain_ids.append(utility) @@ -514,9 +537,15 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '', logger.info("pipeline_llm: 模型 %s 候选不可用(%s),尝试链内下一模型", m.get('name'), cand) # 本机构链全不可用 → 容错落 owner 链 if chosen is None and policy_org != '0': - models0, pref0 = await _model_chain(sor, '0', '', purpose) + models0, pref0 = await _model_chain(sor, '0', model_name or '', purpose) # owner 容错链同样过归属校验(策略里可能配了他机构注册的模型) models0 = [m for m in models0 if _owner_allowed(m.get('org_id'), org_id)] + # 同能力铁律(2026-09-07):显式指定模型时 owner 容错链也只许 + # 同能力替代——要图给文的静默回退在任何一层都不合法。 + if model_name and models0: + _want = (models0[0].get('capability') or 't2t').strip().lower() + models0 = [m for m in models0 + if (m.get('capability') or 't2t').strip().lower() == _want] for m in models0: ok, cand = await _pick_candidate(sor, m, pref0) if ok and isinstance(cand, tuple):