Files
Hy-Memory/hy_memory/agent/llm_provider.py
T
alvinfeiandClaude Opus 5.5 dc762df78b feat(sdk): switch to turn the pipeline log off, keep conversation text out of INFO logs (2.0.8)
MEMORY_PIPELINE_LOG_ENABLED (default true) turns off the pipeline JSONL
log. With the DB trace also off, store_pipeline_log becomes a no-op that
does no timing, redaction, background work or database write. The init line
states the real on/off state.

Lines that carried conversation or memory text at INFO/WARNING now log ids,
counts and lengths; the text goes to DEBUG: [add] input, search queries and
rewrites, coding tasks and fact statements/subjects, reconcile op detail,
extracted info, embedding inputs and unparsable model output.

Bump hy-memory to 2.0.8 (not yet published).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:03:21 +08:00

716 lines
26 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.
"""
Agent Memory - LLM Provider
统一的 LLM 调用接口,支持多种模型后端。
功能:
- 统一的调用接口
- 多模型支持(OpenAI、Hunyuan 等)
- 请求重试和错误处理
- Token 统计
- 模型路由
示例:
provider = LLMProvider(config)
# 调用 LLM。不传 temperature —— 由 config.llm 统一决定(含"完全不传"的情形,
# 见 _resolve_temperature)。调用点写死会绕过用户配置。
response = await provider.complete(
prompt="请总结以下内容:...",
max_tokens=500,
)
print(response.content)
print(response.tokens_used)
"""
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
from enum import Enum
import asyncio
import hashlib
import logging
import os
import weakref
from ..config import MemoryConfig
from ..utils.model_call_context import model_call_stage
from ..core.workload import llm_slot
logger = logging.getLogger(__name__)
def _log_llm_content_enabled() -> bool:
"""Return whether sensitive LLM prompt/response bodies may be logged."""
return os.getenv("MEMORY_LOG_LLM_CONTENT", "false").strip().lower() in {
"1", "true", "yes", "on",
}
def _content_fingerprint(value: str) -> str:
return hashlib.sha256((value or "").encode("utf-8")).hexdigest()[:16]
class LLMRetryError(RuntimeError):
"""LLM 重试全部失败。attempts 携带每一次 attempt 的错误明细,供上层落进
EXTRACT/RECONCILE 的 pipeline trace(parsed.attempts)。"""
def __init__(self, message: str, attempts: List[Dict[str, Any]]):
super().__init__(message)
self.attempts = attempts
def _attempt_record(attempt_no: int, exc: BaseException) -> Dict[str, Any]:
"""把单次 attempt 的异常规范成可落库的 dict(含 HTTP 状态码等常见字段)。"""
import time as _t
rec: Dict[str, Any] = {
"attempt": attempt_no,
"error_type": type(exc).__name__,
"error": str(exc)[:2000],
"ts": _t.strftime("%Y-%m-%dT%H:%M:%S"),
}
# 尽力提取 HTTP 状态码 / 响应体(OpenAI / requests / httpx 风格)
for _attr in ("status_code", "code"):
_v = getattr(exc, _attr, None)
if _v is not None:
rec["status_code"] = _v
break
_resp = getattr(exc, "response", None)
if _resp is not None:
try:
rec.setdefault("status_code", getattr(_resp, "status_code", None))
_body = getattr(_resp, "text", None)
if _body:
body = str(_body)
rec["response_body"] = (
body[:1000] if _log_llm_content_enabled()
else f"<redacted len={len(body)} sha256={_content_fingerprint(body)}>"
)
except Exception:
pass
return rec
def _is_retryable_llm_error(exc: BaseException) -> bool:
"""Retry transient transport errors and 408/429/5xx; fail fast on other 4xx."""
status = getattr(exc, "status_code", None)
if isinstance(status, int):
return status == 408 or status == 429 or status >= 500
return isinstance(exc, (asyncio.TimeoutError, TimeoutError, ConnectionError)) or any(
token in str(exc).lower()
for token in ("timeout", "timed out", "connection", "read operation")
)
def _build_retry_error(
max_retries: int, model: str, backend: str,
attempts: List[Dict[str, Any]], last_exc: Optional[BaseException],
) -> "LLMRetryError":
actual_attempts = len(attempts)
msg = (
f"LLM call failed after {actual_attempts} attempt(s) "
f"(configured max={max_retries}) "
f"(model={model} backend={backend}); "
f"last error: {type(last_exc).__name__}: {last_exc}"
)
err = LLMRetryError(msg, attempts)
err.__cause__ = last_exc
return err
def _parse_tool_calls_list(raw_calls: list, _json) -> List[Dict[str, Any]]:
"""
解析 tool_calls 列表。
保留原始完整结构,只规范化 arguments(dict → JSON str)。
"""
result = []
for tc in raw_calls:
tc_id = tc.get("id") or ""
if not tc_id:
logger.error(
f"[llm] tool_call missing 'id'! keys={list(tc.keys())}"
)
# 保留原始 tc 完整结构,只规范化 arguments
tc_copy = dict(tc) # 浅拷贝保留所有字段(signature 等)
fn = tc_copy.get("function", {})
if isinstance(fn, dict):
args = fn.get("arguments", "")
if isinstance(args, dict):
fn["arguments"] = _json.dumps(args, ensure_ascii=False)
tc_copy["function"] = fn
# 确保基础字段存在
tc_copy.setdefault("id", tc_id)
tc_copy.setdefault("type", "function")
result.append(tc_copy)
return result
def _serialize_tool_calls(tool_calls_raw) -> Optional[List[Dict[str, Any]]]:
"""OpenAI SDK 的 tool_call 对象 → 纯 dict 列表(便于传递 / 落库)。
单条序列化失败只跳过该条并告警,不让整次 LLM 调用失败。
"""
if not tool_calls_raw:
return None
result: List[Dict[str, Any]] = []
for tc in tool_calls_raw:
try:
fn = getattr(tc, "function", None)
result.append({
"id": getattr(tc, "id", "") or "",
"type": getattr(tc, "type", "function") or "function",
"function": {
"name": getattr(fn, "name", "") if fn else "",
"arguments": getattr(fn, "arguments", "") if fn else "",
},
})
except Exception as e:
logger.warning(f"[llm] failed to serialize tool_call: {e}")
return result or None
class LLMBackend(Enum):
"""LLM protocol backend."""
OPENAI = "openai"
@dataclass
class LLMResponse:
"""
LLM 响应
"""
content: str
tokens_used: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
model: str = ""
finish_reason: str = ""
response_id: str = ""
choices_count: int = 0
# 推理模型(hy3 / kimi-k2.5 / deepseek-v4 等)的思维链内容与 token 数。
# content 为空但 reasoning_tokens 打满 max_tokens 时,说明推理吃光了输出配额,
# 需要关闭 thinking(hy3: reasoning_effort=no_think)或调大 agent_max_tokens。
reasoning_content: str = ""
reasoning_tokens: int = 0
# function/tool calling:当 LLM 决定调用工具时填充,结构为 OpenAI 风格:
# [{"id": "...", "type": "function", "function": {"name": "...", "arguments": "<json str>"}}]
# 调用方应使用 tools.base.parse_tool_calls_from_json 解析
tool_calls: Optional[List[Dict[str, Any]]] = None
@dataclass
class LLMConfig:
"""
LLM 配置
"""
backend: LLMBackend = LLMBackend.OPENAI
model: str = "gpt-3.5-turbo"
api_key: str = ""
base_url: str = ""
# None = **请求里完全不带 temperature**。
# 有些网关(如 Opus 5)只接受 temperature=1 或干脆不传,传 0/0.1/0.3 会 400。
temperature: Optional[float] = 0.7
timeout: int = 30
max_retries: int = 5
retry_delay: float = 1.0 # 初始 delay,每次递增
extra_headers: Optional[Dict[str, str]] = None
extra_body: Optional[Dict[str, Any]] = None
class LLMProvider:
"""
LLM 提供器
统一的 LLM 调用接口。
_extract_response: 安全提取 choices[0].message,当 API 返回空 choices 时给出
明确错误信息而非 'NoneType' object is not subscriptable。
"""
@staticmethod
def _extract_response(response) -> tuple:
"""
从 OpenAI 兼容 response 中提取 message、usage、finish_reason。
当 choices 为空时抛出明确的 ValueError。
Returns:
(msg, usage, finish_reason, choices_count)
"""
choices = getattr(response, "choices", None)
if not choices:
# 尝试拿到更多诊断信息
raw = ""
try:
raw = str(response)[:500]
except Exception:
pass
raise ValueError(
f"LLM returned empty choices (no completion). "
f"Response: {raw}"
)
msg = choices[0].message
usage = response.usage
finish_reason = choices[0].finish_reason
return msg, usage, finish_reason, len(choices)
@staticmethod
def _extract_reasoning(msg, usage) -> tuple:
"""
提取推理模型的思维链内容与 token 数。
不同平台字段位置不一:
- hy3 / kimi-k2.5:message.reasoning_content,兼容
message.provider_specific_fields.reasoning_content
- reasoning_tokens:usage.completion_tokens_details.reasoning_tokens,
部分网关平铺在 usage.reasoning_tokens
Returns:
(reasoning_content, reasoning_tokens)
"""
rc = getattr(msg, "reasoning_content", None) or ""
if not rc:
psf = getattr(msg, "provider_specific_fields", None)
if isinstance(psf, dict):
rc = psf.get("reasoning_content") or ""
rt = 0
if usage is not None:
details = getattr(usage, "completion_tokens_details", None)
if details is not None:
rt = getattr(details, "reasoning_tokens", 0) or 0
if not rt:
rt = getattr(usage, "reasoning_tokens", 0) or 0
return rc, int(rt or 0)
def __init__(self, config: Optional[MemoryConfig] = None):
"""
初始化 LLM Provider
Args:
config: 配置对象
"""
self.config = config or MemoryConfig.from_env()
self._llm_config = self._build_llm_config()
self._client = None
self._clients = weakref.WeakKeyDictionary()
# 统计
self._total_calls = 0
self._total_tokens = 0
self._errors = 0
logger.debug(f"LLMProvider initialized, backend={self._llm_config.backend.value}")
def _build_llm_config(self) -> LLMConfig:
"""从配置构建 LLM 配置"""
llm = self.config.llm
return LLMConfig(
backend=LLMBackend.OPENAI,
model=llm.model,
api_key=llm.api_key,
base_url=llm.base_url,
# 不再把 None 兜成 0.7:None 是"不传"的合法表达,兜掉会让
# MEMORY_LLM_TEMPERATURE=none 这类配置失效
temperature=(
None if getattr(llm, "omit_temperature", False)
else llm.temperature
),
timeout=llm.timeout or 120,
max_retries=llm.max_retries if llm.max_retries is not None else 5,
retry_delay=llm.retry_delay if llm.retry_delay is not None else 1.0,
extra_headers=llm.extra_headers,
extra_body=llm.extra_body,
)
def _resolve_temperature(self, temperature: Optional[float]) -> Optional[float]:
"""把调用点的 temperature 收敛到**配置**上。
规则:调用点传了就用它;没传(None)就用 `config.llm.temperature`。
配置本身为 None 时返回 None,`_chat_completions` 会把该参数整个省略。
为什么必须收敛到一处:此前各调用点自己写死 0.1/0.2/0.3/0.4,`complete()` 的
默认值又是 0.7 —— 结果用户配的 `MEMORY_LLM_TEMPERATURE` 只对少数几条路径生效。
换成只接受特定 temperature 的网关(Opus 5 只收 1 或不传)时,这些写死的路径全 400。
"""
if temperature is not None:
return temperature
return getattr(self._llm_config, "temperature", None)
async def complete(
self,
prompt: str,
max_tokens: int = 500,
temperature: Optional[float] = None,
stop: List[str] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
audit_stage: Optional[str] = None,
**kwargs
) -> LLMResponse:
"""
调用 LLM 完成文本生成
Args:
prompt: 提示词
max_tokens: 最大 token 数
temperature: 温度参数
stop: 停止词列表
tools: 可选,OpenAI function-calling 风格的 tools schema 列表
([{"type":"function","function":{"name":..., "parameters":...}}, ...])。
传入时 LLM 可以在响应中返回 tool_calls,由调用方决定如何 dispatch。
tool_choice: 可选,OpenAI 风格 tool_choice("auto" / "none" / {"type":"function","function":{"name":...}})
**kwargs: 其他参数
Returns:
LLM 响应(成功时 response.tool_calls 可能非空)
"""
_last_exc: Optional[BaseException] = None
_attempts: List[Dict[str, Any]] = []
for attempt in range(self._llm_config.max_retries):
try:
if attempt == 0:
logger.debug(
f"[llm] model={self._llm_config.model} "
f"backend={self._llm_config.backend.value} "
f"base_url={self._llm_config.base_url or 'default'} "
f"prompt_len={len(prompt)} tools={len(tools) if tools else 0}"
)
if _log_llm_content_enabled():
logger.debug(f"[llm] prompt:\n{prompt}")
else:
logger.debug(
"[llm] prompt content hidden; len=%d sha256=%s",
len(prompt), _content_fingerprint(prompt),
)
with model_call_stage(audit_stage):
response = await self._call_llm(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
stop=stop,
tools=tools,
tool_choice=tool_choice,
**kwargs
)
self._total_calls += 1
self._total_tokens += response.tokens_used
logger.debug(
f"[llm] response: {len(response.content)} chars, "
f"tokens={response.tokens_used}, "
f"tool_calls={len(response.tool_calls) if response.tool_calls else 0}"
)
if _log_llm_content_enabled():
logger.debug(f"[llm] response content: {response.content!r}")
else:
logger.debug(
"[llm] response content hidden; len=%d sha256=%s",
len(response.content), _content_fingerprint(response.content),
)
return response
except Exception as e:
self._errors += 1
_last_exc = e
_attempts.append(_attempt_record(attempt + 1, e))
logger.warning(
f"LLM call failed (attempt {attempt + 1}/{self._llm_config.max_retries}): "
f"{type(e).__name__}: {e}"
)
if not _is_retryable_llm_error(e):
break
if attempt < self._llm_config.max_retries - 1:
delay = self._llm_config.retry_delay * (attempt + 1)
await asyncio.sleep(delay)
# 所有重试都失败 —— 异常携带每一次 attempt 的错误(供上层落进 EXTRACT trace)
raise _build_retry_error(
self._llm_config.max_retries, self._llm_config.model,
self._llm_config.backend.value, _attempts, _last_exc,
)
def _resolve_endpoint(self) -> tuple:
"""Return the configured OpenAI-compatible endpoint unchanged."""
return self._llm_config.api_key, (self._llm_config.base_url or None)
async def _call_llm(
self,
prompt: str,
max_tokens: int,
temperature: Optional[float] = None,
stop: List[str] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
**kwargs
) -> LLMResponse:
"""单轮 prompt → 统一走 Chat Completions。
The configured endpoint is expected to implement Chat Completions.
"""
messages: List[Dict[str, Any]] = [
{"role": "system", "content": ""},
{"role": "user", "content": prompt},
]
return await self._chat_completions(
messages,
max_tokens=max_tokens,
temperature=temperature,
stop=stop,
tools=tools,
tool_choice=tool_choice,
)
async def _chat_completions(
self,
messages: List[Dict[str, Any]],
*,
max_tokens: int,
temperature: Optional[float] = None,
stop: Optional[List[str]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
) -> LLMResponse:
"""OpenAI Chat Completions 的唯一实现。
All supported endpoints use the OpenAI-compatible Chat Completions API.
"""
try:
from openai import AsyncOpenAI
except ImportError:
raise ImportError("openai is required. Install with: pip install openai")
api_key, base_url = self._resolve_endpoint()
# complete()/complete_messages() already own the retry loop. Disable the
# OpenAI SDK's hidden retries so one configured attempt maps to one HTTP
# request and the configured timeout is actually observable.
loop = asyncio.get_running_loop()
base_api_key = (api_key or "").split("?", 1)[0]
client_key = (base_url or "", base_api_key, self._llm_config.timeout)
clients = self._clients.setdefault(loop, {})
client = clients.get(client_key)
if client is None:
client_options: Dict[str, Any] = {
"api_key": base_api_key,
"base_url": base_url,
"timeout": self._llm_config.timeout,
"max_retries": 0,
}
transport_factory = getattr(self.config, "http_transport_factory", None)
if callable(transport_factory):
import httpx
client_options["http_client"] = httpx.AsyncClient(
transport=transport_factory()
)
client = AsyncOpenAI(
**client_options,
)
clients[client_key] = client
# 确保有 system 消息(部分内部平台要求)
if not any(m.get("role") == "system" for m in messages):
messages = [{"role": "system", "content": ""}] + list(messages)
kwargs: Dict[str, Any] = {
"model": self._llm_config.model,
"messages": messages,
"max_tokens": max_tokens,
}
# 解析到 None → **整个参数不发**(Opus 5 等网关只接受 1 或不传)
_temp = self._resolve_temperature(temperature)
if _temp is not None:
kwargs["temperature"] = _temp
if stop:
kwargs["stop"] = stop
if tools:
kwargs["tools"] = tools
if tool_choice is not None:
kwargs["tool_choice"] = tool_choice
request_headers = dict(self._llm_config.extra_headers or {})
if api_key:
request_headers["Authorization"] = f"Bearer {api_key}"
if request_headers:
kwargs["extra_headers"] = request_headers
if self._llm_config.extra_body:
kwargs["extra_body"] = self._llm_config.extra_body
try:
async with llm_slot():
response = await client.chat.completions.create(**kwargs)
except Exception:
# A transport failure may poison a pooled connection. Evict only
# this endpoint so the outer retry creates a clean client.
clients.pop(client_key, None)
try:
closed = client.close()
if asyncio.iscoroutine(closed):
await closed
except Exception:
pass
raise
msg, usage, _finish, _choices_count = self._extract_response(response)
_reasoning_content, _reasoning_tokens = self._extract_reasoning(msg, usage)
return LLMResponse(
content=msg.content or "",
tokens_used=usage.total_tokens if usage else 0,
prompt_tokens=usage.prompt_tokens if usage else 0,
completion_tokens=usage.completion_tokens if usage else 0,
model=getattr(response, "model", None) or self._llm_config.model,
finish_reason=_finish,
response_id=getattr(response, "id", "") or "",
choices_count=_choices_count,
reasoning_content=_reasoning_content,
reasoning_tokens=_reasoning_tokens,
tool_calls=_serialize_tool_calls(getattr(msg, "tool_calls", None)),
)
async def close(self) -> None:
clients = [
client
for per_loop in list(self._clients.values())
for client in list(per_loop.values())
]
self._clients.clear()
for client in clients:
try:
closed = client.close()
if asyncio.iscoroutine(closed):
await closed
except Exception:
pass
async def complete_messages(
self,
messages: List[Dict[str, Any]],
max_tokens: int = 500,
temperature: Optional[float] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
audit_stage: Optional[str] = None,
) -> LLMResponse:
"""
多轮 messages 调用(支持 tool results 回传)。
Args:
messages: 完整的 messages 列表,可包含:
- {"role": "system", "content": "..."}
- {"role": "user", "content": "..."}
- {"role": "assistant", "content": "...", "tool_calls": [...]}
- {"role": "tool", "tool_call_id": "...", "content": "..."}
max_tokens, temperature, tools, tool_choice: 同 complete()
Returns:
LLMResponse
"""
_last_exc: Optional[BaseException] = None
_attempts: List[Dict[str, Any]] = []
for attempt in range(self._llm_config.max_retries):
try:
if attempt == 0:
logger.debug(
f"[llm] model={self._llm_config.model} "
f"backend={self._llm_config.backend.value} "
f"base_url={self._llm_config.base_url or 'default'} "
f"msgs={len(messages)} tools={len(tools) if tools else 0}"
)
with model_call_stage(audit_stage):
response = await self._chat_completions(
messages,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
tool_choice=tool_choice,
)
self._total_calls += 1
self._total_tokens += response.tokens_used
logger.debug(
f"[llm] response: {len(response.content)} chars, "
f"tokens={response.tokens_used}, "
f"tool_calls={len(response.tool_calls) if response.tool_calls else 0}"
)
return response
except Exception as e:
self._errors += 1
_last_exc = e
_attempts.append(_attempt_record(attempt + 1, e))
logger.warning(
f"LLM call failed (attempt {attempt + 1}/{self._llm_config.max_retries}): "
f"{type(e).__name__}: {e}"
)
if not _is_retryable_llm_error(e):
break
if attempt < self._llm_config.max_retries - 1:
delay = self._llm_config.retry_delay * (attempt + 1)
await asyncio.sleep(delay)
raise _build_retry_error(
self._llm_config.max_retries, self._llm_config.model,
self._llm_config.backend.value, _attempts, _last_exc,
)
async def chat(
self,
messages: List[Dict[str, str]],
max_tokens: int = 500,
temperature: Optional[float] = None,
**kwargs
) -> LLMResponse:
"""
多轮对话
Args:
messages: 消息列表 [{"role": "user/assistant", "content": "..."}]
max_tokens: 最大 token 数
temperature: 温度参数
Returns:
LLM 响应
"""
# 将多轮消息转换为单个 prompt
prompt_parts = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
prompt_parts.append(f"{role}: {content}")
prompt = "\n".join(prompt_parts)
return await self.complete(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
**kwargs
)
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
return {
"total_calls": self._total_calls,
"total_tokens": self._total_tokens,
"errors": self._errors,
"avg_tokens_per_call": (
self._total_tokens / self._total_calls
if self._total_calls > 0 else 0
),
"error_rate": (
self._errors / self._total_calls
if self._total_calls > 0 else 0
),
}