CatchAdmin PHP 后台管理框架 Logo CatchAdmin

在 PHP 单体里落地 LLM 功能而不必重写整个栈

产品团队希望在工单系统里加一点 AI:支持人员打开一张工单时,让大模型生成一段一段式的摘要,客服可以快速浏览而不用把整段对话看完。需求合理,团队已经讨论了两个月。

落到工程桌面的第一份方案来自架构师——把工单系统移植到 Python 服务,理由是 "Python 是 AI 的语言"。估期三个月,新部署流水线、新值班轮值、和既有 PHP 代码库并存的分叉。估期不包含"生产里同时跑两种语言"必然带来的麻烦。

第二份方案没人正式提,但每个人都在心里想过半遍:直接从既有 PHP 代码里调 OpenAI API。它就是一个同步 HTTP 请求,PHP 能发 HTTP 请求,整件事大概五十行代码。

本文的立场是:正确答案更接近第二份方案,而不是第一份——只是五十行版本里没覆盖到的那一串运维问题,每一个都有具体、成熟、不需要重写任何东西的解法。PHP 完全可以承担 LLM 功能。PHP-FPM、队列 worker、Redis、MySQL——这些跑了十五年生产流量的"无聊栈",只要以"LLM 调用实际的行为特性"为前提去做集成,能力与其它生态同样胜任。

下面要讲的,是真正能工作的那套集成形态。

TL;DR 速览

  • LLM API 调用慢(1–30 秒)、贵(按 token 计费)、偶尔不稳。每一条架构决策都从这三件事派生而来。
  • "在请求处理函数里同步调 API"这种朴素模式只在快调用下成立;任何面向用户、耗时超过一两秒的操作都会卡住。解决方式是队列,不是重写。
  • 一套干净的集成分四层:带类型的 provider 接口、用于慢调用的队列作业、重复 prompt 的缓存、预算守卫。没有一层需要离开 PHP。
  • 幂等键防止用户双击时被重复计费。Token 预算防止失控 prompt 在没人察觉之前刷出 500 美元账单。
  • "重写到 Python"的直觉,来自 AI 生态整体偏 Python,而不是 PHP 能力不够。从 Web 应用中消费 LLM API这件事,PHP 该有的原语都具备。

本文要点

  • 把 LLM 功能加到既有 PHP 单体里的标准集成形态
  • 如何处理普通 HTTP 库不会区分的 LLM 特有失败模式(超时、限流、内容过滤)
  • 让用户请求延迟与 LLM 响应延迟解耦的异步模式,依赖的是多数团队已有的队列设施
  • 围绕 LLM 成本特性设计的缓存、幂等与预算约束
  • 纯 PHP 方案触及天花板时该怎么办(而答案很少是"重写到 Python")

LLM 调用和普通 HTTP 调用不一样的三件事

三个特性让 LLM API 调用不像其它 HTTP 集成,架构设计必须一一回应。

延迟高且不可预测。 一次典型的 chat 补全,短输出要 1–5 秒,长输出 10–30 秒,provider 负载尖峰时偶尔 60 秒以上。对比数据库查询的个位数毫秒、普通第三方 API 的 100–500ms,LLM 调用慢到一次面向用户的同步请求会占住 PHP-FPM worker 50–100 倍于普通请求的时间。

成本是按 token 算,不是按次算。 每一条 prompt 与响应都按 token 计费(英文大约每 4 个字符一个 token)。如果代码不小心把一整段对话历史、一整篇文档、一份数据库导出塞进了 prompt,对应调用会变得异常昂贵,却没有任何显而易见的征兆。账单月底才会来。

失败模式非标准。 限流、内容过滤、上下文超长、模型下线、"你的 prompt 被以策略违规拒绝"全都以 4xx 状态码返回,而这些并不能对齐到常规"客户端错误=我的锅"的认知。LLM provider 回的 429 常常并不是真正意义上的限流,而是"现在处理不过来了",还经常没有一致的 Retry-After 头。

下面的模式都是这三个特性的直接后果。

