feat(llm): 会话agent可选模型收窄为对话能力t2t/i2t/m2t+能力字典加m2t(2026-09-06用户定夺)

- selection.CHAT_CAPS=('t2t','i2t','m2t') 作为会话形态能力白名单唯一事实源
- model_options/resolve_model_name 加 capabilities 参数('chat'哨兵),
  IN 列表展开占位符(sqlor传list会崩),capability 空串按 t2t 归一(COALESCE+NULLIF)
- chat_inference 同步门禁从「只认 t2t」放宽到 CHAT_CAPS——
  实测根因:测试库 6 个机构策略主模型全是 qwen3.8-max(i2t),
  16:28 已产生 FAILED「capability mismatch: i2t != t2t」,会话agent选它必挂
- m2t 入字典四处同步:种子 init/data.json + 端点注释 models.dspy
  + design-spec §6 能力表(+ 提取提示词在 pipeline-platform)
This commit is contained in:
yumoqing 2026-09-06 19:32:22 +08:00
parent 671d320d59
commit 8e32cb85f3
6 changed files with 72 additions and 9 deletions

View File

@ -79,6 +79,23 @@
均可,Jinja `is string` 动态判断),变长输入用 `{% for %}` 按文档示例组装。 均可,Jinja `is string` 动态判断),变长输入用 `{% for %}` 按文档示例组装。
- **异步模型执行器**:sync_mode=async 时走提交→按 query_profile_ids 轮询→ - **异步模型执行器**:sync_mode=async 时走提交→按 query_profile_ids 轮询→
取结果;等待预算默认 600 秒,`_timeout` 可覆盖(上限 900)。 取结果;等待预算默认 600 秒,`_timeout` 可覆盖(上限 900)。
- **会话 agent 模型选择收窄为对话能力(2026-09-06 用户定夺)**:
`selection.CHAT_CAPS = ('t2t','i2t','m2t')` 是会话形态能力白名单的**唯一事实源**——
所有会话 agent 模型下拉(agent_model_options / cockpit_model_options /
get_model_options 角色配置)传 `capabilities='chat'` 过滤;持久化入口
(set_agent_model 个人默认 / cockpit_agent 角色模型 / pipeline_service
gateway._resolve_and_persist_model 项目模型)同样只接受对话能力,防绕过下拉。
`chat_inference` 同步门禁从「只认 t2t」放宽到 CHAT_CAPS(实测根因:测试库
机构策略主模型 qwen3.8-max 能力是 i2t,旧门禁直接 FAILED
「capability mismatch: i2t != t2t」,会话 agent 选它必挂)。
存量模型 `capability` 空串按 t2t 语义归一(COALESCE+NULLIF)。
embedding/rerank/图视频生成等非对话模型各有专属入口(list_models 按能力分栏)。
- **m2t(多媒体生文)入能力字典四处同步(m0013)**:种子 `init/data.json` +
端点注释 `api/v1/models.dspy` + `docs/design-spec.md §6` + 提取提示词
(`pipeline_platform.platform_ability._EXTRACT_PROMPT`,判定表补
「多媒体输入→文本 ⇒ m2t」「仅图像→文本 ⇒ i2t」「仅文本→文本 ⇒ t2t」,
且 r2v 判定加「输出为视频」限定防误吸文本输出模型);
`_MEDIA_INPUT_CAPS` 加 m2t(模板生成按输入媒体处理)。
## API 文档端点(2026-09-05) ## API 文档端点(2026-09-05)

View File

