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

博客后台发文章时,「摘要」这一栏我总是懒得填,但列表页空着又难看。于是加了个小功能:保存文章时如果摘要为空,就调一次模型生成一句话,写回本地数据文件。没有框架、没有数据库、没有队列,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 秒,失败走截断降级
- 以内容哈希缓存,避免重复请求
- 文件写入加锁
- 记录每日调用次数与失败率
- 后台提供「重新生成摘要」按钮
整套代码不到一百行,带来的好处却很实在:发布时少填一个字段,列表页不再空着。小站点不一定非要上队列和微服务,把超时、降级、幂等这三件事做对,就足够稳了。