411 lines
19 KiB
Python
411 lines
19 KiB
Python
"""pipeline_llm.inference — 统一推理入口(分类照 llmage:按能力类型 catelogid 路由)。
|
||
|
||
设计(2026-09-04):产线平台所有模型调用收敛到本模块:
|
||
- 进程内:agent/引擎经 pipeline_service.llm_bridge 走 HTTP 自调用 →
|
||
/pipeline_llm/api/v1/chat/completions(Bearer 短期 token)→ chat_inference()
|
||
- 运行环境:短期 token 代理(真 key 永不出进程,沿用原铁律)同一入口
|
||
- 旧运行环境入口 /pipeline_core/api/llm_v1 委托同一实现(收敛唯一实现)
|
||
|
||
门禁链复用 gateway.govern_resolve/govern_settle(限流/限额/主备容错/端点轮转/
|
||
预授权/双维度记账)。治理真实失败抛 GovernError,消息真实可行动,禁止静默回退。
|
||
|
||
一期形态:t2t(OpenAI 兼容 chat/completions)。适配模板 llm_api_profile 的
|
||
path/headers 决定上游请求形态(缺省按 OpenAI 兼容);非 t2t 形态(图/视频/语音)
|
||
进来时按 capability 增加入口(对齐 llmage v1 端点分栏),不改本层结构。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
|
||
logger = logging.getLogger("pipeline_llm.inference")
|
||
|
||
# OpenAI 兼容缺省适配(模型未挂 profile / profile 缺字段时的兜底)——
|
||
# 组包方式借鉴 uapi 模块(llmage 即调 uapi 组包):path/headers/body 全部
|
||
# Jinja2 模板,经 tmpl_engine 渲染后 json.loads;api_key 作模板变量注入。
|
||
_DEFAULT_CHAT_PATH = "/chat/completions"
|
||
# ⚠️ 模板里的 {{ api_key }} 占位符必须用拼接构造——完整模式写在源码字面量里
|
||
# 会被脱敏工具替换成 ***(2026-09-04 实测 401 根因:渲染出字面 *** 当 key)
|
||
_API_KEY_VAR = '{{' + ' api_key ' + '}}'
|
||
_BUILTIN_HEADERS_TMPL = '{"Authorization": "Bearer ' + _API_KEY_VAR + '", "Content-Type": "application/json"}'
|
||
# 缺省请求体模板:model+messages 必填,其余调用方参数经 extra 透传
|
||
_BUILTIN_REQUEST_TMPL = (
|
||
'{"model": {{ model|tojson }}, "messages": {{ messages|tojson }}'
|
||
'{% for k, v in extra.items() %}, "{{ k }}": {{ v|tojson }}{% endfor %}}'
|
||
)
|
||
# 请求体透传字段白名单(OpenAI 兼容常用参数,除 model/messages 外)
|
||
_PASSTHROUGH_FIELDS = ('temperature', 'top_p', 'max_tokens', 'tools',
|
||
'tool_choice', 'presence_penalty', 'frequency_penalty',
|
||
'stop', 'response_format', 'seed')
|
||
_RETRYABLE_STATUS = (429, 500, 502, 503, 504)
|
||
_MAX_ATTEMPTS = 3
|
||
_TOTAL_TIMEOUT = 300
|
||
_CONNECT_TIMEOUT = 30
|
||
|
||
EST_TOKENS_PER_CHAR = 2 # 预估用量:中文约 1 字符≈0.5 token,保守按 2 字符 1 token
|
||
|
||
from .gateway import GovernError # 复用治理层错误类型(消息真实可行动)
|
||
|
||
|
||
def _parse_json(s, default):
|
||
if not s:
|
||
return default
|
||
try:
|
||
v = json.loads(s)
|
||
return v if v is not None else default
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _fnum(v):
|
||
try:
|
||
return float(v or 0)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
|
||
def _texts_len(messages):
|
||
"""messages 内容总长(估预授权用量用)。"""
|
||
try:
|
||
return sum(len(str(m.get('content', ''))) for m in (messages or [])
|
||
if isinstance(m, dict))
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
# ────────────────────────── 解析链 ──────────────────────────
|
||
|
||
async def _resolve_call(sor, org_id, user_id, model_name, capability,
|
||
est_tokens, task_ref):
|
||
"""门禁链 ①-⑥ + 预授权 + 加载模型/适配模板。
|
||
|
||
返回 dict(govern ctx + payload 所需全部信息)。任何一级不过抛 GovernError。
|
||
⚠️ 本层无 __LEGACY__ 兜底——新接口只认新表;机构未配策略直接报可行动错误。
|
||
"""
|
||
from .gateway import govern_resolve
|
||
|
||
if not org_id:
|
||
raise GovernError('缺少机构标识(机构隔离必需):内部调用须带 org_id')
|
||
ok, res = await govern_resolve(
|
||
org_id=org_id, user_id=user_id or '', model_name=model_name or '',
|
||
est_tokens=int(est_tokens or 0), task_ref=task_ref or '')
|
||
if not ok:
|
||
if res == '__LEGACY__':
|
||
# 全平台无策略/指定模型不在新表:不再有旧表兜底,报可行动错误
|
||
if model_name:
|
||
raise GovernError(
|
||
'模型「%s」未注册到模型治理(模型注册表无此名或已停用)。'
|
||
'请在模型治理→模型注册中添加,或改用已注册模型' % model_name)
|
||
raise GovernError(
|
||
'无可用模型策略:机构 %s 未配置且平台也未配置组织容错策略。'
|
||
'请在模型治理→组织容错策略配置主/备模型' % org_id)
|
||
raise GovernError(res)
|
||
if not isinstance(res, dict):
|
||
raise GovernError('治理引擎返回异常结果: %s' % str(res)[:200])
|
||
ctx = res
|
||
# 能力类型校验(防 t2t 入口调到非 t2t 模型)
|
||
model_row = ctx.get('model_row') or {}
|
||
cap = (model_row.get('capability') or 't2t').strip().lower()
|
||
if capability and cap != capability:
|
||
# 释放预授权再报错(调用不会发生)
|
||
from .gateway import govern_settle
|
||
await govern_settle(ctx, False, 0, 0, 'capability mismatch: %s!=%s' % (cap, capability))
|
||
raise GovernError(
|
||
'模型「%s」能力为 %s,与请求能力 %s 不匹配。请按能力分类选择模型' % (
|
||
model_row.get('name', ''), cap, capability))
|
||
# 适配模板(组包:path/headers/request_template,全 Jinja2,借鉴 uapi)
|
||
profile = {}
|
||
pid = model_row.get('profile_id') or ''
|
||
if pid:
|
||
recs = await sor.sqlExe(
|
||
"SELECT path, headers, request_template, response_template FROM llm_api_profile "
|
||
"WHERE id=${i}$ AND status='active' LIMIT 1", {"i": pid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if recs:
|
||
r = recs[0]
|
||
profile = {
|
||
'path': getattr(r, 'path', '') or '',
|
||
'headers': getattr(r, 'headers', '') or '',
|
||
'request_template': getattr(r, 'request_template', '') or '',
|
||
'response_template': getattr(r, 'response_template', '') or '',
|
||
}
|
||
# default_params 合并(调用方显式参数优先)
|
||
ctx['default_params'] = _parse_json(model_row.get('default_params'), {})
|
||
ctx['profile'] = profile
|
||
ctx['capability'] = cap
|
||
return ctx
|
||
|
||
|
||
async def _pick_default_model_name(org_id, capability='t2t'):
|
||
"""机构缺省模型:组织策略主模型 → 备链第一个;
|
||
本机构无策略 → 平台 owner('0') 策略(与 govern_resolve 兜底一致)。都没有返回 ''。"""
|
||
from .gateway import _get_db
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
policy_orgs = [org_id] if (org_id and org_id != '0') else []
|
||
policy_orgs.append('0')
|
||
recs = []
|
||
for po in policy_orgs:
|
||
recs = await sor.sqlExe(
|
||
"SELECT primary_model_id, backup_model_ids FROM llm_org_policy "
|
||
"WHERE org_id=${o}$ AND status='active' LIMIT 1", {"o": po})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if recs:
|
||
break
|
||
if not recs:
|
||
return ''
|
||
mids = []
|
||
pm = getattr(recs[0], 'primary_model_id', '') or ''
|
||
if pm:
|
||
mids.append(pm)
|
||
for b in _parse_json(getattr(recs[0], 'backup_model_ids', ''), []):
|
||
if b and b not in mids:
|
||
mids.append(b)
|
||
for mid in mids:
|
||
r2 = await sor.sqlExe(
|
||
"SELECT name FROM llm_model WHERE id=${i}$ AND status='active' LIMIT 1",
|
||
{"i": mid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if r2:
|
||
n = getattr(r2[0], 'name', '') or ''
|
||
if n:
|
||
return n
|
||
return ''
|
||
|
||
|
||
# ────────────────────────── 上游调用(组包借鉴 uapi) ──────────────────────────
|
||
# uapi 模块(llmage 即调它组包)的做法:path/headers/body 全是 Jinja2 模板,
|
||
# tmpl_engine.renders() 渲染后 json.loads;api_key 等作模板变量注入命名空间。
|
||
# 本层同款实现:模板来自 llm_api_profile(模型挂的适配模板),缺省用内置
|
||
# openai-chat 模板——新增供应商形态只加 profile 记录,不改代码。
|
||
|
||
_tmpl_engine_cache = None
|
||
|
||
|
||
async def _render_tmpl(tmplstr, ns):
|
||
"""渲染一段模板字符串(uapi rendertmpl 同款)。
|
||
|
||
服务进程用 ahserver 的 tmpl_engine(启动时 setupTemplateEngine 注入,异步);
|
||
独立脚本环境没有引擎时用 jinja2.Environment 兜底(同样异步渲染)。
|
||
"""
|
||
global _tmpl_engine_cache
|
||
te = _tmpl_engine_cache
|
||
if te is None:
|
||
try:
|
||
from ahserver.serverenv import ServerEnv
|
||
te = getattr(ServerEnv(), 'tmpl_engine', None)
|
||
except Exception:
|
||
te = None
|
||
if te is not None and callable(getattr(te, 'renders', None)):
|
||
_tmpl_engine_cache = te
|
||
return await te.renders(tmplstr, ns)
|
||
# 兜底:原生 jinja2.Environment 没有 renders 方法,用 from_string+render_async
|
||
try:
|
||
from jinja2 import Environment
|
||
env = Environment(enable_async=True)
|
||
_tmpl_engine_cache = None # 原生引擎不缓存(避免掩盖后续真正的引擎注入)
|
||
t = env.from_string(tmplstr)
|
||
return await t.render_async(ns)
|
||
except Exception as e:
|
||
raise GovernError('模板渲染失败(组包不可用):%s' % str(e)[:200])
|
||
|
||
|
||
async def _render_headers(tmpl, ns):
|
||
"""headers 模板 → dict:Jinja2 渲染后 json.loads(同 uapi)。
|
||
|
||
模板空用内置 Bearer 模板;渲染结果缺 Authorization 头时兜底补标准 Bearer。
|
||
"""
|
||
s = await _render_tmpl((tmpl or '').strip() or _BUILTIN_HEADERS_TMPL, ns)
|
||
try:
|
||
out = json.loads(s)
|
||
except Exception as e:
|
||
raise GovernError('请求头模板渲染结果不是合法 JSON:%s(模板:%s)' % (
|
||
str(e)[:120], (tmpl or '(内置)')[:120]))
|
||
if not isinstance(out, dict):
|
||
raise GovernError('请求头模板必须渲染成 JSON 对象')
|
||
if not any(str(k).lower() == 'authorization' for k in out):
|
||
out['Authorization'] = 'Bearer %s' % (ns.get('api_key') or '')
|
||
out.setdefault('Content-Type', 'application/json')
|
||
return out
|
||
|
||
|
||
async def _build_upstream_body(ctx, payload):
|
||
"""请求体模板渲染(uapi data 模板同款)。
|
||
|
||
合并顺序:模型 default_params 打底 → 调用方显式参数覆盖 → 模板渲染。
|
||
模板取 profile.request_template,缺省内置 openai-chat 模板。
|
||
模板命名空间:model / messages / extra(白名单透传参数) / params(全部) /
|
||
api_key / org_id。
|
||
"""
|
||
profile = ctx.get('profile') or {}
|
||
tmpl = (profile.get('request_template') or '').strip() or _BUILTIN_REQUEST_TMPL
|
||
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', '')
|
||
extra = {k: upstream[k] for k in upstream if k in _PASSTHROUGH_FIELDS}
|
||
ns = {
|
||
'model': model, 'messages': messages, 'extra': extra,
|
||
'params': upstream,
|
||
'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 _call_upstream_chat(ctx, payload):
|
||
"""POST 上游(path/headers/body 全模板组包),带瞬时错误重试。
|
||
|
||
返回上游响应 dict。失败抛 ValueError(消息真实可行动)。
|
||
"""
|
||
import aiohttp
|
||
import asyncio
|
||
|
||
profile = ctx.get('profile') or {}
|
||
ns = {'api_key': ctx.get('api_key') or '', 'org_id': ctx.get('org_id') or ''}
|
||
path = await _render_tmpl((profile.get('path') or '').strip() or _DEFAULT_CHAT_PATH, 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)
|
||
body = await _build_upstream_body(ctx, payload)
|
||
timeout = int(_fnum(ctx.get('timeout')) or _TOTAL_TIMEOUT)
|
||
# 可诊断性:只记长度不记值(真 key 不落日志)。print 确保进应用日志
|
||
# (模块 logging.getLogger 的 handler 未接应用日志管道,2026-09-04 实测)
|
||
print("[inference] 组包 url=%s auth头长度=%d body字段=%s" % (
|
||
url.split('?')[0], len(headers.get('Authorization', '') or ''),
|
||
sorted(body.keys())))
|
||
|
||
last = None
|
||
for attempt in range(_MAX_ATTEMPTS):
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
url, headers=headers, json=body,
|
||
timeout=aiohttp.ClientTimeout(total=timeout, connect=_CONNECT_TIMEOUT),
|
||
) as resp:
|
||
if resp.status != 200:
|
||
text = await resp.text()
|
||
err = ValueError('上游调用失败 HTTP %d: %s' % (resp.status, text[:300]))
|
||
if resp.status in _RETRYABLE_STATUS and attempt < _MAX_ATTEMPTS - 1:
|
||
last = err
|
||
await asyncio.sleep(2 * (attempt + 1))
|
||
continue
|
||
raise err
|
||
data = await resp.json(content_type=None)
|
||
if not isinstance(data, dict) or 'choices' not in data:
|
||
err = ValueError('上游响应缺 choices: %s' % (
|
||
json.dumps(data, ensure_ascii=False, default=str)[:300]))
|
||
if attempt < _MAX_ATTEMPTS - 1:
|
||
last = err
|
||
await asyncio.sleep(2 * (attempt + 1))
|
||
continue
|
||
raise err
|
||
return data
|
||
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
|
||
last = e
|
||
if attempt < _MAX_ATTEMPTS - 1:
|
||
logger.warning("inference: 瞬时错误重试 %d/%d: %s",
|
||
attempt + 1, _MAX_ATTEMPTS, e)
|
||
await asyncio.sleep(2 * (attempt + 1))
|
||
continue
|
||
raise ValueError('上游调用失败(重试 %d 次仍失败): %s' % (_MAX_ATTEMPTS, e))
|
||
raise last if last else ValueError('上游调用失败')
|
||
|
||
|
||
# ────────────────────────── 对外主入口 ──────────────────────────
|
||
|
||
async def chat_inference(org_id, user_id, payload, model_name='', task_ref=''):
|
||
"""t2t 推理:门禁链 → 上游调用 → 结算。返回上游响应 dict(OpenAI 兼容)。
|
||
|
||
payload: 客户端请求体(messages 必需;tools/temperature/max_tokens 透传)。
|
||
model_name 空 → 机构策略缺省模型(主→备链第一个)。
|
||
失败抛 GovernError(消息真实可行动)。
|
||
"""
|
||
from .gateway import govern_settle, _get_db
|
||
|
||
if not isinstance(payload, dict) or not payload.get('messages'):
|
||
raise GovernError('请求体缺 messages')
|
||
model_name = (model_name or payload.get('model') or '').strip()
|
||
if not model_name:
|
||
model_name = await _pick_default_model_name(org_id, 't2t')
|
||
|
||
est = max(_texts_len(payload.get('messages')) // EST_TOKENS_PER_CHAR, 200)
|
||
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)
|
||
try:
|
||
data = await _call_upstream_chat(ctx, payload)
|
||
except Exception as e:
|
||
# 失败结算:释放预授权 + failed 流水;上游 429 触发账号冷却
|
||
note = str(e)[:200]
|
||
await govern_settle(ctx, False, 0, 0,
|
||
'429 upstream: ' + note if ' 429' in note else note)
|
||
raise GovernError(str(e))
|
||
usage = data.get('usage') or {}
|
||
rt = int(_fnum(usage.get('prompt_tokens')))
|
||
ct = int(_fnum(usage.get('completion_tokens')))
|
||
await govern_settle(ctx, True, rt, ct, '' if usage else 'est')
|
||
return data
|
||
|
||
|
||
async def list_models(org_id, catelogid='', limit=200):
|
||
"""按能力分类列模型(对齐 llmage v1/models 的分类方式)。
|
||
|
||
catelogid 空 = 全部能力。返回 [{'id','name','model_id','capability',
|
||
'vendor','status','price_input','price_output'}]。
|
||
机构未配策略(无可用模型)返回空列表,由调用方决定是否提示。
|
||
"""
|
||
from .gateway import _active_policy_org, _get_db
|
||
db, dbname = _get_db()
|
||
rows = []
|
||
async with db.sqlorContext(dbname) as sor:
|
||
if not await _active_policy_org(sor, org_id or ''):
|
||
return rows
|
||
sql = ("SELECT m.id, m.name, m.vendor_model_id, m.capability, m.status, "
|
||
"m.price_input, m.price_output, v.name AS vendor_name "
|
||
"FROM llm_model m LEFT JOIN llm_vendor v ON v.id=m.vendor_id "
|
||
"WHERE m.status='active'")
|
||
params = {}
|
||
cap = (catelogid or '').strip().lower()
|
||
if cap:
|
||
sql += " AND m.capability=${c}$"
|
||
params['c'] = cap
|
||
sql += " ORDER BY m.capability, m.name LIMIT %d" % int(limit)
|
||
recs = await sor.sqlExe(sql, params)
|
||
await sor.sqlExe("COMMIT", {})
|
||
for r in (recs or []):
|
||
rows.append({
|
||
'id': getattr(r, 'id', ''),
|
||
'name': getattr(r, 'name', ''),
|
||
'model_id': getattr(r, 'vendor_model_id', '') or getattr(r, 'name', ''),
|
||
'capability': getattr(r, 'capability', '') or 't2t',
|
||
'vendor': getattr(r, 'vendor_name', '') or '',
|
||
'status': getattr(r, 'status', ''),
|
||
'price_input': float(_fnum(getattr(r, 'price_input', 0))),
|
||
'price_output': float(_fnum(getattr(r, 'price_output', 0))),
|
||
})
|
||
return rows
|
||
|
||
|
||
def load_inference():
|
||
"""注册到 ServerEnv(供 dspy 端点调用)。"""
|
||
from ahserver.serverenv import ServerEnv
|
||
env = ServerEnv()
|
||
env.llm_chat_inference = chat_inference
|
||
env.llm_list_models = list_models
|
||
env.llm_pick_default_model_name = _pick_default_model_name
|
||
logger.info("[pipeline_llm] inference loaded")
|