"""LLM 服务(作业 4)。 fake 与真实 provider 实现同一 ``LLMService`` Protocol,节点不感知差异: - ``FakeLLMService``:脚本化固定输出,离线可重复,日常测试用它; - ``OpenAILLMService``:真实 provider(TODO 关键代码)—— 超时、取消、有限重试、模型配置。 ``build_llm_service`` 按命名提供商列表(Config.LLM_PROVIDERS / LLM_PROVIDER) 选择实现,见 core.config。 """ from collections.abc import Awaitable, Callable from typing import Protocol Responder = Callable[[str, str], str | Awaitable[str]] class LLMError(Exception): """LLM 调用失败基类。""" class LLMTimeoutError(LLMError): """调用超过超时时间。""" class LLMAPIError(LLMError): """provider 返回错误或无效响应。""" class LLMService(Protocol): """节点依赖的最小 LLM 接口。""" async def complete( self, system_prompt: str, user_message: str, *, history: list[dict[str, str]] | None = None, ) -> str: """返回模型文本;history 保留 user/assistant 角色,与工作流对齐。""" ... class FakeLLMService: """按 responder 返回固定输出的假 LLM。 responder 接收 (system_prompt, user_message),返回 str(或 coroutine)。每次调用记录在 ``calls``,测试用它断言 “一次轮次只调用一次模型”等约束。 """ def __init__(self, responder: Responder): self._responder = responder self.calls: list[dict] = [] # 兼容两参 responder;三参 responder 可读取对话历史。 import inspect self._accepts_history = ( len(inspect.signature(responder).parameters) >= 3 ) async def complete( self, system_prompt: str, user_message: str, *, history: list[dict[str, str]] | None = None, ) -> str: self.calls.append( {"system_prompt": system_prompt, "user_message": user_message, "history": [dict(message) for message in (history or [])]} ) import asyncio result = ( self._responder(system_prompt, user_message, history or []) if self._accepts_history else self._responder(system_prompt, user_message) ) if asyncio.iscoroutine(result): result = await result return result class OpenAILLMService: """真实 LLM provider(OpenAI 兼容接口,如 OpenAI / DeepSeek 等)。 参考实现:async 客户端 + asyncio.wait_for 超时(取消会向外传播, 客户端断连时图任务随之取消);仅对可重试错误(429 / 5xx / 连接错) 按 LLM_MAX_RETRIES 指数退避重试;错误统一转换为 LLMTimeoutError / LLMAPIError,不向上层泄漏原始响应体。 """ def __init__( self, model: str, api_key: str, base_url: str = "", timeout_seconds: float = 30.0, max_retries: int = 2, temperature: float | None = None, extra_body: dict | None = None, ): self._model = model self._api_key = api_key self._base_url = base_url self._timeout_seconds = timeout_seconds self._max_retries = max_retries self._temperature = temperature # 附加请求体(如思考开关),原样合并进 chat.completions.create 参数。 self._extra_body = dict(extra_body or {}) self._client = None def _get_client(self): # 延迟构造:fake provider 使用时无需安装/初始化真实客户端。 # AsyncOpenAI 的创建不绑定事件循环,可在首次调用前安全缓存。 # trust_env=False:不走系统代理,本地网关直连(代理曾导致 503/500)。 if self._client is None: import httpx from openai import AsyncOpenAI from ..core.config import Config kwargs = {"api_key": self._api_key, "timeout": self._timeout_seconds} if self._base_url: kwargs["base_url"] = self._base_url kwargs["http_client"] = httpx.AsyncClient(trust_env=Config.LLM_HTTP_TRUST_ENV) self._client = AsyncOpenAI(**kwargs) return self._client async def complete( self, system_prompt: str, user_message: str, *, history: list[dict[str, str]] | None = None, ) -> str: import asyncio from openai import APIConnectionError, APITimeoutError, RateLimitError client = self._get_client() messages = [ {"role": "system", "content": system_prompt}, *[dict(message) for message in (history or [])], {"role": "user", "content": user_message}, ] kwargs: dict = {"model": self._model, "messages": messages} if self._temperature is not None: kwargs["temperature"] = self._temperature kwargs.update(self._extra_body) retries = 0 while True: try: response = await asyncio.wait_for( client.chat.completions.create(**kwargs), timeout=self._timeout_seconds, ) except (APITimeoutError, asyncio.TimeoutError) as exc: # 超时:可重试,重试耗尽后转换为 LLMTimeoutError if retries < self._max_retries: retries += 1 await asyncio.sleep(min(2 ** retries, 10)) continue raise LLMTimeoutError( f"LLM 调用超时({self._timeout_seconds}s," f"重试 {self._max_retries} 次后仍失败)" ) from exc except (RateLimitError, APIConnectionError) as exc: # 限流 / 连接错误:可重试 if retries < self._max_retries: retries += 1 await asyncio.sleep(min(2 ** retries, 10)) continue raise LLMAPIError(f"LLM provider 不可用:{type(exc).__name__}") from exc except Exception as exc: # 其他错误(如 4xx 参数错)不可重试 raise LLMAPIError(f"LLM 调用失败:{type(exc).__name__}") from exc content = response.choices[0].message.content if response.choices else None if not content: raise LLMAPIError("LLM 返回了空内容") return content def _default_fake_responder(system_prompt: str, user_message: str) -> str: """离线兜底假回复:回显输入,保持服务在无配置时可启动。""" return f"1002已收到:{user_message}" def workflow_fake_responder(system_prompt: str, user_message: str, history=None) -> str: """模拟工作流提示词行为的假 LLM(离线测试/冒烟用)。 从注入的运行时上下文(system 末尾的 context_prompt)读取当前 阶段与 clientMode;按工作流提示词语义响应客户端事件: 会话标记 1001→1002、【拍摄完成】沿链推进、失败一次→0005、 第二次无回复→0004;普通输入保持当前阶段。正文字面回显输入。 """ import re from .events import ClientEvent, parse_client_event stage_match = re.search(r'"stage_code":\s*"(\d{4})"', system_prompt) current = stage_match.group(1) if stage_match else "1001" mode = "browser_addon" if '"client_mode": "browser_addon"' in system_prompt else "direct" # 20261022 起 user 消息为 + 包装,取原始输入判断事件 input_match = re.search(r"\n(.*?)\n?", user_message, re.DOTALL) raw_input = input_match.group(1) if input_match else user_message event = parse_client_event(raw_input) if event in (ClientEvent.SESSION_START, ClientEvent.CONTINUE) and current == "1001": return f"1002已收到:{raw_input}" if event is ClientEvent.PHOTO_DONE: chain = { "2000": "2001", "2001": "2002", "2002": "2003", "2003": "2005", "2010": "2011", "2011": "2012", "2012": "2013", "2013": "2014", "2014": "2015", } target = chain.get(current) if target is None: target = "0000" if mode == "browser_addon" else ("3001" if current == "2005" else "3002") return f"{target}已收到:{raw_input}" if event is ClientEvent.PHOTO_FAILED_3X: return "0005多次拍摄识别仍未成功,正在为您转接人工处理。" if event is ClientEvent.NO_REPLY: trailing = 0 for message in reversed(history or []): if message.get("role") != "user": continue if parse_client_event(str(message.get("content", ""))) is ClientEvent.NO_REPLY: trailing += 1 else: break if trailing >= 1: return "0004长时间未收到回应,为您转接人工。" return f"{current}请问您还在吗?" return f"{current}已收到:{raw_input}" def workflow_fake_llm() -> "FakeLLMService": """离线假 LLM:行为对齐工作流提示词,供冒烟与无模型测试。""" return FakeLLMService(workflow_fake_responder) def build_llm_service(provider: str | None = None) -> LLMService: """按命名提供商列表构造 LLM 服务;provider 缺省读 Config.LLM_PROVIDER。 配置见 Config.llm_profiles(LLM_PROVIDERS 名单 + LLM_<名>_* 变量)。 provider 为空或 fake = 离线脚本模型。 """ from ..core.config import Config provider = (provider if provider is not None else Config.LLM_PROVIDER) provider = (provider or "").strip().lower() if not provider or provider == "fake": return FakeLLMService(_default_fake_responder) profiles = Config.llm_profiles() if provider not in profiles: raise ValueError( f"Unknown LLM provider {provider!r}; configured: {sorted(profiles)}" ) profile = profiles[provider] extra_body: dict = {} if profile["extra_body"]: import json as _json try: parsed = _json.loads(profile["extra_body"]) if isinstance(parsed, dict): extra_body = parsed except ValueError as exc: raise ValueError( f"extra_body of provider {provider!r} is not valid JSON: {exc}" ) from exc return OpenAILLMService( model=profile["model"], api_key=profile["api_key"], base_url=profile["base_url"], timeout_seconds=profile["timeout_seconds"], max_retries=profile["max_retries"], temperature=profile["temperature"], extra_body=extra_body, )