feat(async): 异步模型执行器(提交→轮询query_profile_ids→取结果)+usages用量因子记账;供应商去protocol字段(模式归模型适配模板);修_model_chain漏取profile_id等字段致适配模板从未加载的存量bug

This commit is contained in:
yumoqing 2026-09-05 13:07:08 +08:00
parent beef2d81a2
commit c819faef2d
8 changed files with 298 additions and 37 deletions

View File

@ -9,12 +9,6 @@
"id"
],
"alters": {
"protocol": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_llm_protocol_options.dspy')}}",
"valueField": "value",
"textField": "text"
},
"status": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_llm_status_options.dspy')}}",

View File

@ -103,6 +103,12 @@
"nullable": "no",
"default": "'ok'"
},
{
"name": "usages",
"title": "用量详情(JSON)",
"type": "text",
"nullable": "yes"
},
{
"name": "note",
"title": "备注/原因",
@ -154,4 +160,4 @@
"cond": "parentid='llm_usage_status'"
}
]
}
}

View File

@ -24,14 +24,6 @@
"length": 100,
"nullable": "no"
},
{
"name": "protocol",
"title": "协议类型",
"type": "str",
"length": 20,
"nullable": "no",
"default": "'openai_compat'"
},
{
"name": "endpoints",
"title": "端点目录(JSON)",
@ -80,13 +72,6 @@
}
],
"codes": [
{
"field": "protocol",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='llm_protocol'"
},
{
"field": "status",
"table": "appcodes_kv",
@ -95,4 +80,4 @@
"cond": "parentid='llm_status'"
}
]
}
}

View File

