diff --git a/.env.example b/.env.example index 0c5002e..49333cf 100644 --- a/.env.example +++ b/.env.example @@ -54,7 +54,11 @@ MA_LLM_TEXT_PROVIDER=crawler_openai_compatible # MA_LLM_TEXT_PROVIDER=deepseek # DEEPSEEK_API_KEY=sk-... # DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 -# DEEPSEEK_TEXT_MODEL=deepseek-chat +# 思考模式(官方 Thinking Mode,默认开):关=0;力度 high|max(https://api-docs.deepseek.com/guides/thinking_mode) +# DEEPSEEK_THINKING=1 +# DEEPSEEK_REASONING_EFFORT=high +# 未填 DEEPSEEK_TEXT_MODEL 时:开思考默认 deepseek-v4-pro,关思考默认 deepseek-chat +# DEEPSEEK_TEXT_MODEL=deepseek-v4-pro # DEEPSEEK_CONTEXT_WINDOW=64000 # 别名:DEEPSEEK_MODEL # DEEPSEEK_TIMEOUT=600 diff --git a/backend/pipeline/llm/providers/adapters/deepseek_text.py b/backend/pipeline/llm/providers/adapters/deepseek_text.py index 2cce31b..c7688de 100644 --- a/backend/pipeline/llm/providers/adapters/deepseek_text.py +++ b/backend/pipeline/llm/providers/adapters/deepseek_text.py @@ -5,7 +5,10 @@ DeepSeek 官方 OpenAI 兼容 `chat/completions`(`https://api.deepseek.com`) 启用:`MA_LLM_TEXT_PROVIDER=deepseek`(或 `deep_seek`)。 -环境变量:见 `.env.example`(`DEEPSEEK_API_KEY`、基址、模型、上下文字数预检 等)。 +**思考模式**(与官方「Thinking Mode」一致):默认可通过 `DEEPSEEK_THINKING=0` 关闭。开启时在请求中携带 +`thinking.type=enabled` 与 `reasoning_effort`;不发送 `temperature`(官方在思考模式下忽略采样参数)。 +未指定 `DEEPSEEK_TEXT_MODEL` 时,开启思考默认用 `deepseek-v4-pro`,关闭时默认 `deepseek-chat`。 +详见 https://api-docs.deepseek.com/guides/thinking_mode """ from __future__ import annotations @@ -20,7 +23,8 @@ from pipeline.openai_gateway.estimate import ( ) _DEFAULT_BASE = "https://api.deepseek.com/v1" -_DEFAULT_MODEL = "deepseek-chat" +_DEFAULT_MODEL_NO_THINK = "deepseek-chat" +_DEFAULT_MODEL_THINK = "deepseek-v4-pro" # 常见 64k 级;若用长上下文/官方调整上限可改 DEEPSEEK_CONTEXT_WINDOW _DEFAULT_CTX = 64_000 @@ -51,6 +55,28 @@ def _read_timeout() -> tuple[float, float]: return (conn, float(read)) +def _thinking_enabled() -> bool: + v = (os.environ.get("DEEPSEEK_THINKING") or "1").strip().lower() + if v in ("0", "false", "off", "no", "disabled"): + return False + return True + + +def _reasoning_effort() -> str: + raw = (os.environ.get("DEEPSEEK_REASONING_EFFORT") or "high").strip().lower() + if raw in ("max", "high", "low", "medium", "xhigh"): + if raw in ("low", "medium"): + return "high" + if raw == "xhigh": + return "max" + return raw + return "high" + + +def _default_model() -> str: + return _DEFAULT_MODEL_THINK if _thinking_enabled() else _DEFAULT_MODEL_NO_THINK + + def _resolve_deepseek_credentials() -> tuple[str, str, str]: key = (os.environ.get("DEEPSEEK_API_KEY") or "").strip() if not key: @@ -59,9 +85,9 @@ def _resolve_deepseek_credentials() -> tuple[str, str, str]: "与配料/视觉所用 OPENAI_API_KEY 分开配置。" ) base = (os.environ.get("DEEPSEEK_BASE_URL") or _DEFAULT_BASE).strip().rstrip("/") - model = ( - (os.environ.get("DEEPSEEK_TEXT_MODEL") or os.environ.get("DEEPSEEK_MODEL") or _DEFAULT_MODEL) - ).strip() + model = (os.environ.get("DEEPSEEK_TEXT_MODEL") or os.environ.get("DEEPSEEK_MODEL") or "").strip() + if not model: + model = _default_model() return key, base, model @@ -88,15 +114,21 @@ class DeepSeekTextLlm: temperature: float | None = None, ) -> str: api_key, base, model = _resolve_deepseek_credentials() + think = _thinking_enabled() body: dict[str, Any] = { "model": model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], - "temperature": _default_temperature() if temperature is None else float(temperature), "max_tokens": _WANT_MAX, } + if think: + # 与 OpenAI 官方 Python SDK 合并 extra_body 后一致:思考模式不依赖 temperature + body["thinking"] = {"type": "enabled"} + body["reasoning_effort"] = _reasoning_effort() + else: + body["temperature"] = _default_temperature() if temperature is None else float(temperature) est = estimate_crawler_style_input_tokens(system_prompt, user_prompt) context_window = _context_window() if est >= context_window - _BUF - 256: diff --git a/backend/pipeline/tests/test_text_llm_providers.py b/backend/pipeline/tests/test_text_llm_providers.py index 71ca201..9933e35 100644 --- a/backend/pipeline/tests/test_text_llm_providers.py +++ b/backend/pipeline/tests/test_text_llm_providers.py @@ -65,8 +65,13 @@ def test_kimi_complete_text_uses_post_and_returns_content(monkeypatch: pytest.Mo assert out == "kimi_out" -def test_deepseek_complete_text_uses_post_and_returns_content(monkeypatch: pytest.MonkeyPatch) -> None: +def test_deepseek_complete_text_uses_post_and_thinking_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-ds-test") + # 默认开思考,未显式设 DEEPSEEK_TEXT_MODEL 时用 deepseek-v4-pro + monkeypatch.delenv("DEEPSEEK_TEXT_MODEL", raising=False) + monkeypatch.delenv("DEEPSEEK_MODEL", raising=False) def _fake_post( url: str, @@ -78,6 +83,10 @@ def test_deepseek_complete_text_uses_post_and_returns_content(monkeypatch: pytes assert "/chat/completions" in url assert json["messages"][0]["role"] == "system" assert headers.get("Authorization", "").startswith("Bearer sk-ds-") + assert json.get("model") == "deepseek-v4-pro" + assert json.get("thinking") == {"type": "enabled"} + assert json.get("reasoning_effort") == "high" + assert "temperature" not in json r = MagicMock() r.json.return_value = {"choices": [{"message": {"content": "ds_out"}}]} r.raise_for_status = MagicMock() @@ -92,6 +101,34 @@ def test_deepseek_complete_text_uses_post_and_returns_content(monkeypatch: pytes assert out == "ds_out" +def test_deepseek_thinking_off_sends_temperature(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-ds-test") + monkeypatch.setenv("DEEPSEEK_THINKING", "0") + monkeypatch.setenv("DEEPSEEK_TEXT_MODEL", "deepseek-chat") + + def _fake_post( + url: str, + headers: dict, + json: dict, + timeout: object, + ) -> MagicMock: + assert json.get("model") == "deepseek-chat" + assert "thinking" not in json + assert "reasoning_effort" not in json + assert "temperature" in json + r = MagicMock() + r.json.return_value = {"choices": [{"message": {"content": "plain"}}]} + r.raise_for_status = MagicMock() + return r + + with patch( + "pipeline.llm.providers.adapters.deepseek_text.requests.post", + side_effect=_fake_post, + ): + out = DeepSeekTextLlm().complete_text("S", "U", temperature=0.0) + assert out == "plain" + + def test_factory_selects_deepseek_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None: reset_text_llm_client_for_tests() monkeypatch.setenv("MA_LLM_TEXT_PROVIDER", "deepseek")