Procházet zdrojové kódy

添加流输出支持

visuddhinanda před 4 měsíci
rodič
revize
9b983a2718
1 změnil soubory, kde provedl 135 přidání a 44 odebrání
  1. 135 44
      api-v8/app/Services/OpenAIService.php

+ 135 - 44
api-v8/app/Services/OpenAIService.php

@@ -4,7 +4,6 @@ namespace App\Services;
 
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Str;
 use Illuminate\Http\Client\ConnectionException;
 
 class OpenAIService
@@ -16,6 +15,9 @@ class OpenAIService
     protected string $apiKey;
     protected string $systemPrompt = '你是一个有帮助的助手。';
     protected float $temperature = 0.7;
+    protected bool $stream = false;
+    protected int $timeout = 600;
+    protected int $maxTokens = 0;
 
     public static function withRetry(int $retries = 3, int $delayMs = 2000): static
     {
@@ -81,54 +83,18 @@ class OpenAIService
     public function send(string $question): string|array
     {
         for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
-            try {
-                $response = Http::withToken($this->apiKey)
-                    ->timeout(300)
-                    ->retry(3, 2000, function ($exception, $request) {
-                        // 仅当是连接/响应超时才重试
-                        return $exception instanceof ConnectionException;
-                    })
-                    ->post($this->apiUrl, [
-                        'model' => $this->model,
-                        'messages' => [
-                            ['role' => 'system', 'content' => $this->systemPrompt],
-                            ['role' => 'user', 'content' => $question],
-                        ],
-                        'temperature' => $this->temperature,
-                    ]);
-
-                $status = $response->status();
-                $body = $response->json();
-
-                // ✅ 判断 429 限流重试
-                if ($status === 429) {
-                    $retryAfter = $response->header('Retry-After') ?? 10;
-                    Log::warning("第 {$attempt} 次请求被限流(429),等待 {$retryAfter} 秒后重试...");
-                    sleep((int) $retryAfter);
-                    continue;
-                }
-
-                // ✅ 判断是否 GPT 返回 timeout 错误
-                $isTimeout = in_array($status, [408, 504]) ||
-                    (isset($body['error']['message']) && Str::contains(strtolower($body['error']['message']), 'time'));
 
-                if ($isTimeout) {
-                    Log::warning("第 {$attempt} 次 GPT 响应超时,准备重试...");
-                    usleep($this->delayMs * 1000);
-                    continue;
-                }
-
-                if ($response->successful()) {
-                    return $body['choices'][0]['message']['content'] ?? '无内容返回';
+            try {
+                if ($this->stream === false) {
+                    // ⬇ 非流式原逻辑
+                    return $this->sendNormal($question);
                 }
 
-                return [
-                    'error' => $body['error']['message'] ?? '请求失败',
-                    'status' => $status
-                ];
+                // ⬇ 流式逻辑
+                return $this->sendStreaming($question);
             } catch (ConnectionException $e) {
                 Log::warning("第 {$attempt} 次连接超时:{$e->getMessage()},准备重试...");
-                usleep($this->delayMs * 1000);
+                usleep($this->delayMs * 1000 * pow(2, $attempt));
                 continue;
             } catch (\Exception $e) {
                 Log::error("GPT 请求异常:" . $e->getMessage());
@@ -144,4 +110,129 @@ class OpenAIService
             'status' => 504
         ];
     }
+
+    /**
+     * 普通非流式请求
+     */
+    protected function sendNormal(string $question)
+    {
+        $data = [
+            'model' => $this->model,
+            'messages' => [
+                ['role' => 'system', 'content' => $this->systemPrompt],
+                ['role' => 'user', 'content' => $question],
+            ],
+            '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);
+
+        $status = $response->status();
+        $body = $response->json();
+
+        if ($status === 429) {
+            $retryAfter = $response->header('Retry-After') ?? 20;
+            Log::warning("请求被限流(429),等待 {$retryAfter} 秒后重试...");
+            sleep((int)$retryAfter);
+            return $this->sendNormal($question);
+        }
+
+        if ($response->successful()) {
+            return $body;
+        }
+        Log::error('llm request error', [
+            'error' => $body['error']['message'] ?? '请求失败',
+            'status' => $status
+        ]);
+        return [
+            'error' => $body['error']['message'] ?? '请求失败',
+            'status' => $status
+        ];
+    }
+
+    /**
+     * =====================================================
+     *  ⭐ 原生 CURL SSE + 完整拼接
+     *  ⭐ 不输出浏览器
+     *  ⭐ 无 IDE 报错(使用 \CurlHandle)
+     * =====================================================
+     */
+    protected function sendStreaming(string $question): string|array
+    {
+        $payload = [
+            'model' => $this->model,
+            'messages' => [
+                ['role' => 'system', 'content' => $this->systemPrompt],
+                ['role' => 'user', 'content' => $question],
+            ],
+            'temperature' => $this->temperature,
+            'stream' => true,
+        ];
+
+        /** @var \CurlHandle $ch */
+        $ch = curl_init($this->apiUrl);
+
+        $fullContent = '';
+
+        curl_setopt_array($ch, [
+            CURLOPT_POST => true,
+            CURLOPT_HTTPHEADER => [
+                "Authorization: Bearer {$this->apiKey}",
+                "Content-Type: application/json",
+                "Accept: text/event-stream",
+            ],
+            CURLOPT_POSTFIELDS => json_encode($payload),
+            CURLOPT_RETURNTRANSFER => false,
+            CURLOPT_TIMEOUT => 0,      // SSE 必须无限
+            CURLOPT_HEADER => false,
+            CURLOPT_FOLLOWLOCATION => true,
+
+            CURLOPT_WRITEFUNCTION =>
+            function (\CurlHandle $curl, string $data) use (&$fullContent): int {
+
+                $lines = explode("\n", $data);
+
+                foreach ($lines as $line) {
+                    $line = trim($line);
+
+                    if (!str_starts_with($line, 'data: '))
+                        continue;
+
+                    $json = substr($line, 6);
+
+                    if ($json === '[DONE]')
+                        continue;
+
+                    $obj = json_decode($json, true);
+                    if (!is_array($obj)) continue;
+
+                    $delta = $obj['choices'][0]['delta']['content'] ?? '';
+                    if ($delta !== '') {
+                        $fullContent .= $delta;
+                    }
+                }
+
+                return strlen($data);
+            },
+        ]);
+
+        curl_exec($ch);
+
+        if ($error = curl_error($ch)) {
+            curl_close($ch);
+            return [
+                'error' => $error,
+                'status' => 500
+            ];
+        }
+
+        curl_close($ch);
+
+        return $fullContent;
+    }
 }