neural-sync/backend/app/services/llm_client.py

185 lines
6.5 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.

"""
统一 LLM 客户端 - 支持多种提供商
支持的提供商:
- ollama: 本地运行 (免费,但占用 CPU/内存)
- deepseek: 云端 API (便宜,¥1/百万 tokens)
- openai: 云端 API (GPT-4)
- qwen: 阿里云通义千问 (便宜,国内稳定)
"""
import httpx
from typing import Dict, List, Optional, Any
import os
import json
class LLMClient:
"""统一 LLM 客户端"""
def __init__(self):
self.provider = os.getenv("LLM_PROVIDER", "deepseek").lower()
self.client = self._create_client()
def _create_client(self) -> 'LLMProvider':
"""根据配置创建对应的提供商客户端"""
if self.provider == "ollama":
return OllamaClient()
elif self.provider == "deepseek":
return OpenAICompatibleClient(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"),
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
)
elif self.provider == "openai":
return OpenAICompatibleClient(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini")
)
elif self.provider == "qwen":
return OpenAICompatibleClient(
api_key=os.getenv("QWEN_API_KEY"),
base_url=os.getenv("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
model=os.getenv("QWEN_MODEL", "qwen-plus")
)
else:
raise ValueError(f"不支持的 LLM 提供商: {self.provider}")
def generate(self, prompt: str, **kwargs) -> str:
"""生成文本"""
return self.client.generate(prompt, **kwargs)
def generate_json(self, prompt: str, **kwargs) -> Dict:
"""生成 JSON 格式响应"""
return self.client.generate_json(prompt, **kwargs)
def get_embedding(self, text: str) -> List[float]:
"""获取向量嵌入"""
return self.client.get_embedding(text)
class LLMProvider:
"""LLM 提供商基类"""
def generate(self, prompt: str, **kwargs) -> str:
raise NotImplementedError
def generate_json(self, prompt: str, **kwargs) -> Dict:
raise NotImplementedError
def get_embedding(self, text: str) -> List[float]:
raise NotImplementedError
class OllamaClient(LLMProvider):
"""Ollama 本地客户端"""
def __init__(self):
self.host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip('/')
self.chat_model = os.getenv("OLLAMA_CHAT_MODEL", "llama3:8b")
self.embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
self.client = httpx.Client(base_url=self.host, timeout=300.0, trust_env=False)
def generate(self, prompt: str, **kwargs) -> str:
response = self.client.post("/api/generate", json={
"model": self.chat_model,
"prompt": prompt,
"stream": False,
**kwargs
})
response.raise_for_status()
return response.json()["response"]
def generate_json(self, prompt: str, **kwargs) -> Dict:
response = self.client.post("/api/generate", json={
"model": self.chat_model,
"prompt": prompt,
"stream": False,
"format": "json",
**kwargs
})
response.raise_for_status()
content = response.json()["response"]
# 清理 markdown 代码块
if content.startswith("```json"):
content = content[7:]
if content.startswith("```"):
content = content[3:]
if content.endswith("```"):
content = content[:-3]
content = content.strip()
return json.loads(content)
def get_embedding(self, text: str) -> List[float]:
response = self.client.post("/api/embeddings", json={
"model": self.embedding_model,
"prompt": text
})
response.raise_for_status()
return response.json().get("embedding", [])
class OpenAICompatibleClient(LLMProvider):
"""OpenAI 兼容客户端 (适用于 DeepSeek, Qwen 等)"""
def __init__(self, api_key: str, base_url: str, model: str):
self.model = model
self.base_url = base_url
self.api_key = api_key
# 延迟初始化客户端,在实际调用时才检查 API_KEY
self._client = None
@property
def client(self):
"""延迟初始化客户端"""
if self._client is None:
if not self.api_key:
raise ValueError(f"请设置 {self.base_url} 的 API_KEY")
self._client = httpx.Client(
base_url=self.base_url,
timeout=120.0,
headers={"Authorization": f"Bearer {self.api_key}"}
)
return self._client
def generate(self, prompt: str, **kwargs) -> str:
response = self.client.post("/chat/completions", json={
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
**kwargs
})
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
def generate_json(self, prompt: str, **kwargs) -> Dict:
response = self.client.post("/chat/completions", json={
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
**kwargs
})
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"]
return json.loads(content)
def get_embedding(self, text: str) -> List[float]:
# DeepSeek 不支持 embedding,使用替代方案或返回空
if "deepseek" in self.base_url:
print("[WARNING] DeepSeek 不支持 embedding API,使用简化向量")
# 简化方案:基于文本内容生成伪向量(实际使用时应该用其他服务)
import hashlib
hash_obj = hashlib.md5(text.encode())
# 生成 768 维伪向量(与 nomic-embed-text 维度一致)
return [float((hash_obj.digest()[i % 16] / 255.0) * 2 - 1) for _ in range(768)]
response = self.client.post("/embeddings", json={
"model": self.model,
"input": text
})
response.raise_for_status()
return response.json()["data"][0]["embedding"]
# 单例实例
llm_client = LLMClient()