PHP 调用大模型 API 完整示例
上周帮一个做 SaaS 后台的朋友接大模型 API,他一上来就想用 file_get_contents 糊一个 POST 请求,结果超时设置不生效、错误信息丢光、流式输出根本跑不起来。这类坑我踩过太多次了,这篇就把 PHP 接大模型 API 的完整链路捋一遍:从最基础的 cURL 单次请求,到并发批量调用、流式输出、重试退避,每一处的坑我都标出来。
PHP 调用大模型 API 最直接的方式是内置 cURL 扩展,框架项目推荐 Guzzle HTTP 客户端——两者都走标准 HTTP POST,改 base_url 即可兼容 DeepSeek、通义等所有 OpenAI 格式平台。
环境准备
PHP 7.4+ 均可,确认 cURL 扩展已启用:
php -m | grep curl # 输出 curl 即可
Guzzle 通过 Composer 安装:
composer require guzzlehttp/guzzle
API key 通过环境变量注入:
export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.lidayun.com/v1"
注意这两条 export 只在当前终端会话有效,重开终端就丢了;要长期生效写进 ~/.bashrc 或项目根目录的 .env 再配合 phpdotenv 加载。如果是宝塔或者传统虚拟主机部署,getenv() 经常读不到系统环境变量(PHP-FPM 进程和 shell 环境是隔离的),这种情况我一般直接在 php.ini 里用 env[OPENAI_API_KEY] = sk-xxx 声明,或者干脆写一个 config.php 常量文件,别指望 putenv 在 FPM 模式下能跨请求生效——它设置的值只在当前请求进程里有效,下一个请求过来又是空的,这是新手最容易踩的一个坑。
方式一:cURL(零依赖)
适合不使用 Composer 的纯 PHP 项目或 WordPress 插件:
<?php
function chat(array $messages, string $model = 'gpt-4o-mini'): string
{
$apiKey = getenv('OPENAI_API_KEY');
$baseUrl = getenv('OPENAI_BASE_URL') ?: 'https://api.openai.com/v1';
$payload = json_encode([
'model' => $model,
'messages' => $messages,
'max_tokens' => 1024,
]);
$ch = curl_init($baseUrl . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
throw new RuntimeException("API error $code: $body");
}
$data = json_decode($body, true);
return $data['choices'][0]['message']['content'];
}
// 使用示例
$reply = chat([['role' => 'user', 'content' => '用 PHP 实现单例模式']]);
echo $reply;
这段代码看着简单,但有几处细节决定了它能不能扛住生产环境。
第一,CURLOPT_TIMEOUT => 60 只是防止请求卡死的兜底,它是从发起连接到收完全部响应体的总时长上限。真正容易被忽略的是 CURLOPT_CONNECTTIMEOUT——这个不设的话默认是操作系统级别的连接超时(有的环境能到 300 秒),如果你的服务端网络到大模型接口的出口不稳定,用户请求会在”连不上”这一步就卡很久,而不是卡在”等回复”。我一般会显式加一行 CURLOPT_CONNECTTIMEOUT => 10,把连接阶段和响应阶段的超时拆开控制,连接 10 秒连不上直接判定网络问题,没必要陪它耗到 60 秒。
第二,json_encode 默认会把中文转成 \uXXXX 的 Unicode 转义序列,这不影响接口正常工作(JSON 规范允许),但如果你要打日志排查问题,看到一堆 你好 会很难受。想要日志里保留可读中文,就在 json_encode 里加 JSON_UNESCAPED_UNICODE 标志:json_encode($payload, JSON_UNESCAPED_UNICODE),同时建议再加上 JSON_UNESCAPED_SLASHES,否则 URL 里的斜杠会被转义成 \/,排查请求体的时候看着碍眼。
第三,$code !== 200 这个判断是简化写法,实际生产环境我会区分对待:401 是 API key 错了或者过期,直接终止重试没有意义;429 是限流,应该退避重试;500/502/503 是服务端临时故障,也该重试;400 多半是请求体格式错了(比如 messages 里 role 拼错),重试没用,得改代码。把这几类错误糅在一个 if 里直接抛异常,排查故障时你只知道”挂了”,不知道”为什么挂”,下面单独列一节讲怎么拆。
方式二:Guzzle(推荐 Laravel / Symfony)
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
class LlmClient
{
private Client $http;
private string $apiKey;
private string $baseUrl;
public function __construct()
{
$this->apiKey = getenv('OPENAI_API_KEY');
$this->baseUrl = getenv('OPENAI_BASE_URL') ?: 'https://api.openai.com/v1';
$this->http = new Client([
'base_uri' => $this->baseUrl,
'timeout' => 60,
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
]);
}
public function chat(array $messages, string $model = 'gpt-4o-mini'): string
{
try {
$resp = $this->http->post('/chat/completions', [
'json' => [
'model' => $model,
'messages' => $messages,
'max_tokens' => 1024,
],
]);
$data = json_decode($resp->getBody(), true);
return $data['choices'][0]['message']['content'];
} catch (ClientException $e) {
$code = $e->getResponse()->getStatusCode();
$body = $e->getResponse()->getBody();
throw new RuntimeException("API error $code: $body");
}
}
}
$client = new LlmClient();
echo $client->chat([['role' => 'user', 'content' => '解释 PHP 的 trait']]);
Guzzle 版本比 cURL 版本多了一层价值不只是”少写几行代码”。它的 ClientException(4xx)和 ServerException(5xx)是分开的两个异常类,你可以在业务代码里精确捕获——比如 429 限流你想自动重试,500 服务端错误你想告警但不重试,用 cURL 得自己手写状态码判断分支,用 Guzzle 直接 catch (ClientException $e) 和 catch (ServerException $e) 分开处理,代码可读性高很多。另外 Guzzle 的 base_uri 配置在构造函数里定死一次,后面所有请求都不用再拼 URL,团队协作时不容易有人手滑拼错域名。
有个真实踩过的坑:Guzzle 默认不会自动重试,很多人以为配了 retry 中间件就完事了,但如果你的 timeout 设置得比大模型接口的实际响应时间短(比如大模型这次生成得比较长,服务端用了 40 秒,你只给了 30 秒超时),Guzzle 抛出的是 ConnectException(连接类异常),不是 ClientException,如果你只 catch 了 ClientException,这个超时异常会直接往外抛,导致整个请求 500。所以生产环境我一般会再加一层 catch (\Exception $e) 兜底,把没预料到的异常类型也记录下来,不要让它裸奔到用户面前。
两种方式对比
| 维度 | cURL | Guzzle |
|---|---|---|
| 依赖 | 无(PHP 内置扩展) | composer require |
| 异步请求 | 需 curl_multi_* | 原生 Promise |
| 错误分类 | 手动判断状态码 | ClientException / ServerException |
| 适用场景 | WordPress / 独立脚本 | Laravel / Symfony 生产项目 |
选型上我给个明确建议:如果你就是要给一个老旧的 WordPress 站点或者一个几十行的独立脚本加个 AI 功能,别为了这一个接口去 composer require 一整套 Guzzle 依赖,cURL 版本足够用,部署也简单,不用操心 vendor 目录和 autoload。但凡是 Laravel、Symfony 这种已经在用 Composer 管理依赖的项目,就没理由不用 Guzzle——它的 Promise 异步能力、中间件机制(重试、日志、缓存都能挂中间件)在后面业务复杂起来之后是刚需,cURL 版本到时候要自己造轮子重新实现一遍。
错误排查:从报错文案定位根因
实际接入时最耗时间的不是写代码,是踩到错误之后不知道从哪下手。这里把我处理过的高频报错整理成一张表,拿到报错先对表查:
| 报错现象 | 根因 | 排查/修法 |
|---|---|---|
HTTP 401,body 里 invalid_api_key | key 拼错、多了空格换行,或者 key 已经在控制台被吊销 | echo strlen(trim($apiKey)) 确认长度和格式;去控制台核对 key 是否还在有效列表 |
HTTP 429,rate_limit_exceeded | 触发了每分钟请求数(RPM)或每分钟 token 数(TPM)限流 | 不是配额用完,是短时间请求太密;加指数退避重试,看下文完整实现 |
HTTP 400,invalid_request_error | messages 数组里某条缺 role 或 content,或者 model 名字打错 | 打印实际发出的 $payload(用 JSON_UNESCAPED_UNICODE)核对结构 |
cURL 返回空字符串,curl_error($ch) 也是空 | 大概率是 CURLOPT_RETURNTRANSFER 没设或者被覆盖 | 确认 curl_setopt_array 里这一项确实是 true |
SSL certificate problem: unable to get local issuer certificate | 本地或服务器的 CA 证书链不完整,多见于 Windows 开发环境和精简版 Docker 镜像 | 下载最新 cacert.pem,设置 CURLOPT_CAINFO 指向它;不要图省事直接把 CURLOPT_SSL_VERIFYPEER 设成 false 上生产 |
PHP 层 Fatal error: Allowed memory size exhausted | 流式或长文本响应没有分块处理,一次性塞进内存拼接超大字符串 | 见下文流式输出小节,边收边输出,不要全部攒在变量里 |
| 中文乱码或问号 | 响应头没有 charset=utf-8,或者 json_decode 后再拼接时用了错误的 mb 转换 | 确认 Content-Type: application/json; charset=utf-8;别对已经是 UTF-8 的字符串再跑一次 mb_convert_encoding |
多轮对话
$history = [['role' => 'system', 'content' => '你是一个 PHP 编程助手。']];
// 第一轮
$history[] = ['role' => 'user', 'content' => '什么是依赖注入?'];
$reply1 = chat($history);
$history[] = ['role' => 'assistant', 'content' => $reply1];
// 第二轮(携带上下文)
$history[] = ['role' => 'user', 'content' => '能给个 PHP 代码示例吗?'];
$reply2 = chat($history);
echo $reply2;
这里有个容易被忽略但很重要的成本问题:多轮对话每次请求都要把完整的 $history 数组重新发一遍,模型是无状态的,它不会帮你”记住”上一轮说了什么,全靠你每次把历史消息原样带上。这意味着对话轮次越多,每次请求消耗的 token 就越多——第 10 轮对话可能光是历史上下文就占掉大几百 token,而这部分是每轮都要重复计费的。生产环境如果对话轮次可能很长,我一般会做两件事:一是只保留最近 N 轮加上一条系统摘要(用模型自己把前面对话总结成一两句话替换掉原文),二是给 messages 数组设置一个长度上限,超过就从最早的一轮开始丢弃(第一条 system 角色始终保留不丢)。这两种策略哪种更合适取决于你的场景对”记忆长度”的要求,客服类场景丢得激进一点没关系,需要长期追踪上下文的场景(比如代码调试助手)就得用摘要压缩而不是硬丢。
并发请求:一次调用多个 prompt
如果你要批量处理一批文本(比如给 100 条用户评论做情感分类),一条一条同步请求太慢,PHP 内置的 curl_multi_* 系列函数可以并发发起多个请求,等所有请求都返回再统一处理:
<?php
function batchChat(array $prompts, string $model = 'gpt-4o-mini'): array
{
$apiKey = getenv('OPENAI_API_KEY');
$baseUrl = getenv('OPENAI_BASE_URL') ?: 'https://api.openai.com/v1';
$mh = curl_multi_init();
$handles = [];
foreach ($prompts as $key => $prompt) {
$ch = curl_init($baseUrl . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'model' => $model,
'messages' => [['role' => 'user', 'content' => $prompt]],
], JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
]);
curl_multi_add_handle($mh, $ch);
$handles[$key] = $ch;
}
$running = null;
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh);
} while ($running > 0);
$results = [];
foreach ($handles as $key => $ch) {
$body = curl_multi_getcontent($ch);
$data = json_decode($body, true);
$results[$key] = $data['choices'][0]['message']['content'] ?? null;
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
return $results;
}
// 100 条评论并发跑,比同步循环快一个数量级
$results = batchChat(['这个产品太好用了', '客服态度很差', '价格有点贵但值']);
这里有个坑必须提:并发数不是越高越好。大模型接口通常有 RPM(每分钟请求数)限制,如果你一次性把 100 个请求全塞进 curl_multi,很可能前几十个成功、后面全部 429。实际项目里我会分批发,每批 10~20 个并发,批次之间留个几百毫秒的间隔,或者干脆用一个信号量控制同时在途的请求数不超过接口文档写明的并发上限——这个上限每个平台不一样,接入前一定要去对应的接入教程专题或官方文档确认清楚,别凭感觉设。
流式输出:边生成边显示
如果你的场景是网页聊天界面,用户体验上肯定希望看到文字一个字一个字往外蹦,而不是等模型把整段话生成完再一次性显示。这需要在请求里加 stream: true,服务端会用 SSE(Server-Sent Events)格式持续推送数据块。cURL 处理流式响应的关键是用 CURLOPT_WRITEFUNCTION 注册一个回调,每收到一块数据就立刻处理并输出,而不是等全部收完:
<?php
function chatStream(array $messages, string $model = 'gpt-4o-mini'): void
{
$apiKey = getenv('OPENAI_API_KEY');
$baseUrl = getenv('OPENAI_BASE_URL') ?: 'https://api.openai.com/v1';
$payload = json_encode([
'model' => $model,
'messages' => $messages,
'stream' => true,
], JSON_UNESCAPED_UNICODE);
$ch = curl_init($baseUrl . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
// 关键:每收到一块数据就调用这个回调,实现边收边输出
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if ($line === '' || strpos($line, 'data: ') !== 0) {
continue;
}
$json = substr($line, 6);
if ($json === '[DONE]') {
continue;
}
$data = json_decode($json, true);
$delta = $data['choices'][0]['delta']['content'] ?? '';
if ($delta !== '') {
echo $delta;
// 立刻刷新输出缓冲区,否则浏览器要等 PHP 脚本结束才能看到内容
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
}
这段代码最容易被忽略的是 ob_flush() + flush() 这两行——没有它们,PHP 会把输出攒在 buffer 里,直到脚本整个执行完才一次性吐给浏览器,“流式”就变成了假流式,用户照样是等一整段时间才看到结果。另外注意 CURLOPT_WRITEFUNCTION 的回调必须返回处理掉的字节数(strlen($chunk)),如果返回值和实际长度不一致,cURL 会认为写入失败直接中断请求,这是我见过最隐蔽的一个流式接入 bug——代码逻辑全对,就因为漏了这个返回值,请求跑到一半莫名其妙断掉。如果是 Web 场景,控制器层还要记得关掉 Nginx/PHP-FPM 的输出缓冲(比如 Nginx 的 proxy_buffering off),否则反向代理这一层也会把流式响应攒起来再转发。
重试退避:让请求扛住限流
429 限流不是偶发情况,请求量稍微上来一点就会遇到,靠人工重试不现实,得写进代码里自动处理。指数退避的核心思路是:第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒,逐次翻倍,避免在服务端还在限流窗口里的时候又立刻发起下一次请求:
<?php
function chatWithRetry(array $messages, string $model = 'gpt-4o-mini', int $maxRetries = 3): string
{
$attempt = 0;
while (true) {
try {
return chat($messages, $model);
} catch (RuntimeException $e) {
$attempt++;
// 只对 429(限流)和 5xx(服务端临时故障)重试,401/400 重试没有意义
$shouldRetry = preg_match('/API error (429|5\d{2})/', $e->getMessage());
if (!$shouldRetry || $attempt > $maxRetries) {
throw $e;
}
$wait = (2 ** $attempt) + random_int(0, 1000) / 1000; // 加随机抖动避免多个请求同时重试
sleep((int) ceil($wait));
}
}
}
加 random_int(0, 1000) / 1000 这个随机抖动不是多此一举——如果你的服务同时有多个并发请求触发 429,且都严格按 1、2、4 秒的固定节奏重试,它们会在同一时刻扎堆再次发起请求,等于自己造了一次小型请求洪峰,抖动能把这些重试请求在时间上错开。$maxRetries = 3 这个值也不是随便设的,退避到第三次已经是等了 1+2+4=7 秒左右,如果这时候还失败,大概率不是短暂限流,而是配额确实用完了或者服务端出了更大的问题,继续重试只会让用户等得更久,不如尽早把错误暴露出来走人工介入或降级逻辑。
常见问题
cURL 返回空内容或 SSL 错误怎么排查? 先检查 curl_error($ch) 的输出;SSL 证书问题可临时设 CURLOPT_SSL_VERIFYPEER => false 定位,生产环境须修复证书而非关闭验证。
在 Laravel 中最优雅的接入方式是什么? 将 LlmClient 注册为 Service Provider 中的单例,api_key 和 base_url 写入 config/llm.php,通过 config() 读取;控制器通过构造注入使用。
API 响应 429 如何自动重试? Guzzle 可用 guzzle-retry-middleware 中间件;cURL 方案手动 for 循环,捕获到 429 后 sleep(2 ** $attempt) 指数退避。
更多接入方案见大模型 API 接入完全指南与接入教程专题。C# 版本实现参考C# 调用大模型 API。需要统一多模型入口?申请力达云聚合 API 内测。