返回博客

date() 混进 Hash 导致缓存每天失效——LLM 响应缓存的三个反直觉坑

某后台系统接入了第三方大模型做智能分析,P95 响应时间 40s。为了不让用户每次都干等,我们加了缓存。加完之后发现:缓存形同虚设,每天零点后第一个请求必然重新调用 API,且提供商一旦超时就直接返回 500。排查下来,三个坑同时踩到。

现象

  1. 缓存日抛:相同用户、状态没变,第二天打开页面依然等 40s,cached: false
  2. 提供商超时 = 硬错误:DeepSeek P95 实测 40s,旧的 cURL timeout 设 30s——结果 30% 的正常请求直接被掐断,返回 500。
  3. 修改一个 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。用户状态一点没变,只是"今天"换了。

坑:时变字段污染 content-hash

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
done

timeout 设为实测 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 体验好得多。

正解:三条硬规则
  1. 时变字段只进 prompt,不进 hash——date/time/request_id 全部排除在 content-hash 之外。
  2. timeout = 实测 P95 × 1.5——不信文档,自己跑 5 次取最慢值。
  3. 失败走 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 就让你的缓存每天清零。