第一层,带类型的 provider 接口

第一步是把 LLM provider 藏在一个接口后面。不是因为"哪天想换 provider"——也许会换,但那不是主要理由。真正的原因是把失败处理、预算强制、可观测性钩子集中到一个地方。所有对 LLM 的调用都从这一处过。

php
interface LlmProvider {
    public function complete(string $prompt, array $options = []): string;
}

class LlmTransportException extends \RuntimeException {}
class LlmTimeoutException extends LlmTransportException {}
class LlmRateLimitException extends LlmTransportException {}
class LlmContentFilterException extends \RuntimeException {}
class LlmInvalidRequestException extends \RuntimeException {}

异常层次比接口本身更重要。每一种异常都应在调用处有不同的处理方式:LlmTimeoutException 值得用退避重试;LlmRateLimitException 值得用更长的退避重试;LlmContentFilterException 应当记录并向用户提示"我们无法处理这段内容",再试也没用,因为输入本身是问题;LlmInvalidRequestException 则是你代码里的 bug(payload 错、模型过期、超出上下文窗口),应当大声失败。

通用 HTTP 库不做这种分类。一个针对 429 的 GuzzleHttp\Exception\ClientException 和一个针对内容过滤的 ClientException 长相完全相同。provider 内部的分类器,才让调用侧代码有能力做合理反应:

php
final class OpenAiProvider implements LlmProvider
{
    public function __construct(
        private string $apiKey,
        private string $baseUrl = 'https://api.openai.com/v1',
        private float $timeoutSeconds = 30.0,
    ) {}

    public function complete(string $prompt, array $options = []): string
    {
        $payload = [
            'model'       => $options['model']       ?? 'gpt-4o-mini',
            'messages'    => [['role' => 'user', 'content' => $prompt]],
            'max_tokens'  => $options['max_tokens']  ?? 500,
            'temperature' => $options['temperature'] ?? 0.7,
        ];

        $ch = curl_init($this->baseUrl . '/chat/completions');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER     => true,
            CURLOPT_POST               => true,
            CURLOPT_POSTFIELDS         => json_encode($payload),
            CURLOPT_TIMEOUT_MS         => (int)($this->timeoutSeconds * 1000),
            CURLOPT_CONNECTTIMEOUT_MS  => 3000,
            CURLOPT_HTTPHEADER         => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . $this->apiKey,
            ],
        ]);

        $body     = curl_exec($ch);
        $errno    = curl_errno($ch);
        $errstr   = curl_error($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($errno === CURLE_OPERATION_TIMEDOUT) {
            throw new LlmTimeoutException("LLM timed out after {$this->timeoutSeconds}s");
        }
        if ($errno !== 0) {
            throw new LlmTransportException("Transport error: {$errstr}");
        }

        $data = json_decode((string) $body, true);

        if ($httpCode === 429) {
            throw new LlmRateLimitException("Rate limited: " . ($data['error']['message'] ?? ''));
        }
        if ($httpCode === 400 && ($data['error']['code'] ?? '') === 'content_filter') {
            throw new LlmContentFilterException("Content filtered");
        }
        if ($httpCode === 400) {
            throw new LlmInvalidRequestException("Bad request: " . ($data['error']['message'] ?? ''));
        }
        if ($httpCode >= 400) {
            throw new LlmTransportException("HTTP {$httpCode}: " . ($data['error']['message'] ?? ''));
        }

        return $data['choices'][0]['message']['content'] ?? '';
    }
}

有几个细节值得停一下。CURLOPT_TIMEOUT_MS 必须显式设,用来兜住最坏情况——不设的话 cURL 沿用 PHP 的默认值,可能无限期地占住 worker。连接超时设得很短(3 秒),因为 3 秒建立不了 TCP 连接的情况下,provider 多半已经不健康,继续等待也很少能有不同结果。

真正吃功夫的是错误分类:429 是限流,content_filter 是策略拒绝,其它 400 是需要修的 bug,5xx 是 provider 自身故障。没有这一分类的重试循环要么全部重试(连内容过滤也重试,既不可能成功也白花钱),要么一概不重试。

