OpenAIService.php 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\Http;
  4. use Illuminate\Support\Facades\Log;
  5. use Illuminate\Http\Client\ConnectionException;
  6. class OpenAIService
  7. {
  8. protected int $retries = 3;
  9. protected int $delayMs = 2000;
  10. protected string $model = 'gpt-4-1106-preview';
  11. protected string $apiUrl = 'https://api.openai.com/v1/chat/completions';
  12. protected string $apiKey;
  13. protected string $systemPrompt = '你是一个有帮助的助手。';
  14. protected float $temperature = 0.7;
  15. protected bool $stream = false;
  16. protected int $timeout = 600;
  17. protected int $maxTokens = 0;
  18. public static function withRetry(int $retries = 3, int $delayMs = 2000): static
  19. {
  20. return (new static())->setRetry($retries, $delayMs);
  21. }
  22. public function setRetry(int $retries, int $delayMs): static
  23. {
  24. $this->retries = $retries;
  25. $this->delayMs = $delayMs;
  26. return $this;
  27. }
  28. public function setModel(string $model): static
  29. {
  30. $this->model = $model;
  31. return $this;
  32. }
  33. public function setApiUrl(string $url): static
  34. {
  35. $this->apiUrl = $url;
  36. return $this;
  37. }
  38. public function setApiKey(string $key): static
  39. {
  40. $this->apiKey = $key;
  41. return $this;
  42. }
  43. public function setSystemPrompt(string $prompt): static
  44. {
  45. $this->systemPrompt = $prompt;
  46. return $this;
  47. }
  48. public function setTemperature(float $temperature): static
  49. {
  50. $this->temperature = $temperature;
  51. return $this;
  52. }
  53. public function setStream(bool $stream): static
  54. {
  55. $this->stream = $stream;
  56. // 流式时需要无限超时
  57. if ($stream) {
  58. $this->timeout = 0;
  59. }
  60. return $this;
  61. }
  62. public function setMaxToken(int $maxTokens): static
  63. {
  64. $this->maxTokens = $maxTokens;
  65. return $this;
  66. }
  67. /**
  68. * 发送 GPT 请求(支持流式与非流式)
  69. */
  70. public function send(string $question): string|array
  71. {
  72. for ($attempt = 1; $attempt <= $this->retries; $attempt++) {
  73. try {
  74. if ($this->stream === false) {
  75. // ⬇ 非流式原逻辑
  76. return $this->sendNormal($question);
  77. }
  78. // ⬇ 流式逻辑
  79. return $this->sendStreaming($question);
  80. } catch (ConnectionException $e) {
  81. Log::warning("第 {$attempt} 次连接超时:{$e->getMessage()},准备重试...");
  82. usleep($this->delayMs * 1000 * pow(2, $attempt));
  83. continue;
  84. } catch (\Exception $e) {
  85. Log::error("GPT 请求异常:" . $e->getMessage());
  86. return [
  87. 'error' => $e->getMessage(),
  88. 'status' => 500
  89. ];
  90. }
  91. }
  92. return [
  93. 'error' => '请求多次失败或超时,请稍后再试。',
  94. 'status' => 504
  95. ];
  96. }
  97. /**
  98. * 普通非流式请求
  99. */
  100. protected function sendNormal(string $question)
  101. {
  102. $data = [
  103. 'model' => $this->model,
  104. 'messages' => [
  105. ['role' => 'system', 'content' => $this->systemPrompt],
  106. ['role' => 'user', 'content' => $question],
  107. ],
  108. 'temperature' => $this->temperature,
  109. 'stream' => false,
  110. ];
  111. if ($this->maxTokens > 0) {
  112. $data['max_tokens'] = $this->maxTokens;
  113. }
  114. $response = Http::withToken($this->apiKey)
  115. ->timeout($this->timeout)
  116. ->post($this->apiUrl, $data);
  117. $status = $response->status();
  118. $body = $response->json();
  119. if ($status === 429) {
  120. $retryAfter = $response->header('Retry-After') ?? 20;
  121. Log::warning("请求被限流(429),等待 {$retryAfter} 秒后重试...");
  122. sleep((int)$retryAfter);
  123. return $this->sendNormal($question);
  124. }
  125. if ($response->successful()) {
  126. return $body;
  127. }
  128. Log::error('llm request error', [
  129. 'error' => $body['error']['message'] ?? '请求失败',
  130. 'status' => $status
  131. ]);
  132. return [
  133. 'error' => $body['error']['message'] ?? '请求失败',
  134. 'status' => $status
  135. ];
  136. }
  137. /**
  138. * =====================================================
  139. * ⭐ 原生 CURL SSE + 完整拼接
  140. * ⭐ 不输出浏览器
  141. * ⭐ 无 IDE 报错(使用 \CurlHandle)
  142. * =====================================================
  143. */
  144. protected function sendStreaming(string $question): string|array
  145. {
  146. $payload = [
  147. 'model' => $this->model,
  148. 'messages' => [
  149. ['role' => 'system', 'content' => $this->systemPrompt],
  150. ['role' => 'user', 'content' => $question],
  151. ],
  152. 'temperature' => $this->temperature,
  153. 'stream' => true,
  154. ];
  155. /** @var \CurlHandle $ch */
  156. $ch = curl_init($this->apiUrl);
  157. $fullContent = '';
  158. curl_setopt_array($ch, [
  159. CURLOPT_POST => true,
  160. CURLOPT_HTTPHEADER => [
  161. "Authorization: Bearer {$this->apiKey}",
  162. "Content-Type: application/json",
  163. "Accept: text/event-stream",
  164. ],
  165. CURLOPT_POSTFIELDS => json_encode($payload),
  166. CURLOPT_RETURNTRANSFER => false,
  167. CURLOPT_TIMEOUT => 0, // SSE 必须无限
  168. CURLOPT_HEADER => false,
  169. CURLOPT_FOLLOWLOCATION => true,
  170. CURLOPT_WRITEFUNCTION =>
  171. function (\CurlHandle $curl, string $data) use (&$fullContent): int {
  172. $lines = explode("\n", $data);
  173. foreach ($lines as $line) {
  174. $line = trim($line);
  175. if (!str_starts_with($line, 'data: '))
  176. continue;
  177. $json = substr($line, 6);
  178. if ($json === '[DONE]')
  179. continue;
  180. $obj = json_decode($json, true);
  181. if (!is_array($obj)) continue;
  182. $delta = $obj['choices'][0]['delta']['content'] ?? '';
  183. if ($delta !== '') {
  184. $fullContent .= $delta;
  185. }
  186. }
  187. return strlen($data);
  188. },
  189. ]);
  190. curl_exec($ch);
  191. if ($error = curl_error($ch)) {
  192. curl_close($ch);
  193. return [
  194. 'error' => $error,
  195. 'status' => 500
  196. ];
  197. }
  198. curl_close($ch);
  199. return $fullContent;
  200. }
  201. }