feat(llm): DeepSeek 适配器默认开启思考模式

请求体含 thinking+reasoning_effort,思考时不下发 temperature;未指定模型时开思考用 deepseek-v4-pro。DEEPSEEK_THINKING=0 可关。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-27 11:14:38 +08:00
parent e60e8170f9
commit 7823e8d6f8
3 changed files with 81 additions and 8 deletions

View File

@ -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|maxhttps://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

View File

@ -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:

View File

@ -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")