ai.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """真实 HTTP 接入与熔断,未配置或解析失败明确降级,不构造虚假 AI 输出。"""
  2. import asyncio
  3. import hashlib
  4. import json
  5. import math
  6. import time
  7. from datetime import timedelta
  8. from typing import TypeVar
  9. import httpx
  10. from pydantic import BaseModel, ValidationError
  11. from sqlalchemy import select
  12. from sqlalchemy.dialects.sqlite import insert
  13. from app.domain.models import CacheEntry
  14. from app.infrastructure.database import Session, transaction
  15. from app.shared.common import BusinessError, utcnow
  16. from app.shared.config import settings
  17. T = TypeVar("T", bound=BaseModel)
  18. class AIUnavailable(Exception):
  19. """分类为外部服务失败,上层可回规则,但不能标为成功。"""
  20. class CircuitBreaker:
  21. """半开只允许一个探测请求,避免冷却结束时全部等待请求涌向故障服务。"""
  22. def __init__(self):
  23. self.failures = 0
  24. self.opened_at = None
  25. self.probing = False
  26. self.lock = asyncio.Lock()
  27. async def enter(self):
  28. async with self.lock:
  29. if self.opened_at is not None:
  30. if time.monotonic() - self.opened_at < settings.ai_recovery_seconds or self.probing:
  31. raise AIUnavailable("服务熔断中")
  32. self.probing = True
  33. async def finish(self, success: bool):
  34. async with self.lock:
  35. self.probing = False
  36. if success:
  37. self.failures, self.opened_at = 0, None
  38. else:
  39. self.failures += 1
  40. if self.opened_at is not None or self.failures >= settings.ai_failure_threshold:
  41. self.opened_at = time.monotonic()
  42. def status(self):
  43. return "half_open" if self.probing else "open" if self.opened_at is not None else "closed"
  44. def parse_structured(raw: str, schema: type[T]) -> T:
  45. """用 JSON 解码器识别嵌套对象,而非贪婪正则吞掉两个独立 JSON。"""
  46. candidates = [raw.strip()]
  47. decoder = json.JSONDecoder()
  48. for position, char in enumerate(raw):
  49. if char == "{":
  50. try:
  51. item, end = decoder.raw_decode(raw[position:])
  52. if isinstance(item, dict):
  53. candidates.append(json.dumps(item, ensure_ascii=False))
  54. except json.JSONDecodeError:
  55. continue
  56. for candidate in candidates:
  57. try:
  58. return schema.model_validate_json(candidate)
  59. except (ValidationError, ValueError):
  60. continue
  61. raise AIUnavailable("模型结构化输出不符合契约")
  62. class AIClient:
  63. def __init__(self):
  64. self.http = httpx.AsyncClient(timeout=settings.ai_timeout, follow_redirects=False)
  65. self.semaphore = asyncio.Semaphore(settings.ai_concurrency)
  66. self.breakers = {}
  67. async def close(self):
  68. await self.http.aclose()
  69. async def cached(self, namespace: str, model: str, payload: dict, ttl: int, operation):
  70. """模型名、端点和参数都参与键生成;只缓存成功响应,不让短暂故障长期固化。"""
  71. key = hashlib.sha256(json.dumps([namespace, model, payload], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
  72. try:
  73. async with Session() as session:
  74. row = await session.get(CacheEntry, key)
  75. if row and row.expires_at > utcnow():
  76. value = dict(row.value_json)
  77. value["cache_hit"] = True
  78. return value
  79. except Exception:
  80. # 缓存不是业务前提,读取失败仍调用真实服务。
  81. pass
  82. value = await operation()
  83. try:
  84. async with transaction() as session:
  85. await session.execute(insert(CacheEntry).values(key=key, namespace=namespace, model_version=model,
  86. value_json=value, expires_at=utcnow() + timedelta(seconds=ttl)).on_conflict_do_update(
  87. index_elements=["key"], set_={"value_json": value, "expires_at": utcnow() + timedelta(seconds=ttl)}))
  88. except Exception:
  89. pass
  90. return value
  91. async def request(self, url: str, payload: dict, timeout: float | None = None):
  92. """HTTP 层不自动重试;图级重试掌握总尝试预算,避免叠加指数放大。"""
  93. if not url.startswith(("http://", "https://")):
  94. raise AIUnavailable("AI 服务未配置")
  95. breaker = self.breakers.setdefault(url, CircuitBreaker())
  96. await breaker.enter()
  97. success = False
  98. try:
  99. async with self.semaphore:
  100. response = await self.http.post(url, json=payload, timeout=timeout or settings.ai_timeout,
  101. headers={"Authorization": "Bearer " + settings.ai_api_key} if settings.ai_api_key else {})
  102. response.raise_for_status()
  103. result = response.json()
  104. success = True
  105. return result
  106. except (httpx.HTTPError, ValueError, KeyError):
  107. raise AIUnavailable("AI 服务不可用或响应格式错误")
  108. finally:
  109. await breaker.finish(success)
  110. async def structured(self, prompt: str, schema: type[T], vision: str | None = None, timeout: float | None = None):
  111. """OpenAI 兼容 JSON Schema 请求;不支持时明确上抛,图级预算控制降级。"""
  112. base = settings.vlm_base_url or settings.llm_base_url if vision else settings.llm_base_url
  113. model = settings.vlm_model or settings.llm_model if vision else settings.llm_model
  114. if not base or not model:
  115. raise AIUnavailable("模型未配置")
  116. content = [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": vision}}] if vision else prompt
  117. payload = {"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0.1,
  118. "response_format": {"type": "json_schema", "json_schema": {"name": schema.__name__, "schema": schema.model_json_schema()}}}
  119. async def invoke():
  120. response = await self.request(base.rstrip("/") + "/chat/completions", payload, timeout)
  121. try:
  122. data = parse_structured(response["choices"][0]["message"]["content"], schema)
  123. except (KeyError, IndexError, TypeError):
  124. raise AIUnavailable("模型未返回可解析的消息")
  125. return {"result": data.model_dump(mode="json"), "usage": response.get("usage", {})}
  126. value = await self.cached("llm", model, {"base": base, **payload}, 86400, invoke)
  127. usage = {"cache_hit": True} if value.get("cache_hit") else value.get("usage", {})
  128. return schema.model_validate(value["result"]), usage
  129. async def embeddings(self, texts: list[str]) -> list[list[float]]:
  130. """不混用不同模型的向量;服务失败可走同模型 OpenAI embeddings 协议。"""
  131. async def invoke():
  132. response = None
  133. if settings.embedding_base_url:
  134. try:
  135. response = await self.request(settings.embedding_base_url.rstrip("/") + "/embeddings", {"texts": texts})
  136. except AIUnavailable:
  137. response = None
  138. if response is None and settings.llm_base_url:
  139. response = await self.request(settings.llm_base_url.rstrip("/") + "/embeddings", {"model": settings.embedding_model, "input": texts})
  140. if response is None:
  141. raise AIUnavailable("Embedding 服务未配置")
  142. try:
  143. vectors = response.get("vectors")
  144. if vectors is None:
  145. vectors = [item["embedding"] for item in sorted(response["data"], key=lambda item: item["index"])]
  146. if len(vectors) != len(texts):
  147. raise ValueError()
  148. for vector in vectors:
  149. if len(vector) != settings.embedding_dimension or not all(isinstance(x, (int, float)) and math.isfinite(x) for x in vector) or sum(x * x for x in vector) <= 0:
  150. raise ValueError()
  151. except (KeyError, TypeError, ValueError):
  152. raise AIUnavailable("Embedding 数量、维度或数值不合法")
  153. return {"vectors": vectors}
  154. value = await self.cached("embed", settings.embedding_model, {"texts": texts, "base": settings.embedding_base_url,
  155. "fallback": settings.llm_base_url, "dim": settings.embedding_dimension}, 604800, invoke)
  156. return value["vectors"]
  157. async def rerank(self, query: str, candidates: list[dict]) -> list[dict]:
  158. """精排失败保留原 COSINE 排序,同时返回降级标记供界面解释。"""
  159. if not settings.rerank_base_url or not candidates:
  160. return candidates
  161. try:
  162. response = await self.request(settings.rerank_base_url.rstrip("/") + "/rerank",
  163. {"query": query, "documents": [item["text"] for item in candidates]})
  164. results = response["results"]
  165. indexes = [item["index"] for item in results]
  166. if len(indexes) != len(candidates) or set(indexes) != set(range(len(candidates))):
  167. raise ValueError()
  168. ordered = []
  169. for result in results:
  170. score = float(result["relevance_score"])
  171. if not math.isfinite(score):
  172. raise ValueError()
  173. ordered.append({**candidates[result["index"]], "rerank_score": score})
  174. return sorted(ordered, key=lambda item: item["rerank_score"], reverse=True)
  175. except (AIUnavailable, KeyError, TypeError, ValueError):
  176. return [{**item, "rerank_degraded": True} for item in candidates]
  177. ai = AIClient()