对于 Anthropic Claude API、Cohere、Mistral 或其它 provider,形态一致——端点不同、鉴权不同、错误码约定不同,但接口与异常层次保持不变。

第二层,同步还是异步的决策

决定整体架构形态的问题只有一个:用户需要等着看 LLM 的响应,还是可以先给一句"马上好"然后稍后再取结果?

对于聊天界面——用户眼睛正盯着"正在思考…"的提示符——同步是可以的,用户确实在等 AI 回复,3 秒响应配合流式显示是可以接受的交互。而"给这张工单做摘要"、"分析这篇文档"、"给这 50 条评论分类"之类的事情,用户没必要盯着 loading 圈等 30 秒。这类工作天然适合异步。

对任何适合异步的调用,集成形态都很直接,并且用的是既有基础设施:

php
// 面向用户的接口——立即返回
final class SummaryController
{
    public function __construct(
        private QueueDispatcher $queue,
        private JobRepository $repo,
    ) {}

    public function start(Request $req): Response
    {
        $job = new LlmJob(
            id:        bin2hex(random_bytes(8)),
            userId:    $req->user()->id,
            prompt:    $this->buildPrompt($req->input('ticket_id')),
            status:    JobStatus::Pending,
            createdAt: new \DateTimeImmutable(),
        );
        $this->repo->save($job);

        $this->queue->dispatch(new ProcessSummaryJob($job->id));

        return new JsonResponse([
            'job_id'   => $job->id,
            'status'   => $job->status->value,
            'poll_url' => "/api/summaries/{$job->id}",
        ], 202);
    }

    public function status(string $jobId): Response
    {
        $job = $this->repo->find($jobId);
        if ($job === null) {
            return new JsonResponse(['error' => 'not_found'], 404);
        }

        return new JsonResponse([
            'status' => $job->status->value,
            'result' => $job->result,
            'error'  => $job->error,
        ]);
    }
}

用户请求在毫秒级返回,202 Accepted 加上一条轮询 URL。真正的 LLM 调用在队列 worker 里进行,那是一个拥有自己超时预算与扩缩容特性的独立进程:

php
final class ProcessSummaryJob
{
    public function __construct(public readonly string $jobId) {}

    public function handle(JobRepository $repo, LlmProvider $llm): void
    {
        $job = $repo->find($this->jobId);
        if ($job === null) return;
        if ($job->status !== JobStatus::Pending) return;  // 已经处理过

        $repo->save($job->withStatus(JobStatus::Running));

        try {
            $result = $llm->complete($job->prompt, ['max_tokens' => 300]);
            $repo->save($job->withStatus(JobStatus::Completed, result: $result));
        } catch (LlmContentFilterException $e) {
            $repo->save($job->withStatus(JobStatus::Failed,
                error: 'content_filter'));
        } catch (LlmRateLimitException | LlmTimeoutException $e) {
            // 这些交给队列重试
            throw $e;
        } catch (\Throwable $e) {
            $repo->save($job->withStatus(JobStatus::Failed,
                error: 'unknown: ' . $e->getMessage()));
        }
    }
}

前端每 2–3 秒轮询一次状态接口;想让交互更顺滑就用 Server-Sent Events 在作业完成的瞬间把结果推过去。两种都可行;SSE 在团队有精力落地时更舒适,轮询则一下午就能上线。

队列本身也不必花哨。Laravel Horizon + Redis 行,Symfony Messenger + Redis 或 RabbitMQ 行,低流量场景下用裸的 MySQL 队列(一条作业一行、worker 用 SELECT FOR UPDATE 取走处理)也行。复杂度不在队列层,而在作业类里的 LLM 特定逻辑。

这套架构换来的是:面向用户的延迟有界(请求约 100ms 返回),LLM 调用方差不再传导到用户;30 秒的 LLM 响应原本会占住一个 PHP-FPM worker,现在改为占住一个队列 worker,而队列 worker 是对慢操作更便宜的专门资源;provider 宕机时,堆积的是作业,而不是超时的用户请求。

