date() 混进 Hash 导致缓存每天失效——LLM 响应缓存的三个反直觉坑
某后台系统接入了第三方大模型做智能分析,P95 响应时间 40s。为了不让用户每次都干等,我们加了缓存。加完之后发现:缓存形同虚设,每天零点后第一个请求必然重新调用 API,且提供商一旦超时就直接返回 500。排查下来,三个坑同时踩到。
现象
- 缓存日抛:相同用户、状态没变,第二天打开页面依然等 40s,
cached: false。 - 提供商超时 = 硬错误:DeepSeek P95 实测 40s,旧的 cURL timeout 设 30s——结果 30% 的正常请求直接被掐断,返回 500。
- 修改一个 tag 竟然缓存命中:用 user_id 做 key,修改后的输入仍命中旧缓存,返回陈旧分析。
三个问题,三个独立根因。
根因
坑一:date() 进了 Hash
// 错误写法
$input = [
'member_code' => $member->code,
'today' => date('Y-m-d'), // ← 这个会让 hash 每天变
'total_amount' => $member->total,
];
$hash = hash('sha256', json_encode($input));date('Y-m-d') 每天零点翻新,content-hash 随之变化,L2 DB 查不到匹配行,每天必走 LLM。用户状态一点没变,只是"今天"换了。
date()、time()、request_id、session nonce 等字段「逻辑上无关输出」却被放进 hash——结果是 key 每天/每次都不同,cache 完全失效。Prompt 里可以用今天的日期,但 hash 里绝对不能有。
坑二:timeout 设得比 P95 还短
文档标称 timeout 30s,实测同等 payload 的 P95 是 40s。timeout < P95 意味着三成正常请求被误杀,触发错误路径,最终返回 500。这是文档与实际不一致的经典坑。
坑三:只用 entity_id 做 key,无法感知输入变化
// 错误:key = user + 固定前缀
$cacheKey = "analysis:{$userId}";用户改了消费记录或标签,key 没变,旧缓存照样命中。content-hash 的意义就是让 key 随输入内容联动,user_id-only 完全绕开了这个机制。
正解
三层架构 + stale fallback
请求 ──▶ L1(内存/文件,短 TTL)
├─ 命中 ──▶ 返回(微秒级)
└─ 未命中 ──▶ L2(DB,content-hash 精确匹配)
├─ 命中 ──▶ 返回 + 回填 L1(毫秒级)
└─ 未命中 ──▶ 调 LLM API(秒级)
├─ 成功 ──▶ 写 L2 再写 L1 + 返回
└─ 失败 ──▶ stale fallback(该实体最新一条旧缓存)
├─ 有 ──▶ 返回 {stale:true}
└─ 无 ──▶ 5xx
Hash Key 的正确构造
$input = [
'member_code' => $member->code,
'level_name' => $member->level_name,
'total_sale_amount'=> (string)round($member->total_amount, 2),
'sale_count' => (int)$member->sale_count,
'tags' => $member->tags ?: [],
'records' => array_slice($records, 0, 10),
// ❌ 不要放:'today' => date('Y-m-d'), 'generated_at' => time()
];
ksort($input);
foreach (['tags', 'records'] as $k) {
if (isset($input[$k]) && is_array($input[$k])) sort($input[$k]);
}
$hash = hash('sha256', json_encode($input, JSON_UNESCAPED_UNICODE));列表字段排序后再 hash,避免 ['A','B'] 和 ['B','A'] 产生不同 key。
LLM 超时校准
# 实测 P95,别信文档
for i in 1 2 3 4 5; do
time curl -s -X POST https://api.example-llm.com/v1/chat/completions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d @payload.json > /dev/null
donetimeout 设为实测 P95 的 1.5~2 倍。P95=40s → timeout=60~80s。
Stale Fallback(关键)
// LLM 调用失败时,取该实体最新一条历史缓存
$stale = Db::name('llm_response_cache')
->where('entity_id', $entityId)
->order('created_at', 'desc')
->find();
if ($stale) {
return ['result' => $stale['result'], 'stale' => true, 'layer' => 'L2-stale'];
}UI 层检测 stale:true 显示"分析结果来自 XX 时间,可能非最新",比直接 500 体验好得多。
- 时变字段只进 prompt,不进 hash——date/time/request_id 全部排除在 content-hash 之外。
- timeout = 实测 P95 × 1.5——不信文档,自己跑 5 次取最慢值。
- 失败走 stale fallback,不走硬错误——有旧缓存就返回旧结果 + stale 标记,让 UI 降级提示。
实测数据
| 路径 | 耗时 | |------|------| | 冷调用(LLM API) | ~43,000 ms | | 热命中(L2 DB 缓存) | ~3 ms(约 14,000× 提速) | | Stale fallback | ~12 ms |
一句话外卖
凡是会随时间自动变化的字段(date、time、nonce、request_id),一律从 content-hash 的输入中排除——它们进 prompt 可以,进 key 就让你的缓存每天清零。