[控场AI]
· 12 分钟阅读· 6,377 字

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

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() 链式调用。

PHP调用OpenAI兼容API示意图

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/completions404
https://host/chat/completions404
https://host/v1/chat/completions/v1/chat/completions/chat/completions404
host/v1https://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)]。三个实际影响:

  1. 默认30秒。慢模型的长补全会撞上,拿到 TransporterException 和cURL error 28,相关路由要调大 OPENAI_REQUEST_TIMEOUT。
  2. 流式没有 read_timeout。30秒管不住流,也拦不住卡死的流。
  3. 跑了 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;
}

落地清单

  1. 用 OpenAI::factory(),别用 OpenAI::client()。
  2. base URI写成 https://host/v1,无尾斜杠,协议写全。
  3. 始终传Guzzle client并设 timeout 和 connect_timeout。
  4. 流式加 read_timeout,循环外catch \RuntimeException。
  5. 只重试 RateLimitException、ServerException、TransporterException。
  6. Laravel里调大 OPENAI_REQUEST_TIMEOUT,改完 .env 记得重新 config:cache。

原文作者声明自己开发了文中示例用到的网关APIClaw,观点请自行权衡。以上规则对任何OpenAI兼容端点都适用。

#openai-php/client#Laravel OpenAI#PHP调用LLM API#OpenAI兼容API#流式请求超时#Guzzle read_timeout#openai-php/laravel
分享:

相关推荐