LlmClient.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. <?php
  2. namespace app\common\library\ps;
  3. use think\Config;
  4. use think\Exception;
  5. /**
  6. * 大模型客户端。本地 Ollama 做简单任务,线上 OpenAI 兼容接口做拆版。
  7. */
  8. class LlmClient
  9. {
  10. protected $profile;
  11. protected $driver;
  12. protected $baseUrl;
  13. protected $apiKey;
  14. protected $timeout;
  15. protected $textModel;
  16. protected $visionModel;
  17. protected $visionModelComplex;
  18. protected $temperature;
  19. protected $onlineEnable;
  20. protected $forceEnable;
  21. /**
  22. * @param array $config profile=local|online,可覆盖 base_url / api_key / 模型名
  23. */
  24. public function __construct(array $config = [])
  25. {
  26. $file = Config::get('llm') ?: [];
  27. $site = Config::get('site') ?: [];
  28. $this->profile = ($config['profile'] ?? 'local') === 'online' ? 'online' : 'local';
  29. $this->forceEnable = !empty($config['force']);
  30. $this->temperature = (float)($config['temperature'] ?? $file['temperature'] ?? 0.1);
  31. if ($this->profile === 'online') {
  32. $this->driver = 'openai';
  33. $this->onlineEnable = self::isOn($config['online_enable'] ?? $site['llm_online_enable'] ?? $file['online_enable'] ?? '0');
  34. $this->baseUrl = rtrim((string)($config['base_url'] ?? self::siteOr($site, 'llm_online_base_url', $file['online_base_url'] ?? '')), '/');
  35. $this->apiKey = (string)($config['api_key'] ?? self::siteOr($site, 'llm_online_api_key', $file['online_api_key'] ?? ''));
  36. $this->timeout = (int)($config['timeout'] ?? self::siteOr($site, 'llm_online_timeout', $file['online_timeout'] ?? 180));
  37. $this->textModel = (string)($config['text_model'] ?? self::siteOr($site, 'llm_online_model', $file['online_model'] ?? 'qwen-plus'));
  38. $this->visionModel = (string)($config['vision_model'] ?? self::siteOr($site, 'llm_online_vision_model', $file['online_vision_model'] ?? 'qwen-vl-max'));
  39. $this->visionModelComplex = $this->visionModel;
  40. if ($this->timeout < 30) {
  41. $this->timeout = 30;
  42. }
  43. return;
  44. }
  45. $this->driver = 'ollama';
  46. $this->onlineEnable = false;
  47. $this->baseUrl = rtrim((string)($config['base_url'] ?? self::siteOr($site, 'llm_base_url', $file['base_url'] ?? 'http://127.0.0.1:11434')), '/');
  48. $this->apiKey = (string)($config['api_key'] ?? self::siteOr($site, 'llm_api_key', $file['api_key'] ?? ''));
  49. $this->timeout = (int)($config['timeout'] ?? self::siteOr($site, 'llm_timeout', $file['timeout'] ?? 300));
  50. $this->textModel = (string)($config['text_model'] ?? self::siteOr($site, 'llm_text_model', $file['text_model'] ?? 'qwen2.5:7b-instruct'));
  51. $this->visionModel = (string)($config['vision_model'] ?? self::siteOr($site, 'llm_vision_model', $file['vision_model'] ?? 'minicpm-v4.6'));
  52. $this->visionModelComplex = (string)($config['vision_model_complex'] ?? self::siteOr($site, 'llm_vision_complex', $file['vision_model_complex'] ?? 'llama3.2-vision:11b'));
  53. }
  54. /**
  55. * 本地 Ollama,简单文本 / 备用识版。
  56. * @return self
  57. */
  58. public static function local()
  59. {
  60. return new self(['profile' => 'local']);
  61. }
  62. /**
  63. * 线上 OpenAI 兼容接口。
  64. * @return self
  65. */
  66. public static function online()
  67. {
  68. return new self(['profile' => 'online']);
  69. }
  70. /**
  71. * 拆版:线上已启用则走线上,否则本地。
  72. * @return self
  73. */
  74. public static function forRecognize()
  75. {
  76. $online = self::online();
  77. return $online->enabled() ? $online : self::local();
  78. }
  79. /**
  80. * @return bool
  81. */
  82. public function enabled()
  83. {
  84. if ($this->profile === 'online') {
  85. if (!$this->forceEnable && !$this->onlineEnable) {
  86. return false;
  87. }
  88. return $this->baseUrl !== '' && $this->apiKey !== '' && $this->visionModel !== '';
  89. }
  90. return $this->baseUrl !== '';
  91. }
  92. /**
  93. * @return string local|online
  94. */
  95. public function profile()
  96. {
  97. return $this->profile;
  98. }
  99. /**
  100. * @param array $site
  101. * @param string $key
  102. * @param mixed $default
  103. * @return mixed
  104. */
  105. protected static function siteOr(array $site, $key, $default)
  106. {
  107. if (isset($site[$key]) && $site[$key] !== '' && $site[$key] !== null) {
  108. return $site[$key];
  109. }
  110. return $default;
  111. }
  112. /**
  113. * @param mixed $v
  114. * @return bool
  115. */
  116. protected static function isOn($v)
  117. {
  118. return $v === 1 || $v === '1' || $v === true || $v === 'true' || $v === 'on';
  119. }
  120. /**
  121. * 列出本地已安装模型(Ollama /api/tags)
  122. * @return array
  123. */
  124. public function listModels()
  125. {
  126. if ($this->driver !== 'ollama') {
  127. return $this->listOnlineModels();
  128. }
  129. $result = $this->httpJson('GET', $this->baseUrl . '/api/tags', null, (int)Config::get('llm.list_timeout') ?: 8);
  130. $names = [];
  131. foreach ($result['models'] ?? [] as $item) {
  132. if (!empty($item['name'])) {
  133. $names[] = $item['name'];
  134. }
  135. }
  136. return $names;
  137. }
  138. /**
  139. * @return array
  140. */
  141. protected function listOnlineModels()
  142. {
  143. if ($this->baseUrl === '' || $this->apiKey === '') {
  144. return [];
  145. }
  146. try {
  147. $result = $this->httpJson('GET', $this->openaiRoot() . '/models', null, 15, true);
  148. } catch (\Exception $e) {
  149. return [];
  150. }
  151. $names = [];
  152. foreach ($result['data'] ?? [] as $item) {
  153. if (!empty($item['id'])) {
  154. $names[] = $item['id'];
  155. }
  156. }
  157. return $names;
  158. }
  159. /**
  160. * @return string
  161. */
  162. public function textModel()
  163. {
  164. return $this->textModel;
  165. }
  166. /**
  167. * @return string
  168. */
  169. public function visionModel()
  170. {
  171. return $this->visionModel;
  172. }
  173. /**
  174. * @return string
  175. */
  176. public function visionModelComplex()
  177. {
  178. return $this->visionModelComplex;
  179. }
  180. /**
  181. * 指定单个视觉模型。CSV 识版不要开 JSON 模式,弱模型会去抄示例 JSON。
  182. * @param string $prompt
  183. * @param string $imagePath
  184. * @param string $model
  185. * @param bool $jsonMode
  186. * @return string
  187. */
  188. public function visionRaw($prompt, $imagePath, $model, $jsonMode = false)
  189. {
  190. $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], $jsonMode);
  191. if (trim((string)$content) === '' && $jsonMode) {
  192. $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], false);
  193. }
  194. if (is_array($content)) {
  195. $content = json_encode($content, JSON_UNESCAPED_UNICODE);
  196. }
  197. return trim((string)$content);
  198. }
  199. /**
  200. * 按文件类型给出默认模型:图片用视觉,PDF 用文本。
  201. * @param string $fileType image|pdf
  202. * @return string
  203. */
  204. public function defaultModel($fileType = 'image')
  205. {
  206. return $fileType === 'pdf' ? $this->textModel : $this->visionModel;
  207. }
  208. /**
  209. * 后台下拉:线上识版优先,本地模型保留作简单任务。
  210. * @return array name => label
  211. */
  212. public function formOptions()
  213. {
  214. $options = [];
  215. $online = $this->profile === 'online' ? $this : self::online();
  216. $local = $this->profile === 'local' ? $this : self::local();
  217. if ($online->enabled()) {
  218. if ($online->visionModel) {
  219. $options[$online->visionModel] = $online->visionModel . '(线上识版,推荐)';
  220. }
  221. if ($online->textModel && !isset($options[$online->textModel])) {
  222. $options[$online->textModel] = $online->textModel . '(线上文本)';
  223. }
  224. }
  225. if ($local->visionModel) {
  226. $key = $local->visionModel;
  227. if (!isset($options[$key])) {
  228. $options[$key] = $key . '(本地视觉,简单/备用)';
  229. }
  230. }
  231. if ($local->visionModelComplex && !isset($options[$local->visionModelComplex])) {
  232. $options[$local->visionModelComplex] = $local->visionModelComplex . '(本地视觉备用)';
  233. }
  234. if ($local->textModel && !isset($options[$local->textModel])) {
  235. $options[$local->textModel] = $local->textModel . '(本地文本,简单任务)';
  236. }
  237. try {
  238. foreach ($local->listModels() as $name) {
  239. if ($name && !isset($options[$name])) {
  240. $options[$name] = $name . '(本地)';
  241. }
  242. }
  243. } catch (\Exception $e) {
  244. // Ollama 未启动时仍展示已配置项
  245. }
  246. return $options;
  247. }
  248. /**
  249. * 是否为本地 Ollama 模型名(含冒号标签)。
  250. * @param string $name
  251. * @return bool
  252. */
  253. public static function isLocalModelName($name)
  254. {
  255. $name = trim((string)$name);
  256. if ($name === '') {
  257. return false;
  258. }
  259. $local = self::local();
  260. $known = [$local->textModel(), $local->visionModel(), $local->visionModelComplex()];
  261. if (in_array($name, $known, true)) {
  262. return true;
  263. }
  264. return strpos($name, ':') !== false;
  265. }
  266. /**
  267. * 文本对话
  268. * @param string $prompt
  269. * @param string|null $model
  270. * @return string
  271. */
  272. public function chat($prompt, $model = null, $jsonMode = false)
  273. {
  274. return $this->requestChat($model ?: $this->textModel, $prompt, [], $jsonMode);
  275. }
  276. /**
  277. * 视觉识版。优先用指定模型,失败时回退复杂视觉模型。
  278. * @param string $prompt
  279. * @param string $imagePath 本地文件路径
  280. * @param string|null $preferredModel
  281. * @return array {content, model}
  282. */
  283. public function vision($prompt, $imagePath, $preferredModel = null)
  284. {
  285. $models = [];
  286. if ($preferredModel) {
  287. $models[] = $preferredModel;
  288. }
  289. if ($this->visionModel && !in_array($this->visionModel, $models, true)) {
  290. $models[] = $this->visionModel;
  291. }
  292. if ($this->visionModelComplex && !in_array($this->visionModelComplex, $models, true)) {
  293. $models[] = $this->visionModelComplex;
  294. }
  295. if (!$models) {
  296. $models[] = $this->visionModel;
  297. }
  298. $lastError = null;
  299. foreach ($models as $model) {
  300. try {
  301. $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], true);
  302. if (trim((string)$content) === '') {
  303. $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], false);
  304. }
  305. if (trim((string)$content) !== '') {
  306. return ['content' => $content, 'model' => $model];
  307. }
  308. } catch (\Exception $e) {
  309. $lastError = $e;
  310. if (!$this->isModelLoadError($e)) {
  311. throw $e;
  312. }
  313. }
  314. }
  315. throw $lastError ?: new Exception('视觉模型没有返回内容,请再试一次');
  316. }
  317. /**
  318. * 从模型输出中抽取 JSON
  319. * @param string $text
  320. * @return array
  321. */
  322. public static function extractJson($text)
  323. {
  324. $text = trim((string)$text);
  325. $text = preg_replace('/<think>[\s\S]*?<\/think>/i', '', $text);
  326. $text = preg_replace('/^```(?:json)?\s*/i', '', $text);
  327. $text = preg_replace('/\s*```$/', '', $text);
  328. $text = trim($text);
  329. if ($text === '') {
  330. throw new Exception('识版模型没有返回内容,请再点一次「重新 AI 拆版」');
  331. }
  332. $candidates = [$text];
  333. if (preg_match('/\{[\s\S]*\}/', $text, $m)) {
  334. $candidates[] = $m[0];
  335. }
  336. foreach ($candidates as $candidate) {
  337. $decoded = self::decodeLooseJson($candidate);
  338. if (is_array($decoded)) {
  339. return $decoded;
  340. }
  341. }
  342. $salvaged = self::salvageJson($text);
  343. if (is_array($salvaged)) {
  344. return $salvaged;
  345. }
  346. throw new Exception('识版结果不完整,请再点一次「重新 AI 拆版」');
  347. }
  348. /**
  349. * @param string $text
  350. * @return array|null
  351. */
  352. protected static function decodeLooseJson($text)
  353. {
  354. $text = trim((string)$text);
  355. if ($text === '') {
  356. return null;
  357. }
  358. $decoded = json_decode($text, true);
  359. if (is_array($decoded)) {
  360. return $decoded;
  361. }
  362. $text = preg_replace('/,\s*([}\]])/', '$1', $text);
  363. $text = preg_replace('/"([A-Za-z_][A-Za-z0-9_]*)"\s*([}\]])/', '"$1":0$2', $text);
  364. $text = preg_replace('/":\s*([}\],])/', ':0$1', $text);
  365. $decoded = json_decode($text, true);
  366. return is_array($decoded) ? $decoded : null;
  367. }
  368. /**
  369. * 截断的 JSON 尽量补全到最后一个完整对象。
  370. * @param string $text
  371. * @return array|null
  372. */
  373. public static function salvageJson($text)
  374. {
  375. $start = strpos($text, '{');
  376. if ($start === false) {
  377. $start = strpos($text, '[');
  378. }
  379. if ($start === false) {
  380. return null;
  381. }
  382. $json = substr($text, $start);
  383. $stack = [];
  384. $inStr = false;
  385. $esc = false;
  386. $lastComplete = -1;
  387. $stackAtComplete = [];
  388. $len = strlen($json);
  389. for ($i = 0; $i < $len; $i++) {
  390. $ch = $json[$i];
  391. if ($inStr) {
  392. if ($esc) {
  393. $esc = false;
  394. continue;
  395. }
  396. if ($ch === '\\') {
  397. $esc = true;
  398. continue;
  399. }
  400. if ($ch === '"') {
  401. $inStr = false;
  402. }
  403. continue;
  404. }
  405. if ($ch === '"') {
  406. $inStr = true;
  407. continue;
  408. }
  409. if ($ch === '{' || $ch === '[') {
  410. $stack[] = $ch;
  411. continue;
  412. }
  413. if ($ch === '}' || $ch === ']') {
  414. if ($stack) {
  415. array_pop($stack);
  416. }
  417. $lastComplete = $i;
  418. $stackAtComplete = $stack;
  419. if (!$stack) {
  420. $decoded = json_decode(substr($json, 0, $i + 1), true);
  421. if (is_array($decoded)) {
  422. return $decoded;
  423. }
  424. }
  425. }
  426. }
  427. if ($lastComplete < 0) {
  428. return null;
  429. }
  430. $slice = rtrim(substr($json, 0, $lastComplete + 1));
  431. $slice = preg_replace('/,\s*$/', '', $slice);
  432. $close = array_reverse($stackAtComplete);
  433. foreach ($close as $open) {
  434. $slice .= $open === '{' ? '}' : ']';
  435. }
  436. $slice = preg_replace('/,\s*([}\]])/', '$1', $slice);
  437. $decoded = self::decodeLooseJson($slice);
  438. return is_array($decoded) ? $decoded : null;
  439. }
  440. /**
  441. * @param string $model
  442. * @param string $prompt
  443. * @param array $images base64 列表
  444. * @return string
  445. */
  446. protected function requestChat($model, $prompt, array $images, $jsonMode = false)
  447. {
  448. if ($this->driver === 'openai') {
  449. return $this->openaiChat($model, $prompt, $images, $jsonMode);
  450. }
  451. return $this->ollamaChat($model, $prompt, $images, $jsonMode);
  452. }
  453. protected function ollamaChat($model, $prompt, array $images, $jsonMode = false)
  454. {
  455. $message = [
  456. 'role' => 'user',
  457. 'content' => $prompt,
  458. ];
  459. if ($images) {
  460. $message['images'] = $images;
  461. }
  462. $payload = [
  463. 'model' => $model,
  464. 'messages' => [$message],
  465. 'stream' => false,
  466. 'options' => [
  467. 'temperature' => $this->temperature,
  468. 'num_predict' => 8192,
  469. ],
  470. ];
  471. if ($jsonMode) {
  472. $payload['format'] = 'json';
  473. }
  474. try {
  475. $result = $this->httpJson('POST', $this->baseUrl . '/api/chat', $payload, $this->timeout);
  476. } catch (\Exception $e) {
  477. if ($jsonMode && (strpos($e->getMessage(), 'HTTP 400') !== false || strpos($e->getMessage(), 'format') !== false)) {
  478. unset($payload['format']);
  479. $result = $this->httpJson('POST', $this->baseUrl . '/api/chat', $payload, $this->timeout);
  480. } else {
  481. throw $e;
  482. }
  483. }
  484. $content = $result['message']['content'] ?? ($result['response'] ?? '');
  485. if (is_array($content)) {
  486. $content = json_encode($content, JSON_UNESCAPED_UNICODE);
  487. }
  488. $content = preg_replace('/<think>[\s\S]*?<\/think>/i', '', (string)$content);
  489. return trim($content);
  490. }
  491. /**
  492. * @return string
  493. */
  494. public function openaiRoot()
  495. {
  496. $base = rtrim($this->baseUrl, '/');
  497. if ($base === '') {
  498. return '';
  499. }
  500. if (preg_match('#/v\d+$#', $base) || preg_match('#/compatible-mode/v\d+$#', $base)) {
  501. return $base;
  502. }
  503. return $base . '/v1';
  504. }
  505. protected function openaiChat($model, $prompt, array $images, $jsonMode = false)
  506. {
  507. $content = [['type' => 'text', 'text' => $prompt]];
  508. foreach ($images as $b64) {
  509. $content[] = [
  510. 'type' => 'image_url',
  511. 'image_url' => ['url' => 'data:image/jpeg;base64,' . $b64],
  512. ];
  513. }
  514. $payload = [
  515. 'model' => $model,
  516. 'temperature' => $this->temperature,
  517. 'max_tokens' => 8192,
  518. 'enable_thinking' => false,
  519. 'messages' => [
  520. ['role' => 'user', 'content' => $images ? $content : $prompt],
  521. ],
  522. ];
  523. if ($jsonMode) {
  524. $payload['response_format'] = ['type' => 'json_object'];
  525. }
  526. $url = $this->openaiRoot() . '/chat/completions';
  527. try {
  528. $result = $this->httpJson('POST', $url, $payload, $this->timeout, true);
  529. } catch (\Exception $e) {
  530. if ($jsonMode && (strpos($e->getMessage(), 'HTTP 400') !== false || stripos($e->getMessage(), 'response_format') !== false || stripos($e->getMessage(), 'json_object') !== false || stripos($e->getMessage(), 'enable_thinking') !== false)) {
  531. unset($payload['response_format'], $payload['enable_thinking']);
  532. $result = $this->httpJson('POST', $url, $payload, $this->timeout, true);
  533. } else {
  534. throw $e;
  535. }
  536. }
  537. if (!empty($result['error'])) {
  538. $msg = $result['error']['message'] ?? json_encode($result['error'], JSON_UNESCAPED_UNICODE);
  539. throw new Exception('线上模型:' . mb_substr((string)$msg, 0, 300));
  540. }
  541. $text = $result['choices'][0]['message']['content'] ?? '';
  542. if (is_array($text)) {
  543. $parts = [];
  544. foreach ($text as $part) {
  545. if (is_string($part)) {
  546. $parts[] = $part;
  547. } elseif (is_array($part) && isset($part['text'])) {
  548. $parts[] = $part['text'];
  549. }
  550. }
  551. $text = implode('', $parts);
  552. }
  553. return trim((string)$text);
  554. }
  555. protected function encodeImage($path)
  556. {
  557. if (!is_file($path)) {
  558. throw new Exception('识版文件不存在: ' . $path);
  559. }
  560. $data = file_get_contents($path);
  561. if ($data === false || $data === '') {
  562. throw new Exception('识版文件无法读取');
  563. }
  564. if (function_exists('imagecreatefromstring')) {
  565. $img = @imagecreatefromstring($data);
  566. if ($img) {
  567. $w = imagesx($img);
  568. $h = imagesy($img);
  569. $max = $this->driver === 'openai' ? 1280 : 1600;
  570. if ($w > $max || $h > $max || strlen($data) > 800 * 1024 || $this->driver === 'openai') {
  571. $scale = min(1, $max / max($w, $h));
  572. $nw = max(1, (int)($w * $scale));
  573. $nh = max(1, (int)($h * $scale));
  574. $dst = imagecreatetruecolor($nw, $nh);
  575. imagecopyresampled($dst, $img, 0, 0, 0, 0, $nw, $nh, $w, $h);
  576. ob_start();
  577. imagejpeg($dst, null, 82);
  578. $data = ob_get_clean();
  579. imagedestroy($dst);
  580. }
  581. imagedestroy($img);
  582. }
  583. }
  584. return base64_encode($data);
  585. }
  586. protected function isModelLoadError(\Exception $e)
  587. {
  588. $msg = strtolower($e->getMessage());
  589. return strpos($msg, 'mllama') !== false
  590. || strpos($msg, 'unknown model architecture') !== false
  591. || strpos($msg, 'status code: 500') !== false;
  592. }
  593. /**
  594. * @param string $method
  595. * @param string $url
  596. * @param array|null $payload
  597. * @param int $timeout
  598. * @param bool $useBearer
  599. * @return array
  600. */
  601. protected function httpJson($method, $url, $payload, $timeout, $useBearer = false)
  602. {
  603. $ch = curl_init($url);
  604. $headers = ['Content-Type: application/json'];
  605. if (($useBearer || $this->driver === 'openai') && $this->apiKey) {
  606. $headers[] = 'Authorization: Bearer ' . $this->apiKey;
  607. }
  608. $opts = [
  609. CURLOPT_RETURNTRANSFER => true,
  610. CURLOPT_CUSTOMREQUEST => $method,
  611. CURLOPT_HTTPHEADER => $headers,
  612. CURLOPT_TIMEOUT => $timeout,
  613. CURLOPT_CONNECTTIMEOUT => $this->driver === 'openai' ? 15 : 5,
  614. ];
  615. $ca = CONF_PATH . 'extra' . DS . 'cacert.pem';
  616. if (is_file($ca)) {
  617. $opts[CURLOPT_CAINFO] = $ca;
  618. }
  619. if ($payload !== null) {
  620. $opts[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_UNESCAPED_UNICODE);
  621. }
  622. curl_setopt_array($ch, $opts);
  623. $body = curl_exec($ch);
  624. $errno = curl_errno($ch);
  625. $error = curl_error($ch);
  626. $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
  627. curl_close($ch);
  628. if ($errno) {
  629. throw new Exception('大模型请求失败(' . $this->baseUrl . '): ' . $error);
  630. }
  631. if ($code >= 400) {
  632. throw new Exception('大模型 HTTP ' . $code . ': ' . mb_substr((string)$body, 0, 300));
  633. }
  634. $decoded = json_decode((string)$body, true);
  635. if (!is_array($decoded)) {
  636. throw new Exception('大模型返回非 JSON');
  637. }
  638. return $decoded;
  639. }
  640. }