feat: add DeepSeek official API support and capture reasoning content

- Add DeepSeek provider routing: deepseek-chat/deepseek-reasoner use
  api.deepseek.com, other models use SiliconFlow
- Add stream_with_reasoning() using raw OpenAI SDK to capture
  reasoning_content (langchain_openai strips this field)
- RAG chain and conversation chain both use stream_with_reasoning
  for proper reasoning display in thinking models
- Frontend model selector: grouped by provider (DeepSeek official +
  SiliconFlow), default changed to deepseek-chat
- Regenerate message converted to streaming with reasoning capture
- Minor UI: globals.css additions, chat store refactoring

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 12:37:31 +08:00
parent 51d6fd22c0
commit ecd5c7ff31
9 changed files with 385 additions and 215 deletions
+4
View File
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
siliconflow_api_key: str
siliconflow_base_url: str = "https://api.siliconflow.cn/v1"
siliconflow_model: str = "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B"
# DeepSeek官方API配置
deepseek_api_key: str = ""
deepseek_base_url: str = "https://api.deepseek.com"
# 数据库配置
database_url: str = "sqlite:///../data/database/course_agent.db"
+93 -35
View File
@@ -1,44 +1,63 @@
"""
硅基流动大模型API集成
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方
"""
import os
from typing import List, Dict, Any, Optional, AsyncGenerator
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
from langchain_openai import ChatOpenAI
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
import openai
from ..core.config import get_settings
settings = get_settings()
# DeepSeek 官方模型 ID 前缀(用于自动路由)
DEEPSEEK_OFFICIAL_MODELS = {
"deepseek-chat",
"deepseek-reasoner",
}
# 模型 ID → (api_key, base_url) 的路由映射
def _resolve_provider(model: str) -> tuple[str, str]:
"""根据模型 ID 选择 API provider,返回 (api_key, base_url)"""
# DeepSeek 官方模型(不带 siliconflow 前缀的纯 deepseek-xxx
if model in DEEPSEEK_OFFICIAL_MODELS or model.startswith("deepseek/"):
if not settings.deepseek_api_key:
raise ValueError(
f"模型 {model} 需要 DeepSeek 官方 API Key"
"请在 .env 中配置 DEEPSEEK_API_KEY"
)
actual_model = model.replace("deepseek/", "")
return settings.deepseek_api_key, settings.deepseek_base_url, actual_model
# 默认走 SiliconFlow
return settings.siliconflow_api_key, settings.siliconflow_base_url, model
class SiliconFlowLLM:
"""硅基流动大模型客户端"""
"""大模型客户端 — 自动路由 SiliconFlow / DeepSeek 官方"""
def __init__(self, model: Optional[str] = None):
"""初始化LLM客户端"""
# 设置环境变量
os.environ["OPENAI_API_KEY"] = settings.siliconflow_api_key
os.environ["OPENAI_API_BASE"] = settings.siliconflow_base_url
# 使用传入的模型或默认模型
self.model_name = model or settings.siliconflow_model
print(f"[DEBUG-LLM] 初始化LLM客户端,使用模型: {self.model_name} (传入参数: {model}, 默认配置: {settings.siliconflow_model})")
# 创建LLM实例
raw_model = model or settings.siliconflow_model
api_key, base_url, resolved_model = _resolve_provider(raw_model)
self.model_name = resolved_model
print(f"[LLM] 模型: {resolved_model}, API: {base_url}")
self.llm = ChatOpenAI(
model=self.model_name,
api_key=settings.siliconflow_api_key,
base_url=settings.siliconflow_base_url,
model=resolved_model,
api_key=api_key,
base_url=base_url,
temperature=0.7,
max_tokens=2000,
streaming=True
)
# 检查实际使用的模型名称
actual_model = getattr(self.llm, 'model_name', None) or getattr(self.llm, 'model', None) or str(self.llm)
print(f"[DEBUG-LLM] ChatOpenAI实例创建完成,实际模型: {actual_model}")
self._api_key = api_key
self._base_url = base_url
def chat(self, messages: List[BaseMessage], **kwargs) -> str:
"""同步聊天"""
try:
@@ -46,7 +65,7 @@ class SiliconFlowLLM:
return response.content
except Exception as e:
raise Exception(f"LLM调用失败: {str(e)}")
async def achat(self, messages: List[BaseMessage], **kwargs) -> str:
"""异步聊天"""
try:
@@ -54,7 +73,7 @@ class SiliconFlowLLM:
return response.content
except Exception as e:
raise Exception(f"LLM异步调用失败: {str(e)}")
async def stream_chat(self, messages: List[BaseMessage], **kwargs) -> AsyncGenerator[str, None]:
"""流式聊天"""
try:
@@ -63,31 +82,71 @@ class SiliconFlowLLM:
yield chunk.content
except Exception as e:
raise Exception(f"LLM流式调用失败: {str(e)}")
async def stream_with_reasoning(
self, messages: List[BaseMessage], **kwargs
) -> AsyncGenerator[Tuple[str, str], None]:
"""流式调用(直接使用 OpenAI SDK,捕获推理内容)
Yields:
(type, content) — type 为 "reasoning""content"
"""
client = openai.AsyncOpenAI(
api_key=self._api_key,
base_url=self._base_url
)
openai_messages = []
for msg in messages:
if isinstance(msg, SystemMessage):
openai_messages.append({"role": "system", "content": msg.content})
elif isinstance(msg, HumanMessage):
openai_messages.append({"role": "user", "content": msg.content})
elif isinstance(msg, AIMessage):
openai_messages.append({"role": "assistant", "content": msg.content})
else:
openai_messages.append({"role": "user", "content": str(msg.content)})
stream = await client.chat.completions.create(
model=self.model_name,
messages=openai_messages,
stream=True,
**kwargs
)
async for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, 'reasoning_content', None)
if rc:
yield ("reasoning", rc)
if delta.content:
yield ("content", delta.content)
def create_messages(
self,
user_message: str,
self,
user_message: str,
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict[str, str]]] = None
) -> List[BaseMessage]:
"""创建消息列表"""
messages = []
# 添加系统提示
if system_prompt:
messages.append(SystemMessage(content=system_prompt))
# 添加聊天历史
if chat_history:
for msg in chat_history:
if msg["role"] == "user":
messages.append(HumanMessage(content=msg["content"]))
elif msg["role"] == "assistant":
messages.append(AIMessage(content=msg["content"]))
# 添加当前用户消息
messages.append(HumanMessage(content=user_message))
return messages
@@ -97,12 +156,11 @@ llm_client = SiliconFlowLLM()
def get_llm_client(model: Optional[str] = None) -> SiliconFlowLLM:
"""获取LLM客户端实例
Args:
model: 可选的模型名称,如果提供则创建新的实例,否则返回默认实例
"""
if model is None:
return llm_client
else:
# 为指定模型创建新实例
return SiliconFlowLLM(model=model)
+13 -17
View File
@@ -32,7 +32,9 @@ class RAGChain:
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
"""
print(f"[DEBUG-RAGChain] 初始化,knowledge_base_ids: {knowledge_base_ids}, model: {model}")
self.llm = get_llm_client(model=model).llm
_sf_client = get_llm_client(model=model)
self.llm = _sf_client.llm
self.client = _sf_client
self.vector_store = get_vector_store()
self.knowledge_base_ids = knowledge_base_ids
@@ -116,7 +118,8 @@ class RAGChain:
context = "\n\n".join(doc.page_content for doc in docs)
# 构建prompt
messages = await self.prompt.ainvoke({"context": context, "question": question})
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
messages = prompt_value.to_messages()
# 流式生成答案
async for chunk in self.llm.astream(messages):
@@ -161,24 +164,17 @@ class RAGChain:
# 2. 构建prompt
yield {"type": "thinking", "stage": "generating", "message": f"基于 {len(docs)} 篇文档生成回答..."}
messages = await self.prompt.ainvoke({"context": context, "question": question})
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
messages = prompt_value.to_messages()
# 3. 流式生成答案(捕获推理内容)
# 3. 流式生成答案(使用原始 OpenAI SDK 捕获推理内容)
answer_chunks = []
reasoning_parts = []
async for chunk in self.llm.astream(messages):
# 捕获推理内容(DeepSeek-R1/QwQ 等推理模型)
if hasattr(chunk, 'additional_kwargs') and 'reasoning_content' in chunk.additional_kwargs:
reasoning_text = chunk.additional_kwargs['reasoning_content']
if reasoning_text:
reasoning_parts.append(reasoning_text)
yield {"type": "thinking", "stage": "reasoning", "message": reasoning_text}
elif hasattr(chunk, 'reasoning_content') and chunk.reasoning_content:
reasoning_parts.append(chunk.reasoning_content)
yield {"type": "thinking", "stage": "reasoning", "message": chunk.reasoning_content}
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
if content:
async for event_type, content in self.client.stream_with_reasoning(messages):
if event_type == "reasoning":
reasoning_parts.append(content)
yield {"type": "thinking", "stage": "reasoning", "message": content}
elif event_type == "content":
answer_chunks.append(content)
yield {"type": "chunk", "content": content}
+10 -14
View File
@@ -24,7 +24,9 @@ class ConversationChain:
system_prompt: 系统提示词
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
"""
self.llm = get_llm_client(model=model).llm
_sf_client = get_llm_client(model=model)
self.llm = _sf_client.llm
self.client = _sf_client
self.system_prompt = system_prompt or "你是一个专业的国土空间规划知识问答助手。请基于你的知识回答用户的问题。"
# 创建带历史的Prompt模板
@@ -94,24 +96,18 @@ class ConversationChain:
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
# 直接流式调用 LLM 以捕获推理内容
prompt_messages = await self.prompt.ainvoke({
# 直接使用原始 OpenAI SDK 捕获推理内容
prompt_value = await self.prompt.ainvoke({
"question": question,
"chat_history": history_messages
})
prompt_messages = prompt_value.to_messages()
content_started = False
async for chunk in self.llm.astream(prompt_messages):
# 捕获推理内容(DeepSeek-R1/QwQ 等推理模型)
if hasattr(chunk, 'additional_kwargs') and 'reasoning_content' in chunk.additional_kwargs:
reasoning_text = chunk.additional_kwargs['reasoning_content']
if reasoning_text:
yield {"type": "thinking", "stage": "reasoning", "message": reasoning_text}
elif hasattr(chunk, 'reasoning_content') and chunk.reasoning_content:
yield {"type": "thinking", "stage": "reasoning", "message": chunk.reasoning_content}
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
if content:
async for event_type, content in self.client.stream_with_reasoning(prompt_messages):
if event_type == "reasoning":
yield {"type": "thinking", "stage": "reasoning", "message": content}
elif event_type == "content":
if not content_started:
content_started = True
yield {"type": "chunk", "content": content}