feat(server): add workbench API, DSH planner worker and RAG pipeline

This commit is contained in:
yi
2026-09-16 19:06:20 +08:00
parent 72fc5b47c7
commit d4b5b3f43a
122 changed files with 31906 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
"""LLM 多 Provider 客户端:重试/错误分类/拒审检测/调用日志"""
import json
import re
import time
import hashlib
import uuid
from sqlmodel import Session
from openai import OpenAI
from .hermes_core import retry_sleep
from .secret_store import resolve_secret
from .models import Campaign, CampaignRun, LLMCall, ModelProvider
from .billing import (consume_points, pricing_for, record_usage_event,
require_ai_points)
from . import system_log
REJECT_PATTERNS = [
"抱歉", "无法生成", "不能生成", "我不能", "无法满足", "露骨", "合规",
"安全地", "作为AI", "作为人工智能", "色情内容", "不合适",
]
def humanize_error(kind: str, raw: str) -> str:
"""把错误翻译成用户能行动的话;限流类明确告知原因和换模型建议。"""
if kind == "rate_limit":
wait = ""
m = re.search(r"try again in ([\d.]+)s", raw, re.IGNORECASE)
if m:
wait = f"(建议约 {m.group(1)} 秒后重试)"
return (f"模型服务商限流(429):请求太频繁或超出并发额度{wait}。"
"系统会自动放慢重试;若持续失败,建议在「模型管理」更换其他模型或 Provider")
if kind == "auth":
return "模型服务认证失败:API Key 无效或已过期,请到「模型管理」核对密钥"
if kind == "model_not_found":
return "模型不存在或未开通:请到「模型管理」更换该 Provider 下的其他模型"
if kind == "timeout":
return "模型响应超时:可能是网络波动或服务商繁忙,系统会自动重试"
if kind == "connection":
return "连接模型服务失败:请检查 Provider 地址/网络;持续失败建议更换其他模型"
if kind == "balance":
return "服务商账户余额不足:请到服务商平台充值,或更换其他 Provider"
return raw
def classify_error(e: Exception) -> str:
msg = str(e).lower()
if "timeout" in msg or "timed out" in msg or "read" in msg and "deadline" in msg:
return "timeout"
if "429" in msg or "rate limit" in msg or "quota" in msg:
return "rate_limit"
if "401" in msg or "403" in msg or "invalid api key" in msg or "authentication" in msg:
return "auth"
if "404" in msg or "model" in msg and "not found" in msg or "does not exist" in msg:
return "model_not_found"
if "balance" in msg or "insufficient" in msg or "余额" in msg:
return "balance"
if "connection" in msg or "connect" in msg:
return "connection"
return "other"
def is_rejection(text: str) -> bool:
head = text[:120]
return any(p in head for p in REJECT_PATTERNS)
def retryable(kind: str) -> bool:
return kind in ("timeout", "rate_limit", "connection")
def tools_unsupported(e: Exception) -> bool:
message = str(e).lower()
return ("tool" in message or "function" in message) and any(token in message for token in (
"unsupported", "not support", "unknown parameter", "invalid parameter", "not allowed",
))
def get_provider(session: Session, provider_id: int) -> ModelProvider:
p = session.get(ModelProvider, provider_id)
if not p or not p.enabled:
raise ValueError(f"Provider {provider_id} 不可用")
return p
def _payload_hash(value) -> str:
try:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
except Exception:
raw = str(value)
return hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()
def _write_ai_log(session: Session, *, level: str, message: str,
trace_id: str = "", status: str = "ok") -> None:
"""Best-effort AI observability; logging must never break generation or tests."""
try:
# Ordinary AI lifecycle messages use the bounded writer so generation
# does not wait on SQLite. ``emit`` still falls back to synchronous
# persistence for ERROR and other critical levels.
system_log.emit(session, level=level, source="ai", message=message,
trace_id=trace_id, status=status)
except Exception:
# Fake sessions and a temporarily unavailable log table must not mask the
# provider response. LLMCall/UsageEvent remain the primary audit trail.
try:
rollback = getattr(session, "rollback", None)
if rollback:
rollback()
except Exception:
pass
def _usage_owner(session: Session, user_id: int, organization_id: int,
campaign_id: int = 0, run_id: int = 0) -> tuple[int, int]:
"""运行器调用未显式传用户时,从持久化运行记录恢复归属。"""
user = int(user_id or 0)
organization = int(organization_id or 0)
run = session.get(CampaignRun, run_id) if run_id else None
campaign = session.get(Campaign, campaign_id) if campaign_id else None
if not user:
user = int((run.user_id if run else 0) or (campaign.owner_user_id if campaign else 0) or 0)
if not organization:
organization = int((run.organization_id if run else 0) or
(campaign.organization_id if campaign else 1) or 1)
return user, organization
def chat(session: Session, messages: list, provider_id: int, model: str,
campaign_id: int = 0, segment_id: int = 0, agent_id: int = 0, agent_name: str = "",
run_id: int = 0, trace_id: str = "", span_id: str = "", attempt: int = 1,
tools: list | None = None, max_retries: int = 2, temperature: float = 0.8,
user_id: int = 0, organization_id: int = 0, _failover_left: int = 1,
sdk_retries: int | None = None, on_rate_limit=None, before_request=None) -> dict:
"""调用 LLM,返回 {text, status, error, provider, model, tokens, duration_ms}
status: ok | rejected | failed
Hermes 融合:主 Provider 失败且属于可切换错误时,自动 failover 到下一个
启用 Provider 重试(每条调用链最多一次)。"""
p = get_provider(session, provider_id)
resolved_user, resolved_org = _usage_owner(session, user_id, organization_id,
campaign_id, run_id)
try:
require_ai_points(session, user_id=resolved_user, organization_id=resolved_org)
except ValueError as exc:
# 点数不足也是一次可追踪的 AI 操作,写入系统日志供管理员定位。
_write_ai_log(session, level="WARNING",
message=f"AI 调用未执行 · 模型:{model} · 原因:{exc}",
trace_id=trace_id, status="failed")
return {"text": "", "status": "failed", "error": str(exc),
"provider": p.name, "model": model, "tokens": 0, "duration_ms": 0}
api_key = resolve_secret(p.api_key, f"provider-{p.id}")
if not api_key:
error = '没有读取到可用模型密钥,请在模型管理重新填写并保存;已保存时请检查服务环境与密钥存储'
_write_ai_log(session, level='ERROR', message=f'AI 调用未执行 · Provider:{p.name} · 原因:{error}',
trace_id=trace_id, status='failed')
return {'text': '', 'status': 'failed', 'error': error, 'provider': p.name,
'model': model, 'tokens': 0, 'duration_ms': 0, 'error_kind': 'credential_unavailable'}
client = OpenAI(base_url=p.base_url,
api_key=api_key,
timeout=120, **({'max_retries': sdk_retries} if sdk_retries is not None else {}))
t0 = time.time()
retry_attempt = 0
last_err = ""
prompt_hash = _payload_hash(messages)
tool_fallback_used = False
while True:
if before_request is not None:
before_request()
try:
request = {"model": model, "messages": messages, "temperature": temperature}
if tools:
request["tools"] = tools
request["tool_choice"] = "auto"
resp = client.chat.completions.create(**request)
text = resp.choices[0].message.content or ""
raw_calls = getattr(resp.choices[0].message, "tool_calls", None) or []
tool_calls = [{
"id": str(getattr(call, "id", "")),
"type": str(getattr(call, "type", "function")),
"function": {
"name": str(getattr(getattr(call, "function", None), "name", "")),
"arguments": str(getattr(getattr(call, "function", None), "arguments", "{}")),
},
} for call in raw_calls]
dt = int((time.time() - t0) * 1000)
prompt_tk = resp.usage.prompt_tokens if resp.usage else 0
comp_tk = resp.usage.completion_tokens if resp.usage else 0
status = "rejected" if is_rejection(text) else "ok"
point_charged = _log(session, run_id, campaign_id, segment_id, agent_id, agent_name, p.name, model,
status, "", prompt_tk, comp_tk, dt, trace_id, span_id, attempt,
prompt_hash, _payload_hash({"text": text, "tool_calls": tool_calls}),
provider_id=p.id or provider_id, user_id=resolved_user,
organization_id=resolved_org)
if status == "ok" and resolved_user and not point_charged:
session.commit()
_write_ai_log(session, level="WARNING",
message=f"内容生成未交付 · Agent:{agent_name or '系统'} · 模型:{model} · 原因:点数余额不足 · 输入 {prompt_tk} Token · 输出 {comp_tk} Token",
trace_id=trace_id, status="failed")
return {"text": "", "status": "failed", "error": "点数余额不足,本次生成未交付,请联系管理员增加点数",
"provider": p.name, "model": model, "tokens": 0, "duration_ms": dt}
if status == "rejected":
session.commit()
elif status == "ok":
# _log() flushes the usage event and point charge. Leaving
# that transaction open while the caller waits for the next
# model request turns SQLite's single writer lock into a
# system-wide stall (health checks, estimates and drafts then
# appear as 524). Commit each completed call before emitting
# the asynchronous observability record so long AI jobs do not
# hold the database lock across network waits.
session.commit()
# 记录 AI 调用结果与拒答原因,但只保留有限摘要,不落库完整提示词/正文。
summary = f"输出 {len(text)} 字符"
if status == "rejected":
summary += f" · 模型返回:{system_log.safe_detail(text, limit=240)}"
_write_ai_log(session, level="WARNING" if status == "rejected" else "INFO",
message=f"AI{'内容生成' if campaign_id or segment_id else '调用'}完成 · Agent:{agent_name or '系统'} · 模型:{model} · {summary} · 输入 {prompt_tk} Token · 输出 {comp_tk} Token",
trace_id=trace_id, status="rejected" if status == "rejected" else "success")
return {"text": text, "status": status, "provider": p.name, "model": model,
"tokens": prompt_tk + comp_tk, "duration_ms": dt, "tool_calls": tool_calls}
except Exception as e:
last_err = str(e)
kind = classify_error(e)
if kind == "rate_limit" and on_rate_limit is not None:
on_rate_limit()
if tools and not tool_fallback_used and tools_unsupported(e):
# 某些 OpenAI 兼容 Provider 不实现 tools;保留同一上下文退化为单轮输出。
tools = None
tool_fallback_used = True
continue
retry_attempt += 1
if retryable(kind) and retry_attempt <= max_retries:
# Hermes 融合:抖动退避 + 服务端 Retry-After,防并发重试尖峰。
headers = getattr(e, "response", None)
headers = getattr(headers, "headers", None) if headers is not None else None
time.sleep(retry_sleep(retry_attempt, retry_after=headers))
continue
dt = int((time.time() - t0) * 1000)
_log(session, run_id, campaign_id, segment_id, agent_id, agent_name, p.name, model,
"error", f"{kind}: {last_err[:300]}", 0, 0, dt, trace_id, span_id,
attempt, prompt_hash, "", provider_id=p.id or provider_id,
user_id=resolved_user, organization_id=resolved_org)
session.commit()
_write_ai_log(session, level="ERROR",
message=f"AI{'内容生成' if campaign_id or segment_id else '调用'}失败 · Agent:{agent_name or '系统'} · 模型:{model} · 错误类型:{kind} · 详情:{system_log.safe_detail(last_err, limit=300)}",
trace_id=trace_id, status="failed")
# Hermes 融合:主 Provider 报可切换错误时,自动切下一个 Provider 重试一次。
if _failover_left > 0:
from .failover import failover_target, should_failover
if should_failover(kind):
target = failover_target(session, p.id or provider_id, model)
if target:
alt_provider, alt_model = target
session.commit()
result = chat(
session, messages, alt_provider.id, alt_model,
campaign_id=campaign_id, segment_id=segment_id,
agent_id=agent_id, agent_name=agent_name, run_id=run_id,
trace_id=trace_id, span_id=span_id, attempt=attempt,
tools=tools, max_retries=max_retries, temperature=temperature,
user_id=resolved_user, organization_id=resolved_org,
_failover_left=0, sdk_retries=sdk_retries, on_rate_limit=on_rate_limit,
before_request=before_request)
if result.get("status") == "ok":
result["failover"] = {"from_provider": p.name, "from_error": kind}
return result
return {"text": "", "status": "failed",
"error": humanize_error(kind, last_err[:300]),
"provider": p.name, "model": model, "tokens": 0, "duration_ms": dt,
"error_kind": kind}
def chat_json(session: Session, messages: list, provider_id: int, model: str, **kw) -> dict:
"""要求模型输出 JSON,容错解析;解析失败自动重试 1 次"""
msgs = messages + [{"role": "user", "content":
"只输出一个合法的 JSON 对象,不要输出任何其他文字、markdown 代码块标记或解释。"}]
r = chat(session, msgs, provider_id, model, **kw)
if r["status"] != "ok":
return r
text = r["text"].strip()
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text)
try:
r["json"] = json.loads(text)
return r
except Exception:
m = re.search(r"\{[\s\S]*\}", text)
if m:
try:
r["json"] = json.loads(m.group(0))
return r
except Exception:
pass
# JSON 解析失败 → 自动重试 1 次(换措辞)
r2 = chat(session, msgs, provider_id, model, **kw)
if r2["status"] != "ok":
return r2
text2 = re.sub(r"^```(?:json)?\s*|\s*```$", "", r2["text"].strip())
try:
r2["json"] = json.loads(text2)
except Exception:
m2 = re.search(r"\{[\s\S]*\}", text2)
if m2:
try:
r2["json"] = json.loads(m2.group(0))
except Exception:
r2["status"] = "failed"
r2["error"] = "JSON 解析失败(重试后仍失败)"
else:
r2["status"] = "failed"
r2["error"] = "JSON 解析失败(重试后仍失败)"
return r2
def _log(session, run_id, campaign_id, segment_id, agent_id, agent_name, provider, model,
status, error, pt, ct, dt, trace_id="", span_id="", attempt=1,
prompt_hash="", output_hash="", provider_id=0, user_id=0, organization_id=1):
"""写入调用日志并同步追加一条不可变用量事件。"""
owner = session.get(Campaign, campaign_id) if campaign_id else None
run = session.get(CampaignRun, run_id) if run_id else None
resolved_user = int(user_id or (run.user_id if run else 0) or (owner.owner_user_id if owner else 0) or 0)
resolved_org = int(organization_id or (run.organization_id if run else 1) or (owner.organization_id if owner else 1) or 1)
call = LLMCall(run_id=run_id, campaign_id=campaign_id, segment_id=segment_id, agent_id=agent_id,
agent_name=agent_name, provider=provider, model=model,
status=status, error=error, prompt_tokens=pt,
completion_tokens=ct, duration_ms=dt, trace_id=trace_id,
span_id=span_id, attempt=attempt, prompt_hash=prompt_hash,
output_hash=output_hash, user_id=resolved_user,
organization_id=resolved_org, billing_status="measured" if pt or ct else "estimated")
session.add(call)
session.flush()
rates = pricing_for(session, provider_id, model)
event_key = f"llm:{trace_id or uuid.uuid4().hex}:{span_id or uuid.uuid4().hex}:{attempt}:{call.id}"
billable_tokens = max(1, int(pt or 0) + int(ct or 0)) if status == "ok" else 0
event = record_usage_event(
session, idempotency_key=event_key, user_id=resolved_user, organization_id=resolved_org,
campaign_id=campaign_id, run_id=run_id, segment_id=segment_id, trace_id=trace_id,
llm_call_id=call.id, agent_id=agent_id, provider_id=provider_id,
provider=provider, model=model, service_type="llm", unit="token",
quantity=billable_tokens,
input_quantity=pt, output_quantity=ct, input_rate=rates["input_rate"],
output_rate=rates["output_rate"], pricing_version=rates["version"],
status="measured" if pt or ct else "estimated",
metadata={"status": status, "error": error[:300], "prompt_hash": prompt_hash,
"output_hash": output_hash,
# 用量明细直接展示用户能理解的业务动作,而不是只显示 LLM。
"operation": "内容生成" if (campaign_id or segment_id) else "AI 调用",
"agent_name": agent_name or "系统"},
)
call.usage_event_id = event.id
call.billing_status = event.status
session.add(call)
if status != "ok" or not resolved_user:
return True
charged = consume_points(
session, user_id=resolved_user, organization_id=resolved_org,
points=billable_tokens * 10, reason=f"AI 模型调用:{model}",
idempotency_key=f"points:{event.idempotency_key}",
metadata={"usage_event_id": event.id, "llm_call_id": call.id,
"tokens": billable_tokens, "model": model},
)
if not charged:
event.usage_metadata = {**(event.usage_metadata or {}),
"point_charge": "insufficient"}
session.add(event)
return charged