2026-10-20 14:53:46 +08:00
|
|
|
|
"""LLM 服务(作业 4)。
|
|
|
|
|
|
|
|
|
|
|
|
fake 与真实 provider 实现同一 ``LLMService`` Protocol,节点不感知差异:
|
|
|
|
|
|
- ``FakeLLMService``:脚本化固定输出,离线可重复,日常测试用它;
|
|
|
|
|
|
- ``OpenAILLMService``:真实 provider(TODO 关键代码)——
|
|
|
|
|
|
超时、取消、有限重试、模型配置。
|
|
|
|
|
|
|
2026-10-22 13:59:18 +08:00
|
|
|
|
``build_llm_service`` 按命名提供商列表(Config.LLM_PROVIDERS / LLM_PROVIDER)
|
|
|
|
|
|
选择实现,见 core.config。
|
2026-10-20 14:53:46 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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 接口。"""
|
|
|
|
|
|
|
2026-10-21 15:10:51 +08:00
|
|
|
|
async def complete(
|
|
|
|
|
|
self, system_prompt: str, user_message: str, *,
|
|
|
|
|
|
history: list[dict[str, str]] | None = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""返回模型文本;history 保留 user/assistant 角色,与工作流对齐。"""
|
2026-10-20 14:53:46 +08:00
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeLLMService:
|
|
|
|
|
|
"""按 responder 返回固定输出的假 LLM。
|
|
|
|
|
|
|
|
|
|
|
|
responder 接收 (system_prompt, user_message),返回 str(或
|
|
|
|
|
|
coroutine)。每次调用记录在 ``calls``,测试用它断言
|
|
|
|
|
|
“一次轮次只调用一次模型”等约束。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, responder: Responder):
|
|
|
|
|
|
self._responder = responder
|
2026-10-21 15:10:51 +08:00
|
|
|
|
self.calls: list[dict] = []
|
|
|
|
|
|
# 兼容两参 responder;三参 responder 可读取对话历史。
|
|
|
|
|
|
import inspect
|
2026-10-20 14:53:46 +08:00
|
|
|
|
|
2026-10-21 15:10:51 +08:00
|
|
|
|
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:
|
2026-10-20 14:53:46 +08:00
|
|
|
|
self.calls.append(
|
2026-10-21 15:10:51 +08:00
|
|
|
|
{"system_prompt": system_prompt, "user_message": user_message,
|
|
|
|
|
|
"history": [dict(message) for message in (history or [])]}
|
2026-10-20 14:53:46 +08:00
|
|
|
|
)
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
2026-10-21 15:10:51 +08:00
|
|
|
|
result = (
|
|
|
|
|
|
self._responder(system_prompt, user_message, history or [])
|
|
|
|
|
|
if self._accepts_history
|
|
|
|
|
|
else self._responder(system_prompt, user_message)
|
|
|
|
|
|
)
|
2026-10-20 14:53:46 +08:00
|
|
|
|
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,
|
2026-10-21 15:10:51 +08:00
|
|
|
|
temperature: float | None = None,
|
2026-10-22 13:59:18 +08:00
|
|
|
|
extra_body: dict | None = None,
|
2026-10-20 14:53:46 +08:00
|
|
|
|
):
|
|
|
|
|
|
self._model = model
|
|
|
|
|
|
self._api_key = api_key
|
|
|
|
|
|
self._base_url = base_url
|
|
|
|
|
|
self._timeout_seconds = timeout_seconds
|
|
|
|
|
|
self._max_retries = max_retries
|
2026-10-21 15:10:51 +08:00
|
|
|
|
self._temperature = temperature
|
2026-10-22 13:59:18 +08:00
|
|
|
|
# 附加请求体(如思考开关),原样合并进 chat.completions.create 参数。
|
|
|
|
|
|
self._extra_body = dict(extra_body or {})
|
2026-10-20 14:53:46 +08:00
|
|
|
|
self._client = None
|
|
|
|
|
|
|
|
|
|
|
|
def _get_client(self):
|
|
|
|
|
|
# 延迟构造:fake provider 使用时无需安装/初始化真实客户端。
|
|
|
|
|
|
# AsyncOpenAI 的创建不绑定事件循环,可在首次调用前安全缓存。
|
2026-10-22 13:59:18 +08:00
|
|
|
|
# trust_env=False:不走系统代理,本地网关直连(代理曾导致 503/500)。
|
2026-10-20 14:53:46 +08:00
|
|
|
|
if self._client is None:
|
2026-10-22 13:59:18 +08:00
|
|
|
|
import httpx
|
2026-10-20 14:53:46 +08:00
|
|
|
|
from openai import AsyncOpenAI
|
|
|
|
|
|
|
2026-10-22 13:59:18 +08:00
|
|
|
|
from ..core.config import Config
|
|
|
|
|
|
|
2026-10-20 14:53:46 +08:00
|
|
|
|
kwargs = {"api_key": self._api_key, "timeout": self._timeout_seconds}
|
|
|
|
|
|
if self._base_url:
|
|
|
|
|
|
kwargs["base_url"] = self._base_url
|
2026-10-22 13:59:18 +08:00
|
|
|
|
kwargs["http_client"] = httpx.AsyncClient(trust_env=Config.LLM_HTTP_TRUST_ENV)
|
2026-10-20 14:53:46 +08:00
|
|
|
|
self._client = AsyncOpenAI(**kwargs)
|
|
|
|
|
|
return self._client
|
|
|
|
|
|
|
2026-10-21 15:10:51 +08:00
|
|
|
|
async def complete(
|
|
|
|
|
|
self, system_prompt: str, user_message: str, *,
|
|
|
|
|
|
history: list[dict[str, str]] | None = None,
|
|
|
|
|
|
) -> str:
|
2026-10-20 14:53:46 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
from openai import APIConnectionError, APITimeoutError, RateLimitError
|
|
|
|
|
|
|
|
|
|
|
|
client = self._get_client()
|
|
|
|
|
|
messages = [
|
|
|
|
|
|
{"role": "system", "content": system_prompt},
|
2026-10-21 15:10:51 +08:00
|
|
|
|
*[dict(message) for message in (history or [])],
|
2026-10-20 14:53:46 +08:00
|
|
|
|
{"role": "user", "content": user_message},
|
|
|
|
|
|
]
|
2026-10-21 15:10:51 +08:00
|
|
|
|
kwargs: dict = {"model": self._model, "messages": messages}
|
|
|
|
|
|
if self._temperature is not None:
|
|
|
|
|
|
kwargs["temperature"] = self._temperature
|
2026-10-22 13:59:18 +08:00
|
|
|
|
kwargs.update(self._extra_body)
|
2026-10-20 14:53:46 +08:00
|
|
|
|
retries = 0
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await asyncio.wait_for(
|
2026-10-21 15:10:51 +08:00
|
|
|
|
client.chat.completions.create(**kwargs),
|
2026-10-20 14:53:46 +08:00
|
|
|
|
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"<state>1002</state>已收到:{user_message}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 15:10:51 +08:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-10-22 12:28:24 +08:00
|
|
|
|
# 20261022 起 user 消息为 <turn_context>+<user_input> 包装,取原始输入判断事件
|
|
|
|
|
|
input_match = re.search(r"<user_input>\n(.*?)\n?</user_input>", user_message, re.DOTALL)
|
|
|
|
|
|
raw_input = input_match.group(1) if input_match else user_message
|
|
|
|
|
|
|
|
|
|
|
|
event = parse_client_event(raw_input)
|
2026-10-21 15:10:51 +08:00
|
|
|
|
if event in (ClientEvent.SESSION_START, ClientEvent.CONTINUE) and current == "1001":
|
2026-10-22 12:28:24 +08:00
|
|
|
|
return f"<state>1002</state>已收到:{raw_input}"
|
2026-10-21 15:10:51 +08:00
|
|
|
|
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")
|
2026-10-22 12:28:24 +08:00
|
|
|
|
return f"<state>{target}</state>已收到:{raw_input}"
|
2026-10-21 15:10:51 +08:00
|
|
|
|
if event is ClientEvent.PHOTO_FAILED_3X:
|
|
|
|
|
|
return "<state>0005</state>多次拍摄识别仍未成功,正在为您转接人工处理。"
|
|
|
|
|
|
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 "<state>0004</state>长时间未收到回应,为您转接人工。"
|
|
|
|
|
|
return f"<state>{current}</state>请问您还在吗?"
|
2026-10-22 12:28:24 +08:00
|
|
|
|
return f"<state>{current}</state>已收到:{raw_input}"
|
2026-10-21 15:10:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def workflow_fake_llm() -> "FakeLLMService":
|
|
|
|
|
|
"""离线假 LLM:行为对齐工作流提示词,供冒烟与无模型测试。"""
|
|
|
|
|
|
return FakeLLMService(workflow_fake_responder)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-20 14:53:46 +08:00
|
|
|
|
def build_llm_service(provider: str | None = None) -> LLMService:
|
2026-10-22 13:59:18 +08:00
|
|
|
|
"""按命名提供商列表构造 LLM 服务;provider 缺省读 Config.LLM_PROVIDER。
|
|
|
|
|
|
|
|
|
|
|
|
配置见 Config.llm_profiles(LLM_PROVIDERS 名单 + LLM_<名>_* 变量)。
|
|
|
|
|
|
provider 为空或 fake = 离线脚本模型。
|
|
|
|
|
|
"""
|
2026-10-20 14:53:46 +08:00
|
|
|
|
from ..core.config import Config
|
|
|
|
|
|
|
2026-10-22 13:59:18 +08:00
|
|
|
|
provider = (provider if provider is not None else Config.LLM_PROVIDER)
|
|
|
|
|
|
provider = (provider or "").strip().lower()
|
|
|
|
|
|
if not provider or provider == "fake":
|
2026-10-20 14:53:46 +08:00
|
|
|
|
return FakeLLMService(_default_fake_responder)
|
2026-10-22 13:59:18 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|