落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
"""
|
||||
LLM 网关与队列:DeepSeek / 千问 mode schema、认领、重试。
|
||||
LLM 网关与队列:DeepSeek / 千问 mode、入队、认领(Worker 侧后续接)。
|
||||
|
||||
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁。
|
||||
骨架:仅包占位。
|
||||
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁;禁止回调线程等完整 LLM。
|
||||
"""
|
||||
|
||||
from agent.llm.gateway import LlmEnqueueResult, LlmGateway
|
||||
from agent.llm.http_client import LlmHttpClient, LlmHttpResult
|
||||
from agent.llm.mode_registry import invoke_mode_shell, list_mode_shells
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
__all__ = [
|
||||
"LlmMode",
|
||||
"LlmGateway",
|
||||
"LlmEnqueueResult",
|
||||
"LlmHttpClient",
|
||||
"LlmHttpResult",
|
||||
"invoke_mode_shell",
|
||||
"list_mode_shells",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
LLM 网关骨架:入队 Redis Stream,不在回调线程等完整结果。
|
||||
|
||||
本文件职责:校验 mode、短幂等、XADD;真实模型 HTTP 后续按 mode 实现。
|
||||
禁止:wait_result 堵在请求线程;禁止本包维护工单六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.llm.modes import LlmMode
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmEnqueueResult:
|
||||
"""入队结果。"""
|
||||
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmGateway:
|
||||
"""
|
||||
LLM 网关。
|
||||
|
||||
调用:HTTP/Handler 只 enqueue;Worker 槽 claim 后调 invoke_stub/未来真调用。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
redis: RedisRuntime
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LlmGateway":
|
||||
cfg = settings or get_settings()
|
||||
return cls(settings=cfg, redis=get_redis_runtime(cfg))
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> LlmEnqueueResult:
|
||||
"""
|
||||
将任务写入 LLM Stream。
|
||||
|
||||
重复 idempotency_key(TTL 内)返回 accepted=False。
|
||||
副作用:可能写 Redis;不调外部模型。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
try:
|
||||
LlmMode(mode_val)
|
||||
except ValueError:
|
||||
return LlmEnqueueResult(accepted=False, reason=f"unknown_mode:{mode_val}")
|
||||
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
self.redis.client,
|
||||
scope=f"llm:{mode_val}",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=300,
|
||||
)
|
||||
if not ok:
|
||||
return LlmEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
|
||||
body = {"mode": mode_val, "payload": payload}
|
||||
entry_id = self.redis.llm_stream.enqueue(
|
||||
payload=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
logger.info("llm.enqueue mode=%s id=%s", mode_val, entry_id)
|
||||
return LlmEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
def invoke_stub(self, *, mode: LlmMode | str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
模型调用占位:返回固定结构,不访问外网。
|
||||
|
||||
正式实现按 mode 调 DeepSeek/千问;本方法仅供骨架与单测。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
if mode_val != LlmMode.ORDERED_ACTIONS.value:
|
||||
from agent.llm.mode_registry import invoke_mode_shell
|
||||
|
||||
return invoke_mode_shell(mode_val, payload=payload)
|
||||
return {
|
||||
"ok": True,
|
||||
"stub": True,
|
||||
"mode": mode_val,
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
"note": "骨架不调用真实模型",
|
||||
}
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
messages: list[dict[str, Any]],
|
||||
allow_network: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Worker 槽内调用模型。
|
||||
|
||||
allow_network=False:走 stub(默认,防误打费)。
|
||||
allow_network=True:走 LlmHttpClient(需已配 API Key)。
|
||||
"""
|
||||
if not allow_network:
|
||||
return self.invoke_stub(mode=mode, payload={"messages": len(messages)})
|
||||
from agent.llm.http_client import LlmHttpClient
|
||||
|
||||
result = LlmHttpClient.from_settings(self.settings).chat(mode=mode, messages=messages)
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"stub": False,
|
||||
"provider": result.provider,
|
||||
"content": result.content,
|
||||
"error": result.error,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
LLM 出站 HTTP 客户端(DeepSeek / 千问兼容 OpenAI Chat)。
|
||||
|
||||
本文件职责:带超时的 chat.completions 调用;缺 key / 未确认数据用途时明确失败。
|
||||
禁止:在企微回调线程同步长等;正式长调用走 Worker 槽 + 队列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmHttpResult:
|
||||
ok: bool
|
||||
content: str = ""
|
||||
raw: Optional[dict[str, Any]] = None
|
||||
error: str = ""
|
||||
provider: str = ""
|
||||
tool_calls: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmHttpClient:
|
||||
"""
|
||||
兼容 OpenAI 的 Chat Completions 客户端。
|
||||
|
||||
DeepSeek / 千问 DashScope compatible-mode 共用此壳。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
timeout_seconds: float = 60.0
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LlmHttpClient":
|
||||
return cls(settings=settings or get_settings())
|
||||
|
||||
def _provider_for_mode(self, mode: LlmMode | str) -> tuple[str, str, str]:
|
||||
"""
|
||||
返回 (provider, base_url, api_key)。
|
||||
|
||||
A/B 文本 → DeepSeek;附件/视觉 → 千问。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
visionish = mode_val in {
|
||||
LlmMode.ATTACHMENT_TEXT.value,
|
||||
LlmMode.VISION.value,
|
||||
LlmMode.SHEET_MAP.value,
|
||||
}
|
||||
if visionish:
|
||||
return (
|
||||
"qwen",
|
||||
(self.settings.qwen_base_url or "").rstrip("/"),
|
||||
self.settings.qwen_api_key or "",
|
||||
)
|
||||
return (
|
||||
"deepseek",
|
||||
(self.settings.deepseek_base_url or "").rstrip("/"),
|
||||
self.settings.deepseek_api_key or "",
|
||||
)
|
||||
|
||||
def _model_for(self, provider: str, mode: LlmMode | str) -> str:
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
if provider == "qwen":
|
||||
if mode_val == LlmMode.VISION.value:
|
||||
return self.settings.qwen_vl_model or "qwen-vl-plus"
|
||||
return self.settings.qwen_text_model or "qwen-plus"
|
||||
return self.settings.deepseek_model or "deepseek-chat"
|
||||
|
||||
def chat(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float = 0.0,
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Any = None,
|
||||
) -> LlmHttpResult:
|
||||
"""
|
||||
发起一次 chat.completions(可带 tools)。
|
||||
|
||||
副作用:出站 HTTPS;超时由 timeout_seconds 限制。
|
||||
未确认 LLM_DATA_USAGE_CONFIRMED 时拒绝调用(prompt/04)。
|
||||
"""
|
||||
if not self.settings.llm_data_usage_confirmed:
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error="llm_data_usage_not_confirmed",
|
||||
provider="none",
|
||||
)
|
||||
provider, base, key = self._provider_for_mode(mode)
|
||||
if not key or not base:
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error=f"{provider}_not_configured",
|
||||
provider=provider,
|
||||
)
|
||||
model = self._model_for(provider, mode)
|
||||
url = f"{base}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if tool_choice is not None:
|
||||
body["tool_choice"] = tool_choice
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout_seconds) as client:
|
||||
resp = client.post(url, headers=headers, json=body)
|
||||
data = resp.json() if resp.content else {}
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"llm.http 失败 provider=%s status=%s",
|
||||
provider,
|
||||
resp.status_code,
|
||||
)
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error=f"http_{resp.status_code}",
|
||||
raw=data if isinstance(data, dict) else None,
|
||||
provider=provider,
|
||||
)
|
||||
choices = (data or {}).get("choices") or []
|
||||
content = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
if choices:
|
||||
msg = (choices[0].get("message") or {})
|
||||
content = str(msg.get("content") or "")
|
||||
raw_calls = msg.get("tool_calls") or []
|
||||
if isinstance(raw_calls, list):
|
||||
tool_calls = [c for c in raw_calls if isinstance(c, dict)]
|
||||
return LlmHttpResult(
|
||||
ok=True,
|
||||
content=content,
|
||||
raw=data if isinstance(data, dict) else None,
|
||||
provider=provider,
|
||||
tool_calls=tool_calls or None,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("llm.http 异常 provider=%s err=%s", provider, exc)
|
||||
return LlmHttpResult(ok=False, error=str(exc), provider=provider)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
千问:附件文本 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.ATTACHMENT_TEXT
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "attachment_text",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "attachment_text_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
DeepSeek B:文本字段抽取 mode 壳。
|
||||
|
||||
本文件职责:声明 mode 元数据与空 invoke_shell;不写字段合同激活规则。
|
||||
禁止:在本文件改主账六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.EXTRACT_FIELDS
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
"""返回本 mode 的框架元数据(无 prompt 正文业务)。"""
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "deepseek",
|
||||
"purpose": "text_field_extract",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""空壳:不调模型,返回未实现。"""
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "extract_fields_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
群报价抽取 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.GROUP_QUOTE
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "deepseek",
|
||||
"purpose": "group_quote",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "group_quote_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
LLM mode 空壳注册表:按 mode 名取 invoke_shell。
|
||||
|
||||
本文件只做分发到各 mode_* 模块;ordered_actions 走 routing/DeepSeekA。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from agent.llm import mode_attachment_text, mode_extract_fields, mode_group_quote, mode_sheet_map, mode_vision
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
_SHELLS: dict[str, Callable[..., dict[str, Any]]] = {
|
||||
LlmMode.EXTRACT_FIELDS.value: mode_extract_fields.invoke_shell,
|
||||
LlmMode.ATTACHMENT_TEXT.value: mode_attachment_text.invoke_shell,
|
||||
LlmMode.VISION.value: mode_vision.invoke_shell,
|
||||
LlmMode.GROUP_QUOTE.value: mode_group_quote.invoke_shell,
|
||||
LlmMode.SHEET_MAP.value: mode_sheet_map.invoke_shell,
|
||||
}
|
||||
|
||||
|
||||
def invoke_mode_shell(mode: str | LlmMode, *, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""调用对应 mode 空壳;未知 mode 返回错误结构。"""
|
||||
key = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
fn = _SHELLS.get(key)
|
||||
if fn is None:
|
||||
return {"ok": False, "stub": True, "mode": key, "error": "unknown_or_unregistered_mode"}
|
||||
return fn(payload=payload)
|
||||
|
||||
|
||||
def list_mode_shells() -> list[str]:
|
||||
"""已注册空壳 mode 名。"""
|
||||
return sorted(_SHELLS.keys())
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
报价工作表映射 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.SHEET_MAP
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "sheet_map",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "sheet_map_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
千问视觉:读图 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.VISION
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "vision",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "vision_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
LLM 模式名(对齐 prompt/06)。
|
||||
|
||||
本文件只声明枚举;具体 prompt/schema 后续按 mode 分文件,禁止堆进上帝模块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LlmMode(str, Enum):
|
||||
"""允许入队的 LLM 模式。"""
|
||||
|
||||
ORDERED_ACTIONS = "ordered_actions" # DeepSeek A
|
||||
EXTRACT_FIELDS = "extract_fields" # DeepSeek B
|
||||
ATTACHMENT_TEXT = "attachment_text" # 千问读附件
|
||||
VISION = "vision" # 千问读图
|
||||
GROUP_QUOTE = "group_quote" # 群报价抽取
|
||||
SHEET_MAP = "sheet_map" # 报价工作表映射
|
||||
Reference in New Issue
Block a user