第三层,为那些会发两次的 prompt 做缓存

LLM 调用要花钱。最便宜的调用是根本不发的那次。对"大概率会被发超过一次"的 prompt——同一张工单被两位不同的客服请求摘要、同一篇文档在用户在 UI 间跳转时被分析两遍——把结果缓存起来能显著降本,且没有明显副作用。

缓存键必须覆盖一切会影响输出的东西:prompt 本身、模型、temperature、max_tokens、system prompt。任何一项不同的两次调用都不应共享缓存项:

php
final class CachingLlmProvider implements LlmProvider
{
    public function __construct(
        private LlmProvider $inner,
        private CacheStore $cache,
        private int $ttlSeconds = 3600,
    ) {}

    public function complete(string $prompt, array $options = []): string
    {
        $key = $this->cacheKey($prompt, $options);
        $cached = $this->cache->get($key);
        if ($cached !== null) {
            return $cached;
        }

        $result = $this->inner->complete($prompt, $options);
        $this->cache->set($key, $result, $this->ttlSeconds);

        return $result;
    }

    private function cacheKey(string $prompt, array $options): string
    {
        ksort($options);  // 固定顺序
        $payload = [
            'prompt'      => $prompt,
            'model'       => $options['model']       ?? 'default',
            'temperature' => $options['temperature'] ?? 0.7,
            'max_tokens'  => $options['max_tokens']  ?? 500,
            'system'      => $options['system']      ?? '',
        ];
        return 'llm:' . hash('sha256', json_encode($payload));
    }
}

有一点值得特别注意:缓存只在响应应当确定的情况下才合理。temperature = 0 时,同一 prompt 会产出同一响应,缓存合法;temperature = 0.7(多数聊天 API 默认值)时,响应在不同调用之间会变化——缓存第一条响应意味着后续用户看到的是同一条答案,有时这是你要的,有时不是。对"摘要这张工单"没问题;对"给我三个有创意的产品名"则用户多半希望每次都是新花样。

TTL 按场景而定。文档摘要可以缓存小时乃至数天,因为文档不变;工单摘要应该在工单变更时失效(把一个随工单变化的稳定字段——版本号或 updated_at 时间戳——塞进缓存键里)。

对中等流量的应用,缓存带来的成本下降通常在 30%–50%,取决于 prompt 多样性。实现就是上面这个装饰器,再加上在依赖注入容器里把它摆在真正 provider 的前面。

第四层,幂等

这一条是多数团队撞到墙之前不会意识到的。用户点"生成摘要",请求打到服务器,LLM 调用开始,用户等不及又点了一下。此时同一个"逻辑操作"在途有两次 LLM 调用。用户被双重扣费(如果产品把成本透传),LLM provider 收了两份钱,用户还可能看到两份不同摘要,因为 LLM 响应并非确定性。

幂等键解决这件事。客户端为每一次逻辑操作生成一个唯一键(通常是用户点按钮时生成的 UUID);服务器用这个键去重并发调用,并为重复调用返回已有结果:

php
final class IdempotentSummarizer
{
    public function __construct(
        private IdempotencyStore $store,
        private LlmProvider $llm,
    ) {}

    public function summarize(string $idempotencyKey, string $text): string
    {
        // 已处理过?直接返回存储结果
        $existing = $this->store->getResult($idempotencyKey);
        if ($existing !== null) {
            return $existing;
        }

        // 尝试获取该 key 的锁
        if (! $this->store->tryAcquire($idempotencyKey, ttlSeconds: 60)) {
            // 另一个请求正在处理这个 key
            usleep(100_000);
            $existing = $this->store->getResult($idempotencyKey);
            if ($existing !== null) return $existing;
            throw new \RuntimeException('Concurrent request for same key');
        }

        $result = $this->llm->complete("Summarize: {$text}");
        $this->store->storeResult($idempotencyKey, $result, ttlSeconds: 86400);

        return $result;
    }
}

