Files
ZNJJ-api-server/src/agent/llm.py
T
Eric Wang e5d2a78886 feat(config): named LLM provider list via env vars, runtime selection
- Config.llm_profiles: LLM_PROVIDERS declares names, each group defined by
  LLM_<NAME>_BASE_URL/_API_KEY/_MODEL (required) and optional _TIMEOUT_SECONDS/
  _MAX_RETRIES/_TEMPERATURE/_EXTRA_BODY; JSON-dict form of LLM_PROVIDERS also
  supported. LLM_PROVIDER selects the active group at runtime; empty/fake =
  offline scripted model
- removed fixed openai/kimi groups (AGENT_LLM_PROVIDER, KIMI_LLM_*)
- validate(): incomplete groups and unknown LLM_PROVIDER fail fast with
  the configured-name list
- OpenAILLMService: http client trust_env from LLM_HTTP_TRUST_ENV (default
  False - local gateways must bypass the Windows system proxy)
- build_llm_service(provider): explicit name or LLM_PROVIDER default
- .env/.env.example: deepseek + kimi groups side by side, switch by
  LLM_PROVIDER=kimi (thinking off via EXTRA_BODY reasoning_effort=none)
- eval probe scripts updated to the new config interface

Verified: 172 unit tests; live calls succeed on both providers
(deepseek 28s, kimi 6.6s, both emitting valid <state> prefixes)
2026-10-22 13:59:18 +08:00

276 lines
11 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 服务(作业 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"<state>1002</state>已收到:{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 消息为 <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)
if event in (ClientEvent.SESSION_START, ClientEvent.CONTINUE) and current == "1001":
return f"<state>1002</state>已收到:{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"<state>{target}</state>已收到:{raw_input}"
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>请问您还在吗?"
return f"<state>{current}</state>已收到:{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,
)