mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 08:01:34 +08:00
feat(llm): 增加 DeepSeek 纯文本适配器与 DEEPSEEK_*
MA_LLM_TEXT_PROVIDER=deepseek|deep_seek;.env.example 增加 ②-D;单测与 Kimi 同形。 Made-with: Cursor
This commit is contained in:
parent
fec52b5c24
commit
e60e8170f9
13
.env.example
13
.env.example
@ -29,8 +29,8 @@ CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
|||||||
# OPENAI_TEXT_MODEL=
|
# OPENAI_TEXT_MODEL=
|
||||||
# 别名:LLM_API_KEY、LLM_BASE_URL、LLM_MODEL
|
# 别名:LLM_API_KEY、LLM_BASE_URL、LLM_MODEL
|
||||||
|
|
||||||
# --- LLM ② 纯文本(报告/策略/关键词等 call_llm;与 ②-B、②-C 三选一,只让一条 MA_LLM_TEXT_PROVIDER 生效,换方案时先改/注释本行再启 ②-B 或 ②-C)---
|
# --- LLM ② 纯文本(报告/策略/关键词等 call_llm;与 ②-B~②-D 多选一,只让一条 MA_LLM_TEXT_PROVIDER 生效,换方案时先改/注释本行再启别节)---
|
||||||
# 方案 A(默认)= 经 openai_gateway 调 ① 的 Base URL;要换 B=官站、C=Kimi 时,把下面这行改成 openai_official|chatgpt|kimi 等,并去对应小节填凭据
|
# 方案 A(默认)= 经 openai_gateway 调 ①;要换 B=官站、C=Kimi、D=DeepSeek 时,把下面这行改成 openai_official|kimi|deepseek 等,并去对应小节填凭据
|
||||||
MA_LLM_TEXT_PROVIDER=crawler_openai_compatible
|
MA_LLM_TEXT_PROVIDER=crawler_openai_compatible
|
||||||
|
|
||||||
# --- ②-B 纯文本用 OpenAI 官站(仅当 MA=openai_official / openai_chatgpt / chatgpt;与 ②-A、②-C 互斥;凭据与 ① 可不同)---
|
# --- ②-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
|
# 别名:MOONSHOT_API_KEY、KIMI_MODEL、MOONSHOT_MODEL
|
||||||
# KIMI_TIMEOUT=600
|
# 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 无冲突)---
|
# --- 报告/策略:部分 LLM 块单独温度(默认 0.1;与上面 MA 无冲突)---
|
||||||
# MA_STRATEGY_LLM_TEMPERATURE=0.1
|
# MA_STRATEGY_LLM_TEMPERATURE=0.1
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .adapters import (
|
from .adapters import (
|
||||||
CrawlerOpenAiCompatibleTextLlm,
|
CrawlerOpenAiCompatibleTextLlm,
|
||||||
|
DeepSeekTextLlm,
|
||||||
KimiMoonshotTextLlm,
|
KimiMoonshotTextLlm,
|
||||||
OpenAiOfficialChatGptTextLlm,
|
OpenAiOfficialChatGptTextLlm,
|
||||||
)
|
)
|
||||||
@ -12,6 +13,7 @@ from .protocol import TextLlmClient
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CrawlerOpenAiCompatibleTextLlm",
|
"CrawlerOpenAiCompatibleTextLlm",
|
||||||
|
"DeepSeekTextLlm",
|
||||||
"KimiMoonshotTextLlm",
|
"KimiMoonshotTextLlm",
|
||||||
"OpenAiOfficialChatGptTextLlm",
|
"OpenAiOfficialChatGptTextLlm",
|
||||||
"TextLlmClient",
|
"TextLlmClient",
|
||||||
|
|||||||
@ -2,11 +2,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm
|
from .crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm
|
||||||
|
from .deepseek_text import DeepSeekTextLlm
|
||||||
from .kimi_moonshot_text import KimiMoonshotTextLlm
|
from .kimi_moonshot_text import KimiMoonshotTextLlm
|
||||||
from .openai_official_chatgpt import OpenAiOfficialChatGptTextLlm
|
from .openai_official_chatgpt import OpenAiOfficialChatGptTextLlm
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CrawlerOpenAiCompatibleTextLlm",
|
"CrawlerOpenAiCompatibleTextLlm",
|
||||||
|
"DeepSeekTextLlm",
|
||||||
"KimiMoonshotTextLlm",
|
"KimiMoonshotTextLlm",
|
||||||
"OpenAiOfficialChatGptTextLlm",
|
"OpenAiOfficialChatGptTextLlm",
|
||||||
]
|
]
|
||||||
|
|||||||
140
backend/pipeline/llm/providers/adapters/deepseek_text.py
Normal file
140
backend/pipeline/llm/providers/adapters/deepseek_text.py
Normal file
@ -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()
|
||||||
@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from .adapters.crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm
|
from .adapters.crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm
|
||||||
|
from .adapters.deepseek_text import DeepSeekTextLlm
|
||||||
from .adapters.kimi_moonshot_text import KimiMoonshotTextLlm
|
from .adapters.kimi_moonshot_text import KimiMoonshotTextLlm
|
||||||
from .adapters.openai_official_chatgpt import OpenAiOfficialChatGptTextLlm
|
from .adapters.openai_official_chatgpt import OpenAiOfficialChatGptTextLlm
|
||||||
from .protocol import TextLlmClient
|
from .protocol import TextLlmClient
|
||||||
@ -35,10 +36,12 @@ def _build_client(pid: str) -> TextLlmClient:
|
|||||||
return OpenAiOfficialChatGptTextLlm()
|
return OpenAiOfficialChatGptTextLlm()
|
||||||
if pid in ("kimi", "moonshot", "kimi_moonshot", "moonshot_kimi"):
|
if pid in ("kimi", "moonshot", "kimi_moonshot", "moonshot_kimi"):
|
||||||
return KimiMoonshotTextLlm()
|
return KimiMoonshotTextLlm()
|
||||||
|
if pid in ("deepseek", "deep_seek"):
|
||||||
|
return DeepSeekTextLlm()
|
||||||
known = (
|
known = (
|
||||||
"crawler_openai_compatible, crawler, default, openai_compatible, "
|
"crawler_openai_compatible, crawler, default, openai_compatible, "
|
||||||
"openai_official, openai_chatgpt, chatgpt, "
|
"openai_official, openai_chatgpt, chatgpt, "
|
||||||
"kimi, moonshot, kimi_moonshot"
|
"kimi, moonshot, kimi_moonshot, deepseek, deep_seek"
|
||||||
)
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"不支持的 {_ENV_KEY}={pid!r};已知取值:{known}。",
|
f"不支持的 {_ENV_KEY}={pid!r};已知取值:{known}。",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from pipeline.llm.providers import (
|
from pipeline.llm.providers import (
|
||||||
CrawlerOpenAiCompatibleTextLlm,
|
CrawlerOpenAiCompatibleTextLlm,
|
||||||
|
DeepSeekTextLlm,
|
||||||
KimiMoonshotTextLlm,
|
KimiMoonshotTextLlm,
|
||||||
OpenAiOfficialChatGptTextLlm,
|
OpenAiOfficialChatGptTextLlm,
|
||||||
get_text_llm,
|
get_text_llm,
|
||||||
@ -64,6 +65,46 @@ def test_kimi_complete_text_uses_post_and_returns_content(monkeypatch: pytest.Mo
|
|||||||
assert out == "kimi_out"
|
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:
|
def test_factory_selects_kimi_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
reset_text_llm_client_for_tests()
|
reset_text_llm_client_for_tests()
|
||||||
monkeypatch.setenv("MA_LLM_TEXT_PROVIDER", "kimi")
|
monkeypatch.setenv("MA_LLM_TEXT_PROVIDER", "kimi")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user