fischer-agentkit/src/agentkit/llm/gateway.py

268 lines
9.9 KiB
Python

"""LLM Gateway - 统一 LLM 调用入口"""
import logging
import time
from agentkit.core.exceptions import LLMProviderError, ModelNotFoundError
from agentkit.llm.config import LLMConfig
from agentkit.llm.protocol import LLMProvider, LLMRequest, LLMResponse, StreamChunk, TokenUsage
from agentkit.llm.providers.tracker import UsageSummary, UsageTracker
from agentkit.telemetry.tracing import get_tracer, _OTEL_AVAILABLE
from agentkit.telemetry.metrics import llm_token_histogram
logger = logging.getLogger(__name__)
class LLMGateway:
"""LLM 网关 - Provider 注册、模型别名解析、Fallback、Usage 追踪"""
def __init__(self, config: LLMConfig | None = None):
self._providers: dict[str, LLMProvider] = {}
self._usage_tracker = UsageTracker()
self._config = config or LLMConfig()
def register_provider(self, name: str, provider: LLMProvider) -> None:
"""注册 Provider"""
self._providers[name] = provider
logger.info(f"LLM provider '{name}' registered")
@property
def has_providers(self) -> bool:
"""Return True if at least one LLM provider is registered."""
return bool(self._providers)
async def chat(
self,
messages: list[dict[str, str]],
model: str,
agent_name: str = "",
task_type: str = "",
tools: list[dict] | None = None,
tool_choice: str = "auto",
**kwargs,
) -> LLMResponse:
"""发送 chat 请求,自动解析别名和 Fallback"""
resolved_model = self._resolve_model_alias(model)
if not self._providers:
raise LLMProviderError("", "No provider registered")
# Telemetry: start LLM span
_span_cm = None
_span = None
if _OTEL_AVAILABLE:
tracer = get_tracer()
if tracer is not None:
from opentelemetry.trace import SpanKind
_span_cm = tracer.start_as_current_span(
"gen_ai.chat",
kind=SpanKind.CLIENT,
attributes={
"gen_ai.system": resolved_model.split("/")[0] if "/" in resolved_model else "unknown",
"gen_ai.operation.name": "chat",
"gen_ai.request.model": resolved_model,
},
)
_span = _span_cm.__enter__()
start = time.monotonic()
models_to_try = self._get_models_to_try(resolved_model)
last_error: LLMProviderError | None = None
try:
for model_name in models_to_try:
try:
provider, actual_model = self._resolve_model(model_name)
except ModelNotFoundError:
continue
req = LLMRequest(
messages=messages,
model=actual_model,
tools=tools,
tool_choice=tool_choice,
**kwargs,
)
try:
response = await provider.chat(req)
break
except LLMProviderError as e:
last_error = e
logger.warning(f"Model '{model_name}' failed, trying next: {e}")
continue
else:
raise last_error or LLMProviderError("", f"All models failed for '{resolved_model}'")
latency_ms = (time.monotonic() - start) * 1000
# 计算成本
cost = self._calculate_cost(response.model, response.usage)
# 记录使用量
self._usage_tracker.record(
agent_name=agent_name,
model=response.model,
usage=response.usage,
cost=cost,
latency_ms=latency_ms,
)
# Telemetry: record token usage and end span
if _span is not None:
_span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens)
_span.set_attribute("gen_ai.usage.output_tokens", response.usage.completion_tokens)
_span.set_attribute("gen_ai.response.model", response.model)
_span.set_attribute("gen_ai.duration_ms", int(latency_ms))
llm_token_histogram().record(
response.usage.total_tokens,
{"gen_ai.request.model": resolved_model},
)
return response
finally:
if _span_cm is not None:
_span_cm.__exit__(None, None, None)
async def chat_stream(
self,
messages: list[dict[str, str]],
model: str,
agent_name: str = "",
task_type: str = "",
tools: list[dict] | None = None,
tool_choice: str = "auto",
**kwargs,
):
"""Stream chat response with fallback support.
If the primary model fails before any chunk is yielded, tries fallback
models. If it fails after chunks have been sent, yields an error chunk
and terminates (cannot switch mid-stream).
"""
resolved_model = self._resolve_model_alias(model)
if not self._providers:
raise LLMProviderError("", "No provider registered")
models_to_try = self._get_models_to_try(resolved_model)
last_error: Exception | None = None
for model_name in models_to_try:
try:
provider, actual_model = self._resolve_model(model_name)
except ModelNotFoundError:
continue
stream_request = LLMRequest(
messages=messages,
model=actual_model,
tools=tools,
tool_choice=tool_choice,
**kwargs,
)
chunk_yielded = False
start = time.monotonic()
total_content = ""
final_usage = None
final_model = model_name
try:
async for chunk in provider.chat_stream(stream_request):
chunk_yielded = True
if chunk.content:
total_content += chunk.content
if chunk.usage:
final_usage = chunk.usage
if chunk.model:
final_model = chunk.model
yield chunk
# Track usage after successful stream
latency_ms = (time.monotonic() - start) * 1000
if final_usage is None:
final_usage = TokenUsage()
cost = self._calculate_cost(final_model, final_usage)
self._usage_tracker.record(
agent_name=agent_name,
model=final_model,
usage=final_usage,
cost=cost,
latency_ms=latency_ms,
)
return # Success, done
except Exception as e:
last_error = e
if chunk_yielded:
# Can't switch mid-stream, terminate gracefully
logger.error(f"Stream failed after chunks sent for '{model_name}': {e}")
yield StreamChunk(
content="",
model=final_model,
usage=None,
is_final=True,
)
return
# No chunks yet, try next fallback
logger.warning(f"Stream failed for '{model_name}', trying fallback: {e}")
continue
# All models failed
raise last_error or LLMProviderError("", f"No provider available for streaming '{resolved_model}'")
def _get_models_to_try(self, resolved_model: str) -> list[str]:
"""Return [primary_model] + fallback_models for the given resolved model."""
fallback_models = self._config.fallbacks.get(resolved_model, [])
return [resolved_model] + fallback_models
def _resolve_model_alias(self, model: str) -> str:
"""解析模型别名"""
if model in self._config.model_aliases:
return self._config.model_aliases[model]
return model
def _resolve_model(self, model: str) -> tuple[LLMProvider, str]:
"""解析模型为 (provider, actual_model_name)"""
# model 格式: "provider/model_name" 或 "model_name"
if "/" in model:
provider_name, model_name = model.split("/", 1)
if provider_name not in self._providers:
raise ModelNotFoundError(model)
return self._providers[provider_name], model_name
# 无 "/" 前缀:仅当只有一个 provider 时自动匹配
if len(self._providers) == 1:
provider = next(iter(self._providers.values()))
return provider, model
raise ModelNotFoundError(model)
def _get_fallback_model(self, model: str) -> str | None:
"""获取 Fallback 模型"""
fallbacks = self._config.fallbacks.get(model, [])
return fallbacks[0] if fallbacks else None
def _calculate_cost(self, model: str, usage: TokenUsage) -> float:
"""计算成本"""
# 在 provider config 的 models 中查找成本配置
for provider_config in self._config.providers.values():
if model in provider_config.models:
model_conf = provider_config.models[model]
input_cost = usage.prompt_tokens * model_conf.get("cost_per_1k_input", 0) / 1000
output_cost = usage.completion_tokens * model_conf.get("cost_per_1k_output", 0) / 1000
return input_cost + output_cost
return 0.0
def get_usage(
self,
agent_name: str | None = None,
start_time=None,
end_time=None,
) -> UsageSummary:
"""查询使用量"""
return self._usage_tracker.get_usage(
agent_name=agent_name,
start_time=start_time,
end_time=end_time,
)