280 lines
14 KiB
Python
280 lines
14 KiB
Python
"""
|
||
LLM bridge for pipeline handlers — 统一收敛到模型治理模块(pipeline-llm)推理 API。
|
||
|
||
2026-09-04 改造:产线平台所有模型调用切换到 /pipeline-llm/api/v1(OpenAI 兼容,
|
||
分类照 llmage)。本模块不再直查旧 `llm` 表——签名保持不变,内部改为:
|
||
签发内部短期 token(机构隔离,真 key 不出进程)
|
||
→ HTTP 自调用本进程 /pipeline-llm/api/v1/chat/completions
|
||
→ 门禁链(限流/限额/主备容错/端点轮转/预授权)+ 双维度记账由端点侧执行
|
||
|
||
自调用走 127.0.0.1:<本进程端口>(web/worker 进程都起 HTTP,worker 端口 9090+N)。
|
||
每次自调用带机构/用户上下文,用量流水可归属到真实用户。
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import time
|
||
|
||
logger = logging.getLogger("pipeline.llm_bridge")
|
||
|
||
# 内部自调用 token 缓存:(org_id, user_id, model_name) -> {token, calls, expires_at}
|
||
# 每次 LLM 调用都签发新 token 会让 tokens 表膨胀,故按上下文缓存复用;
|
||
# 接近调用上限或临近过期时换新(上限/过期由签发侧强制,本地计数只是提前量)。
|
||
_token_cache: dict = {}
|
||
_TOKEN_MAX_LOCAL_CALLS = 400 # token max_calls=500,留 100 余量防竞态
|
||
_TOKEN_REFRESH_BEFORE_EXPIRY = 600 # 距过期不足 10 分钟即换新
|
||
_TOKEN_TTL_HOURS = 8
|
||
|
||
|
||
async def _self_base_url():
|
||
"""本进程推理 API 基址(自调用,不出本机)。"""
|
||
port = 9090
|
||
try:
|
||
from ahserver.serverenv import ServerEnv
|
||
p = getattr(ServerEnv(), 'port', None)
|
||
if p:
|
||
port = int(p)
|
||
except (TypeError, ValueError):
|
||
port = 9090
|
||
except Exception:
|
||
port = 9090
|
||
return "http://127.0.0.1:%d/pipeline-llm/api/v1" % port
|
||
|
||
|
||
async def _get_internal_token(org_id, user_id, model_name, project_id=''):
|
||
"""取/发内部短期 token。失败抛 ValueError(消息真实可行动)。
|
||
|
||
project_id(2026-09-10 用户定夺):随 token 落 pipeline_llm_tokens.project_id,
|
||
推理端点透传到 llm_usage.project_id 支撑按项目统计费用。缓存键必须含
|
||
project_id——否则同 org/user/model 的不同项目会串用同一 token,费用记错项目。
|
||
"""
|
||
key = (org_id or '0', user_id or '', model_name or '', project_id or '')
|
||
now = time.time()
|
||
ent = _token_cache.get(key)
|
||
if ent and ent['calls'] < _TOKEN_MAX_LOCAL_CALLS \
|
||
and ent['expires_at'] - now > _TOKEN_REFRESH_BEFORE_EXPIRY:
|
||
ent['calls'] += 1
|
||
return ent['token']
|
||
from .llm_proxy import create_llm_token
|
||
ok, token = await create_llm_token(
|
||
org_id or '0', project_id=project_id or '', task_id='', model_name=model_name or '',
|
||
purpose='internal_bridge', ttl_hours=_TOKEN_TTL_HOURS,
|
||
max_calls=500, created_by=user_id or '')
|
||
if not ok:
|
||
raise ValueError('内部 LLM token 签发失败:%s(模型治理模块未就绪或机构标识缺失)' % token)
|
||
_token_cache[key] = {
|
||
'token': token, 'calls': 1,
|
||
'expires_at': now + _TOKEN_TTL_HOURS * 3600,
|
||
}
|
||
return token
|
||
|
||
|
||
async def _http_chat(payload, org_id, user_id, model_name, timeout: int = 0,
|
||
project_id: str = ''):
|
||
"""POST 本进程推理端点。返回上游响应 dict;失败抛 ValueError(消息真实可行动)。
|
||
|
||
timeout:客户端等待秒数(0=缺省 330)。异步生成模型(视频等)端点侧最长
|
||
等 900 秒,调用方须同步放大客户端超时,否则客户端先断连。
|
||
"""
|
||
import aiohttp
|
||
|
||
token = await _get_internal_token(org_id, user_id, model_name, project_id)
|
||
base = await _self_base_url()
|
||
# ⚠️ "Bearer " 前缀用拼接构造——字面量写在源码里会被脱敏工具替换成 ***
|
||
# (2026-09-04 实测:Authorization 头变成 "***plk-..." 致端点校验失败)
|
||
_BEARER = 'Bea' + 'rer '
|
||
headers = {"Authorization": _BEARER + token, "Content-Type": "application/json"}
|
||
_total = int(timeout) if timeout and int(timeout) > 0 else 330
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
base + "/chat/completions", headers=headers, json=payload,
|
||
timeout=aiohttp.ClientTimeout(total=_total, connect=30),
|
||
) as resp:
|
||
text = await resp.text()
|
||
status = resp.status
|
||
except Exception as e:
|
||
# 如实区分错误类型(2026-09-14 pbls 事故教训):TimeoutError/ServerTimeoutError
|
||
# 的 str() 为空 → 旧文案「端点不可达:。」冒号后空白且把超时误报成不可达,
|
||
# 下游 _classify_failure 按「不可达」的中文查不到英文 hint → 误判永久错误
|
||
# 直接暂停任务链。超时就报超时(瞬时故障,应重试);连接类错误才报不可达。
|
||
import aiohttp as _ah
|
||
if isinstance(e, (asyncio.TimeoutError, _ah.ServerTimeoutError)):
|
||
raise ValueError(
|
||
'LLM 推理端点调用超时(%s/chat/completions,客户端上限 %ss):'
|
||
'请求已发出但未在上限内收到响应,属瞬时故障可重试。'
|
||
% (base, _total))
|
||
if isinstance(e, (_ah.ClientConnectionError, ConnectionError, OSError)):
|
||
raise ValueError(
|
||
'LLM 推理端点不可达(%s/chat/completions):%s: %s。'
|
||
'请检查本进程服务是否正常' % (base, type(e).__name__, e))
|
||
raise ValueError(
|
||
'LLM 推理端点调用失败(%s/chat/completions):%s: %s'
|
||
% (base, type(e).__name__, e))
|
||
try:
|
||
data = json.loads(text)
|
||
except Exception:
|
||
raise ValueError('LLM 推理端点返回非 JSON(HTTP %s):%s' % (status, text[:200]))
|
||
if isinstance(data, dict) and data.get('error'):
|
||
err = data['error']
|
||
msg = err.get('message', '') if isinstance(err, dict) else str(err)
|
||
raise ValueError(msg or 'LLM 推理失败(无详情)')
|
||
return data
|
||
|
||
|
||
def _no_llm_error(model_name=None, org_id=None) -> ValueError:
|
||
"""兜底错误(正常路径错误消息来自端点侧,这里只防解析异常)。"""
|
||
return ValueError(
|
||
"模型「%s」调用失败:机构 %s 未完成模型治理接入(无容错策略或模型未注册)。"
|
||
"请在模型治理→组织容错策略配置主/备模型。" % (model_name or '(缺省)', org_id or '(未指定)'))
|
||
|
||
|
||
async def llm_call(prompt: str, model: str = None, temperature: float = 0.7,
|
||
org_id: str = None, user_id: str = None, purpose: str = '',
|
||
timeout: int = 0, project_id: str = '', session_id: str = '') -> str:
|
||
"""Call LLM and return text response.
|
||
|
||
统一走模型治理推理 API(门禁链 + 双维度记账)。
|
||
org_id 为空 = 系统级('0'),与旧语义(不过滤机构)等价。
|
||
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链
|
||
(机构策略配置辅助模型优先),经 payload 的 _purpose 键透传到端点。
|
||
session_id(2026-09-11 用户定夺):会话粘性键——同会话固定同一账号,
|
||
上游 KV/前缀缓存按账号隔离,固定账号命中缓存省钱;经 payload._session_id
|
||
透传到端点(不进 token 缓存键——token 与账号选择解耦)。
|
||
"""
|
||
# 兼容旧优先级:harnessed_agent(若宿主加载了独立推理后端)
|
||
try:
|
||
from ahserver.serverenv import ServerEnv
|
||
env = ServerEnv()
|
||
fn = getattr(env, 'llm_chat', None)
|
||
if callable(fn):
|
||
result = await fn(prompt, model=model, temperature=temperature)
|
||
if isinstance(result, dict):
|
||
return result.get("content", result.get("text", str(result)))
|
||
return str(result)
|
||
except Exception:
|
||
pass
|
||
|
||
payload = {
|
||
"model": model or '',
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": temperature,
|
||
}
|
||
if purpose:
|
||
payload["_purpose"] = purpose
|
||
if timeout:
|
||
payload["_timeout"] = int(timeout)
|
||
if session_id:
|
||
payload["_session_id"] = session_id
|
||
data = await _http_chat(payload, org_id or '0', user_id or '', model or '',
|
||
project_id=project_id or '')
|
||
try:
|
||
return data["choices"][0]["message"]["content"]
|
||
except (KeyError, IndexError, TypeError) as e:
|
||
raise ValueError('LLM 响应缺 choices: %s' % (
|
||
json.dumps(data, ensure_ascii=False, default=str)[:300])) from e
|
||
|
||
|
||
async def call_llm(tenant_id: str, prompt: str, model: str = None, temperature: float = 0.7) -> str:
|
||
"""SDLC handler interface — delegates to llm_call."""
|
||
return await llm_call(prompt, model=model, temperature=temperature)
|
||
|
||
|
||
async def llm_call_msgs(messages: list, model: str = None, temperature: float = 0.7,
|
||
org_id: str = None, user_id: str = None, purpose: str = '',
|
||
timeout: int = 0, project_id: str = '', session_id: str = '') -> str:
|
||
"""Call LLM with full message array (system/user/assistant).
|
||
|
||
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链。
|
||
timeout:单次上游调用超时秒数(0=用端点默认;上限 900,超长文本提取用)。
|
||
session_id(2026-09-11):会话粘性账号键,经 payload._session_id 透传。
|
||
"""
|
||
payload = {"model": model or '', "messages": messages, "temperature": temperature}
|
||
if purpose:
|
||
payload["_purpose"] = purpose
|
||
if timeout:
|
||
payload["_timeout"] = int(timeout)
|
||
if session_id:
|
||
payload["_session_id"] = session_id
|
||
# 客户端等待须覆盖端点侧预算 + 余量(同 llm_infer 模式),否则客户端先断连、
|
||
# 报模糊 TimeoutError 而非端点的结构化错误
|
||
data = await _http_chat(payload, org_id or '0', user_id or '', model or '',
|
||
timeout=(int(timeout) + 60 if timeout else 0),
|
||
project_id=project_id or '')
|
||
try:
|
||
return data["choices"][0]["message"]["content"]
|
||
except (KeyError, IndexError, TypeError) as e:
|
||
raise ValueError('LLM 响应缺 choices: %s' % (
|
||
json.dumps(data, ensure_ascii=False, default=str)[:300])) from e
|
||
|
||
|
||
async def llm_call_msgs_native(messages: list, tools: list = None, model: str = None,
|
||
temperature: float = 0.7, org_id: str = None,
|
||
user_id: str = None, purpose: str = '',
|
||
project_id: str = '', session_id: str = '',
|
||
timeout: int = 0) -> dict:
|
||
"""Native function calling. 传入 tools JSON schema,返回 message dict。
|
||
|
||
Returns:
|
||
{"content": str, "tool_calls": [{"id","type","function":{"name","arguments"}}]}
|
||
当模型返回 tool_calls 时,content 通常为空字符串。
|
||
purpose='utility':辅助任务(分类/选择/摘要),治理层按用途选模型链。
|
||
session_id(2026-09-11):会话粘性账号键,经 payload._session_id 透传。
|
||
"""
|
||
payload = {"model": model or '', "messages": messages, "temperature": temperature}
|
||
if tools:
|
||
payload["tools"] = tools
|
||
payload["tool_choice"] = "auto"
|
||
if purpose:
|
||
payload["_purpose"] = purpose
|
||
if timeout:
|
||
# 端点侧上游预算必须随调用方透传(2026-09-15 pbls design 三连超时根因):
|
||
# 原来 native 只把 timeout 用作客户端等待,payload 不带 _timeout → 端点按
|
||
# 供应商端点配置(百炼 timeout=120)掐断上游,而 design 收尾大请求天然
|
||
# 72~115s,一半概率 120s 被杀 → 内部 3 重试全灭 → 任务 failed。
|
||
# 端点预算 = 客户端等待 - 60s 余量:让端点先掐并返回结构化错误(可分类
|
||
# 重试),而不是客户端 aiohttp 断连报模糊 TimeoutError。
|
||
payload["_timeout"] = max(int(timeout) - 60, 60)
|
||
if session_id:
|
||
payload["_session_id"] = session_id
|
||
data = await _http_chat(payload, org_id or '0', user_id or '', model or '',
|
||
timeout=timeout, project_id=project_id or '')
|
||
try:
|
||
msg = data["choices"][0]["message"]
|
||
except (KeyError, IndexError, TypeError) as e:
|
||
raise ValueError('LLM 响应缺 choices: %s' % (
|
||
json.dumps(data, ensure_ascii=False, default=str)[:300])) from e
|
||
return {
|
||
"content": msg.get("content") or "",
|
||
"tool_calls": msg.get("tool_calls") or [],
|
||
}
|
||
|
||
|
||
async def llm_infer(payload: dict, model: str = None, org_id: str = None,
|
||
user_id: str = None, timeout: int = 0,
|
||
project_id: str = '', session_id: str = '') -> dict:
|
||
"""通用推理(全能力,2026-09-07):透传任意 payload 到统一推理端点,
|
||
返回上游响应 dict(OpenAI 兼容 choices;生成类另带 media/output/task_id)。
|
||
|
||
与 llm_call* 的区别:不假设 messages 结构,调用方自己组包——供 agent 的
|
||
invoke_model 工具调用非对话能力(t2i/t2v/i2v/tts/asr 等)。payload 里的
|
||
messages 由调用方按能力构造;媒体参数按三数组契约(image_files/audio_files/
|
||
video_files),inference 层的 _normalize_media_aliases 会归一旧别名。
|
||
|
||
model 空 = 端点按机构策略缺省模型;owner/机构归属硬校验在治理层执行。
|
||
timeout:单次调用超时秒数(生成类慢,可传大值;上限 900,端点侧封顶)。
|
||
失败抛 ValueError(消息真实可行动,直接展示给 agent/用户)。
|
||
"""
|
||
body = dict(payload or {})
|
||
if model:
|
||
body["model"] = model
|
||
if timeout:
|
||
body["_timeout"] = int(timeout)
|
||
if session_id and not body.get('_session_id'):
|
||
body["_session_id"] = session_id
|
||
# 客户端等待须覆盖端点侧预算(_timeout 端点封顶 900)+ 余量,否则客户端先断连
|
||
client_timeout = min(int(timeout or 0), 900) + 60 if timeout else 0
|
||
return await _http_chat(body, org_id or '0', user_id or '', model or '',
|
||
timeout=client_timeout, project_id=project_id or '')
|