From 3c56f0bfdc16b7bb1b7efb51a5bbdcc5874a72dc Mon Sep 17 00:00:00 2001 From: ChantXu64 Date: Sat, 3 Oct 2026 19:55:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=E5=85=81=E8=AE=B8=E6=B8=A9?= =?UTF-8?q?=E5=BA=A6=E7=95=99=E7=A9=BA=E4=BB=A5=E7=9C=81=E7=95=A5=E4=B8=8D?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E7=9A=84=E8=AF=B7=E6=B1=82=E5=8F=82=E6=95=B0?= =?UTF-8?q?=20(#6865)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/llm/helper.py | 24 ++- app/agent/llm/provider.py | 2 +- .../resources/system_settings_catalog.json | 2 +- app/runtime/config.py | 6 +- docs/cli.md | 5 + skills/moviepilot-api/SKILL.md | 5 + tests/test_config_type_conversion.py | 27 ++++ tests/test_llm_temperature.py | 143 ++++++++++++++++++ 8 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 tests/test_llm_temperature.py diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index 8ae6a66a5..c68edb0c5 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -5,7 +5,7 @@ import inspect import json import time from functools import wraps -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional, cast from urllib.parse import urlsplit from uuid import uuid4 @@ -19,6 +19,9 @@ if TYPE_CHECKING: from app.agent.llm.tools import ServerToolResolution +_TEMPERATURE_UNSET = object() + + class LLMTestError(RuntimeError): """LLM 测试调用异常,附带请求耗时。""" @@ -1467,7 +1470,7 @@ class LLMHelper: base_url: str | None = None, base_url_preset: str | None = None, user_agent: str | None = None, - temperature: Optional[float] = None, + temperature: Optional[float] = cast(Optional[float], _TEMPERATURE_UNSET), use_proxy: bool | None = None, api_protocol: str | None = None, web_search_mode: str | None = None, @@ -1487,7 +1490,7 @@ class LLMHelper: :param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。 :param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。 :param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。 - :param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。 + :param temperature: LLM 温度参数。未传入时使用配置项 LLM_TEMPERATURE;显式 None 不覆盖提供商默认值。 :param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。 :param api_protocol: OpenAI 兼容接口 API 协议 (auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。 @@ -1508,7 +1511,11 @@ class LLMHelper: base_url_preset if base_url_preset is not None else get_runtime_setting('LLM_BASE_URL_PRESET') ) user_agent_value = user_agent if user_agent is not None else get_runtime_setting('LLM_USER_AGENT') - temperature_value = temperature if temperature is not None else get_runtime_setting('LLM_TEMPERATURE') + temperature_value = ( + get_runtime_setting('LLM_TEMPERATURE') + if temperature is _TEMPERATURE_UNSET + else temperature + ) normalized_thinking_level = cls._resolve_thinking_level( thinking_level=thinking_level, ) @@ -1592,11 +1599,12 @@ class LLMHelper: # 会导致工具调用时报错 400 from langchain_google_genai import ChatGoogleGenerativeAI + # Google SDK 不接受 None;留空时保留 SDK 对各模型默认温度的选择。 model = ChatGoogleGenerativeAI( model=model_name, api_key=runtime["api_key"], retries=3, - temperature=temperature_value, + **({"temperature": temperature_value} if temperature_value is not None else {}), streaming=streaming, client_args=_build_google_client_args(llm_proxy), **thinking_kwargs, @@ -1791,7 +1799,7 @@ class LLMHelper: base_url: str | None = None, base_url_preset: str | None = None, user_agent: str | None = None, - temperature: Optional[float] = None, + temperature: Optional[float] = cast(Optional[float], _TEMPERATURE_UNSET), use_proxy: bool | None = None, api_protocol: str | None = None, web_search_mode: str | None = None, @@ -1800,7 +1808,7 @@ class LLMHelper: """ 使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。 - :param temperature: LLM 温度参数。未显式传入时沿用已保存配置。 + :param temperature: LLM 温度参数。未传入时沿用已保存配置;显式 None 不覆盖提供商默认值。 :param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。 :param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。 :param provider_runtime: 已解析的 Provider 运行时,用于阻断管理入口回绕 Gateway。 @@ -1823,7 +1831,7 @@ class LLMHelper: } if provider_runtime is not None: llm_kwargs["provider_runtime"] = provider_runtime - if temperature is not None: + if temperature is not _TEMPERATURE_UNSET: llm_kwargs["temperature"] = temperature llm = await LLMHelper.get_llm(**llm_kwargs) diff --git a/app/agent/llm/provider.py b/app/agent/llm/provider.py index b676ad786..b5cd0247a 100644 --- a/app/agent/llm/provider.py +++ b/app/agent/llm/provider.py @@ -275,7 +275,7 @@ class LLMProviderManager( "web_search_mode": params.get("web_search_mode"), "provider_runtime": self, } - if params.get("temperature") is not None: + if "temperature" in params: test_kwargs["temperature"] = params.get("temperature") try: diff --git a/app/application/settings/resources/system_settings_catalog.json b/app/application/settings/resources/system_settings_catalog.json index d237b22f3..41700b7a5 100644 --- a/app/application/settings/resources/system_settings_catalog.json +++ b/app/application/settings/resources/system_settings_catalog.json @@ -878,7 +878,7 @@ "source_line": 829 }, "LLM_TEMPERATURE": { - "description": "LLM温度参数", + "description": "LLM温度参数,留空时不覆盖提供商默认值", "group": "ai_agent", "source_file": "app/runtime/config.py", "source_line": 847 diff --git a/app/runtime/config.py b/app/runtime/config.py index 77f753cec..8319a16a4 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -843,8 +843,8 @@ class ConfigModel(BaseModel): LLM_MAX_CONTEXT_TOKENS: int = 256 # LLM OpenAI兼容接口请求User-Agent LLM_USER_AGENT: Optional[str] = None - # LLM温度参数 - LLM_TEMPERATURE: float = 0.3 + # LLM温度参数,留空时不覆盖提供商默认值 + LLM_TEMPERATURE: Optional[float] = None # LLM最大迭代次数 LLM_MAX_ITERATIONS: int = 512 # LLM工具调用超时时间(秒) @@ -1002,7 +1002,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): if isinstance(value, str): converted = int(value) return converted, str(converted) != str(original_value) - elif expected_type is float: + elif expected_type in (float, Optional[float]): if isinstance(value, (int, float)) and not isinstance(value, bool): converted = float(value) return converted, str(converted) != str(original_value) diff --git a/docs/cli.md b/docs/cli.md index e041415e3..d63cc323b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -293,6 +293,11 @@ moviepilot setup --config-dir /path/to/moviepilot-config - 媒体服务器 - 消息通知渠道 +`LLM_TEMPERATURE` 为可选配置,默认留空。设置页留空或清空配置后,LLM 请求不发送 +`temperature`,使用模型服务默认值;Google 原生运行时使用 SDK 的模型默认温度。 +显式填写 `0`、`1` 等数值仍按填写值发送。 +清空后保存、重新进入设置页及重启均保留这一语义。 + 如果希望在自动化安装时直接预设超级管理员,也可以在一键安装脚本中透传: ```shell diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 780a0c385..42682eacf 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -90,6 +90,11 @@ operation contracts live in the linked category files under `api/`; load only the one category file needed for the selected operation. Do not load every category file by default. +When updating `LLM_TEMPERATURE` through `config.system.update`, use `null` to +clear it and use the provider default (the model-specific SDK default for the +native Google runtime). An explicit `0` is a +real temperature value, not a request to clear the setting. + Never provide a URL, method, authentication header, API key, or access token. Never fall back to a retired tool name or `moviepilot tool` MCP command. If an operation is not listed in this skill, do not simulate it through arbitrary HTTP; diff --git a/tests/test_config_type_conversion.py b/tests/test_config_type_conversion.py index 40b9979b4..fce23aa13 100644 --- a/tests/test_config_type_conversion.py +++ b/tests/test_config_type_conversion.py @@ -1,5 +1,7 @@ from typing import Any +import pytest + from app.runtime.config import Settings, settings @@ -40,6 +42,31 @@ def test_update_float_setting_accepts_json_integer(monkeypatch) -> None: } +@pytest.mark.parametrize( + ("value", "expected"), + [(None, None), ("", None), (" ", None), (0, 0.0), (1, 1.0), ("0.3", 0.3)], +) +def test_llm_temperature_survives_save_and_reload(tmp_path, monkeypatch, value, expected) -> None: + """清空温度后重启仍不发送,显式零和整数则保持数值语义。""" + env_path = tmp_path / "app.env" + env_path.write_text("LLM_TEMPERATURE='0.7'\n", encoding="utf-8") + monkeypatch.delenv("LLM_TEMPERATURE", raising=False) + monkeypatch.setattr("app.runtime.config.get_env_path", lambda: env_path) + config = Settings(_env_file=env_path, CONFIG_DIR=str(tmp_path), API_TOKEN="0123456789abcdef") + + success, _message = config.update_setting("LLM_TEMPERATURE", value) + + assert success is True + assert config.LLM_TEMPERATURE == expected + reloaded = Settings(_env_file=env_path, CONFIG_DIR=str(tmp_path), API_TOKEN="0123456789abcdef") + assert reloaded.LLM_TEMPERATURE == expected + if expected is None: + assert "LLM_TEMPERATURE=" not in env_path.read_text(encoding="utf-8") + else: + assert isinstance(config.LLM_TEMPERATURE, float) + assert isinstance(reloaded.LLM_TEMPERATURE, float) + + def test_short_api_token_update_does_not_log_token(monkeypatch) -> None: """短 API_TOKEN 自动替换时日志不得包含令牌原文。""" config = Settings(API_TOKEN="0123456789abcdef") diff --git a/tests/test_llm_temperature.py b/tests/test_llm_temperature.py new file mode 100644 index 000000000..da0e921b8 --- /dev/null +++ b/tests/test_llm_temperature.py @@ -0,0 +1,143 @@ +"""LLM 温度的未指定、显式留空与数值透传合同。""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from app.agent.llm import helper +from app.agent.llm.helper import LLMHelper +from app.agent.llm.provider import LLMProviderManager + + +@pytest.fixture +def offline_runtime(monkeypatch): + runtime = SimpleNamespace(resolve_runtime=AsyncMock(return_value={ + "provider_id": "openai", + "runtime": "openai_compatible", + "model_id": "gpt-6-luna", + "api_key": "offline-key", + "base_url": "https://offline.example/v1", + "model_metadata": None, + })) + monkeypatch.setattr(helper, "_patch_openai_responses_instructions_support", lambda: None) + monkeypatch.setattr(helper, "_patch_tool_schema_request_support", lambda *_args, **_kwargs: None) + clients = [] + + def reject_request(_request): + raise AssertionError("温度序列化测试不得发起外部请求") + + def make_client(_proxy, *, async_client=False): + client_type = httpx.AsyncClient if async_client else httpx.Client + client = client_type(transport=httpx.MockTransport(reject_request)) + clients.append(client) + return client + + monkeypatch.setattr(helper, "_build_httpx_client", make_client) + yield runtime + for client in clients: + if isinstance(client, httpx.AsyncClient): + asyncio.run(client.aclose()) + else: + client.close() + + +@pytest.mark.parametrize("protocol", ["chat_completions", "responses"]) +@pytest.mark.parametrize( + ("saved", "override", "expected"), + [ + (None, {}, None), + (0.3, {}, 0.3), + (0.3, {"temperature": None}, None), + (0.3, {"temperature": 0}, 0), + (None, {"temperature": 1}, 1), + ], +) +def test_llm_payload_omits_empty_temperature( + monkeypatch, offline_runtime, protocol, saved, override, expected, +) -> None: + """两个 OpenAI 协议均省略空温度,并保留显式零及未覆盖的已保存值。""" + original_setting = helper.get_runtime_setting + monkeypatch.setattr( + helper, "get_runtime_setting", + lambda key, default=None: saved if key == "LLM_TEMPERATURE" else original_setting(key, default), + ) + model = asyncio.run(LLMHelper.get_llm( + provider="openai", model="gpt-6-luna", api_key="offline-key", + base_url="https://offline.example/v1", use_proxy=False, + api_protocol=protocol, web_search_mode="disabled", thinking_level="off", + provider_runtime=offline_runtime, **override, + )) + + payload = model._get_request_payload("只回复 OK") + + if expected is None: + assert "temperature" not in payload + else: + assert payload["temperature"] == expected + + +@pytest.mark.parametrize("model_name", ["gemini-2.5-flash", "gemini-3.6-flash-preview"]) +@pytest.mark.parametrize("temperature", [None, 0, 1]) +def test_google_empty_temperature_uses_sdk_default( + monkeypatch, offline_runtime, model_name, temperature, +) -> None: + """原生 Google SDK 不接受 None,留空须保留其模型默认值且显式零仍有效。""" + from langchain_google_genai import chat_models + + offline_runtime.resolve_runtime.return_value.update( + provider_id="google", runtime="google", model_id=model_name, base_url=None, + ) + monkeypatch.setattr(helper, "_patch_gemini_thought_signature", lambda: None) + monkeypatch.setattr(chat_models, "Client", Mock(return_value=Mock(aio=None))) + + model = asyncio.run(LLMHelper.get_llm( + provider="google", model=model_name, api_key="offline-key", use_proxy=False, + web_search_mode="disabled", thinking_level="off", temperature=temperature, + provider_runtime=offline_runtime, + )) + + expected = temperature if temperature is not None else (0.7 if "2.5" in model_name else 1.0) + assert model.temperature == expected + assert model._prepare_params(None).temperature == expected + + +@pytest.mark.parametrize("override", [{}, {"temperature": None}, {"temperature": 0}]) +def test_llm_test_preserves_temperature_presence(monkeypatch, override) -> None: + """连接测试区分未传温度与显式留空,避免清空草稿沿用旧配置。""" + get_model = AsyncMock(return_value=SimpleNamespace( + ainvoke=AsyncMock(return_value=SimpleNamespace(content="OK")), + )) + monkeypatch.setattr(LLMHelper, "get_llm", get_model) + + result = asyncio.run(LLMHelper.test_current_settings( + provider="openai", model="gpt-6-luna", **override, + )) + + assert result["reply_preview"] == "OK" + forwarded = get_model.await_args.kwargs + assert ("temperature" in forwarded) == ("temperature" in override) + if "temperature" in override: + assert forwarded["temperature"] == override["temperature"] + + +@pytest.mark.parametrize("override", [{}, {"temperature": None}, {"temperature": 0}]) +def test_provider_test_preserves_temperature_presence(monkeypatch, override) -> None: + """管理 API 原样保留温度是否出现及其空值,不借用已保存温度。""" + manager = LLMProviderManager() + monkeypatch.setattr(manager, "get_saved_auth", lambda _provider: None) + monkeypatch.setattr(manager, "get_provider", lambda _provider: SimpleNamespace(oauth_methods=())) + test_settings = AsyncMock(return_value={"reply_preview": "OK"}) + monkeypatch.setattr(LLMHelper, "test_current_settings", test_settings) + + result = asyncio.run(manager.provider_manage( + "openai", "test", enabled=True, model="gpt-6-luna", api_key="offline-key", **override, + )) + + assert result["success"] is True + forwarded = test_settings.await_args.kwargs + assert ("temperature" in forwarded) == ("temperature" in override) + if "temperature" in override: + assert forwarded["temperature"] == override["temperature"]