247 lines
8.5 KiB
Python
247 lines
8.5 KiB
Python
"""
|
||
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方 + 视觉模型
|
||
"""
|
||
import logging
|
||
import os
|
||
import base64
|
||
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
|
||
from langchain_openai import ChatOpenAI
|
||
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
|
||
import openai
|
||
|
||
from ..core.config import get_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
settings = get_settings()
|
||
|
||
# 图片描述提示词
|
||
IMAGE_DESCRIPTION_PROMPT = """你是一个国土空间规划专家。请详细描述这张PDF文档中的图片内容。
|
||
|
||
图片周围文字上下文(来自PDF页面):{context_text}
|
||
|
||
要求:
|
||
1. 说明图片类型(地图/规划图/图表/流程图/示意图/照片等)
|
||
2. 描述图片中的关键信息、数据和空间关系
|
||
3. 提取图中所有文字标注
|
||
4. 描述控制在200-300字"""
|
||
|
||
# DeepSeek 官方模型 ID 前缀(用于自动路由)
|
||
DEEPSEEK_OFFICIAL_MODELS = {
|
||
"deepseek-chat",
|
||
"deepseek-reasoner",
|
||
}
|
||
|
||
# 模型 ID → (api_key, base_url) 的路由映射
|
||
def _resolve_provider(model: str) -> tuple[str, str]:
|
||
"""根据模型 ID 选择 API provider,返回 (api_key, base_url)"""
|
||
# DeepSeek 官方模型(不带 siliconflow 前缀的纯 deepseek-xxx)
|
||
if model in DEEPSEEK_OFFICIAL_MODELS or model.startswith("deepseek/"):
|
||
if not settings.deepseek_api_key:
|
||
raise ValueError(
|
||
f"模型 {model} 需要 DeepSeek 官方 API Key,"
|
||
"请在 .env 中配置 DEEPSEEK_API_KEY"
|
||
)
|
||
actual_model = model.replace("deepseek/", "")
|
||
return settings.deepseek_api_key, settings.deepseek_base_url, actual_model
|
||
|
||
# 默认走 SiliconFlow
|
||
return settings.siliconflow_api_key, settings.siliconflow_base_url, model
|
||
|
||
|
||
class SiliconFlowLLM:
|
||
"""大模型客户端 — 自动路由 SiliconFlow / DeepSeek 官方"""
|
||
|
||
def __init__(self, model: Optional[str] = None):
|
||
raw_model = model or settings.siliconflow_model
|
||
api_key, base_url, resolved_model = _resolve_provider(raw_model)
|
||
|
||
self.model_name = resolved_model
|
||
logger.info(f"模型: {resolved_model}, API: {base_url}")
|
||
|
||
self.llm = ChatOpenAI(
|
||
model=resolved_model,
|
||
api_key=api_key,
|
||
base_url=base_url,
|
||
temperature=0.7,
|
||
max_tokens=2000,
|
||
streaming=True
|
||
)
|
||
|
||
self._api_key = api_key
|
||
self._base_url = base_url
|
||
|
||
def chat(self, messages: List[BaseMessage], **kwargs) -> str:
|
||
"""同步聊天"""
|
||
try:
|
||
response = self.llm.invoke(messages, **kwargs)
|
||
return response.content
|
||
except Exception as e:
|
||
raise Exception(f"LLM调用失败: {str(e)}")
|
||
|
||
async def achat(self, messages: List[BaseMessage], **kwargs) -> str:
|
||
"""异步聊天"""
|
||
try:
|
||
response = await self.llm.ainvoke(messages, **kwargs)
|
||
return response.content
|
||
except Exception as e:
|
||
raise Exception(f"LLM异步调用失败: {str(e)}")
|
||
|
||
async def stream_chat(self, messages: List[BaseMessage], **kwargs) -> AsyncGenerator[str, None]:
|
||
"""流式聊天"""
|
||
try:
|
||
async for chunk in self.llm.astream(messages, **kwargs):
|
||
if hasattr(chunk, 'content') and chunk.content:
|
||
yield chunk.content
|
||
except Exception as e:
|
||
raise Exception(f"LLM流式调用失败: {str(e)}")
|
||
|
||
async def stream_with_reasoning(
|
||
self, messages: List[BaseMessage], **kwargs
|
||
) -> AsyncGenerator[Tuple[str, str], None]:
|
||
"""流式调用(直接使用 OpenAI SDK,捕获推理内容)
|
||
|
||
Yields:
|
||
(type, content) — type 为 "reasoning" 或 "content"
|
||
"""
|
||
client = openai.AsyncOpenAI(
|
||
api_key=self._api_key,
|
||
base_url=self._base_url
|
||
)
|
||
|
||
openai_messages = []
|
||
for msg in messages:
|
||
if isinstance(msg, SystemMessage):
|
||
openai_messages.append({"role": "system", "content": msg.content})
|
||
elif isinstance(msg, HumanMessage):
|
||
openai_messages.append({"role": "user", "content": msg.content})
|
||
elif isinstance(msg, AIMessage):
|
||
openai_messages.append({"role": "assistant", "content": msg.content})
|
||
else:
|
||
openai_messages.append({"role": "user", "content": str(msg.content)})
|
||
|
||
stream = await client.chat.completions.create(
|
||
model=self.model_name,
|
||
messages=openai_messages,
|
||
stream=True,
|
||
**kwargs
|
||
)
|
||
|
||
async for chunk in stream:
|
||
if not chunk.choices:
|
||
continue
|
||
delta = chunk.choices[0].delta
|
||
|
||
rc = getattr(delta, 'reasoning_content', None)
|
||
if rc:
|
||
yield ("reasoning", rc)
|
||
|
||
if delta.content:
|
||
yield ("content", delta.content)
|
||
|
||
def create_messages(
|
||
self,
|
||
user_message: str,
|
||
system_prompt: Optional[str] = None,
|
||
chat_history: Optional[List[Dict[str, str]]] = None
|
||
) -> List[BaseMessage]:
|
||
"""创建消息列表"""
|
||
messages = []
|
||
|
||
if system_prompt:
|
||
messages.append(SystemMessage(content=system_prompt))
|
||
|
||
if chat_history:
|
||
for msg in chat_history:
|
||
if msg["role"] == "user":
|
||
messages.append(HumanMessage(content=msg["content"]))
|
||
elif msg["role"] == "assistant":
|
||
messages.append(AIMessage(content=msg["content"]))
|
||
|
||
messages.append(HumanMessage(content=user_message))
|
||
|
||
return messages
|
||
|
||
|
||
async def describe_image(self, image_path: str, context_text: str = "") -> str:
|
||
"""使用VLM模型描述图片内容
|
||
|
||
Args:
|
||
image_path: 图片文件路径
|
||
context_text: 图片周围的PDF文本上下文
|
||
|
||
Returns:
|
||
图片的文字描述
|
||
"""
|
||
import asyncio
|
||
import time
|
||
|
||
# 读取图片并编码为base64,不支持的格式先转为PNG
|
||
ext = os.path.splitext(image_path)[1].lower()
|
||
mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
|
||
|
||
if ext not in mime_map:
|
||
from PIL import Image
|
||
import io
|
||
img = Image.open(image_path)
|
||
if img.mode in ("CMYK", "P"):
|
||
img = img.convert("RGB")
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
image_data = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||
mime_type = "image/png"
|
||
else:
|
||
with open(image_path, "rb") as f:
|
||
image_data = base64.b64encode(f.read()).decode("utf-8")
|
||
mime_type = mime_map[ext]
|
||
|
||
prompt = IMAGE_DESCRIPTION_PROMPT.format(context_text=context_text[:600])
|
||
|
||
vision_model = "Qwen/Qwen3-VL-8B-Instruct"
|
||
api_key, base_url, _ = _resolve_provider(vision_model)
|
||
|
||
client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||
|
||
max_retries = 3
|
||
for attempt in range(max_retries):
|
||
try:
|
||
response = await client.chat.completions.create(
|
||
model=vision_model,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "text", "text": prompt},
|
||
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_data}"}},
|
||
],
|
||
}],
|
||
max_tokens=600,
|
||
temperature=0.3,
|
||
timeout=90.0,
|
||
)
|
||
return response.choices[0].message.content or ""
|
||
|
||
except Exception as e:
|
||
logger.warning(f"描述失败 attempt={attempt+1}: {e}")
|
||
if attempt < max_retries - 1:
|
||
await asyncio.sleep(2 ** attempt)
|
||
|
||
return ""
|
||
|
||
|
||
# 全局LLM实例(使用默认模型)
|
||
llm_client = SiliconFlowLLM()
|
||
|
||
|
||
def get_llm_client(model: Optional[str] = None) -> SiliconFlowLLM:
|
||
"""获取LLM客户端实例
|
||
|
||
Args:
|
||
model: 可选的模型名称,如果提供则创建新的实例,否则返回默认实例
|
||
"""
|
||
if model is None:
|
||
return llm_client
|
||
else:
|
||
return SiliconFlowLLM(model=model)
|