|
|
@@ -5,6 +5,7 @@ namespace App\Services;
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
|
+use Illuminate\Http\Client\RequestException;
|
|
|
|
|
|
class OpenAIService
|
|
|
{
|
|
|
@@ -72,49 +73,82 @@ class OpenAIService
|
|
|
|
|
|
return $this;
|
|
|
}
|
|
|
+
|
|
|
public function setMaxToken(int $maxTokens): static
|
|
|
{
|
|
|
$this->maxTokens = $maxTokens;
|
|
|
return $this;
|
|
|
}
|
|
|
+
|
|
|
/**
|
|
|
* 发送 GPT 请求(支持流式与非流式)
|
|
|
*/
|
|
|
public function send(string $question): string|array
|
|
|
{
|
|
|
- for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
|
|
|
+ $lastException = null;
|
|
|
|
|
|
+ for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
|
|
|
try {
|
|
|
if ($this->stream === false) {
|
|
|
- // ⬇ 非流式原逻辑
|
|
|
return $this->sendNormal($question);
|
|
|
}
|
|
|
|
|
|
- // ⬇ 流式逻辑
|
|
|
return $this->sendStreaming($question);
|
|
|
+ } catch (RateLimitException $e) {
|
|
|
+ // 429 速率限制,等待后重试
|
|
|
+ $retryAfter = $e->getRetryAfter();
|
|
|
+ Log::warning("请求被限流(429),等待 {$retryAfter} 秒后重试...(第 {$attempt} 次)");
|
|
|
+ sleep($retryAfter);
|
|
|
+ $lastException = $e;
|
|
|
+ continue;
|
|
|
+ } catch (ServerErrorException $e) {
|
|
|
+ // 5xx 服务器错误,使用指数退避重试
|
|
|
+ Log::warning("服务器错误({$e->getStatusCode()}):{$e->getMessage()},准备重试...(第 {$attempt} 次)");
|
|
|
+ if ($attempt < $this->retries) {
|
|
|
+ usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
|
|
|
+ }
|
|
|
+ $lastException = $e;
|
|
|
+ continue;
|
|
|
} catch (ConnectionException $e) {
|
|
|
+ // 网络连接错误,使用指数退避重试
|
|
|
Log::warning("第 {$attempt} 次连接超时:{$e->getMessage()},准备重试...");
|
|
|
- usleep($this->delayMs * 1000 * pow(2, $attempt));
|
|
|
+ if ($attempt < $this->retries) {
|
|
|
+ usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
|
|
|
+ }
|
|
|
+ $lastException = $e;
|
|
|
+ continue;
|
|
|
+ } catch (NetworkException $e) {
|
|
|
+ // 其他网络错误,使用指数退避重试
|
|
|
+ Log::warning("网络错误:{$e->getMessage()},准备重试...(第 {$attempt} 次)");
|
|
|
+ if ($attempt < $this->retries) {
|
|
|
+ usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
|
|
|
+ }
|
|
|
+ $lastException = $e;
|
|
|
continue;
|
|
|
+ } catch (ClientErrorException $e) {
|
|
|
+ // 4xx 客户端错误(除429外)不重试,直接抛出
|
|
|
+ Log::error("客户端错误({$e->getStatusCode()}):{$e->getMessage()}");
|
|
|
+ throw $e;
|
|
|
} catch (\Exception $e) {
|
|
|
+ // 其他未知异常,不重试,直接抛出
|
|
|
Log::error("GPT 请求异常:" . $e->getMessage());
|
|
|
- return [
|
|
|
- 'error' => $e->getMessage(),
|
|
|
- 'status' => 500
|
|
|
- ];
|
|
|
+ throw $e;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- return [
|
|
|
- 'error' => '请求多次失败或超时,请稍后再试。',
|
|
|
- 'status' => 504
|
|
|
- ];
|
|
|
+ // 所有重试都失败了
|
|
|
+ Log::error("请求多次失败,已重试 {$this->retries} 次");
|
|
|
+ throw new \RuntimeException(
|
|
|
+ '请求多次失败或超时,请稍后再试。原因: ' . ($lastException ? $lastException->getMessage() : '未知'),
|
|
|
+ 504,
|
|
|
+ $lastException
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 普通非流式请求
|
|
|
*/
|
|
|
- protected function sendNormal(string $question)
|
|
|
+ protected function sendNormal(string $question): array
|
|
|
{
|
|
|
$data = [
|
|
|
'model' => $this->model,
|
|
|
@@ -125,9 +159,11 @@ class OpenAIService
|
|
|
'temperature' => $this->temperature,
|
|
|
'stream' => false,
|
|
|
];
|
|
|
+
|
|
|
if ($this->maxTokens > 0) {
|
|
|
$data['max_tokens'] = $this->maxTokens;
|
|
|
}
|
|
|
+
|
|
|
$response = Http::withToken($this->apiKey)
|
|
|
->timeout($this->timeout)
|
|
|
->post($this->apiUrl, $data);
|
|
|
@@ -135,34 +171,48 @@ class OpenAIService
|
|
|
$status = $response->status();
|
|
|
$body = $response->json();
|
|
|
|
|
|
+ // 处理 429 速率限制
|
|
|
if ($status === 429) {
|
|
|
- $retryAfter = $response->header('Retry-After') ?? 20;
|
|
|
- Log::warning("请求被限流(429),等待 {$retryAfter} 秒后重试...");
|
|
|
- sleep((int)$retryAfter);
|
|
|
- return $this->sendNormal($question);
|
|
|
+ $retryAfter = (int)($response->header('Retry-After') ?? 20);
|
|
|
+ throw new RateLimitException(
|
|
|
+ $body['error']['message'] ?? '请求被限流',
|
|
|
+ $status,
|
|
|
+ $retryAfter
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
+ // 处理 5xx 服务器错误
|
|
|
+ if ($status >= 500 && $status < 600) {
|
|
|
+ throw new ServerErrorException(
|
|
|
+ $body['error']['message'] ?? '服务器错误',
|
|
|
+ $status
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理 4xx 客户端错误
|
|
|
+ if ($status >= 400 && $status < 500) {
|
|
|
+ throw new ClientErrorException(
|
|
|
+ $body['error']['message'] ?? '客户端请求错误',
|
|
|
+ $status
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理成功响应
|
|
|
if ($response->successful()) {
|
|
|
return $body;
|
|
|
}
|
|
|
- Log::error('llm request error', [
|
|
|
- 'error' => $body['error']['message'] ?? '请求失败',
|
|
|
- 'status' => $status
|
|
|
- ]);
|
|
|
- return [
|
|
|
- 'error' => $body['error']['message'] ?? '请求失败',
|
|
|
- 'status' => $status
|
|
|
- ];
|
|
|
+
|
|
|
+ // 其他未知错误
|
|
|
+ throw new \RuntimeException(
|
|
|
+ $body['error']['message'] ?? '请求失败',
|
|
|
+ $status
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * =====================================================
|
|
|
- * ⭐ 原生 CURL SSE + 完整拼接
|
|
|
- * ⭐ 不输出浏览器
|
|
|
- * ⭐ 无 IDE 报错(使用 \CurlHandle)
|
|
|
- * =====================================================
|
|
|
+ * 流式请求(使用原生 CURL SSE)
|
|
|
*/
|
|
|
- protected function sendStreaming(string $question): string|array
|
|
|
+ protected function sendStreaming(string $question): string
|
|
|
{
|
|
|
$payload = [
|
|
|
'model' => $this->model,
|
|
|
@@ -174,10 +224,14 @@ class OpenAIService
|
|
|
'stream' => true,
|
|
|
];
|
|
|
|
|
|
- /** @var \CurlHandle $ch */
|
|
|
- $ch = curl_init($this->apiUrl);
|
|
|
+ if ($this->maxTokens > 0) {
|
|
|
+ $payload['max_tokens'] = $this->maxTokens;
|
|
|
+ }
|
|
|
|
|
|
+ $ch = curl_init($this->apiUrl);
|
|
|
$fullContent = '';
|
|
|
+ $httpCode = 0;
|
|
|
+ $errorMessage = '';
|
|
|
|
|
|
curl_setopt_array($ch, [
|
|
|
CURLOPT_POST => true,
|
|
|
@@ -188,28 +242,35 @@ class OpenAIService
|
|
|
],
|
|
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
|
|
CURLOPT_RETURNTRANSFER => false,
|
|
|
- CURLOPT_TIMEOUT => 0, // SSE 必须无限
|
|
|
+ CURLOPT_TIMEOUT => 0,
|
|
|
CURLOPT_HEADER => false,
|
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
|
-
|
|
|
- CURLOPT_WRITEFUNCTION =>
|
|
|
- function (\CurlHandle $curl, string $data) use (&$fullContent): int {
|
|
|
-
|
|
|
+ CURLOPT_WRITEFUNCTION => function (\CurlHandle $curl, string $data) use (&$fullContent, &$errorMessage): int {
|
|
|
$lines = explode("\n", $data);
|
|
|
|
|
|
foreach ($lines as $line) {
|
|
|
$line = trim($line);
|
|
|
|
|
|
- if (!str_starts_with($line, 'data: '))
|
|
|
+ if (!str_starts_with($line, 'data: ')) {
|
|
|
continue;
|
|
|
+ }
|
|
|
|
|
|
$json = substr($line, 6);
|
|
|
|
|
|
- if ($json === '[DONE]')
|
|
|
+ if ($json === '[DONE]') {
|
|
|
continue;
|
|
|
+ }
|
|
|
|
|
|
$obj = json_decode($json, true);
|
|
|
- if (!is_array($obj)) continue;
|
|
|
+ if (!is_array($obj)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否有错误
|
|
|
+ if (isset($obj['error'])) {
|
|
|
+ $errorMessage = $obj['error']['message'] ?? 'Stream error';
|
|
|
+ return 0; // 停止接收
|
|
|
+ }
|
|
|
|
|
|
$delta = $obj['choices'][0]['delta']['content'] ?? '';
|
|
|
if ($delta !== '') {
|
|
|
@@ -222,17 +283,106 @@ class OpenAIService
|
|
|
]);
|
|
|
|
|
|
curl_exec($ch);
|
|
|
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
|
|
- if ($error = curl_error($ch)) {
|
|
|
+ if ($curlError = curl_error($ch)) {
|
|
|
curl_close($ch);
|
|
|
- return [
|
|
|
- 'error' => $error,
|
|
|
- 'status' => 500
|
|
|
- ];
|
|
|
+ throw new NetworkException("CURL 错误: {$curlError}");
|
|
|
}
|
|
|
|
|
|
curl_close($ch);
|
|
|
|
|
|
+ // 检查流式响应中的错误
|
|
|
+ if ($errorMessage) {
|
|
|
+ if ($httpCode === 429) {
|
|
|
+ throw new RateLimitException($errorMessage, $httpCode);
|
|
|
+ } elseif ($httpCode >= 500) {
|
|
|
+ throw new ServerErrorException($errorMessage, $httpCode);
|
|
|
+ } elseif ($httpCode >= 400) {
|
|
|
+ throw new ClientErrorException($errorMessage, $httpCode);
|
|
|
+ } else {
|
|
|
+ throw new \RuntimeException($errorMessage, $httpCode);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查 HTTP 状态码
|
|
|
+ if ($httpCode === 429) {
|
|
|
+ throw new RateLimitException('请求被限流', $httpCode);
|
|
|
+ } elseif ($httpCode >= 500) {
|
|
|
+ throw new ServerErrorException('服务器错误', $httpCode);
|
|
|
+ } elseif ($httpCode >= 400) {
|
|
|
+ throw new ClientErrorException('客户端请求错误', $httpCode);
|
|
|
+ } elseif ($httpCode < 200 || $httpCode >= 300) {
|
|
|
+ throw new \RuntimeException("HTTP 错误: {$httpCode}");
|
|
|
+ }
|
|
|
+
|
|
|
return $fullContent;
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 速率限制异常(429)
|
|
|
+ */
|
|
|
+class RateLimitException extends \RuntimeException
|
|
|
+{
|
|
|
+ protected int $retryAfter;
|
|
|
+
|
|
|
+ public function __construct(string $message, int $code = 429, int $retryAfter = 20)
|
|
|
+ {
|
|
|
+ parent::__construct($message, $code);
|
|
|
+ $this->retryAfter = $retryAfter;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getRetryAfter(): int
|
|
|
+ {
|
|
|
+ return $this->retryAfter;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getStatusCode(): int
|
|
|
+ {
|
|
|
+ return $this->code;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 服务器错误异常(5xx)
|
|
|
+ */
|
|
|
+class ServerErrorException extends \RuntimeException
|
|
|
+{
|
|
|
+ public function __construct(string $message, int $code = 500)
|
|
|
+ {
|
|
|
+ parent::__construct($message, $code);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getStatusCode(): int
|
|
|
+ {
|
|
|
+ return $this->code;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 客户端错误异常(4xx,除429外)
|
|
|
+ */
|
|
|
+class ClientErrorException extends \RuntimeException
|
|
|
+{
|
|
|
+ public function __construct(string $message, int $code = 400)
|
|
|
+ {
|
|
|
+ parent::__construct($message, $code);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getStatusCode(): int
|
|
|
+ {
|
|
|
+ return $this->code;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 网络错误异常
|
|
|
+ */
|
|
|
+class NetworkException extends \RuntimeException
|
|
|
+{
|
|
|
+ public function __construct(string $message, int $code = 0, ?\Throwable $previous = null)
|
|
|
+ {
|
|
|
+ parent::__construct($message, $code, $previous);
|
|
|
+ }
|
|
|
+}
|