首页归档标签关于
← 返回首页

给 PHP 博客接一个 AI 摘要:不引入框架的做法

#AI#PHP#折腾

博客后台发文章时,「摘要」这一栏我总是懒得填,但列表页空着又难看。于是加了个小功能:保存文章时如果摘要为空,就调一次模型生成一句话,写回本地数据文件。没有框架、没有数据库、没有队列,PHP 自带 curl 就够了。

一、先定边界

这个功能的约束很明确:

  • 不能阻塞发布:模型接口超时或挂了,文章照样要能存;
  • 不能重复烧钱:同一篇文章内容没变就不要再调;
  • 密钥不能进前端:只在服务端读环境变量;
  • 输出必须可预期:拿不到 JSON 就走降级,别把模型返回的解释性文字塞进摘要。

二、调用与降级

function ai_summary(string $title, string $body): ?string {
    $key = getenv('LLM_KEY');
    if (!$key) return null;                        // 没配置就静默跳过

    // 正文截到 3000 字,控制 token 成本,也避免超长请求
    $excerpt = mb_substr(preg_replace('/\s+/u', ' ', $body), 0, 3000);
    $prompt  = '你是技术博客编辑。把下面的文章概括成一句 40~60 字的中文摘要,'
             . '只输出摘要本身,不要解释、不要引号、不要 Markdown。';
    $payload = [
        'model'       => getenv('LLM_MODEL') ?: 'default',
        'temperature' => 0,
        'messages'    => [
            ['role' => 'system', 'content' => $prompt],
            ['role' => 'user',   'content' => "标题:{$title}\n\n正文:{$excerpt}"],
        ],
    ];

    $ch = curl_init(getenv('LLM_ENDPOINT'));
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json',
                                   'Authorization: Bearer ' . $key],
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 8,        // 发布流程不能被它拖死
        CURLOPT_CONNECTTIMEOUT => 3,
    ]);
    $raw  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($raw === false || $code < 200 || $code >= 300) return null;
    $resp = json_decode($raw, true);
    $text = trim($resp['choices'][0]['message']['content'] ?? '');
    $text = preg_replace('/^["「『]|["」』]$/u', '', $text);   // 去掉包裹引号
    if ($text === '' || mb_strlen($text) > 120) return null;   // 明显异常就丢弃
    return $text;
}

function plain_summary(string $body): string {
    $t = trim(preg_replace('/\s+/u', ' ', strip_tags($body)));
    return mb_substr($t, 0, 60) . (mb_strlen($t) > 60 ? '…' : '');
}

关键点都在注释里:超时设短、返回异常就返回 null、长度做上限。降级不是可选项,它决定了这个功能会不会在某天拖垮整个发布流程。

三、缓存与幂等

摘要生成后存一份以正文哈希为键的缓存,内容没变就直接复用:

function cached_summary(string $id, string $title, string $body): string {
    $hash = hash('sha256', $title . "\n" . $body);
    $file = __DIR__ . '/data/summaries.json';
    $map  = is_file($file) ? (json_decode(file_get_contents($file), true) ?: []) : [];
    if (isset($map[$id]['hash']) && $map[$id]['hash'] === $hash)
        return $map[$id]['text'];

    $text = ai_summary($title, $body) ?? plain_summary($body);
    $map[$id] = ['hash' => $hash, 'text' => $text, 'at' => date('c')];
    file_put_contents($file, json_encode($map, JSON_UNESCAPED_UNICODE), LOCK_EX);
    return $text;
}

LOCK_EX 别省:后台可能同时开两个标签页保存文章,并发写会把 JSON 写坏。数据量大之后再换成按文章分文件,逻辑不变。

四、接入发布流程

在保存文章的位置只加一行判断,保持对原有逻辑的侵入最小:

if ($item['description'] === '') {
    $item['description'] = cached_summary($item['id'], $item['title'], $item['content']);
}
// 然后再写 posts 数据文件

手工填了摘要就完全不调用模型——这点很重要,编辑权始终在人手里,AI 只补空缺。

五、几个安全细节

风险处理
密钥泄露只从环境变量读,不写进仓库,也不下发到浏览器
正文里含注入指令正文放在 user 消息、且明确标注为「待概括的资料」
接口被刷按文章哈希缓存,必要时加每日调用上限
输出含 HTML入库前 strip_tags,前台统一转义

六、检查清单

  • 摘要为空才调用,手工填写优先
  • 超时 ≤ 8 秒,失败走截断降级
  • 以内容哈希缓存,避免重复请求
  • 文件写入加锁
  • 记录每日调用次数与失败率
  • 后台提供「重新生成摘要」按钮

整套代码不到一百行,带来的好处却很实在:发布时少填一个字段,列表页不再空着。小站点不一定非要上队列和微服务,把超时、降级、幂等这三件事做对,就足够稳了。