refactor(aibox): 解耦大模型调用,新增 llm_client 抽象层以支持多厂商切换
将 think_and_respond 与 _compress_via_llm 中的直接 DeepSeek API 调用 收敛到 LLMClient 抽象层(DeepSeekClient 实现 + create_llm_client 工厂), 统一流式事件格式,为子代理/多模型切换预留扩展点;配置新增 api.provider。
This commit is contained in:
1 parent
417fda5479
commit
d24066369b
4 files changed
+363
-184
No files matched your search
@@ -437,6 +437,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
|||||||
"broadcast_enabled": true
|
"broadcast_enabled": true
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
|
"provider": "deepseek",
|
||||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||||
"deepseek_model": "deepseek-v4-flash",
|
"deepseek_model": "deepseek-v4-flash",
|
||||||
"reasoning_enabled": false,
|
"reasoning_enabled": false,
|
||||||
@@ -508,6 +509,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
|||||||
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
|
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
|
||||||
>
|
>
|
||||||
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
|
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
|
||||||
|
>
|
||||||
|
> **多厂商切换**:大模型调用已通过 `aibox/llm_client.py` 抽象层解耦,业务层不再直接依赖具体厂商。配置 `api.provider`(默认 `deepseek`)即可切换厂商,当前内置 `DeepSeekClient`(兼容 OpenAI Chat Completions 协议,Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。新增厂商只需在此文件新增一个 `LLMClient` 子类并在 `create_llm_client` 工厂中注册,无需改动业务代码,为后续子代理/多模型共发预留了扩展点。
|
||||||
|
|
||||||
## 🛠️ 命令行参数
|
## 🛠️ 命令行参数
|
||||||
|
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ CREATE TABLE memos (
|
|||||||
|
|
||||||
| 字段 | 类型 | 默认值 | 说明 |
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
|
| `provider` | str | `deepseek` | 模型厂商,用于 `llm_client` 工厂路由 |
|
||||||
| `deepseek_api_url` | str | `https://api.deepseek.com/chat/completions` | API URL |
|
| `deepseek_api_url` | str | `https://api.deepseek.com/chat/completions` | API URL |
|
||||||
| `deepseek_model` | str | `deepseek-v4-flash` | 模型名 |
|
| `deepseek_model` | str | `deepseek-v4-flash` | 模型名 |
|
||||||
| `api_key_env` | str | `DEEPSEEK_API_KEY` | API Key 环境变量名 |
|
| `api_key_env` | str | `DEEPSEEK_API_KEY` | API Key 环境变量名 |
|
||||||
|
|||||||
+94
-184
@@ -35,6 +35,9 @@ from logging.handlers import RotatingFileHandler
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
# 大模型客户端抽象层(支持多厂商切换)
|
||||||
|
from llm_client import create_llm_client
|
||||||
|
|
||||||
# ==================== 语言检测和多语言支持 ====================
|
# ==================== 语言检测和多语言支持 ====================
|
||||||
|
|
||||||
|
|
||||||
@@ -2013,7 +2016,7 @@ class ConversationCompressor:
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
def _compress_via_llm(self, messages: List[Dict]) -> Optional[str]:
|
def _compress_via_llm(self, messages: List[Dict]) -> Optional[str]:
|
||||||
"""通过语言模型压缩消息"""
|
"""通过语言模型压缩消息(经由客户端抽象层调用,支持多厂商)"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -2022,36 +2025,15 @@ class ConversationCompressor:
|
|||||||
print(f"📦 {i18n.get('conversation_compress')}: 正在压缩 {len(messages)} 条消息...")
|
print(f"📦 {i18n.get('conversation_compress')}: 正在压缩 {len(messages)} 条消息...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
headers = {
|
summary = self.ai.client.chat_completion(
|
||||||
"Authorization": f"Bearer {self.ai.api_key}",
|
[
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": constants.DEEPSEEK_MODEL,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": "你是一个专业的对话摘要助手,擅长提取对话核心信息并进行结构化总结。请保留工具调用的关键结果。"},
|
{"role": "system", "content": "你是一个专业的对话摘要助手,擅长提取对话核心信息并进行结构化总结。请保留工具调用的关键结果。"},
|
||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
],
|
],
|
||||||
"temperature": 0.3,
|
temperature=0.3,
|
||||||
"max_tokens": constants.COMPRESSOR_LLM_MAX_TOKENS
|
max_tokens=constants.COMPRESSOR_LLM_MAX_TOKENS
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
constants.DEEPSEEK_API_URL,
|
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
timeout=30
|
|
||||||
)
|
)
|
||||||
|
return summary
|
||||||
if response.status_code == 200:
|
|
||||||
result = response.json()
|
|
||||||
summary = result["choices"][0]["message"]["content"].strip()
|
|
||||||
return summary
|
|
||||||
else:
|
|
||||||
print(f" ❌ 语言模型压缩失败: HTTP {response.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"语言模型压缩异常: {e}")
|
self.logger.error(f"语言模型压缩异常: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -6580,10 +6562,13 @@ class DeepSeekChat:
|
|||||||
history_manager: ConversationHistory = None, memory_db: MemoryDatabase = None,
|
history_manager: ConversationHistory = None, memory_db: MemoryDatabase = None,
|
||||||
memo_db: MemoDatabase = None, command_executor: CommandExecutor = None,
|
memo_db: MemoDatabase = None, command_executor: CommandExecutor = None,
|
||||||
prompt_manager: PromptManager = None):
|
prompt_manager: PromptManager = None):
|
||||||
self.name = "DeepSeek"
|
|
||||||
self.debug_level = debug_level if debug_level is not None else Logger("").level
|
|
||||||
self.logger = Logger(self.name, self.debug_level)
|
|
||||||
self.config = ConfigManager()
|
self.config = ConfigManager()
|
||||||
|
self.logger = Logger("DeepSeek", debug_level if debug_level is not None else Logger("").level)
|
||||||
|
self.debug_level = self.logger.level
|
||||||
|
# 大模型客户端(抽象层,支持多厂商切换)
|
||||||
|
self.client = create_llm_client(self.config, api_key=api_key, logger=self.logger)
|
||||||
|
self.api_key = self.client.api_key
|
||||||
|
self.name = self.client.name
|
||||||
self.history_manager = history_manager
|
self.history_manager = history_manager
|
||||||
self.memory_db = memory_db
|
self.memory_db = memory_db
|
||||||
self.memo_db = memo_db
|
self.memo_db = memo_db
|
||||||
@@ -6595,24 +6580,12 @@ class DeepSeekChat:
|
|||||||
# 添加对话压缩器
|
# 添加对话压缩器
|
||||||
self.compressor = ConversationCompressor(self, self.logger)
|
self.compressor = ConversationCompressor(self, self.logger)
|
||||||
|
|
||||||
api_key_env = self.config.get("api.api_key_env")
|
|
||||||
self.api_key = api_key or os.environ.get(api_key_env)
|
|
||||||
if not self.api_key:
|
|
||||||
try:
|
|
||||||
home = os.environ.get("HOME", os.environ["USERPROFILE"])
|
|
||||||
key_path = os.path.join(home, ".cert", "deepseek.key")
|
|
||||||
with open(f"{key_path}", "r") as f:
|
|
||||||
self.api_key = f.read().strip()
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"请设置环境变量 {api_key_env} 或在配置文件中设置API Key")
|
|
||||||
|
|
||||||
self.max_history = max_history or constants.DEFAULT_MAX_HISTORY
|
self.max_history = max_history or constants.DEFAULT_MAX_HISTORY
|
||||||
self.preserve_system = constants.PRESERVE_SYSTEM_PROMPTS
|
self.preserve_system = constants.PRESERVE_SYSTEM_PROMPTS
|
||||||
self.stats = {"api_calls": 0, "tool_calls": 0, "errors": []}
|
self.stats = {"api_calls": 0, "tool_calls": 0, "errors": []}
|
||||||
|
|
||||||
# 取消机制:threading.Event 用于跨线程信号,_current_response 用于中断HTTP请求
|
# 取消机制:threading.Event 用于跨线程信号,HTTP 中断由客户端抽象层负责
|
||||||
self._cancel_event = threading.Event()
|
self._cancel_event = threading.Event()
|
||||||
self._current_response = None
|
|
||||||
self._command_executor = command_executor # 保存命令执行器引用,用于取消时kill子进程
|
self._command_executor = command_executor # 保存命令执行器引用,用于取消时kill子进程
|
||||||
|
|
||||||
self._init_system_prompts(system_prompt)
|
self._init_system_prompts(system_prompt)
|
||||||
@@ -6624,19 +6597,10 @@ class DeepSeekChat:
|
|||||||
"""中断当前正在进行的AI思考/工具执行。线程安全,可从任意线程调用。"""
|
"""中断当前正在进行的AI思考/工具执行。线程安全,可从任意线程调用。"""
|
||||||
self.logger.info("收到中断信号,正在取消当前操作...")
|
self.logger.info("收到中断信号,正在取消当前操作...")
|
||||||
self._cancel_event.set()
|
self._cancel_event.set()
|
||||||
# 强制关闭当前HTTP响应,立即中断 iter_lines() 的阻塞读取
|
# 关闭当前流式HTTP响应,立即中断阻塞读取(由客户端抽象层负责)
|
||||||
# 仅调用 response.close() 可能无法中断底层socket recv(),需要先关闭raw连接
|
if self.client is not None:
|
||||||
if self._current_response is not None:
|
|
||||||
try:
|
try:
|
||||||
resp = self._current_response
|
self.client.cancel()
|
||||||
self._current_response = None
|
|
||||||
# 先关闭底层raw socket连接,强制阻塞的recv()立即抛出异常
|
|
||||||
if hasattr(resp, 'raw') and resp.raw is not None:
|
|
||||||
try:
|
|
||||||
resp.raw.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
resp.close()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 终止正在运行的子进程
|
# 终止正在运行的子进程
|
||||||
@@ -7039,11 +7003,6 @@ class DeepSeekChat:
|
|||||||
if self.history_manager:
|
if self.history_manager:
|
||||||
self.history_manager.add_message("user", enhanced_input, "用户")
|
self.history_manager.add_message("user", enhanced_input, "用户")
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 构建消息列表,确保包含 reasoning_content
|
# 构建消息列表,确保包含 reasoning_content
|
||||||
enhanced_messages = []
|
enhanced_messages = []
|
||||||
|
|
||||||
@@ -7081,25 +7040,8 @@ class DeepSeekChat:
|
|||||||
else:
|
else:
|
||||||
enhanced_messages.append(msg)
|
enhanced_messages.append(msg)
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": constants.DEEPSEEK_MODEL,
|
|
||||||
"messages": enhanced_messages,
|
|
||||||
"temperature": constants.DEFAULT_TEMPERATURE,
|
|
||||||
"max_tokens": constants.DEFAULT_MAX_TOKENS,
|
|
||||||
"stream": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# 思考模式:启用 thinking 参数
|
|
||||||
if constants.REASONING_ENABLED:
|
|
||||||
payload["thinking"] = {
|
|
||||||
"type": "enabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工具调用(v4-flash 支持思考+工具调用)
|
# 工具调用(v4-flash 支持思考+工具调用)
|
||||||
tools = self.tool_registry.get_tools_schemas()
|
tools = self.tool_registry.get_tools_schemas() or None
|
||||||
if tools:
|
|
||||||
payload["tools"] = tools
|
|
||||||
payload["tool_choice"] = "auto"
|
|
||||||
|
|
||||||
current_time = datetime.now().strftime("%H:%M:%S")
|
current_time = datetime.now().strftime("%H:%M:%S")
|
||||||
if output_callback and not is_continuation:
|
if output_callback and not is_continuation:
|
||||||
@@ -7125,113 +7067,81 @@ class DeepSeekChat:
|
|||||||
MAX_CHUNK_DELAY = 0.1
|
MAX_CHUNK_DELAY = 0.1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxies = self.config.get_proxy()
|
# 通过客户端抽象层发起流式请求(统一事件格式,支持多厂商切换)
|
||||||
response = requests.post(
|
for event in self.client.stream_chat(
|
||||||
constants.DEEPSEEK_API_URL,
|
enhanced_messages,
|
||||||
headers=headers,
|
temperature=constants.DEFAULT_TEMPERATURE,
|
||||||
json=payload,
|
max_tokens=constants.DEFAULT_MAX_TOKENS,
|
||||||
timeout=constants.DEFAULT_TIMEOUT,
|
tools=tools,
|
||||||
stream=True,
|
reasoning_enabled=constants.REASONING_ENABLED,
|
||||||
proxies=proxies # 动态代理
|
):
|
||||||
)
|
|
||||||
self._current_response = response # 保存引用以便cancel()关闭连接
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
error_msg = f"\n错误状态码: {response.status_code}\n错误响应: {response.text}"
|
|
||||||
if output_callback:
|
|
||||||
output_callback("error", error_msg)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
for line in response.iter_lines():
|
|
||||||
# 流式循环中检查取消信号
|
# 流式循环中检查取消信号
|
||||||
if self._is_cancelled():
|
if self._is_cancelled():
|
||||||
break
|
break
|
||||||
if line:
|
|
||||||
line = line.decode('utf-8')
|
etype = event.get("type")
|
||||||
if line.startswith('data: '):
|
|
||||||
data = line[6:]
|
if etype == "reasoning":
|
||||||
if data == '[DONE]':
|
# 处理思考内容(仅当配置启用时才会收到该事件)
|
||||||
if chunk_buffer and output_callback:
|
reasoning = event.get("text", "")
|
||||||
output_callback("chunk", chunk_buffer)
|
full_reasoning += reasoning
|
||||||
chunk_buffer = ""
|
if not reasoning_started and output_callback:
|
||||||
if output_callback:
|
reasoning_started = True
|
||||||
output_callback("line", "\n")
|
if output_callback:
|
||||||
break
|
output_callback("reasoning", reasoning)
|
||||||
|
|
||||||
try:
|
elif etype == "content":
|
||||||
chunk = json.loads(data)
|
# 思考结束:当开始收到content且之前有reasoning时,发送结束信号
|
||||||
choices = chunk.get('choices', [])
|
if reasoning_started and not reasoning_ended:
|
||||||
if not choices:
|
reasoning_ended = True
|
||||||
continue
|
if output_callback:
|
||||||
|
output_callback("reasoning_end", "")
|
||||||
delta = choices[0].get('delta', {})
|
output_callback("line", i18n.get('assistant_prefix'))
|
||||||
|
content = event.get("text", "")
|
||||||
# 【关键】处理 reasoning_content(思考内容),仅当配置启用时才展示
|
chunk_buffer += content
|
||||||
if constants.REASONING_ENABLED and 'reasoning_content' in delta and delta['reasoning_content']:
|
full_response += content
|
||||||
reasoning = delta['reasoning_content']
|
|
||||||
full_reasoning += reasoning
|
if (len(chunk_buffer) >= MIN_CHUNK_SIZE or
|
||||||
|
time.time() - last_send_time >= MAX_CHUNK_DELAY):
|
||||||
# 发送思考开始信号(仅第一次)
|
if output_callback:
|
||||||
if not reasoning_started and output_callback:
|
output_callback("chunk", chunk_buffer)
|
||||||
reasoning_started = True
|
chunk_buffer = ""
|
||||||
|
last_send_time = time.time()
|
||||||
# 实时发送思考内容给前端
|
|
||||||
if output_callback:
|
elif etype == "tool_call":
|
||||||
output_callback("reasoning", reasoning)
|
# 处理工具调用(流式增量)
|
||||||
|
index = event.get("index", 0)
|
||||||
# 思考结束:当开始收到content且之前有reasoning时,发送结束信号
|
if index not in current_tool_calls:
|
||||||
if 'content' in delta and delta['content'] and reasoning_started and not reasoning_ended:
|
# 确保 tool_call id 非空,空 id 会导致 API 400 错误污染对话历史
|
||||||
reasoning_ended = True
|
tc_id = event.get("id") or f"call_{self.stats['tool_calls']}_{index}_{int(time.time()*1000)}"
|
||||||
if output_callback:
|
current_tool_calls[index] = {
|
||||||
output_callback("reasoning_end", "")
|
'id': tc_id,
|
||||||
output_callback("line", i18n.get('assistant_prefix'))
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
# 处理正常内容
|
'name': '',
|
||||||
if 'content' in delta and delta['content']:
|
'arguments': ''
|
||||||
content = delta['content']
|
}
|
||||||
chunk_buffer += content
|
}
|
||||||
full_response += content
|
else:
|
||||||
|
# 流式传输中 id 可能分片到达,后续补充
|
||||||
if (len(chunk_buffer) >= MIN_CHUNK_SIZE or
|
existing_id = current_tool_calls[index].get('id', '')
|
||||||
time.time() - last_send_time >= MAX_CHUNK_DELAY):
|
new_id = event.get("id", "")
|
||||||
if output_callback:
|
if new_id and (not existing_id or existing_id.startswith('call_')):
|
||||||
output_callback("chunk", chunk_buffer)
|
current_tool_calls[index]['id'] = new_id
|
||||||
chunk_buffer = ""
|
|
||||||
last_send_time = time.time()
|
if event.get("name"):
|
||||||
|
current_tool_calls[index]['function']['name'] = event["name"]
|
||||||
# 处理工具调用
|
if event.get("arguments"):
|
||||||
if 'tool_calls' in delta:
|
current_tool_calls[index]['function']['arguments'] += event["arguments"]
|
||||||
tool_calls = delta['tool_calls']
|
|
||||||
for tc in tool_calls:
|
elif etype == "done":
|
||||||
index = tc.get('index', 0)
|
# 流结束:flush 剩余缓冲
|
||||||
|
if chunk_buffer and output_callback:
|
||||||
if index not in current_tool_calls:
|
output_callback("chunk", chunk_buffer)
|
||||||
# 确保 tool_call id 非空,空 id 会导致 API 400 错误污染对话历史
|
chunk_buffer = ""
|
||||||
tc_id = tc.get('id', '') or f"call_{self.stats['tool_calls']}_{index}_{int(time.time()*1000)}"
|
if output_callback:
|
||||||
current_tool_calls[index] = {
|
output_callback("line", "\n")
|
||||||
'id': tc_id,
|
break
|
||||||
'type': 'function',
|
|
||||||
'function': {
|
|
||||||
'name': '',
|
|
||||||
'arguments': ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# 流式传输中 id 可能分片到达,后续补充
|
|
||||||
existing_id = current_tool_calls[index].get('id', '')
|
|
||||||
new_id = tc.get('id', '')
|
|
||||||
if new_id and (not existing_id or existing_id.startswith('call_')):
|
|
||||||
current_tool_calls[index]['id'] = new_id
|
|
||||||
|
|
||||||
if 'function' in tc:
|
|
||||||
if 'name' in tc['function']:
|
|
||||||
current_tool_calls[index]['function']['name'] = tc['function']['name']
|
|
||||||
if 'arguments' in tc['function']:
|
|
||||||
current_tool_calls[index]['function']['arguments'] += tc['function']['arguments']
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
self.logger.error(f"JSON解析错误: {e}, 数据: {data}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 流式循环结束,检查是否被取消
|
# 流式循环结束,检查是否被取消
|
||||||
if self._is_cancelled():
|
if self._is_cancelled():
|
||||||
@@ -7373,8 +7283,8 @@ class DeepSeekChat:
|
|||||||
self.logger.error(f"未知错误,已回滚消息到位置 {msgs_before_turn}: {e}")
|
self.logger.error(f"未知错误,已回滚消息到位置 {msgs_before_turn}: {e}")
|
||||||
return error_msg
|
return error_msg
|
||||||
finally:
|
finally:
|
||||||
# 清除当前响应引用
|
# 客户端抽象层在流结束时已自行清理响应引用
|
||||||
self._current_response = None
|
pass
|
||||||
|
|
||||||
def _rollback_messages(self, to_length: int):
|
def _rollback_messages(self, to_length: int):
|
||||||
"""回滚 self.messages 到指定长度,用于 API 失败时恢复对话状态。"""
|
"""回滚 self.messages 到指定长度,用于 API 失败时恢复对话状态。"""
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
大模型客户端抽象层
|
||||||
|
|
||||||
|
为 aibox 提供统一的 LLM 调用接口,将「调用哪个厂商 / 哪个模型」与业务逻辑解耦。
|
||||||
|
业务层只依赖 `LLMClient` 抽象方法与统一的流式事件格式,切换厂商只需:
|
||||||
|
1. 新增一个 `LLMClient` 子类实现;
|
||||||
|
2. 在 `create_llm_client` 工厂里按 `api.provider` 进行路由。
|
||||||
|
|
||||||
|
当前内置实现:
|
||||||
|
- `DeepSeekClient`:兼容 OpenAI Chat Completions 协议(DeepSeek / Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。
|
||||||
|
|
||||||
|
统一流式事件格式(`stream_chat` 逐条 yield 的 dict):
|
||||||
|
{"type": "content", "text": str} # 增量正文
|
||||||
|
{"type": "reasoning", "text": str} # 增量思考内容(可选,厂商支持时)
|
||||||
|
{"type": "tool_call", "index": int, "id": str, "name": str, "arguments": str} # 工具调用增量
|
||||||
|
{"type": "done"} # 流结束
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Iterator, List, Optional
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient(ABC):
|
||||||
|
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str, base_url: str,
|
||||||
|
timeout: int, config=None, logger=None):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.base_url = base_url
|
||||||
|
self.timeout = timeout
|
||||||
|
self.config = config # 可选,用于读取代理等动态配置
|
||||||
|
self.logger = logger # 可选,用于日志输出
|
||||||
|
# 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取
|
||||||
|
self._current_response = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。"""
|
||||||
|
return "LLM"
|
||||||
|
|
||||||
|
# ---------- 工具方法 ----------
|
||||||
|
|
||||||
|
def _resolve_proxies(self) -> Optional[Dict[str, str]]:
|
||||||
|
"""从 config 解析 requests 可用的 proxies 字典(动态代理)。"""
|
||||||
|
if self.config is not None and hasattr(self.config, "get_proxy"):
|
||||||
|
return self.config.get_proxy()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _log(self, level: str, msg: str):
|
||||||
|
if self.logger is not None and hasattr(self.logger, level):
|
||||||
|
try:
|
||||||
|
getattr(self.logger, level)(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""中断当前流式请求。线程安全,可从任意线程调用。"""
|
||||||
|
resp = self._current_response
|
||||||
|
self._current_response = None
|
||||||
|
if resp is not None:
|
||||||
|
try:
|
||||||
|
# 先关闭底层 raw socket,强制阻塞的 recv() 立即抛出异常
|
||||||
|
if getattr(resp, "raw", None) is not None:
|
||||||
|
resp.raw.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
resp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---------- 抽象接口 ----------
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def chat_completion(self, messages: List[Dict], temperature: float = 0.3,
|
||||||
|
max_tokens: int = 1024, **kwargs) -> str:
|
||||||
|
"""非流式对话,返回完整文本。用于对话压缩等离线摘要场景。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def stream_chat(self, messages: List[Dict], temperature: float = 0.7,
|
||||||
|
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||||
|
tool_choice: Optional[str] = None,
|
||||||
|
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||||
|
"""流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。"""
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekClient(LLMClient):
|
||||||
|
"""DeepSeek 客户端(OpenAI Chat Completions 协议兼容)。
|
||||||
|
|
||||||
|
思考模式通过 `thinking: {"type": "enabled"}` 参数控制,思考内容字段为
|
||||||
|
`delta.reasoning_content`。二者均为 DeepSeek 特色,其他 OpenAI 兼容厂商
|
||||||
|
若字段不同,可在子类中覆写 `_parse_delta` 归一化。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str, base_url: str,
|
||||||
|
timeout: int = 30, config=None, logger=None):
|
||||||
|
super().__init__(api_key, model, base_url, timeout, config, logger)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "DeepSeek"
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def chat_completion(self, messages: List[Dict], temperature: float = 0.3,
|
||||||
|
max_tokens: int = 1024, **kwargs) -> str:
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
payload.update(kwargs)
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
self.base_url,
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
proxies=self._resolve_proxies(),
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
return result["choices"][0]["message"]["content"].strip()
|
||||||
|
self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
self._log("error", f"LLM 压缩请求异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stream_chat(self, messages: List[Dict], temperature: float = 0.7,
|
||||||
|
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||||
|
tool_choice: Optional[str] = None,
|
||||||
|
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
# 思考模式
|
||||||
|
if reasoning_enabled:
|
||||||
|
payload["thinking"] = {"type": "enabled"}
|
||||||
|
# 工具调用
|
||||||
|
if tools:
|
||||||
|
payload["tools"] = tools
|
||||||
|
payload["tool_choice"] = tool_choice or "auto"
|
||||||
|
payload.update(extra)
|
||||||
|
|
||||||
|
proxies = self._resolve_proxies()
|
||||||
|
response = requests.post(
|
||||||
|
self.base_url,
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
stream=True,
|
||||||
|
proxies=proxies,
|
||||||
|
)
|
||||||
|
self._current_response = response
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"HTTP {response.status_code}: {response.text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
line_text = line.decode("utf-8")
|
||||||
|
if not line_text.startswith("data: "):
|
||||||
|
continue
|
||||||
|
data = line_text[6:]
|
||||||
|
if data == "[DONE]":
|
||||||
|
yield {"type": "done"}
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
self._log("error", f"JSON 解析错误: {e}, 数据: {data}")
|
||||||
|
continue
|
||||||
|
choices = chunk.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
continue
|
||||||
|
delta = choices[0].get("delta", {})
|
||||||
|
for event in self._parse_delta(delta, reasoning_enabled):
|
||||||
|
yield event
|
||||||
|
finally:
|
||||||
|
self._current_response = None
|
||||||
|
|
||||||
|
def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]:
|
||||||
|
"""将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。"""
|
||||||
|
# 思考内容
|
||||||
|
if reasoning_enabled and delta.get("reasoning_content"):
|
||||||
|
yield {"type": "reasoning", "text": delta["reasoning_content"]}
|
||||||
|
# 正文
|
||||||
|
if delta.get("content"):
|
||||||
|
yield {"type": "content", "text": delta["content"]}
|
||||||
|
# 工具调用(增量)
|
||||||
|
for tc in delta.get("tool_calls", []):
|
||||||
|
index = tc.get("index", 0)
|
||||||
|
function = tc.get("function", {})
|
||||||
|
yield {
|
||||||
|
"type": "tool_call",
|
||||||
|
"index": index,
|
||||||
|
"id": tc.get("id", ""),
|
||||||
|
"name": function.get("name", ""),
|
||||||
|
"arguments": function.get("arguments", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_api_key(api_key: Optional[str], config=None) -> str:
|
||||||
|
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。"""
|
||||||
|
if api_key:
|
||||||
|
return api_key
|
||||||
|
env_name = None
|
||||||
|
if config is not None and hasattr(config, "get"):
|
||||||
|
env_name = config.get("api.api_key_env")
|
||||||
|
env_name = env_name or "DEEPSEEK_API_KEY"
|
||||||
|
env_key = os.environ.get(env_name)
|
||||||
|
if env_key:
|
||||||
|
return env_key
|
||||||
|
try:
|
||||||
|
home = os.environ.get("HOME", os.environ["USERPROFILE"])
|
||||||
|
key_path = os.path.join(home, ".cert", "deepseek.key")
|
||||||
|
with open(key_path, "r") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"请设置环境变量 {env_name} 或在配置文件中设置 API Key")
|
||||||
|
|
||||||
|
|
||||||
|
def create_llm_client(config, api_key: Optional[str] = None,
|
||||||
|
logger=None) -> LLMClient:
|
||||||
|
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。"""
|
||||||
|
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
|
||||||
|
base_url = config.get("api.deepseek_api_url")
|
||||||
|
model = config.get("api.deepseek_model", "deepseek-v4-flash")
|
||||||
|
timeout = config.get("api.timeout", 30)
|
||||||
|
|
||||||
|
resolved_key = resolve_api_key(api_key, config)
|
||||||
|
|
||||||
|
if provider in ("deepseek", "deepseek-chat"):
|
||||||
|
return DeepSeekClient(
|
||||||
|
api_key=resolved_key,
|
||||||
|
model=model,
|
||||||
|
base_url=base_url,
|
||||||
|
timeout=timeout,
|
||||||
|
config=config,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析),
|
||||||
|
# 或在此接入专有实现。新增厂商时在此扩展即可。
|
||||||
|
raise ValueError(f"未知的模型提供商: {provider}")
|
||||||
Reference in new issue
Block a user