@ -232,6 +232,7 @@ async def llm_call(prompt, model=None, org_id=None, user_id=None, task_ref='', *
| t2t | 文本对话/生成 | sync | | t2t | 文本对话/生成 | sync |
| t2i | 文生图 | async_task | | t2i | 文生图 | async_task |
| i2t | 图生文(多模态理解) | sync | | i2t | 图生文(多模态理解) | sync |
| m2t | 多媒体生文(视频/音频等多媒体输入→文本) | sync |
| t2v | 文生视频 | async_task | | t2v | 文生视频 | async_task |
| embedding | 文本向量化 | sync | | embedding | 文本向量化 | sync |
| mm-embedding | 多模态向量化 | sync | | mm-embedding | 多模态向量化 | sync |

View File

@ -15,6 +15,7 @@
{"k": "t2t", "v": "文本对话/生成"}, {"k": "t2t", "v": "文本对话/生成"},
{"k": "t2i", "v": "文生图"}, {"k": "t2i", "v": "文生图"},
{"k": "i2t", "v": "图生文"}, {"k": "i2t", "v": "图生文"},
{"k": "m2t", "v": "多媒体生文"},
{"k": "t2v", "v": "文生视频"}, {"k": "t2v", "v": "文生视频"},
{"k": "i2v", "v": "图生视频"}, {"k": "i2v", "v": "图生视频"},
{"k": "r2v", "v": "参考生视频"}, {"k": "r2v", "v": "参考生视频"},

View File

@ -721,13 +721,16 @@ async def chat_inference(org_id, user_id, payload, model_name='', task_ref=''):
model_row = ctx.get('model_row') or {} model_row = ctx.get('model_row') or {}
if (model_row.get('sync_mode') or 'sync') == 'async': if (model_row.get('sync_mode') or 'sync') == 'async':
return await _async_inference(ctx, payload, req_timeout) return await _async_inference(ctx, payload, req_timeout)
# 同步模型守能力边界:聊天入口只认 t2t(embedding/rerank 等另有入口) # 同步模型守能力边界:聊天入口只认文本输出的对话能力(t2t/i2t/m2t)——
# embedding/rerank/图视频生成等另有专属入口(list_models 按能力分栏选择)。
from .selection import CHAT_CAPS
cap = (model_row.get('capability') or 't2t').strip().lower() cap = (model_row.get('capability') or 't2t').strip().lower()
if cap != 't2t': if cap not in CHAT_CAPS:
from .gateway import govern_settle from .gateway import govern_settle
await govern_settle(ctx, False, 0, 0, 'capability mismatch: %s != t2t' % cap) await govern_settle(ctx, False, 0, 0,
raise GovernError('模型「%s」能力为 %s,聊天入口仅支持 t2t——请按能力分类选择模型' % ( 'capability mismatch: %s not in chat caps' % cap)
model_row.get('name', ''), cap)) raise GovernError('模型「%s」能力为 %s,聊天入口仅支持 %s——请按能力分类选择模型' % (
model_row.get('name', ''), cap, '/'.join(CHAT_CAPS)))
try: try:
data = await _call_upstream_chat(ctx, payload) data = await _call_upstream_chat(ctx, payload)
except Exception as e: except Exception as e:

View File

@ -19,6 +19,23 @@ from ahserver.serverenv import ServerEnv
logger = logging.getLogger("pipeline_llm.selection") logger = logging.getLogger("pipeline_llm.selection")
# 会话/chat 形态可用的能力类型(2026-09-06 用户定夺):文本输出的对话能力。
# 会话 agent 模型下拉只列这些;embedding/rerank/图视频生成等形态各有专属入口。
CHAT_CAPS = ('t2t', 'i2t', 'm2t')
def _norm_caps(capabilities):
"""capabilities 参数归一:'chat' 哨兵 → CHAT_CAPS;逗号串 → tuple;
tuple/list 原样。返回 tuple(空 = 不过滤)。dspy 薄壳只传 'chat',零 import。"""
if isinstance(capabilities, str):
s = capabilities.strip().lower()
if not s:
return ()
if s == 'chat':
return CHAT_CAPS
return tuple(c.strip().lower() for c in s.split(',') if c.strip())
return tuple(str(c).strip().lower() for c in (capabilities or ()) if str(c or '').strip())
def _get_sor(): def _get_sor():
env = ServerEnv() env = ServerEnv()
@ -33,16 +50,21 @@ def _get_sor():
async def model_options(org_id, uid='', session_id='', pipeline_id='', async def model_options(org_id, uid='', session_id='', pipeline_id='',
value_field='id'): value_field='id', capabilities=()):
"""模型选择下拉的唯一数据源(对齐 llmage 分类:capability=能力类型)。 """模型选择下拉的唯一数据源(对齐 llmage 分类:capability=能力类型)。
机构语义与推理链一致:本机构 + 系统级共享(org_id 空/'0')可见。 机构语义与推理链一致:本机构 + 系统级共享(org_id 空/'0')可见。
selected 标记:项目已设模型(sd_projects.default_model,存 name)> selected 标记:项目已设模型(sd_projects.default_model,存 name)>
个人全局选择(pipeline_agent_settings.default_llm_id,存 id)。 个人全局选择(pipeline_agent_settings.default_llm_id,存 id)。
value_field: 'id'=下拉值用模型 id(AgentIO 场景);'name'=用注册名(角色模型配置场景)。 value_field: 'id'=下拉值用模型 id(AgentIO 场景);'name'=用注册名(角色模型配置场景)。
capabilities: 能力类型白名单(tuple/list,如会话 agent 传 CHAT_CAPS=('t2t','i2t','m2t'))。
空 = 不过滤(历史行为)。注意:存量模型 capability 为空的行按 't2t' 语义对待,
过滤时用 COALESCE+NULLIF 把空串归一成 't2t' 再比对(DDL 有默认值,
但历史迁移行可能是空串)。
""" """
db, dbname = _get_sor() db, dbname = _get_sor()
rows = [] rows = []
caps = _norm_caps(capabilities)
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
sql = ("SELECT m.id, m.name, m.vendor_model_id, m.capability, v.name AS vendor_name " sql = ("SELECT m.id, m.name, m.vendor_model_id, m.capability, v.name AS vendor_name "
"FROM llm_model m LEFT JOIN llm_vendor v ON v.id=m.vendor_id " "FROM llm_model m LEFT JOIN llm_vendor v ON v.id=m.vendor_id "
@ -51,6 +73,15 @@ async def model_options(org_id, uid='', session_id='', pipeline_id='',
if org_id and org_id != '0': if org_id and org_id != '0':
sql += " AND (m.org_id=${org}$ OR m.org_id='' OR m.org_id='0')" sql += " AND (m.org_id=${org}$ OR m.org_id='' OR m.org_id='0')"
params['org'] = org_id params['org'] = org_id
if caps:
# sqlor 的 IN 列表必须展开占位符(传 list 会崩):${c0}$,${c1}$,...
ph = []
for i, c in enumerate(caps):
k = 'c%d' % i
ph.append('${%s}$' % k)
params[k] = c
sql += (" AND COALESCE(NULLIF(m.capability,''),'t2t') IN (%s)"
% ','.join(ph))
sql += " ORDER BY m.name" sql += " ORDER BY m.name"
recs = await sor.sqlExe(sql, params) recs = await sor.sqlExe(sql, params)
@ -97,18 +128,22 @@ async def model_options(org_id, uid='', session_id='', pipeline_id='',
return rows return rows
async def resolve_model_name(model_ref, org_id=''): async def resolve_model_name(model_ref, org_id='', capabilities=()):
"""模型引用解析的唯一入口:id / vendor_model_id / name → 模型注册名。 """模型引用解析的唯一入口:id / vendor_model_id / name → 模型注册名。
机构隔离:非系统级机构只能解析「本机构 + 系统级共享」模型。 机构隔离:非系统级机构只能解析「本机构 + 系统级共享」模型。
capabilities: 能力白名单('chat' 哨兵 / tuple),非空时能力不符也解析失败
(会话 agent 个人默认模型/角色模型入口传 'chat',防把 embedding 等非对话
模型持久化成会话模型)。
解析不到返回 ''(调用方决定回退/报错,禁止自行另查表)。 解析不到返回 ''(调用方决定回退/报错,禁止自行另查表)。
""" """
if not model_ref: if not model_ref:
return '' return ''
caps = _norm_caps(capabilities)
db, dbname = _get_sor() db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe( recs = await sor.sqlExe(
"SELECT name, org_id FROM llm_model WHERE status='active' " "SELECT name, org_id, capability FROM llm_model WHERE status='active' "
"AND (id=${m}$ OR vendor_model_id=${m}$ OR name=${m}$) LIMIT 1", "AND (id=${m}$ OR vendor_model_id=${m}$ OR name=${m}$) LIMIT 1",
{"m": model_ref}) {"m": model_ref})
await sor.sqlExe("COMMIT", {}) await sor.sqlExe("COMMIT", {})
@ -118,6 +153,12 @@ async def resolve_model_name(model_ref, org_id=''):
if org_id and org_id != '0' and m_org not in ('', '0', org_id): if org_id and org_id != '0' and m_org not in ('', '0', org_id):
logger.warning("resolve_model_name: 模型 %s 不属于机构 %s", model_ref, org_id) logger.warning("resolve_model_name: 模型 %s 不属于机构 %s", model_ref, org_id)
return '' return ''
if caps:
cap = (getattr(recs[0], 'capability', '') or 't2t').strip().lower()
if cap not in caps:
logger.warning("resolve_model_name: 模型 %s 能力 %s 不在白名单 %s",
model_ref, cap, caps)
return ''
return getattr(recs[0], 'name', '') or '' return getattr(recs[0], 'name', '') or ''

View File

@ -1,7 +1,7 @@
# models.dspy — 按能力分类列出可用模型(对齐 llmage v1/models 的分类方式) # models.dspy — 按能力分类列出可用模型(对齐 llmage v1/models 的分类方式)
# #
# URL: /pipeline_llm/api/v1/models?catelogid=t2t # URL: /pipeline_llm/api/v1/models?catelogid=t2t
# catelogid 可选:t2t / t2i / t2v / i2v / r2v / i2i / embedding / rerank / tts / asr / i2t ... # catelogid 可选:t2t / t2i / t2v / i2v / r2v / i2i / embedding / rerank / tts / asr / i2t / m2t ...
# (能力类型标准 = 模型治理 appcodes llm_capability,新增走评审禁止野生标签) # (能力类型标准 = 模型治理 appcodes llm_capability,新增走评审禁止野生标签)
# 不传 = 全部能力 # 不传 = 全部能力
# #