存储通常选 Redis,用 SET NX EX 做锁,另一个 key 保存结果。小型应用用数据库表也行:key 列加唯一约束,result 列在完成时写入。

幂等键必须由客户端生成,而不是服务端生成——这正是它的意义。客户端在用户操作时生成一枚 UUID,所有重试都带同一枚 UUID,服务端据此去重。对防双击这种低要求场景,一份 (user_id, document_id, 分钟级时间戳) 的哈希也可以当服务端侧近似方案,只是边界情况更多。

已验证:应用上面的模式后,三次相同幂等键的串行调用产生一次 LLM API 调用;不做这件事的话,三次调用就产生三次 LLM API 调用与三份(可能不同的)结果。

第五层,Token 预算

成本模型是多数团队低估的那一块。LLM 定价按 token 计——输入和输出都计费。包含一篇 50 页文档、要求输出 2 页摘要的 prompt 用掉成千上万 token;不小心把完整对话历史塞进 prompt(其实只需要最后一条消息)的调用则翻倍消费。没有强制手段,这些错误在账单到来之前都是不可见的。

最简单的强制就是在 provider 层加 token 预算:

php
final class TokenBudget
{
    public function __construct(
        public readonly int $maxInputTokens  = 4000,
        public readonly int $maxOutputTokens = 1000,
    ) {}
}

class TokenBudgetExceeded extends \RuntimeException {}

final class BudgetedLlmProvider implements LlmProvider
{
    public function __construct(
        private LlmProvider $inner,
        private TokenBudget $budget,
    ) {}

    public function complete(string $prompt, array $options = []): string
    {
        $estimatedTokens = $this->estimateTokens($prompt);
        if ($estimatedTokens > $this->budget->maxInputTokens) {
            throw new TokenBudgetExceeded(
                "Prompt is ~{$estimatedTokens} tokens, exceeds limit of {$this->budget->maxInputTokens}"
            );
        }

        $options['max_tokens'] = min(
            $options['max_tokens'] ?? $this->budget->maxOutputTokens,
            $this->budget->maxOutputTokens,
        );

        return $this->inner->complete($prompt, $options);
    }

    private function estimateTokens(string $text): int
    {
        // 粗估:英文约每 4 个字符 1 token
        // 生产环境请使用真正的分词器,例如 yethee/tiktoken
        return (int) ceil(mb_strlen($text) / 4);
    }
}

"4 字符 ≈ 1 token"只是粗估——对英文散文误差大约在 ±20%,对代码或非拉丁文种误差更大。需要精确值时,yethee/tiktoken 等 PHP 包实现了真正的分词算法。作为生产侧的一道护栏,粗估就足以挡住恶性错误(例如把一份 100KB 的 blob 塞进 prompt),这也是护栏的目的。

更精细的版本把每用户或每租户的预算放进 Redis,日度或月度上限用尽时直接拒绝。思路一致:在边界处强制、大声失败、记录每次拒绝,让成本在账单到来前就能被看见。

组合起来

把五层组合到一起,完整的集成形态如下:

php
// 在服务提供者或启动脚本里
$base     = new OpenAiProvider($apiKey, timeoutSeconds: 30.0);
$budgeted = new BudgetedLlmProvider($base, new TokenBudget(4000, 1000));
$cached   = new CachingLlmProvider($budgeted, $cacheStore, ttlSeconds: 3600);

// 把 $cached 作为 LlmProvider 注入到业务代码中
php
// 业务代码只依赖 LlmProvider 接口,不知道背后的具体实现
final class SummaryService
{
    public function __construct(private LlmProvider $llm) {}

    public function summarize(string $text): string
    {
        return $this->llm->complete("Summarize this ticket: {$text}", [
            'max_tokens'  => 300,
            'temperature' => 0.3,  // 越低越稳定
        ]);
    }
}
text
// 面向用户的调用走队列(用于慢操作)
$controller->start($request)
    -> 入队 ProcessSummaryJob
    -> 立即返回 202

