pipeline-llm/pipeline_llm/inference.py

411 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""pipeline_llm.inference — 统一推理入口(分类照 llmage按能力类型 catelogid 路由)。
设计2026-09-04产线平台所有模型调用收敛到本模块
- 进程内agent/引擎经 pipeline_service.llm_bridge 走 HTTP 自调用 →
/pipeline_llm/api/v1/chat/completionsBearer 短期 token→ chat_inference()
- 运行环境:短期 token 代理(真 key 永不出进程,沿用原铁律)同一入口
- 旧运行环境入口 /pipeline_core/api/llm_v1 委托同一实现(收敛唯一实现)
门禁链复用 gateway.govern_resolve/govern_settle限流/限额/主备容错/端点轮转/
预授权/双维度记账)。治理真实失败抛 GovernError消息真实可行动禁止静默回退。
一期形态t2tOpenAI 兼容 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.loadsapi_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):
"""门禁链 ①-⑥ + 预授权 + 加载模型/适配模板。
返回 dictgovern 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.loadsapi_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 模板 → dictJinja2 渲染后 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 推理:门禁链 → 上游调用 → 结算。返回上游响应 dictOpenAI 兼容)。
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")