PHP调用OpenAI兼容API:Laravel超时与流式踩坑指南

PHP接入OpenAI兼容API的实测避坑指南:base URI、超时、异常与流式。
文章基于 openai-php/client 0.21.0 与 PHP 8.4 的实测,系统梳理了 PHP 项目接入 OpenAI 兼容端点(vLLM、LiteLLM、Ollama 或托管网关)时的关键差异。核心结论包括:必须用 OpenAI::factory() 而非 client(),base URI 须写成 https://host/v1 且无尾斜杠,否则会拼出双斜杠导致 404;不传 HTTP 客户端时库不设任何超时,必须显式配置 timeout 与 connect_timeout 以免占死 PHP-FPM worker;异常分为 RateLimitException、ServerException、ErrorException、TransporterException 四类,库本身不带重试,重试应只覆盖前三类;流式请求中 timeout 只覆盖响应头等待,需额外配置 read_timeout,且其异常是 Guzzle 的 RuntimeException。Laravel 集成部分还指出默认 30 秒超时、流式无 read_timeout、config:cache 后改 .env 不生效等问题。
为什么PHP开发者需要这篇指南
如果你用PHP写业务,想把LLM接进来,最顺手的库是 openai-php/client;Laravel项目再加一个 openai-php/laravel。这两个包都能连vLLM、LiteLLM、Ollama的 /v1 端点或任何托管网关,但它们的默认行为跟Python/Node SDK有几处不一样,出问题时往往表现为「模型好像不太对」。其实多半是base URI、超时或流式处理惹的祸。
原文作者Aman Kumar用 openai-php/client 0.21.0、PHP 8.4、Guzzle 7.15.5与8.2.0,对着一个只实现 /v1/chat/completions 和 /v1/models 的本地mock服务做了实测。以下结论来自实测,而非文档推测。
最小客户端:从factory开始
$client = OpenAI::factory()
->withApiKey(getenv('LLM_API_KEY'))
->withBaseUri('https://your-gateway.example/v1')
->withHttpClient(new \GuzzleHttp\Client([
'timeout' => 120,
'connect_timeout' => 10,
]))
->make();
注意 OpenAI::client($key) 只是快捷方式,永远指向 api.openai.com。要连第三方兼容服务,必须走 factory() 链式调用。