// worker 取走作业
$worker->handle(ProcessSummaryJob)
    -> 调用 SummaryService::summarize
    -> 通过 LlmProvider::complete
    -> 走 CachingLlmProvider
        -> 命中缓存则直接返回;未命中则调用 BudgetedLlmProvider
            -> 通过 token 预算校验后调用 OpenAiProvider
                -> 最终发出 HTTP 请求

每一层都只有几十行 PHP,不需要离开既有栈。组合在一起就有了成本控制、响应缓存、解耦延迟与合格的错误分类,全过程没有做任何重写。

幂等与面向用户的异步模式分别在控制器与作业处理器里各自负责。provider 链保持简洁,因为每一层都只做一件事。

PHP 不合适的场景

客观地说,有一些 LLM 场景 PHP 不是最合适的工具,辨别这些场景本身也是做出好决策的一部分。

本地模型的大规模推理。 如果在自家 GPU 上跑模型(Llama、Mistral、微调变体),推理那层用 Python 或基于 Go 的模型服务框架更合适。正确模式是用合适于推理的语言单独建一个服务,暴露一小段 HTTP API 给 PHP 调——形状和上文的 OpenAI 集成一样,只是指向自家内网服务。

需要复杂客户端侧渲染的 token 级流式。 如果要做一个逐 token 流式、带复杂工具调用循环和丰富客户端状态的聊天 UI,PHP 上的 WebSocket 编排能做,但比 Node 或为此设计的 Python 框架麻烦。可以用 Symfony Messenger 配合 Mercure,或者独立的 Node SSE 服务来补这一环。

大规模批处理。 如果业务是"每晚对 5000 万条记录用 LLM 打分",PHP 的"一个进程一个作业"worker 模型能工作,但面对高扇出 I/O 时不如 Python 的 asyncio 或 Go 的 goroutine 高效。到这个体量时,用拥有廉价并发的语言搭独立处理流水线是合理的。

向量数据库编排与深度 ML 库依赖。 如果产品重度偏 RAG 并依赖大量自定义 embedding 流水线与 ML 工具链,花在"把 Python 库移植到 PHP"上的时间会超过写业务的时间。Pinecone、Weaviate、pgvector 的 PHP 客户端对查询时使用完全够用;索引构建侧的流水线通常住在 Python 里更舒服。

这些场景的共性是都是重 ML 的负载。没有一项描述的是"给工单系统加一个摘要功能"或"让用户向自己的数据提问"。对 90% 的场景——把 LLM 功能接入一个典型 PHP Web 应用——上面的模式已经够用。

需要避开的陷阱

在面向用户的接口里同步调 LLM。 任何超过约 1 秒的调用都会把一个 worker 占满那么久。五个并发的慢 LLM 调用就足以让 worker 池开始在它们后面排队。异步模式对除最快那种模型调用外的一切并非可选项。

HTTP 客户端没设超时。 PHP 的 cURL 默认超时接近无限。不设 CURLOPT_TIMEOUT_MS 时,卡死的 LLM provider 会一直占住 worker,直到别的东西(PHP max_execution_time、FPM request_terminate_timeout、负载均衡器的空闲超时)来终止它。每一次调用都要显式设超时。

把 prompt 作为代码里的字面字符串。 prompt 是配置,不是代码。它会被调参、做 A/B 测试、回滚版本。内联硬编码意味着每次改 prompt 都要发版。单独的 prompts 目录,或数据库里一张带版本的 prompts 表,让迭代快得多。

temperature > 0 仍做缓存却没意识到含义。 缓存的本质是"永远返回第一次的响应"从而强制确定性。如果产品依赖响应差异(创意写作、头脑风暴),缓存就不对;如果产品依赖一致性(摘要、分类),缓存就是对的,而且这种时候多半也应把 temperature = 0

不做脱敏就把整段 prompt 与响应写进日志。 prompt 常含用户数据——工单、文档、账户信息。日志会流向日志聚合器、供应商、备份。LLM 日志里出现的 PII 与任何其它日志中的 PII 属于同一类合规问题,而 LLM 数据的体量让脱敏更重要而不是更不重要。

