From e60e8170f9a6c1833cd8ab4b342d59d7e841de02 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Mon, 27 Apr 2026 11:12:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E5=A2=9E=E5=8A=A0=20DeepSeek=20?= =?UTF-8?q?=E7=BA=AF=E6=96=87=E6=9C=AC=E9=80=82=E9=85=8D=E5=99=A8=E4=B8=8E?= =?UTF-8?q?=20DEEPSEEK=5F*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MA_LLM_TEXT_PROVIDER=deepseek|deep_seek;.env.example 增加 ②-D;单测与 Kimi 同形。 Made-with: Cursor --- .env.example | 13 +- backend/pipeline/llm/providers/__init__.py | 2 + .../llm/providers/adapters/__init__.py | 2 + .../llm/providers/adapters/deepseek_text.py | 140 ++++++++++++++++++ backend/pipeline/llm/providers/factory.py | 5 +- .../pipeline/tests/test_text_llm_providers.py | 41 +++++ 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 backend/pipeline/llm/providers/adapters/deepseek_text.py diff --git a/.env.example b/.env.example index 3b936ff..0c5002e 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,8 @@ CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 # OPENAI_TEXT_MODEL= # 别名:LLM_API_KEY、LLM_BASE_URL、LLM_MODEL -# --- LLM ② 纯文本(报告/策略/关键词等 call_llm;与 ②-B、②-C 三选一,只让一条 MA_LLM_TEXT_PROVIDER 生效,换方案时先改/注释本行再启 ②-B 或 ②-C)--- -# 方案 A(默认)= 经 openai_gateway 调 ① 的 Base URL;要换 B=官站、C=Kimi 时,把下面这行改成 openai_official|chatgpt|kimi 等,并去对应小节填凭据 +# --- LLM ② 纯文本(报告/策略/关键词等 call_llm;与 ②-B~②-D 多选一,只让一条 MA_LLM_TEXT_PROVIDER 生效,换方案时先改/注释本行再启别节)--- +# 方案 A(默认)= 经 openai_gateway 调 ①;要换 B=官站、C=Kimi、D=DeepSeek 时,把下面这行改成 openai_official|kimi|deepseek 等,并去对应小节填凭据 MA_LLM_TEXT_PROVIDER=crawler_openai_compatible # --- ②-B 纯文本用 OpenAI 官站(仅当 MA=openai_official / openai_chatgpt / chatgpt;与 ②-A、②-C 互斥;凭据与 ① 可不同)--- @@ -50,6 +50,15 @@ MA_LLM_TEXT_PROVIDER=crawler_openai_compatible # 别名:MOONSHOT_API_KEY、KIMI_MODEL、MOONSHOT_MODEL # KIMI_TIMEOUT=600 +# --- ②-D 纯文本用 DeepSeek 官方(仅当 MA=deepseek / deep_seek;与 ②-A~②-C 互斥;配料/视觉仍只走 ① 的 OPENAI_*)--- +# MA_LLM_TEXT_PROVIDER=deepseek +# DEEPSEEK_API_KEY=sk-... +# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +# DEEPSEEK_TEXT_MODEL=deepseek-chat +# DEEPSEEK_CONTEXT_WINDOW=64000 +# 别名:DEEPSEEK_MODEL +# DEEPSEEK_TIMEOUT=600 + # --- 报告/策略:部分 LLM 块单独温度(默认 0.1;与上面 MA 无冲突)--- # MA_STRATEGY_LLM_TEMPERATURE=0.1 diff --git a/backend/pipeline/llm/providers/__init__.py b/backend/pipeline/llm/providers/__init__.py index d363b32..bac9c2c 100644 --- a/backend/pipeline/llm/providers/__init__.py +++ b/backend/pipeline/llm/providers/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from .adapters import ( CrawlerOpenAiCompatibleTextLlm, + DeepSeekTextLlm, KimiMoonshotTextLlm, OpenAiOfficialChatGptTextLlm, ) @@ -12,6 +13,7 @@ from .protocol import TextLlmClient __all__ = [ "CrawlerOpenAiCompatibleTextLlm", + "DeepSeekTextLlm", "KimiMoonshotTextLlm", "OpenAiOfficialChatGptTextLlm", "TextLlmClient", diff --git a/backend/pipeline/llm/providers/adapters/__init__.py b/backend/pipeline/llm/providers/adapters/__init__.py index f076069..faa29d4 100644 --- a/backend/pipeline/llm/providers/adapters/__init__.py +++ b/backend/pipeline/llm/providers/adapters/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations from .crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm +from .deepseek_text import DeepSeekTextLlm from .kimi_moonshot_text import KimiMoonshotTextLlm from .openai_official_chatgpt import OpenAiOfficialChatGptTextLlm __all__ = [ "CrawlerOpenAiCompatibleTextLlm", + "DeepSeekTextLlm", "KimiMoonshotTextLlm", "OpenAiOfficialChatGptTextLlm", ] diff --git a/backend/pipeline/llm/providers/adapters/deepseek_text.py b/backend/pipeline/llm/providers/adapters/deepseek_text.py new file mode 100644 index 0000000..2cce31b --- /dev/null +++ b/backend/pipeline/llm/providers/adapters/deepseek_text.py @@ -0,0 +1,140 @@ +""" +DeepSeek 官方 OpenAI 兼容 `chat/completions`(`https://api.deepseek.com`),**仅用于纯文本**(`call_llm` / 报告 / 策略)。 + +与 `OPENAI_*` / 配料多模 分离,独立 `DEEPSEEK_*` 凭据。 + +启用:`MA_LLM_TEXT_PROVIDER=deepseek`(或 `deep_seek`)。 + +环境变量:见 `.env.example`(`DEEPSEEK_API_KEY`、基址、模型、上下文字数预检 等)。 +""" +from __future__ import annotations + +import os +from typing import Any + +import requests + +from pipeline.openai_gateway.chat_content import normalize_message_content +from pipeline.openai_gateway.estimate import ( + estimate_chat_input_tokens as estimate_crawler_style_input_tokens, +) + +_DEFAULT_BASE = "https://api.deepseek.com/v1" +_DEFAULT_MODEL = "deepseek-chat" +# 常见 64k 级;若用长上下文/官方调整上限可改 DEEPSEEK_CONTEXT_WINDOW +_DEFAULT_CTX = 64_000 + +_BUF = 256 +_WANT_MAX = 8192 + + +def _read_timeout() -> tuple[float, float]: + read = 600 + raw = ( + os.environ.get("DEEPSEEK_TIMEOUT") + or os.environ.get("LLM_CHAT_TIMEOUT") + or os.environ.get("OPENAI_TIMEOUT") + or "" + ).strip() + if raw: + try: + read = max(60, int(raw)) + except ValueError: + pass + conn = 30.0 + raw_c = (os.environ.get("LLM_CHAT_CONNECT_TIMEOUT") or "").strip() + if raw_c: + try: + conn = max(5.0, float(raw_c)) + except ValueError: + pass + return (conn, float(read)) + + +def _resolve_deepseek_credentials() -> tuple[str, str, str]: + key = (os.environ.get("DEEPSEEK_API_KEY") or "").strip() + if not key: + raise ValueError( + "使用 deepseek 文本适配器需设置 DEEPSEEK_API_KEY," + "与配料/视觉所用 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() + return key, base, model + + +def _context_window() -> int: + raw = (os.environ.get("DEEPSEEK_CONTEXT_WINDOW") or str(_DEFAULT_CTX)).strip() + try: + return max(4096, int(raw)) + except ValueError: + return _DEFAULT_CTX + + +def _default_temperature() -> float: + return 0.2 + + +class DeepSeekTextLlm: + """DeepSeek `chat/completions`;与 `KimiMoonshotTextLlm` 同形(max_tokens 预检)。""" + + def complete_text( + self, + system_prompt: str, + user_prompt: str, + *, + temperature: float | None = None, + ) -> str: + api_key, base, model = _resolve_deepseek_credentials() + 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, + } + est = estimate_crawler_style_input_tokens(system_prompt, user_prompt) + context_window = _context_window() + if est >= context_window - _BUF - 256: + raise ValueError( + f"提示词过长(估算输入约 {est} tokens,DEEPSEEK_CONTEXT_WINDOW={context_window})," + "请缩小输入或调大 DEEPSEEK_TEXT_MODEL / DEEPSEEK_CONTEXT_WINDOW。" + ) + avail = context_window - est - _BUF + want = int(body.get("max_tokens") or _WANT_MAX) + body["max_tokens"] = max(256, min(want, max(avail, 256))) + r = requests.post( + f"{base}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=body, + timeout=_read_timeout(), + ) + try: + r.raise_for_status() + except requests.HTTPError as e: + snippet = "" + if e.response is not None: + snippet = (e.response.text or "")[:1200].replace("\r\n", "\n").replace("\n", " ") + if snippet: + raise requests.HTTPError( + f"{e!s} | body: {snippet}", + response=e.response, + request=e.request, + ) from e + raise + data = r.json() + msg = (data.get("choices") or [{}])[0].get("message") or {} + return normalize_message_content(msg.get("content")) + + def estimate_input_tokens(self, system_prompt: str, user_prompt: str) -> int: + return estimate_crawler_style_input_tokens(system_prompt, user_prompt) + + def context_window_tokens(self) -> int: + return _context_window() diff --git a/backend/pipeline/llm/providers/factory.py b/backend/pipeline/llm/providers/factory.py index a66413c..bd14f19 100644 --- a/backend/pipeline/llm/providers/factory.py +++ b/backend/pipeline/llm/providers/factory.py @@ -6,6 +6,7 @@ from __future__ import annotations import os from .adapters.crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm +from .adapters.deepseek_text import DeepSeekTextLlm from .adapters.kimi_moonshot_text import KimiMoonshotTextLlm from .adapters.openai_official_chatgpt import OpenAiOfficialChatGptTextLlm from .protocol import TextLlmClient @@ -35,10 +36,12 @@ def _build_client(pid: str) -> TextLlmClient: return OpenAiOfficialChatGptTextLlm() if pid in ("kimi", "moonshot", "kimi_moonshot", "moonshot_kimi"): return KimiMoonshotTextLlm() + if pid in ("deepseek", "deep_seek"): + return DeepSeekTextLlm() known = ( "crawler_openai_compatible, crawler, default, openai_compatible, " "openai_official, openai_chatgpt, chatgpt, " - "kimi, moonshot, kimi_moonshot" + "kimi, moonshot, kimi_moonshot, deepseek, deep_seek" ) raise ValueError( f"不支持的 {_ENV_KEY}={pid!r};已知取值:{known}。", diff --git a/backend/pipeline/tests/test_text_llm_providers.py b/backend/pipeline/tests/test_text_llm_providers.py index 6bd1767..71ca201 100644 --- a/backend/pipeline/tests/test_text_llm_providers.py +++ b/backend/pipeline/tests/test_text_llm_providers.py @@ -6,6 +6,7 @@ import pytest from pipeline.llm.providers import ( CrawlerOpenAiCompatibleTextLlm, + DeepSeekTextLlm, KimiMoonshotTextLlm, OpenAiOfficialChatGptTextLlm, get_text_llm, @@ -64,6 +65,46 @@ 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: + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-ds-test") + + def _fake_post( + url: str, + headers: dict, + json: dict, + timeout: object, + ) -> MagicMock: + assert "deepseek" in url + assert "/chat/completions" in url + assert json["messages"][0]["role"] == "system" + assert headers.get("Authorization", "").startswith("Bearer sk-ds-") + r = MagicMock() + r.json.return_value = {"choices": [{"message": {"content": "ds_out"}}]} + r.raise_for_status = MagicMock() + return r + + with patch( + "pipeline.llm.providers.adapters.deepseek_text.requests.post", + side_effect=_fake_post, + ): + llm = DeepSeekTextLlm() + out = llm.complete_text("S", "U", temperature=0.1) + assert out == "ds_out" + + +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") + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-x") + try: + c = get_text_llm() + assert isinstance(c, DeepSeekTextLlm) + finally: + reset_text_llm_client_for_tests() + monkeypatch.delenv("MA_LLM_TEXT_PROVIDER", raising=False) + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + + def test_factory_selects_kimi_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None: reset_text_llm_client_for_tests() monkeypatch.setenv("MA_LLM_TEXT_PROVIDER", "kimi")