PSR-18(PHP Standards Recommendation 18)是 PHP-FIG 制定的 HTTP 客户端通用接口规范,定义了 ClientInterface::sendRequest() 等方法。Guzzle 7+、Symfony HttpClient、php-http 等库都实现了它,因此业务代码可以面向接口编程而不绑定具体实现。openai-php/client 正是依赖这一机制:当调用方没有显式传入 HTTP 客户端时,它会尝试通过 Psr\Http\Client\ClientInterface 的实现来发现可用的客户端。如果项目里同时装了多个 PSR-18 实现,或者宿主环境没有正确注册发现器,就可能出现「以为在用 A 客户端,实际在用 B」的情况。这也是文中建议永远显式 withHttpClient() 的底层原因之一——既拿到超时控制权,也消除实现歧义。
Base URI:404错误的头号来源
客户端对base URI的清理非常少:末尾补一个 /,没有协议就加上 https://,然后拼上 chat/completions 这样的相对路径。实测几种写法:
withBaseUri(...) | 实际请求路径 | 结果 |
|---|---|---|
https://host/v1 | /v1/chat/completions | 正常 |
https://host/v1/ | /v1//chat/completions | 404 |
https://host | /chat/completions | 404 |
https://host/v1/chat/completions | /v1/chat/completions/chat/completions | 404 |
host/v1 | https://host/v1/... | HTTPS正常,纯HTTP本地服务TLS报错 |
尾斜杠是最容易踩的坑。Python SDK容忍它,这个库不容忍,日志里的双斜杠又很容易被忽略。本地跑vLLM或Ollama时一定要写全 http://,否则会看到 cURL error 35: wrong version number。所有404都会以 OpenAI\Exceptions\ErrorException 抛出,getStatusCode() 返回404。怀疑模型ID之前,先把base URI打印出来看看。
超时:默认居然没有
不传HTTP client时,factory通过PSR-18发现机制找Guzzle,而且不设任何超时。作者对着一个故意慢吞吞的端点发请求,结果就一直等着。生产环境里一个挂起的网关会占住PHP-FPM worker,直到 max_execution_time 或Web服务器把它杀掉。
所以永远自己传一个Guzzle client。把 timeout 设为2秒去请求一个5秒才响应的端点,会得到 TransporterException: cURL error 28。
PHP-FPM 采用进程池模型,每个 worker 同一时刻只处理一个请求,worker 数量由 pm.max_children 决定。若某个请求因下游网关挂起而长时间不返回,这个 worker 就被独占;当挂起请求数量逼近 pm.max_children 时,新请求会排队等待,表现为整站变慢甚至 502/504。max_execution_time 只统计 CPU 时间,对阻塞在 socket 读取上的时间通常不计入,所以单靠它并不能及时释放 worker,真正兜底的是 Web 服务器(如 Nginx 的 fastcgi_read_timeout)或 FPM 的 request_terminate_timeout。这就是文中强调必须显式设置 Guzzle timeout 的原因:在应用层主动失败,比被动等待进程被杀更可控、更易观测。
四类异常,对照着catch
这是作者最欣赏的部分,异常映射很清晰:
- 429 抛
RateLimitException,原始响应在$e->response上,能读到Retry-After头。 - 5xx 抛
ServerException。 - 其他带JSON错误体的4xx(错误key、未知模型、404路径)抛
ErrorException,提供getStatusCode()、getErrorMessage()、getErrorType()、getErrorCode()。注意getErrorCode()返回的是服务端塞进code字段的字符串,判断时应该比较状态码而不是这个值。 - 网络故障和超时 抛
TransporterException。
库本身不带重试。作者给了一个小包装函数:优先遵守 Retry-After,否则指数退避加抖动,且只重试 RateLimitException、ServerException、TransporterException 这三类。401或未知模型这类 ErrorException 重试也没用,别浪费次数。实测对着「两次429带 Retry-After: 2、第三次成功」的端点,它睡了两轮2秒后拿到了结果。
流式:timeout管不到它
$stream = $client->chat()->createStreamed([
'model' => 'YOUR_MODEL_ID',
'messages' => [[...]],
'stream_options' => ['include_usage' => true],
]);
foreach ($stream as $chunk) {
if (isset($chunk->choices[0])) {
echo $chunk->choices[0]->delta->content ?? '';
}
}
createStreamed() 会自动设 stream: true。开了 include_usage 后,最后一个chunk的 choices 是空数组,读之前必须 isset 判断。
最反直觉的发现:timeout => 2 根本拦不住一个跑了4秒的流,Guzzle 7和8都一样。原因是库用Guzzle的 stream 选项发请求,timeout只覆盖等待响应头的时间。要抓「流到一半卡住」的情况,得加 read_timeout:
new \GuzzleHttp\Client(['timeout' => 0, 'read_timeout' => 30, 'connect_timeout' => 10]);
read_timeout 触发时抛出的异常不是OpenAI包定义的:Guzzle 7抛裸的 RuntimeException("Unable to read from stream"),Guzzle 8抛 GuzzleHttp\Psr7\Exception\TimeoutException(继承自RuntimeException)。所以要在foreach外面包 catch (\RuntimeException $e)。而且此时用户可能已经看到了一半回答,不要盲目重试把半个流再发一遍。
Guzzle 的 stream 请求选项会让客户端在收到响应头后立即返回一个 PSR-7 流,而不是先读完整个响应体,调用方随后通过迭代逐块读取。这正是 SSE(Server-Sent Events)式 token 流能工作的基础。也因此,Guzzle 的超时语义被拆成两段:timeout 控制「从发出请求到收到响应头」的总时长,connect_timeout 控制 TCP 建连,而 read_timeout 才控制两次读取之间的最长等待。很多人误以为 timeout 是整体上限,于是出现「设了 2 秒,流却跑了 4 秒」的困惑。理解这一分层后,流式场景应把 timeout 设为 0(不限)或较大值,并用 read_timeout 来检测「流到一半卡住」。
Laravel集成:包里到底配了什么
composer require openai-php/laravel
php artisan openai:install
OPENAI_API_KEY=your-key
OPENAI_BASE_URL=https://your-gateway.example/v1
OPENAI_REQUEST_TIMEOUT=120
翻 openai-php/laravel 0.21.0 的源码,service provider从 config('openai.*') 构建客户端,base_uri 直接传给 withBaseUri(),所以尾斜杠规则照旧。它创建的Guzzle client只配了 ['timeout' => config('openai.request_timeout', 30)]。三个实际影响:
- 默认30秒。慢模型的长补全会撞上,拿到
TransporterException和cURL error 28,相关路由要调大OPENAI_REQUEST_TIMEOUT。 - 流式没有
read_timeout。30秒管不住流,也拦不住卡死的流。 - 跑了
config:cache之后改.env不生效。作者见过有人改了服务器上的OPENAI_BASE_URL,纳闷请求怎么还发往旧主机,重新cache配置才解决。
需要 read_timeout、额外header或第二个供应商时,作者建议另起一个绑定名注册自己的客户端,而不是覆盖包原有的绑定:
$this->app->singleton('llm', fn () => \OpenAI::factory()
->withApiKey(config('services.llm.key'))
->withBaseUri(config('services.llm.base_url'))
->withHttpClient(new \GuzzleHttp\Client([
'timeout' => 120, 'read_timeout' => 30, 'connect_timeout' => 10,
]))->make());
php artisan config:cache 会把 config/ 下所有配置文件求值后序列化到 bootstrap/cache/config.php,此后框架不再读取 config/*.php,也不再通过 env() 读取 .env。这意味着在已缓存配置的服务器上修改 .env 不会生效,必须重新执行 config:cache(或先 config:clear)。这是 Laravel 部署中的常见陷阱,尤其在多台机器滚动发布、只更新了环境变量文件却忘了重建缓存的场景下。文中「改了 OPENAI_BASE_URL 请求仍发往旧主机」正是这一机制的直接后果,排查时应先确认 bootstrap/cache/config.php 是否存在及其中的值。
参数透传与模型列表
客户端把请求数组原样传下去,作者发 top_k 和 max_tokens,mock都收到了,所以供应商特有参数只要服务端认就能用。查当前key能用哪些模型:
foreach ($client->models()->list()->data as $model) {
echo $model->id, PHP_EOL;
}
落地清单
- 用
OpenAI::factory(),别用OpenAI::client()。 - base URI写成
https://host/v1,无尾斜杠,协议写全。 - 始终传Guzzle client并设
timeout和connect_timeout。 - 流式加
read_timeout,循环外catch\RuntimeException。 - 只重试
RateLimitException、ServerException、TransporterException。 - Laravel里调大
OPENAI_REQUEST_TIMEOUT,改完.env记得重新config:cache。
原文作者声明自己开发了文中示例用到的网关APIClaw,观点请自行权衡。以上规则对任何OpenAI兼容端点都适用。
相关推荐

一句提示词引发的全球PHP安全风暴:AI是救星还是隐患?
一句AI提示词竟引发全球PHP安全危机,维基百科、互联网档案馆受波及。本文梳理事件脉络,解析OpenAI与亚马逊的争议,探讨AI时代漏洞发现门槛降低带来的安全悖论与开发者应对策略。

当AI被拿走:依赖捷径如何削弱你的解题韧性
一项涉及1,222人的研究发现:使用AI答题后,一旦AI被移除,人们放弃解题的比例高于从未使用AI的对照组。文章分析AI依赖对学习韧性和元认知的影响,并探讨AI学习工具的设计启示。

Carta联手NewView:让创业公司员工提前套现股权
Carta 与 NewView Capital 合作推出新计划,简化创业公司 tender offer 流程,帮助员工提前出售股权套现。本文分析该合作的核心机制、员工股权流动性痛点及对创业公司股权激励的影响。