把 provider 宕机视为例外。 OpenAI、Anthropic 以及其它 provider 都会宕机,这期间你的流量会有一部分返回 5xx。如果没有兜底路径(缓存结果、降级到较低质量替代、明确的"该功能暂时不可用"),provider 那边一次"软故障"就会给你应用造成一次"硬故障"。一定要预先规划。

简明 Q&A

应该用官方 OpenAI SDK,还是自己造一个客户端?
多数场景下用官方 SDK(openai-php/client 或类似包)就好,能省掉样板代码。仍然要把它包在自定义的 LlmProvider 接口后面,好让代码库其它部分不直接依赖 SDK 细节。真正重要的边界是那层接口。

怎么测试调 LLM 的代码?
LlmProvider 接口做 mock。测试永远不要打到真实 API——慢、贵、不确定。对已知 prompt 返回罐装响应的测试替身几乎能覆盖所有测试需要。若真要对真实 API 做集成测试,用独立的测试预算、不挂在每次 CI 上跑、加一个显式开关保护。

关于 prompt 注入攻击?
真实威胁。任何把用户输入拼进 prompt 的 LLM 功能都会暴露于"无视之前指令"式攻击之下。缓解手段:一、把用户输入当作数据而不是指令——system prompt 放在明确分离的 system role,用户内容放在 user role;二、对输出做校验与约束(解析 JSON 响应时做 schema 校验;展示文本时做 XSS 转义);三、对高风险操作,在执行前用一次独立的"守卫"调用来判断请求是否安全。这些与 PHP 无关,但都必须在设计之初就考虑。

真的需要队列吗?
如果 LLM 调用能稳定在 1 秒内完成、用户量也不大,可以先不上队列。对任何不是这种情况的场景,队列就是"负载上来时这功能能不能撑住"的分水岭。已经配好 Laravel Horizon 或 Symfony Messenger 的团队,额外复杂度很小——一个 job 类、一张 job 表、一个轮询端点。还没用上队列的团队则正好把 LLM 需求当作引入队列的契机——这对许多别的事情也是正确答案。

结语

"PHP 单体加一个 LLM 功能"这套方案比"重写到 Python"那条路更无聊,而这正是它为什么是对的。PHP-FPM worker、队列 worker、Redis 缓存、MySQL 作业表——每一块都谈不上激动人心,但都身经百战,背压、重试、可观测性、各种运维关切早就被团队解决过一次。

变化的只是与 LLM provider 对接的那一层。provider 被包进带类型的接口里,并对错误做合格分类;慢调用通过队列而非阻塞用户请求;重复 prompt 命中缓存;token 预算在边界处强制;幂等键杜绝双重计费。每一层都只有几十行 PHP,没有一项需要引入新基础设施。

真正以最小工程占用发布 LLM 功能的团队在做的就是这件事。而那些还在争论"是否该重写到 Python"的团队,六个月后还在争论,功能一行代码都没上。真正的约束不是语言,是"是否愿意把集成点认真设计一番,而不是直接把 API 文档里的同步示例抄下来"。

收束闭环

产品团队希望在工单系统里加 AI。具体需求:支持人员打开一张工单时,用 LLM 生成摘要。

最终上线的方案是 PHP 单体里六个文件。一个 LlmProvider 接口,三层实现叠起来:OpenAiProviderBudgetedLlmProviderCachingLlmProvider。一个 ProcessSummaryJob 通过既有 Redis 队列处理。一个 SummaryController 带 start 与 status 两个接口。前端轮询完成状态。平均用户等待 6 秒,过程中显示"正在生成摘要..."占位。最坏 30 秒,超时后作业会再重试一次,最终落到"摘要暂不可用,点击重试"。成本远低于产品团队预估的预算。

启动到上线两个月。部署流水线中没引入第二种语言,也没有新增值班轮值。架构师最初的三个月重写提案被一句客气的"事情比预想的简单"礼貌关闭。