@ -5,7 +5,6 @@
CREATE TABLE IF NOT EXISTS llm_vendor (
`id` varchar(32) NOT NULL comment '主键ID',
`name` varchar(100) NOT NULL comment '供应商名称',
`protocol` varchar(20) NOT NULL DEFAULT 'openai_compat' comment '协议类型',
`endpoints` text comment '端点目录(JSON): [{region,base_url,proxy,timeout,note}]',
`description` text comment '描述',
`status` varchar(20) NOT NULL DEFAULT 'active' comment '状态',
@ -126,6 +125,7 @@ CREATE TABLE IF NOT EXISTS llm_usage (
`ppid` varchar(32) comment '定价项目ID',
`task_ref` varchar(100) comment '调用来源',
`status` varchar(20) NOT NULL DEFAULT 'ok' comment '状态(ok/failed/recharge)',
`usages` text comment '非token用量因子JSON(时长/分辨率等,异步出账读入定价引擎)',
`note` varchar(200) comment '备注/原因',
`created_at` datetime NOT NULL DEFAULT current_timestamp() comment '创建时间',
PRIMARY KEY (`id`),

View File

@ -12,6 +12,7 @@
"""
import asyncio
import json
import logging
import time
@ -29,7 +30,7 @@ _LOCK_TTL = 90 # 锁 TTL略大于单批预算耗时
async def _get_pending(sor):
recs = await sor.sqlExe(
"SELECT id, org_id, user_id, model_id, req_tokens, resp_tokens, cost, ppid "
"SELECT id, org_id, user_id, model_id, req_tokens, resp_tokens, cost, ppid, usages "
"FROM llm_usage WHERE accounting_status='pending' AND status='ok' "
"ORDER BY created_at LIMIT %d" % _BATCH, {})
await sor.sqlExe("COMMIT", {})
@ -63,6 +64,16 @@ async def _settle_one(sor, row):
'prompt_tokens': int(getattr(row, 'req_tokens', 0) or 0),
'completion_tokens': int(getattr(row, 'resp_tokens', 0) or 0),
}
# 非 token 计价因子(视频时长/分辨率/模型名等2026-09-05
# llm_usage.usages JSON 合并进定价引擎输入,按量计费模型(视频/图像)用
usages_raw = getattr(row, 'usages', '') or ''
if usages_raw:
try:
extra = json.loads(usages_raw)
if isinstance(extra, dict):
usage_data.update(extra)
except Exception:
pass
fn = getattr(env, 'product_accounting_generic', None)
if fn is None:
# 产品模块未加载 → 保留 pending 下轮再试

View File

@ -199,7 +199,7 @@ async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''):
for mid in chain_ids:
recs = await sor.sqlExe(
"SELECT id, name, vendor_id, vendor_model_id, capability, default_params, "
"ppid, org_id "
"ppid, org_id, profile_id, sync_mode, query_profile_ids "
"FROM llm_model WHERE id=${i}$ AND status='active' LIMIT 1", {"i": mid})
await sor.sqlExe("COMMIT", {})
if recs:
@ -208,15 +208,18 @@ async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''):
async def _vendor_endpoints(sor, vendor_id: str):
"""供应商端点目录。返回 (protocol, [endpoint dict])。"""
"""供应商端点目录。返回 [endpoint dict]。
2026-09-05供应商表不再有 protocol 字段请求形态协议/模式由模型挂的
适配模板llm_api_profile决定供应商只提供端点目录账号/区域/超时
"""
recs = await sor.sqlExe(
"SELECT protocol, endpoints FROM llm_vendor WHERE id=${i}$ AND status='active' LIMIT 1",
"SELECT endpoints FROM llm_vendor WHERE id=${i}$ AND status='active' LIMIT 1",
{"i": vendor_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return '', []
r = recs[0]
return getattr(r, 'protocol', '') or '', _parse_json(getattr(r, 'endpoints', ''), [])
return []
return _parse_json(getattr(recs[0], 'endpoints', ''), [])
async def _account_candidates(sor, model: dict):
@ -255,7 +258,7 @@ async def _pick_candidate(sor, model: dict, pref: str):
vendor_id = model.get('vendor_id', '')
if not vendor_id:
return False, '模型「%s」未绑定供应商,无法选择账号' % model.get('name', '')
protocol, endpoints = await _vendor_endpoints(sor, vendor_id)
endpoints = await _vendor_endpoints(sor, vendor_id)
if not endpoints:
return False, '供应商端点目录为空(模型治理→供应商→端点目录),请配置端点'
accounts = await _account_candidates(sor, model)
@ -478,11 +481,14 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
async def govern_settle(ctx: dict, ok_call: bool, req_tokens: int = 0, resp_tokens: int = 0,
note: str = ''):
note: str = '', usages: dict = None):
"""结算 ⑧:成本侧扣减 + 用量流水(记账分流)。
ctx govern_resolve 成功时返回的 dict policy_org_id
ok_call 上游调用是否成功失败也记账 failed 流水
usages token 用量因子JSON llm_usage.usages视频时长/分辨率
图像张数等按量计费模型的计价因子由异步出账读入定价引擎
req_tokens/resp_tokens 并列定价引擎按 pricing_data 选用
记账分流2026-09
本机构模型 owner 自用只记成本侧供应商账号扣减
@ -521,6 +527,12 @@ async def govern_settle(ctx: dict, ok_call: bool, req_tokens: int = 0, resp_toke
if ok_call and is_owner_model:
accounting_status = 'pending'
from appPublic.uniqueID import getID
usages_str = ''
if usages:
try:
usages_str = json.dumps(usages, ensure_ascii=False, default=str)[:2000]
except Exception:
usages_str = ''
await sor.C('llm_usage', {
'id': getID(),
'org_id': caller_org,
@ -536,6 +548,7 @@ async def govern_settle(ctx: dict, ok_call: bool, req_tokens: int = 0, resp_toke
'task_ref': ctx.get('task_ref', ''),
'status': 'ok' if ok_call else 'failed',
'accounting_status': accounting_status,
'usages': usages_str,
'note': (note or '')[:200],
})
await sor.sqlExe("COMMIT", {})

View File

@ -16,6 +16,7 @@ path/headers 决定上游请求形态(缺省按 OpenAI 兼容);非 t2t 形
import json
import logging
import time
logger = logging.getLogger("pipeline_llm.inference")
@ -135,6 +136,33 @@ async def _resolve_call(sor, org_id, user_id, model_name, capability,
ctx['default_params'] = _parse_json(model_row.get('default_params'), {})
ctx['profile'] = profile
ctx['capability'] = cap
# 异步模型的后续步骤模板(提交→查询→[下载]):按 query_profile_ids 顺序加载
qpids = _parse_json(model_row.get('query_profile_ids'), [])
qps = []
for qid in qpids or []:
recs = await sor.sqlExe(
"SELECT path, headers, request_template, response_template FROM llm_api_profile "
"WHERE id=${i}$ AND status='active' LIMIT 1", {"i": qid})
await sor.sqlExe("COMMIT", {})
if not recs:
continue
r = recs[0]
# method 无独立列:从 request_template JSON 的 method 键取(缺省 GET
qmethod = 'GET'
try:
rt = json.loads(getattr(r, 'request_template', '') or '{}')
if isinstance(rt, dict) and rt.get('method'):
qmethod = str(rt.get('method')).upper()
except Exception:
pass
qps.append({
'path': getattr(r, 'path', '') or '',
'headers': getattr(r, 'headers', '') or '',
'method': qmethod,
'request_template': getattr(r, 'request_template', '') or '',
'response_template': getattr(r, 'response_template', '') or '',
})
ctx['query_profiles'] = qps
return ctx
@ -331,14 +359,227 @@ async def _call_upstream_chat(ctx, payload):
raise last if last else ValueError('上游调用失败')
# ────────────────────────── 异步执行器(提交→轮询→取结果) ──────────────────────────
# 异步模型sync_mode='async',如 DashScope 视频/图像生成):
# 1. 提交POST 提交模板(模型挂的 profile→ 返回 output.task_id
# 2. 轮询GET 查询步骤模板query_profiles[0])直到终态
# 3. 结算:用量因子(时长/分辨率等)落 llm_usage.usages异步出账读入定价引擎
#
# 模板驱动:提交/查询的 path/headers/body 全来自 llm_api_profileJinja2 渲染),
# 新增异步形态只加 profile 记录不改代码。DashScope 约定(官方文档核实,
# 2026-09-05提交响应 output.task_id查询响应 output.task_status ∈
# PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/UNKNOWN成功时带结果产物。
_ASYNC_POLL_START = 5.0 # 轮询起始间隔(秒)
_ASYNC_POLL_MAX = 15.0 # 轮询最大间隔(秒)
_ASYNC_TOTAL_TIMEOUT = 600 # 异步任务总等待预算(秒,可被 _timeout 覆盖,上限 900
_ASYNC_TASK_TERMINAL = ('SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN')
def _last_prompt_text(messages):
"""从 messages 提取提示词文本(取末条非空 content多模态列表拼文本部分"""
for m in reversed(messages or []):
if not isinstance(m, dict):
continue
c = m.get('content', '')
if isinstance(c, str) and c.strip():
return c
if isinstance(c, list):
parts = []
for seg in c:
if isinstance(seg, dict):
t = seg.get('text') or ''
if t:
parts.append(str(t))
if parts:
return '\n'.join(parts)
return ''
async def _build_async_body(ctx, payload):
"""异步提交请求体渲染(同 _build_upstream_body另注入 prompt 便利变量)。"""
profile = ctx.get('profile') or {}
tmpl = (profile.get('request_template') or '').strip()
if not tmpl:
raise GovernError('异步模型的提交适配模板缺 request_template——请完善模型挂的适配模板')
upstream = dict(payload or {})
upstream.pop('stream', None)
upstream['model'] = ctx.get('model_id') or upstream.get('model') or ''
for k, v in (ctx.get('default_params') or {}).items():
upstream.setdefault(k, v)
messages = upstream.pop('messages', [])
model = upstream.pop('model', '')
ns = {
'model': model, 'messages': messages,
'prompt': _last_prompt_text(messages),
'params': upstream, # 业务参数resolution/duration/image_url 等)
'api_key': ctx.get('api_key') or '',
'org_id': ctx.get('org_id') or '',
}
s = await _render_tmpl(tmpl, ns)
try:
body = json.loads(s)
except Exception as e:
raise GovernError('提交请求体模板渲染结果不是合法 JSON%s(模板:%s' % (
str(e)[:120], tmpl[:120]))
if not isinstance(body, dict):
raise GovernError('提交请求体模板必须渲染成 JSON 对象')
return body
async def _http_request(method, url, headers, body, timeout):
"""通用上游 HTTP 请求(异步执行器用:提交/查询)。
返回 (status, data)data 尽量 json.loads失败返回原文
超时/连接错误抛 ValueError消息真实可行动含异常类型
"""
import aiohttp
import asyncio
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method.upper(), url, headers=headers,
json=body if body is not None else None,
timeout=aiohttp.ClientTimeout(total=timeout, connect=_CONNECT_TIMEOUT),
) as resp:
text = await resp.text()
try:
data = json.loads(text) if text else {}
except Exception:
data = text
return resp.status, data
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
raise ValueError('上游请求失败(%s%s' % (type(e).__name__, repr(e)))
async def _async_query_once(ctx, qprofile, task_id, ns, timeout):
"""按查询步骤模板请求一次任务状态。path 支持 {task_id} 占位符。"""
path = await _render_tmpl((qprofile.get('path') or '').strip(), ns)
path = path.replace('{task_id}', task_id)
if path.startswith('http://') or path.startswith('https://'):
url = path
else:
url = (ctx.get('api_base') or '').rstrip('/') + path
headers = await _render_headers(qprofile.get('headers', ''), ns)
method = (qprofile.get('method') or 'GET').upper()
status, data = await _http_request(method, url, headers, None, timeout)
if status != 200:
raise ValueError('任务查询失败 HTTP %d: %s' % (
status, str(data)[:200]))
if not isinstance(data, dict):
raise ValueError('任务查询返回非 JSON%s' % str(data)[:200])
return data
async def _async_inference(ctx, payload, req_timeout):
"""异步模型执行:提交任务 → 轮询至终态 → 组装结果 → 结算usages 记账)。
返回 OpenAI 兼容形态choices[0].message.content = 产物地址/文本
另带 output上游原始输出 task_id
任何失败 govern_settle failed 流水再抛 GovernError消息真实可行动
"""
import asyncio
from .gateway import govern_settle
async def _fail(msg):
await govern_settle(ctx, False, 0, 0, msg)
raise GovernError(msg)
qprofiles = ctx.get('query_profiles') or []
if not qprofiles:
await _fail('模型「%s」为异步模型但未登记查询步骤模板query_profile_ids'
'无法获取任务结果——请在模型治理补录查询步骤适配模板' % ctx.get('name', ''))
return {}
profile = ctx.get('profile') or {}
ns = {'api_key': ctx.get('api_key') or '', 'org_id': ctx.get('org_id') or ''}
step_timeout = int(_fnum(ctx.get('timeout')) or 60)
# ── 1. 提交任务 ──
body = await _build_async_body(ctx, payload)
path = await _render_tmpl((profile.get('path') or '').strip(), ns)
if path.startswith('http://') or path.startswith('https://'):
url = path
else:
url = (ctx.get('api_base') or '').rstrip('/') + path
headers = await _render_headers(profile.get('headers', ''), ns)
try:
status, data = await _http_request('POST', url, headers, body, step_timeout)
except ValueError as e:
await _fail('异步任务提交失败:%s' % e)
return {}
if status != 200 or not isinstance(data, dict):
await _fail('异步任务提交失败 HTTP %d: %s' % (status, str(data)[:300]))
return {}
task_id = ((data.get('output') or {}).get('task_id') or data.get('task_id') or '').strip()
if not task_id:
await _fail('异步任务提交响应缺 task_id%s' % (
json.dumps(data, ensure_ascii=False, default=str)[:300]))
return {}
print("[inference] 异步任务已提交 task_id=%s model=%s" % (task_id, ctx.get('model_id', '')))
# ── 2. 轮询至终态 ──
total_budget = req_timeout if req_timeout > 0 else _ASYNC_TOTAL_TIMEOUT
deadline = time.time() + total_budget
interval = _ASYNC_POLL_START
last = data
finished = ''
while time.time() < deadline:
await asyncio.sleep(interval)
interval = min(interval * 1.5, _ASYNC_POLL_MAX)
try:
last = await _async_query_once(ctx, qprofiles[0], task_id, ns, step_timeout)
except ValueError as e:
# 单次查询失败不立即终止(可能瞬时抖动),预算内继续
print("[inference] 异步查询瞬时失败(预算内继续): %s" % e)
continue
finished = str((last.get('output') or {}).get('task_status') or '').upper()
if finished in _ASYNC_TASK_TERMINAL:
break
if finished not in _ASYNC_TASK_TERMINAL:
await _fail('异步任务等待超时(%d 秒):最后状态 %stask_id=%s——'
'可用 _timeout 延长等待(上限 900 秒)' % (
total_budget, finished or 'UNKNOWN', task_id))
if finished != 'SUCCEEDED':
out = last.get('output') or {}
await _fail('异步任务执行失败:%s %stask_id=%s' % (
out.get('code') or finished, str(out.get('message') or '')[:200], task_id))
# ── 3. 组装结果 + 用量因子记账 ──
out = last.get('output') or {}
content = (out.get('video_url') or out.get('image_url') or out.get('result_url')
or out.get('text') or '')
usage = last.get('usage') or {}
usages = dict(usage) if isinstance(usage, dict) else {}
usages['model'] = ctx.get('model_id') or ''
# 计价过滤维度用请求值(与文档计价档位原文一致,如 resolution=720P
# 时长以上游计费返回为准,缺则回请求值
for k in ('resolution', 'size', 'duration'):
if payload.get(k) not in (None, ''):
usages.setdefault(k, payload.get(k))
await govern_settle(ctx, True, 0, 0, '' if usage else 'no-usage', usages)
return {
"choices": [{"message": {"content": content, "role": "assistant"},
"finish_reason": "stop"}],
"usage": usage,
"output": out,
"task_id": task_id,
}
# ────────────────────────── 对外主入口 ──────────────────────────
async def chat_inference(org_id, user_id, payload, model_name='', task_ref=''):
"""t2t 推理:门禁链 → 上游调用 → 结算。返回上游响应 dictOpenAI 兼容)。
"""统一推理:门禁链 → 上游调用 → 结算。返回上游响应 dictOpenAI 兼容形态)。
payload: 客户端请求体messages 必需tools/temperature/max_tokens 透传
payload: 客户端请求体messages 必需tools/temperature/max_tokens 透传
异步模型另收业务参数 resolution/duration/image_url
model_name 机构策略缺省模型备链第一个
失败抛 GovernError消息真实可行动
同步/异步分流2026-09-05按模型 sync_modesync chat/completions
一次往返async 走异步执行器提交任务 query_profile_ids 轮询取结果
用量因子视频时长/分辨率等 llm_usage.usages 供定价引擎计费
本入口不再限定能力类型统一入口按模型实际形态执行能力分栏在
list_models/选择层体现
"""
from .gateway import govern_settle, _get_db
@ -357,9 +598,20 @@ async def chat_inference(org_id, user_id, payload, model_name='', task_ref=''):
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
ctx = await _resolve_call(
sor, org_id, user_id or '', model_name, 't2t', est, task_ref, purpose)
sor, org_id, user_id or '', model_name, '', est, task_ref, purpose)
if req_timeout > 0:
ctx['timeout'] = min(req_timeout, 900)
# 异步模型:提交→轮询→取结果(用量因子走 usages 记账)
model_row = ctx.get('model_row') or {}
if (model_row.get('sync_mode') or 'sync') == 'async':
return await _async_inference(ctx, payload, req_timeout)
# 同步模型守能力边界:聊天入口只认 t2tembedding/rerank 等另有入口)
cap = (model_row.get('capability') or 't2t').strip().lower()
if cap != 't2t':
from .gateway import govern_settle
await govern_settle(ctx, False, 0, 0, 'capability mismatch: %s != t2t' % cap)
raise GovernError('模型「%s」能力为 %s,聊天入口仅支持 t2t——请按能力分类选择模型' % (
model_row.get('name', ''), cap))
try:
data = await _call_upstream_chat(ctx, payload)
except Exception as e:

View File

@ -83,7 +83,7 @@ async def _get_or_create_vendor(sor, provider, base_url, dry):
eps = [{'base_url': base_url, 'region': 'domestic', 'timeout': 60}]
if not dry:
await sor.C('llm_vendor', {
'id': vid, 'name': name, 'protocol': 'openai_compat',
'id': vid, 'name': name,
'endpoints': json.dumps(eps, ensure_ascii=False),
'description': '由旧 llm 表迁移生成', 'status': 'active', 'org_id': '0',
})