feat(adapters): 实现 RedisResponseCache
content-addressed sha256 缓存键,Redis 不可用时静默降级。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""Redis 响应缓存 — content-addressed SHA256 键,Redis 不可用时静默降级。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from core.types import LLMResponse
|
||||
|
||||
|
||||
class RedisResponseCache:
|
||||
"""基于 Redis 的 LLM 响应缓存。
|
||||
|
||||
使用 content-addressed 策略:key = sha256(model + json(messages)),
|
||||
值为 JSON 序列化的 LLMResponse。
|
||||
|
||||
当 Redis 不可用时静默降级:get 返回 None,set 吞异常并记录 warning。
|
||||
|
||||
Args:
|
||||
redis: 异步 Redis 客户端实例(duck-typed,需支持 get/set 方法)。
|
||||
ttl_s: 缓存过期时间(秒)。
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Any, ttl_s: int) -> None:
|
||||
self._redis = redis
|
||||
self._ttl_s = ttl_s
|
||||
|
||||
def _build_key(self, model: str, messages: list[dict[str, str]]) -> str:
|
||||
"""构造 content-addressed 缓存键。
|
||||
|
||||
Args:
|
||||
model: 模型名称。
|
||||
messages: 消息列表。
|
||||
|
||||
Returns:
|
||||
sha256 哈希字符串作为 Redis 键。
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{"model": model, "messages": messages},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
return f"llm_cache:{digest}"
|
||||
|
||||
async def get(
|
||||
self, model: str, messages: list[dict[str, str]]
|
||||
) -> LLMResponse | None:
|
||||
"""从缓存读取 LLM 响应。
|
||||
|
||||
Args:
|
||||
model: 模型名称。
|
||||
messages: 消息列表。
|
||||
|
||||
Returns:
|
||||
缓存命中时返回 LLMResponse,未命中或 Redis 异常时返回 None。
|
||||
"""
|
||||
try:
|
||||
key = self._build_key(model, messages)
|
||||
raw = await self._redis.get(key)
|
||||
except Exception:
|
||||
logger.warning("Redis 缓存读取失败,降级为未命中")
|
||||
return None
|
||||
|
||||
if raw is None:
|
||||
return None
|
||||
|
||||
data = json.loads(raw)
|
||||
return LLMResponse(**data)
|
||||
|
||||
async def set(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, str]],
|
||||
response: LLMResponse,
|
||||
) -> None:
|
||||
"""将 LLM 响应写入缓存。
|
||||
|
||||
Args:
|
||||
model: 模型名称。
|
||||
messages: 消息列表。
|
||||
response: 待缓存的 LLMResponse。
|
||||
"""
|
||||
try:
|
||||
key = self._build_key(model, messages)
|
||||
value = json.dumps(
|
||||
dataclasses.asdict(response), ensure_ascii=False
|
||||
)
|
||||
await self._redis.set(key, value, ex=self._ttl_s)
|
||||
except Exception:
|
||||
logger.warning("Redis 缓存写入失败,跳过缓存")
|
||||
@@ -0,0 +1,136 @@
|
||||
"""RedisResponseCache 单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from adapters.redis_cache import RedisResponseCache
|
||||
from core.types import LLMResponse
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_redis():
|
||||
"""创建 fakeredis 异步实例。"""
|
||||
try:
|
||||
import fakeredis.aioredis
|
||||
except ImportError:
|
||||
pytest.skip("fakeredis not installed")
|
||||
return fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_response() -> LLMResponse:
|
||||
"""构造一个标准测试用 LLMResponse。"""
|
||||
return LLMResponse(
|
||||
content="你好世界",
|
||||
thinking="思考过程",
|
||||
model="gpt-4o",
|
||||
provider="openai",
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
latency_ms=120,
|
||||
ttft_ms=45.0,
|
||||
max_inter_token_ms=12.3,
|
||||
cache_hit=False,
|
||||
call_id="test-call-001",
|
||||
)
|
||||
|
||||
|
||||
MESSAGES = [{"role": "user", "content": "你好"}]
|
||||
|
||||
|
||||
class _BrokenRedis:
|
||||
"""模拟 Redis 连接异常的假实现。"""
|
||||
|
||||
async def get(self, _key: str) -> None:
|
||||
raise ConnectionError("redis down")
|
||||
|
||||
async def set(self, _key: str, _value: str, **_kw: object) -> None:
|
||||
raise ConnectionError("redis down")
|
||||
|
||||
|
||||
# ── 测试用例 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_returns_none(fake_redis: object) -> None:
|
||||
"""缓存未命中时返回 None。"""
|
||||
cache = RedisResponseCache(redis=fake_redis, ttl_s=60)
|
||||
result = await cache.get("gpt-4o", MESSAGES)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_roundtrip(
|
||||
fake_redis: object, sample_response: LLMResponse
|
||||
) -> None:
|
||||
"""set 后 get 应返回相同内容。"""
|
||||
cache = RedisResponseCache(redis=fake_redis, ttl_s=300)
|
||||
await cache.set("gpt-4o", MESSAGES, sample_response)
|
||||
result = await cache.get("gpt-4o", MESSAGES)
|
||||
|
||||
assert result is not None
|
||||
assert result.content == sample_response.content
|
||||
assert result.thinking == sample_response.thinking
|
||||
assert result.model == sample_response.model
|
||||
assert result.provider == sample_response.provider
|
||||
assert result.prompt_tokens == sample_response.prompt_tokens
|
||||
assert result.completion_tokens == sample_response.completion_tokens
|
||||
assert result.latency_ms == sample_response.latency_ms
|
||||
assert result.ttft_ms == sample_response.ttft_ms
|
||||
assert result.max_inter_token_ms == sample_response.max_inter_token_ms
|
||||
assert result.cache_hit == sample_response.cache_hit
|
||||
assert result.call_id == sample_response.call_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_messages_different_keys(
|
||||
fake_redis: object, sample_response: LLMResponse
|
||||
) -> None:
|
||||
"""不同 messages 应产生不同缓存键。"""
|
||||
cache = RedisResponseCache(redis=fake_redis, ttl_s=300)
|
||||
messages_a = [{"role": "user", "content": "问题A"}]
|
||||
messages_b = [{"role": "user", "content": "问题B"}]
|
||||
|
||||
await cache.set("gpt-4o", messages_a, sample_response)
|
||||
|
||||
assert await cache.get("gpt-4o", messages_a) is not None
|
||||
assert await cache.get("gpt-4o", messages_b) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_models_different_keys(
|
||||
fake_redis: object, sample_response: LLMResponse
|
||||
) -> None:
|
||||
"""不同 model 应产生不同缓存键。"""
|
||||
cache = RedisResponseCache(redis=fake_redis, ttl_s=300)
|
||||
|
||||
await cache.set("gpt-4o", MESSAGES, sample_response)
|
||||
|
||||
assert await cache.get("gpt-4o", MESSAGES) is not None
|
||||
assert await cache.get("claude-3-opus", MESSAGES) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graceful_degradation_on_error() -> None:
|
||||
"""Redis 不可用时静默降级:get→None,set→不报错。"""
|
||||
cache = RedisResponseCache(redis=_BrokenRedis(), ttl_s=60)
|
||||
response = LLMResponse(
|
||||
content="x",
|
||||
thinking="",
|
||||
model="m",
|
||||
provider="p",
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
latency_ms=0,
|
||||
ttft_ms=None,
|
||||
max_inter_token_ms=None,
|
||||
cache_hit=False,
|
||||
call_id="broken-test",
|
||||
)
|
||||
|
||||
# get 应返回 None 而非抛异常
|
||||
assert await cache.get("m", MESSAGES) is None
|
||||
|
||||
# set 应静默吞掉异常
|
||||
await cache.set("m", MESSAGES, response)
|
||||
Reference in New Issue
Block a user