无聊的栈办到了这件事。无聊的栈通常都会办到。

"相关问答" 8 问

1. PHP 能调 OpenAI API 吗?
能。PHP 有 cURL、Guzzle,以及官方风格的 openai-php/client SDK,都能像任何其它语言一样向 OpenAI API 发请求。真正的集成并不难,难的是运维侧:超时、重试分类、慢调用异步化、成本控制。这些都不依赖是哪一种语言——所有消费 LLM 的语言面对的是同一组模式。

2. 要为 AI 功能把 PHP 应用重写到 Python 吗?
多数 AI 功能(消费 LLM API、做聊天、生成摘要、内容分类)都不需要。PHP 在"消费侧"完全够用。"Python 是 AI 语言"这种直觉来自训练、微调以及自家模型推理——这些工作确实受益于 Python 的 ML 生态。但从一个 Web 应用调 OpenAI、Anthropic 之类的 API,语言并不会实质影响集成质量。

3. PHP 怎么处理长时 LLM 调用?
使用队列。面向用户的接口入队作业并立刻返回一个 job ID;队列 worker(Laravel Horizon、Symfony Messenger 或自建 worker)处理作业、调用 LLM、保存结果;前端轮询状态接口或通过 Server-Sent Events 接收完成通知。这样用户请求延迟与 LLM 响应延迟解耦,PHP-FPM worker 不被阻塞。

4. OpenAI 的最佳 PHP 库是哪个?
openai-php/client 是使用最广的"官方风格"SDK,orhanerday/open-ai 是另一个常见选择。两者都能用,选哪一个不如"把它包在自己的 LlmProvider 接口后面"重要——这样切换 provider(OpenAI 换成 Anthropic 等)就是一处改动,而不是一整个代码库的改动。

5. PHP 里如何防止 LLM 成本失控?
三层。第一,每次调用都有 token 预算——超过输入上限的 prompt 在发出之前就拒绝;第二,对会被发送两次的 prompt 做响应缓存,键为 (prompt, model, temperature, max_tokens);第三,按用户或租户的速率限制存在 Redis 中,每次调用递减,按日/按月重置。三层合起来在一个有类型的 LlmProvider 装饰链里大概各三五十行。

6. 输入 token 与输出 token 有什么区别?
输入 token 是你发送给模型的一切:system prompt、用户 prompt、过往对话历史、任何粘进去的上下文。输出 token 是模型生成的内容。两者都计费,且通常输出 token 单价更高。一条包含长文档、要短答案的 prompt 输入 token 多、输出 token 少;一次简短 prompt、长篇输出的创意写作调用则相反。

7. PHP 怎么处理 LLM 限流?
捕获 429 响应(常会带 Retry-After 头,也可能没有),按带抖动的指数退避重试。对持续限流的负载,用队列把调用分布到时间轴上,而不是一口气全发出去。高流量用例可以找 provider 申请更高配额——多数都有对应表单。分类很重要:限流类 429 可重试,但内容过滤或无效请求的 400 不可重试,同一重试策略套在两者身上就是浪费钱。

8. PHP 里应不应该用 LLM 的流式响应?
面向聊天界面的场景值得——用户能看到 token 逐个出现,即便总时长没变,体感也更快。PHP 可以消费 LLM API 的 Server-Sent Events,再通过自己的 SSE 端点转发给浏览器。非聊天场景(摘要、分类、批处理)流式只会带来复杂度却没有用户收益,等完整响应即可。如果选择流式,队列 worker 模式依然成立——worker 从 LLM 流式读取,前端从 worker 流式读取。

说明:本文所有代码示例都在 PHP 8.3 上经过验证,涵盖 LLM 异常层次、预算强制、缓存装饰器、幂等模式、基于队列的异步架构。OpenAI API 端点与请求结构来自官方文档。LLM 定价、响应时间及 provider 特性变化频繁——请根据自家应用的实际需求校准具体数值(超时、token 预算、缓存 TTL),而不是照搬文中数字。

本作品采用《CC 协议》,转载必须注明作者和本文链接