Files
yawiii-ComfyUI-Prompt-Assis…/services/llm.py
T
2026-01-10 17:38:47 +08:00

652 lines
30 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服务 - 重构版本
提供大语言模型的扩写和翻译功能
继承OpenAICompatibleService以复用通用逻辑
"""
import json
import time
import asyncio
from typing import Optional, Dict, Any, List, Callable
import httpx
from .openai_base import OpenAICompatibleService, filter_thinking_content
from ..utils.common import (
format_api_error, ProgressBar, log_complete, log_error,
PREFIX, PROCESS_PREFIX, WARN_PREFIX, ERROR_PREFIX, format_elapsed_time,
TASK_EXPAND, TASK_TRANSLATE
)
from .thinking_control import build_thinking_suppression
class LLMService(OpenAICompatibleService):
"""
大语言模型服务
支持提示词扩写和文本翻译
"""
@staticmethod
def _get_config() -> Dict[str, Any]:
"""获取LLM配置"""
from ..config_manager import config_manager
config = config_manager.get_llm_config()
current_provider = config.get('provider')
if 'providers' in config and current_provider in config['providers']:
provider_config = config['providers'][current_provider]
return {
'provider': current_provider,
'model': provider_config.get('model', ''),
'base_url': provider_config.get('base_url', ''),
'api_key': provider_config.get('api_key', ''),
'temperature': provider_config.get('temperature', 0.7),
'top_p': provider_config.get('top_p', 0.9),
'max_tokens': provider_config.get('max_tokens', 2000),
'auto_unload': provider_config.get('auto_unload', True)
}
else:
return config
@staticmethod
def _is_chinese(text: str) -> bool:
"""判断文本是否包含中文"""
return any('\u4e00' <= char <= '\u9fff' for char in text)
@staticmethod
async def _call_ollama_native(
model: str,
messages: List[Dict[str, str]],
temperature: float,
top_p: float,
max_tokens: int,
base_url: str,
stream_callback: Optional[Callable[[str], None]] = None,
request_id: Optional[str] = None,
provider_display_name: str = "Ollama",
auto_unload: bool = True,
enable_advanced_params: bool = False,
thinking_extra: Optional[Dict[str, Any]] = None,
cancel_event: Optional[Any] = None,
task_type: str = None,
source: str = None
) -> Dict[str, Any]:
"""
调用Ollama原生API(支持流式输出)
用于支持智能上下文窗口和思维链控制
参数:
enable_advanced_params: 是否发送高级参数(temperature/top_p/num_predict)
thinking_extra: 思维链控制参数
"""
# ---初始化请求参数---
try:
start_time = time.perf_counter()
_thinking_extra = thinking_extra # 使用传入的参数
_thinking_tag = "(已关闭思维链)" if _thinking_extra else ""
# 计算基准 URL (确保移除 /v1 和末尾斜杠)
native_base = base_url.rstrip('/') if base_url else 'http://localhost:11434'
if native_base.endswith('/v1'):
native_base = native_base[:-3].rstrip('/')
# 智能动态上下文窗口计算 (Token估算策略: 中文0.7/char, 英文0.3/char -> 保守取 0.6/char)
# 安全地计算输入长度 (包含 System Prompt 和 User Prompt,前提是都在 messages 中)
input_char_len = 0
for msg in messages:
input_char_len += len(msg.get('content', '') or '')
estimated_input_tokens = int(input_char_len * 0.6)
# --- 智能预留策略 ---
# 关键点:思考过程 (Thinking Process) 也占用 Output Token 额度
# 1. 如果成功禁用了思维链 (_thinking_extra 非空) -> 安全
# 2. 如果模型名明确包含 instruct/chat (通常无思考过程) -> 安全
# 3. 其他未知模型 -> 假定可能存在思考过程,预留更多空间
is_safe_standard_model = False
if model:
m = model.lower()
if "instruct" in m or "chat" in m:
is_safe_standard_model = True
if _thinking_extra or is_safe_standard_model:
# 已关闭思维链 OR 标准指令模型 -> 极致节省模式
min_output = 512
min_ctx = 1024
else:
# 未知/潜在思考模型 -> 安全能够模式 (为思考过程预留空间)
min_output = 1024
min_ctx = 2048
# 任务类型预留
output_reserve = max(min_output, int(estimated_input_tokens * 1.5))
# 384为系统开销Buffer (System Prompt通常已在estimated_input_tokens中,这里是额外的安全余量)
required_ctx = estimated_input_tokens + output_reserve + 384
# 对齐到 1024 倍数
# 限制在 [min_ctx, 32768] 范围内
num_ctx = max(min_ctx, min(32768, required_ctx))
num_ctx = ((num_ctx + 1023) // 1024) * 1024
# 合并多条 System Message(Ollama 对多条 system 消息处理不佳)
merged_messages = LLMService._merge_system_prompts(messages)
# 构建请求(安全地构建messages列表)
ollama_messages = []
for msg in merged_messages:
ollama_messages.append({
"role": msg.get('role', 'user'),
"content": msg.get('content', '')
})
# 构建基础请求体
payload = {
"model": model,
"messages": ollama_messages,
"stream": True
}
# ---构建 options---
# 基础参数:num_ctx(动态上下文窗口大小)
options = {
"num_ctx": num_ctx
}
# 高级参数:仅在用户启用时发送
# 参数说明(基于 Ollama 官方文档):
# - temperature: 控制随机性,默认0.8,值越低输出越稳定
# - top_p: 核采样,默认0.9,限制候选词概率范围
# - num_predict: 最大生成Token数,默认-1(无限)
if enable_advanced_params:
options["temperature"] = temperature
options["top_p"] = top_p
options["num_predict"] = max_tokens
payload["options"] = options
# 添加思维链控制参数(如 think: true 或 think: false)
if _thinking_extra:
payload.update(_thinking_extra)
from ..server import is_streaming_progress_enabled
# 动态超时计算: 基础30s + 每1000个Token预估增加5秒
estimated_timeout = 30.0 + (num_ctx / 1000) * 5.0
final_timeout = min(600.0, max(60.0, estimated_timeout)) # 限制在 60s - 600s 之间
# 创建统一进度条(自动处理等待→生成→完成的完整生命周期)
extra_info = f"Context:{num_ctx} | Timeout:{int(final_timeout)}s"
pbar = ProgressBar(
request_id=request_id,
service_name=provider_display_name,
extra_info=extra_info,
streaming=is_streaming_progress_enabled(),
task_type=task_type,
source=source
)
start_time = time.perf_counter()
async with httpx.AsyncClient(
timeout=httpx.Timeout(final_timeout, connect=10.0, read=final_timeout),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
) as client:
full_content = ""
# 定义请求核心逻辑
async def _request_core():
async with client.stream('POST', f"{native_base}/api/chat", json=payload, follow_redirects=True) as resp:
if resp.status_code != 200:
error_text = await resp.aread()
try:
error_data = json.loads(error_text)
return {"success": False, "error": error_data.get('error', f'HTTP {resp.status_code}')}
except:
return {"success": False, "error": f'HTTP {resp.status_code}'}
nonlocal full_content
async for line in resp.aiter_lines():
if not line: continue
try:
chunk_data = json.loads(line)
message = chunk_data.get('message')
if message and isinstance(message, dict):
content = message.get('content', '')
if content and content.strip():
full_content += content
pbar.set_generating(len(full_content))
pbar.update(len(full_content))
if stream_callback: stream_callback(content)
if chunk_data.get('done', False):
pbar.done(char_count=len(full_content), elapsed_ms=int((time.perf_counter() - start_time) * 1000))
break
except:
continue
return {"success": True, "content": full_content.strip()}
# 定义监视器逻辑
async def _monitor_interrupts(target_task):
while not target_task.done():
is_interrupted = False
if cancel_event is not None and cancel_event.is_set():
is_interrupted = True
else:
try:
from server import PromptServer
if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted:
is_interrupted = True
except: pass
if is_interrupted:
target_task.cancel()
return True
await asyncio.sleep(0.1)
return False
# 并发执行
req_task = asyncio.create_task(_request_core())
monitor_task = asyncio.create_task(_monitor_interrupts(req_task))
try:
result = await req_task
# 关键修复:检查返回的结果,如果失败则停止进度条
if not result.get("success"):
pbar.error(result.get("error", "未知错误"))
return result
except Exception as req_err:
if 'pbar' in locals() and pbar:
pbar.error(f"Ollama 请求异常: {req_err}")
return {"success": False, "error": f"Ollama 请求异常: {req_err}"}
except asyncio.CancelledError:
pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:Ollama")
return {"success": False, "error": "任务被中断", "interrupted": True}
finally:
if not monitor_task.done(): monitor_task.cancel()
# 强力显存释放保证:不仅是成功,中断也要释放
if auto_unload:
try:
await cls._unload_ollama_model(model, {"base_url": native_base, "auto_unload": True})
except: pass
# 关键修复:单独捕获外层 CancelledError,确保 pbar 被正确停止
except asyncio.CancelledError:
if 'pbar' in locals() and pbar:
pbar.cancel(f"{WARN_PREFIX} 任务被外部取消 | 服务:Ollama")
return {"success": False, "error": "任务被取消", "interrupted": True}
except Exception as e:
# 关键修复:确保 pbar 在异常时也被停止
if 'pbar' in locals() and pbar:
pbar.error(format_api_error(e, provider_display_name))
return {"success": False, "error": format_api_error(e, provider_display_name)}
@staticmethod
async def expand_prompt(
prompt: str,
request_id: Optional[str] = None,
stream_callback: Optional[Callable[[str], None]] = None,
custom_provider: Optional[str] = None,
custom_provider_config: Optional[Dict[str, Any]] = None,
system_message_override: Optional[Dict[str, str]] = None,
cancel_event: Optional[Any] = None,
task_type: str = None,
source: Optional[str] = None
) -> Dict[str, Any]:
"""
使用大语言模型扩写提示词
参数:
prompt: 要扩写的提示词
request_id: 请求ID
stream_callback: 流式输出的回调函数
custom_provider: 自定义服务商
custom_provider_config: 自定义配置
system_message_override: 覆盖系统提示词
返回:
Dict: {"success": bool, "data": {"original": str, "expanded": str}, "error": str}
"""
try:
# 获取配置
if custom_provider and custom_provider_config:
provider = custom_provider
api_key = custom_provider_config.get('api_key')
model = custom_provider_config.get('model')
temperature = custom_provider_config.get('temperature', 0.7)
top_p = custom_provider_config.get('top_p', 0.9)
max_tokens = custom_provider_config.get('max_tokens', 2000)
base_url = custom_provider_config.get('base_url', '')
else:
config = LLMService._get_config()
provider = config.get('provider', 'unknown')
api_key = config.get('api_key')
model = config.get('model')
temperature = config.get('temperature', 0.7)
top_p = config.get('top_p', 0.9)
max_tokens = config.get('max_tokens', 2000)
base_url = config.get('base_url', '')
# 注:允许空API Key,支持无认证服务商(如deepinfra公开端点)
if not model:
return {"success": False, "error": "未配置模型名称"}
provider_display_name = LLMService.get_provider_display_name(provider)
from ..utils.common import REQUEST_PREFIX, PREFIX, format_model_with_thinking
# 获取系统提示词
if system_message_override and system_message_override.get('content'):
system_message = system_message_override
prompt_name = system_message.get('name', '节点自定义规则')
else:
from ..config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
if not system_prompts or 'expand_prompts' not in system_prompts:
return {"success": False, "error": "提示词优化系统提示词加载失败"}
active_prompt_id = system_prompts.get('active_prompts', {}).get('expand', 'expand_default')
if active_prompt_id not in system_prompts['expand_prompts']:
if len(system_prompts['expand_prompts']) > 0:
active_prompt_id = list(system_prompts['expand_prompts'].keys())[0]
else:
return {"success": False, "error": "未找到可用的提示词优化系统提示词"}
system_message = system_prompts['expand_prompts'][active_prompt_id]
prompt_name = system_message.get('name', active_prompt_id)
# 检查服务配置的disable_thinking参数,只有开启时才关闭思维链
from ..config_manager import config_manager
service = config_manager.get_service(provider)
disable_thinking_enabled = service.get('disable_thinking', True) if service else True
# 始终调用 build_thinking_suppression,传递 disable_thinking 参数
_thinking_extra = build_thinking_suppression(provider, model, disable_thinking=disable_thinking_enabled)
thinking_disabled = _thinking_extra is not None and disable_thinking_enabled
model_display = format_model_with_thinking(model, thinking_disabled)
# 构建消息
lang_message = {
"role": "system",
"content": "请用中文回答" if LLMService._is_chinese(prompt) else "Please answer in English."
}
messages = [lang_message, system_message, {"role": "user", "content": prompt}]
# Ollama走原生API (通过服务类型判断)
if service and service.get('type') == 'ollama':
# 读取 Ollama 服务的配置
enable_advanced_params = service.get('enable_advanced_params', False)
filter_thinking_output = service.get('filter_thinking_output', True)
# 统一计算 native_base (确保移除 /v1 和末尾斜杠)
native_base = base_url.rstrip('/')
if native_base.endswith('/v1'):
native_base = native_base[:-3].rstrip('/')
# 再次兜底
if not native_base:
native_base = 'http://localhost:11434'
# 提前计算auto_unload配置
_cfg = {
'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True),
'base_url': native_base
}
auto_unload = _cfg['auto_unload']
result = await LLMService._call_ollama_native(
model=model,
messages=messages,
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
base_url=base_url,
stream_callback=stream_callback,
request_id=request_id,
provider_display_name=provider_display_name,
auto_unload=auto_unload,
enable_advanced_params=enable_advanced_params,
thinking_extra=_thinking_extra,
cancel_event=cancel_event,
task_type=task_type or TASK_EXPAND,
source=source
)
if result["success"]:
# 自动卸载 (原生调用路径需要手动触发,但使用补全后的基类逻辑)
await LLMService._unload_ollama_model(model, _cfg)
# 应用思维链输出过滤
content = result["content"]
if filter_thinking_output:
content = filter_thinking_content(content)
return {
"success": True,
"data": {"original": prompt, "expanded": content}
}
else:
return result
# 其他服务走HTTP直连
if not base_url:
base_url = LLMService.get_provider_base_url(provider, custom_provider_config if custom_provider else None)
# 检查disable_thinking、enable_advanced_params和filter_thinking_output配置
from ..config_manager import config_manager
service = config_manager.get_service(provider)
disable_thinking_enabled = service.get('disable_thinking', True) if service else True
enable_advanced_params = service.get('enable_advanced_params', False) if service else False
filter_thinking_output = service.get('filter_thinking_output', True) if service else True
thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None
result = await LLMService._http_request_chat_completions(
base_url=base_url,
api_key=api_key,
model=model,
messages=messages,
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
thinking_extra=thinking_extra,
enable_advanced_params=enable_advanced_params,
stream_callback=stream_callback,
request_id=request_id,
provider_display_name=provider_display_name,
cancel_event=cancel_event,
task_type=task_type or TASK_EXPAND,
source=source
)
if result["success"]:
# 根据配置决定是否应用思维链输出过滤
content = result["content"]
if filter_thinking_output:
content = filter_thinking_content(content)
return {
"success": True,
"data": {"original": prompt, "expanded": content}
}
else:
return result
except Exception as e:
return {"success": False, "error": format_api_error(e, "LLM服务")}
@staticmethod
async def translate(
text: str,
from_lang: str = 'auto',
to_lang: str = 'zh',
request_id: Optional[str] = None,
stream_callback: Optional[Callable[[str], None]] = None,
custom_provider: Optional[str] = None,
custom_provider_config: Optional[Dict[str, Any]] = None,
cancel_event: Optional[Any] = None,
task_type: str = None,
source: Optional[str] = None
) -> Dict[str, Any]:
"""
使用大语言模型翻译文本
参数:
text: 要翻译的文本
from_lang: 源语言
to_lang: 目标语言
request_id: 请求ID
stream_callback: 流式输出回调
custom_provider: 自定义服务商
custom_provider_config: 自定义配置
返回:
Dict: {"success": bool, "data": {"original": str, "translated": str}, "error": str}
"""
try:
# 获取配置(翻译使用专门的翻译服务配置)
if custom_provider and custom_provider_config:
provider = custom_provider
api_key = custom_provider_config.get('api_key')
model = custom_provider_config.get('model')
temperature = custom_provider_config.get('temperature', 0.7)
top_p = custom_provider_config.get('top_p', 0.9)
max_tokens = custom_provider_config.get('max_tokens', 2000)
base_url = custom_provider_config.get('base_url', '')
else:
# 使用翻译服务配置(而非LLM配置)
from ..config_manager import config_manager
config = config_manager.get_translate_config()
provider = config.get('provider', 'unknown')
api_key = config.get('api_key')
model = config.get('model')
temperature = config.get('temperature', 0.7)
top_p = config.get('top_p', 0.9)
max_tokens = config.get('max_tokens', 2000)
base_url = config.get('base_url', '')
# 注:允许空API Key,支持无认证服务商
if not model:
return {"success": False, "error": "未配置模型名称"}
provider_display_name = LLMService.get_provider_display_name(provider)
from ..config_manager import config_manager
service = config_manager.get_service(provider)
from ..utils.common import REQUEST_PREFIX, PREFIX, format_model_with_thinking
# 检测是否关闭思维链
disable_thinking_enabled = service.get('disable_thinking', True) if service else True
_thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None
thinking_disabled = _thinking_extra is not None and disable_thinking_enabled
model_display = format_model_with_thinking(model, thinking_disabled)
# 翻译提示词
translate_instruction = f"请将以下文本从{from_lang}翻译成{to_lang},只输出翻译结果,不要添加任何解释或额外内容:"
messages = [
{"role": "system", "content": translate_instruction},
{"role": "user", "content": text}
]
# Ollama走原生API (通过服务类型判断)
if service and service.get('type') == 'ollama':
# 读取 Ollama 服务的配置
disable_thinking_enabled = service.get('disable_thinking', True)
enable_advanced_params = service.get('enable_advanced_params', False)
filter_thinking_output = service.get('filter_thinking_output', True)
_ollama_thinking_extra = build_thinking_suppression(provider, model) if disable_thinking_enabled else None
# 统一计算 native_base (确保移除 /v1 和末尾斜杠)
native_base = base_url.rstrip('/')
if native_base.endswith('/v1'):
native_base = native_base[:-3].rstrip('/')
# 再次兜底
if not native_base:
native_base = 'http://localhost:11434'
_cfg = {
'auto_unload': custom_provider_config.get('auto_unload', True) if custom_provider_config else config.get('auto_unload', True),
'base_url': native_base
}
auto_unload = _cfg['auto_unload']
result = await LLMService._call_ollama_native(
model=model,
messages=[{"role": "system", "content": translate_instruction}, {"role": "user", "content": text}],
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
base_url=base_url,
stream_callback=stream_callback,
request_id=request_id,
provider_display_name=provider_display_name,
auto_unload=auto_unload,
enable_advanced_params=enable_advanced_params,
thinking_extra=_ollama_thinking_extra,
cancel_event=cancel_event,
task_type=task_type or TASK_TRANSLATE,
source=source
)
if result["success"]:
await LLMService._unload_ollama_model(model, _cfg)
# 应用思维链输出过滤
content = result["content"]
if filter_thinking_output:
content = filter_thinking_content(content)
return {
"success": True,
"data": {"original": text, "translated": content}
}
else:
return result
# 其他服务走HTTP直连
if not base_url:
base_url = LLMService.get_provider_base_url(provider, custom_provider_config if custom_provider else None)
# 检查enable_advanced_params和filter_thinking_output配置
enable_advanced_params = service.get('enable_advanced_params', False) if service else False
filter_thinking_output = service.get('filter_thinking_output', True) if service else True
thinking_extra = _thinking_extra # 复用前面计算好的 suppression
result = await LLMService._http_request_chat_completions(
base_url=base_url,
api_key=api_key,
model=model,
messages=messages,
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
thinking_extra=thinking_extra,
enable_advanced_params=enable_advanced_params,
stream_callback=stream_callback,
request_id=request_id,
provider_display_name=provider_display_name,
cancel_event=cancel_event,
task_type=task_type or TASK_TRANSLATE,
source=source
)
if result["success"]:
# 根据配置决定是否应用思维链输出过滤
content = result["content"]
if filter_thinking_output:
content = filter_thinking_content(content)
return {
"success": True,
"data": {"original": text, "translated": content}
}
else:
return result
except Exception as e:
return {"success": False, "error": format_api_error(e, "LLM服务")}