|
|
@@ -1298,6 +1298,97 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * GPT 重试次数(env 可配置,默认 5 次)
|
|
|
+ */
|
|
|
+ private function gptRetryTimes()
|
|
|
+ {
|
|
|
+ return (int)env('GPT_RETRY_TIMES', 5);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * GPT 重试间隔秒数(env 可配置,默认 10 秒)
|
|
|
+ */
|
|
|
+ private function gptRetryInterval()
|
|
|
+ {
|
|
|
+ return (int)env('GPT_RETRY_INTERVAL', 10);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断 GPT 请求异常是否可重试
|
|
|
+ * 仅连接失败/超时等传输层异常、5xx、429 可重试;其余 4xx(401/400 等)不重试
|
|
|
+ */
|
|
|
+ private function isGptRetryableException(\Exception $e)
|
|
|
+ {
|
|
|
+ if (!($e instanceof \GuzzleHttp\Exception\GuzzleException)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($e instanceof \GuzzleHttp\Exception\BadResponseException && $e->getResponse()) {
|
|
|
+ $status = $e->getResponse()->getStatusCode();
|
|
|
+ return $status >= 500 || $status == 429;
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * GPT 中转站探活:GET /v1/models,5 秒短超时
|
|
|
+ * 连接成功(含 401/403/404)视为可用;连接失败/超时/5xx/429 视为不可用
|
|
|
+ */
|
|
|
+ private function checkGptServiceAvailable()
|
|
|
+ {
|
|
|
+ $apiKey = env('GPT_54_API_KEY');
|
|
|
+ if (empty($apiKey)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ $checkClient = new Client(['timeout' => 5, 'verify' => false, 'http_errors' => false]);
|
|
|
+ $response = $checkClient->get('https://ai-api.kkidc.com/v1/models', [
|
|
|
+ 'headers' => [
|
|
|
+ 'Authorization' => 'Bearer ' . $apiKey,
|
|
|
+ 'Content-Type' => 'application/json'
|
|
|
+ ]
|
|
|
+ ]);
|
|
|
+ $status = $response->getStatusCode();
|
|
|
+ return $status < 500 && $status != 429;
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 探活并重试:最多重试 gptRetryTimes() 次,间隔 gptRetryInterval() 秒
|
|
|
+ * 全部失败抛出友好错误
|
|
|
+ */
|
|
|
+ private function ensureGptServiceAvailable()
|
|
|
+ {
|
|
|
+ $maxRetries = $this->gptRetryTimes();
|
|
|
+ $interval = $this->gptRetryInterval();
|
|
|
+
|
|
|
+ for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
|
|
|
+ if ($this->checkGptServiceAvailable()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($attempt < $maxRetries) {
|
|
|
+ dLog('deepseek')->warning('GPT 中转站探活失败,准备重试', [
|
|
|
+ 'retry' => $attempt + 1,
|
|
|
+ 'max_retries' => $maxRetries
|
|
|
+ ]);
|
|
|
+ sleep($interval);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ dLog('deepseek')->error('GPT 中转站探活失败,重试次数已用完');
|
|
|
+ logDB('deepseek', 'error', 'GPT 中转站服务不可用', [
|
|
|
+ 'max_retries' => $maxRetries,
|
|
|
+ 'interval' => $interval
|
|
|
+ ]);
|
|
|
+ Utils::throwError('20003:GPT 中转站服务暂时不可用,请稍后重试');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* GPT 流式输出处理方法
|
|
|
*
|
|
|
* @param array $post_data GPT API请求参数
|
|
|
@@ -1310,6 +1401,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
|
|
|
Utils::throwError('20003:GPT API Key未配置');
|
|
|
}
|
|
|
|
|
|
+ // 先探活,服务不可用时自动重试
|
|
|
+ $this->ensureGptServiceAvailable();
|
|
|
+
|
|
|
$client = new Client(['timeout' => 1800, 'verify' => false]);
|
|
|
$headers = [
|
|
|
'Authorization' => 'Bearer ' . $apiKey,
|
|
|
@@ -1325,96 +1419,121 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
|
|
|
}
|
|
|
$post_data['max_completion_tokens'] = 100000;
|
|
|
|
|
|
- // 备用中转站地址1: https://token.ithinkai.cn/v1/chat/completions
|
|
|
- // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
|
|
|
- $response = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
|
|
|
- 'json' => $post_data,
|
|
|
- 'headers' => $headers,
|
|
|
- 'stream' => true // 启用流式响应
|
|
|
- ]);
|
|
|
-
|
|
|
- $body = $response->getBody();
|
|
|
- $fullContent = '';
|
|
|
- $usage = [];
|
|
|
- $buffer = '';
|
|
|
+ $maxRetries = $this->gptRetryTimes();
|
|
|
+ $interval = $this->gptRetryInterval();
|
|
|
+ $attempt = 0;
|
|
|
|
|
|
- dLog('deepseek')->info('开始读取 GPT 流式响应');
|
|
|
+ while (true) {
|
|
|
+ $fullContent = '';
|
|
|
+ $usage = [];
|
|
|
+ $buffer = '';
|
|
|
|
|
|
- // 逐行读取流式响应
|
|
|
- while (!$body->eof()) {
|
|
|
- $chunk = $body->read(1024); // 每次读取 1KB
|
|
|
- $buffer .= $chunk;
|
|
|
-
|
|
|
- // 按行分割
|
|
|
- $lines = explode("\n", $buffer);
|
|
|
- // 保留最后一个不完整的行
|
|
|
- $buffer = array_pop($lines);
|
|
|
-
|
|
|
- foreach ($lines as $line) {
|
|
|
- $line = trim($line);
|
|
|
-
|
|
|
- // 跳过空行
|
|
|
- if (empty($line)) {
|
|
|
- continue;
|
|
|
- }
|
|
|
+ try {
|
|
|
+ // 备用中转站地址1: https://token.ithinkai.cn/v1/chat/completions
|
|
|
+ // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
|
|
|
+ $response = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
|
|
|
+ 'json' => $post_data,
|
|
|
+ 'headers' => $headers,
|
|
|
+ 'stream' => true // 启用流式响应
|
|
|
+ ]);
|
|
|
|
|
|
- // 检查是否是 SSE 数据行
|
|
|
- if (strpos($line, 'data: ') !== 0) {
|
|
|
- dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]);
|
|
|
- continue;
|
|
|
- }
|
|
|
+ $body = $response->getBody();
|
|
|
|
|
|
- $data = substr($line, 6); // 移除 "data: " 前缀
|
|
|
-
|
|
|
- if ($data === '[DONE]') {
|
|
|
- dLog('deepseek')->info('收到结束标记');
|
|
|
- break 2; // 跳出两层循环
|
|
|
- }
|
|
|
+ dLog('deepseek')->info('开始读取 GPT 流式响应');
|
|
|
|
|
|
- try {
|
|
|
- $json = json_decode($data, true);
|
|
|
+ // 逐行读取流式响应
|
|
|
+ while (!$body->eof()) {
|
|
|
+ $chunk = $body->read(1024); // 每次读取 1KB
|
|
|
+ $buffer .= $chunk;
|
|
|
|
|
|
- if (json_last_error() !== JSON_ERROR_NONE) {
|
|
|
- dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
|
|
|
- continue;
|
|
|
- }
|
|
|
+ // 按行分割
|
|
|
+ $lines = explode("\n", $buffer);
|
|
|
+ // 保留最后一个不完整的行
|
|
|
+ $buffer = array_pop($lines);
|
|
|
|
|
|
- if (isset($json['choices'][0]['delta'])) {
|
|
|
- $delta = $json['choices'][0]['delta'];
|
|
|
+ foreach ($lines as $line) {
|
|
|
+ $line = trim($line);
|
|
|
|
|
|
- // 处理回答内容
|
|
|
- if (isset($delta['content'])) {
|
|
|
- $fullContent .= $delta['content'];
|
|
|
- yield [
|
|
|
- 'type' => 'content',
|
|
|
- 'content' => $delta['content'],
|
|
|
- ];
|
|
|
+ // 跳过空行
|
|
|
+ if (empty($line)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否是 SSE 数据行
|
|
|
+ if (strpos($line, 'data: ') !== 0) {
|
|
|
+ dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $data = substr($line, 6); // 移除 "data: " 前缀
|
|
|
+
|
|
|
+ if ($data === '[DONE]') {
|
|
|
+ dLog('deepseek')->info('收到结束标记');
|
|
|
+ break 2; // 跳出 foreach 和流读取循环
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ $json = json_decode($data, true);
|
|
|
+
|
|
|
+ if (json_last_error() !== JSON_ERROR_NONE) {
|
|
|
+ dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isset($json['choices'][0]['delta'])) {
|
|
|
+ $delta = $json['choices'][0]['delta'];
|
|
|
+
|
|
|
+ // 处理回答内容
|
|
|
+ if (isset($delta['content'])) {
|
|
|
+ $fullContent .= $delta['content'];
|
|
|
+ yield [
|
|
|
+ 'type' => 'content',
|
|
|
+ 'content' => $delta['content'],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取使用统计信息
|
|
|
+ if (isset($json['usage'])) {
|
|
|
+ $usage = $json['usage'];
|
|
|
+ dLog('deepseek')->info('收到使用统计', ['usage' => $usage]);
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
|
|
|
+ continue;
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // 获取使用统计信息
|
|
|
- if (isset($json['usage'])) {
|
|
|
- $usage = $json['usage'];
|
|
|
- dLog('deepseek')->info('收到使用统计', ['usage' => $usage]);
|
|
|
- }
|
|
|
-
|
|
|
- } catch (\Exception $e) {
|
|
|
- dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
|
|
|
- continue;
|
|
|
}
|
|
|
- }
|
|
|
- }
|
|
|
|
|
|
- dLog('deepseek')->info('流式读取完成', [
|
|
|
- 'content_length' => strlen($fullContent)
|
|
|
- ]);
|
|
|
+ dLog('deepseek')->info('流式读取完成', [
|
|
|
+ 'content_length' => strlen($fullContent)
|
|
|
+ ]);
|
|
|
|
|
|
- yield [
|
|
|
- 'type' => 'done',
|
|
|
- 'full_content' => $fullContent,
|
|
|
- 'full_reasoning' => '',
|
|
|
- 'usage' => $usage
|
|
|
- ];
|
|
|
+ yield [
|
|
|
+ 'type' => 'done',
|
|
|
+ 'full_content' => $fullContent,
|
|
|
+ 'full_reasoning' => '',
|
|
|
+ 'usage' => $usage
|
|
|
+ ];
|
|
|
+ return;
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ // 已输出内容不重试(避免重复);非可重试异常不重试;重试次数用完则抛出
|
|
|
+ if (!empty($fullContent) || !$this->isGptRetryableException($e) || $attempt >= $maxRetries) {
|
|
|
+ dLog('deepseek')->error('GPT 流式请求失败: ' . $e->getMessage());
|
|
|
+ logDB('deepseek', 'error', 'GPT 流式请求失败', ['error' => $e->getMessage()]);
|
|
|
+ throw $e;
|
|
|
+ }
|
|
|
+
|
|
|
+ dLog('deepseek')->warning('GPT 流式请求失败,准备重试', [
|
|
|
+ 'retry' => $attempt + 1,
|
|
|
+ 'max_retries' => $maxRetries,
|
|
|
+ 'error' => $e->getMessage()
|
|
|
+ ]);
|
|
|
+ sleep($interval);
|
|
|
+ $attempt++;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 与推理模型对话
|
|
|
@@ -11755,6 +11874,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
|
|
|
Utils::throwError('20003:GPT-5.4 API Key未配置');
|
|
|
}
|
|
|
|
|
|
+ // 先探活,服务不可用时自动重试
|
|
|
+ $this->ensureGptServiceAvailable();
|
|
|
+
|
|
|
$client = new Client(['timeout' => 1800, 'verify' => false]);
|
|
|
$headers = [
|
|
|
'Authorization' => 'Bearer ' . $apiKey,
|
|
|
@@ -11773,29 +11895,51 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
|
|
|
// 确保 stream 为 false
|
|
|
$post_data['stream'] = false;
|
|
|
|
|
|
- // 备用中转站地址: https://token.ithinkai.cn/v1/chat/completions
|
|
|
- // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
|
|
|
- $result = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
|
|
|
- 'json' => $post_data,
|
|
|
- 'headers' => $headers
|
|
|
- ]);
|
|
|
+ $maxRetries = $this->gptRetryTimes();
|
|
|
+ $interval = $this->gptRetryInterval();
|
|
|
+ $attempt = 0;
|
|
|
|
|
|
- $response = $result->getBody()->getContents();
|
|
|
- $response_arr = json_decode($response, true);
|
|
|
- dLog('deepseek')->info('GPT-5.4请求完成', ['response' => $response_arr]);
|
|
|
+ while (true) {
|
|
|
+ try {
|
|
|
+ // 备用中转站地址: https://token.ithinkai.cn/v1/chat/completions
|
|
|
+ // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
|
|
|
+ $result = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
|
|
|
+ 'json' => $post_data,
|
|
|
+ 'headers' => $headers
|
|
|
+ ]);
|
|
|
|
|
|
- $fullContent = '';
|
|
|
- $usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
|
|
|
+ $response = $result->getBody()->getContents();
|
|
|
+ $response_arr = json_decode($response, true);
|
|
|
+ dLog('deepseek')->info('GPT-5.4请求完成', ['response' => $response_arr]);
|
|
|
|
|
|
- if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
|
|
|
- $fullContent = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
|
|
|
- }
|
|
|
+ $fullContent = '';
|
|
|
+ $usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
|
|
|
|
|
|
- return [
|
|
|
- 'fullContent' => $fullContent,
|
|
|
- 'fullReasoningContent' => '', // GPT-5.4 不支持思考链
|
|
|
- 'usage' => $usage
|
|
|
- ];
|
|
|
+ if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
|
|
|
+ $fullContent = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'fullContent' => $fullContent,
|
|
|
+ 'fullReasoningContent' => '', // GPT-5.4 不支持思考链
|
|
|
+ 'usage' => $usage
|
|
|
+ ];
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ if (!$this->isGptRetryableException($e) || $attempt >= $maxRetries) {
|
|
|
+ dLog('deepseek')->error('GPT-5.4 请求失败: ' . $e->getMessage());
|
|
|
+ logDB('deepseek', 'error', 'GPT-5.4 请求失败', ['error' => $e->getMessage()]);
|
|
|
+ throw $e;
|
|
|
+ }
|
|
|
+
|
|
|
+ dLog('deepseek')->warning('GPT-5.4 请求失败,准备重试', [
|
|
|
+ 'retry' => $attempt + 1,
|
|
|
+ 'max_retries' => $maxRetries,
|
|
|
+ 'error' => $e->getMessage()
|
|
|
+ ]);
|
|
|
+ sleep($interval);
|
|
|
+ $attempt++;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private function splitContent($content) {
|