DeepResearchSystem 0x07:搜索缓存

回顾 -> 问题引出

  1. DeepRearchSystem 0x00:初识
  2. DeepRearchSystem 0x01:Agent 基础
  3. DeepRearchSystem 0x02:Graph 构建
  4. DeepRearchSystem 0x03:HITL
  5. DeepResearchSystem 0x04:MAS 进阶
  6. DeepResearchSystem 0x06:LLM as Judge

前面经过几轮改造,DeepRearchSystem 已经算是一个接近生产级的深度搜索研报 Agent。但是,想象一下现在有一个这样的 case: 100 个用户搜索同一个问题,会发生什么?成本是怎样的?

  • 触发 100 * N 次 WebSearch,LLm 调用 100 +次;整体成本成倍,同时还会产生延迟。

那么怎么办呢?能不能设置一个缓存系统来来解决以上问题。

兵来将挡

当然可以,其实方案也简单。从我们客户端架构 的视角看,之前有个 SDWebImage 的图片库,对下载的图片就做了二级缓存。这样短时间内请求同样的图片就不会发出真实请求,而是直接从缓存里拿。

SDWebImage 借鉴

  • 二级缓存

    • P0:内存缓存,优先访问
    • P1:磁盘缓存,内存访问不到再访问
    • 以上都无,再直接发起请求
  • 存储方式

    • key:image_url 或其 md5
    • value:Nsdata(image)

research_cache 设计

本质上这个缓存是一个服务端架构 的缓存机制,那么他是否能完全复用 SDWebImage 的缓存机制呢。

  • 二级缓存

    • P0:Redis,优先访问
      • Redis 在本身就可以理解为服务端的 "磁盘存储",处理相对友好
        • 如果直接使用磁盘内存,高并发场景下可能把磁盘瞬间打爆
      • 为什么是一级缓存?理论上内存缓存应该更快啊❓❓❓
        • 吞吐量 :Redis 多实例共享
        • 一致性
    • P1:内存缓存,Redis 不可用再访问,完全的兜底缓存,容灾手段
    • 以上都无,再直接发起请求
  • 存储方式

    • key:(prompt, count) 作为业务维度的唯一标识,通过 MD5 哈希生成固定的 Redis Key
    • value:search_result
  • 双写双读机制

    • 主路径 (Redis) :利用 setex 原子操作写入数据并设置 TTL,利用 get 读取数据并通过 ttl 命令监控剩余时间。

    • 降级路径 (In-Memory) :当 Redis 连接失败(ping 不通或读写异常),系统自动切换至基于 dictthreading.Lock 的本地缓存。

  • TTL 生命周期

    • 通过 Redis 的 TTL 机制或本地的时间戳比对,自动处理缓存过期,并提供 clear_cache 用于测试隔离,cache_stats 用于可观测性。
  • 回填机制 (新增)

    • 读 Redis :获取 result_redistimestamp_redis

    • **读 In-Memory **:获取 result_localremaining_local

    • 决策

      • 如果 remaining_local > remaining_redis(说明 Redis 宕机期间本地更新过,且 Redis 还没被纠正):

        • Action :立即用 result_local 覆盖 Redis(异步或同步)。
        • Log :打印 WARN 日志,标记发生了"数据回填"。
      • 否则:正常返回 Redis 数据。

    • 全链路异常隔离:回写失败仅打日志,绝不阻断主业务流程,符合 AP 可用性优先原则。

  • 可观测性

    • 缓存命中率
    • 兜底成功率
    • 内存泄露

整体架构

Coding

基础配置

注意,我们这里内存缓存 _fallback_cache 存储的是缓存内容的过期时间戳。

Python 复制代码
# ── configuration ─────────────────────────────────────────────────────
DEFAULT_TTL = 7200  # 秒(2 小时)
TRUNCATE_LEN = 100   # 日志中截断 query / title 时的最大字符数
REDIS_KEY_PREFIX = "search_cache"
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")

# ── Redis 连接(惰性初始化)───────────────────────────────────────────
_redis_client: redis.Redis | None = None
_redis_available: bool | None = None  # None=未检测, True=可用, False=不可用

# ── 降级:内存缓存(Redis 不可用时使用)────────────────────────────────
# 对齐 Redis 结构,float 存储 ttl 过期时间戳
_fallback_cache: dict[str, tuple[float, list[dict]]] = {}
_fallback_lock = threading.Lock()

get_cached

基础代码

优先访问 redis,当且仅当 redis 不可用时,才使用本地内存缓存兜底。

Python 复制代码
def get_cached(prompt: str, count: int = 10) -> Optional[list[dict]]:
    """
    返回缓存的搜索结果(如果存在且未过期),否则返回 None。
    命中时打印前 3 条结果的标题,未命中时打印简要日志。
    """
    key = _make_key(prompt, count)
    r = _get_redis()

    if r is not None:
        # ── Redis 路径 ────────────────────────────────────────────
        try:
            raw = r.get(key)
            if raw is None:
                logger.info(f"[Cache] ✗ MISS --- '{prompt[:TRUNCATE_LEN]}' 未命中,将通过MCP搜索")
                return None

            result_redis = json.loads(raw)
            # key 距离被删除还有多少时间(s)
            remaining_redis = r.ttl(key)

            # 回填机制:从本地缓存更新数据(加锁保证线程安全)
            with _fallback_lock:
                if key in _fallback_cache:
                    ttl, result_local = _fallback_cache[key]
                    remaining_local = ttl - time.time()
                    # 核心逻辑:本地数据比 Redis 更新,触发回写
                    if remaining_local > remaining_redis:
                        logger.info(
                            f"[Cache] 🔄 检测到本地缓存更新于 Redis "
                            f"(本地ts={remaining_local:.0f}, Redis ts={remaining_redis:.0f}),触发回写 → Redis"
                        )
                        try:
                            # 用本地最新数据覆盖 Redis,重置 TTL
                            payload_to_write = {
                                "data": result_local,
                                "ts": time.time()
                            }
                            # 宽松重置,直接使用 DEFAULT_TTL 重置。搜索场景下可用
                            r.setex(key, DEFAULT_TTL, json.dumps(payload_to_write, ensure_ascii=False))
                            logger.info(f"[Cache] ✅ 回写 Redis 成功 (key={key[:TRUNCATE_LEN]})")
                            # 使用本地最新数据返回
                            result = result_local
                        except Exception as write_exc:
                            logger.warning(f"[Cache] ❌ 回写 Redis 失败: {write_exc},返回本地数据")
                            # 回写失败不影响返回本地数据,保证可用性
                            result = result_local
                    else:
                        # Redis 数据更新或持平,同步到本地缓存,保持数据一致
                        logger.debug(
                            f"[Cache] Redis 数据更新于本地,同步到本地缓存 "
                            f"(Redis ts={remaining_redis:.0f}, 本地ts={remaining_local:.0f})"
                        )
                        _fallback_cache[key] = (remaining_redis + time.time(), result_redis)
                        result = result_redis
                else:
                    # 本地无缓存,直接写入本地,实现双向同步
                    logger.debug(f"[Cache] Redis 命中,写入本地缓存 (key={key[:TRUNCATE_LEN]})")
                    _fallback_cache[key] = (remaining_redis + time.time(), result_redis)
                    result = result_redis

            titles = [
                r.get("title", r.get("snippet", "?"))[:TRUNCATE_LEN]
                for r in result[:3]
            ]
            logger.info(
                f"[Cache] ✓ HIT --- '{prompt[:TRUNCATE_LEN]}' 命中缓存 "
                f"({len(result)} 条结果, 剩余TTL={remaining_redis}s)"
            )
            logger.info(f"[Cache]   Top results: {', '.join(titles)}")
            return result
        except Exception as exc:
            logger.warning(f"[Cache] Redis 读取失败 ({type(exc).__name__}): {exc}")
            return None

    # ── 降级:内存缓存路径(Redis 不可用或读取失败时执行) ─────────────────────────────────────────
    with _fallback_lock:
        if key not in _fallback_cache:
            logger.info(f"[Cache] ✗ MISS --- '{prompt[:TRUNCATE_LEN]}' 未命中,将通过MCP搜索")
            return None

        ts, result = _fallback_cache[key]
        remaining = ts - time.time()

        if remaining <= 0:
            logger.info(
                f"[Cache] ✗ EXPIRED --- '{prompt[:TRUNCATE_LEN]}' 缓存已过期(TTL={DEFAULT_TTL}s),将重新搜索"
            )
            del _fallback_cache[key]
            return None

    titles = [
        r.get("title", r.get("snippet", "?"))[:TRUNCATE_LEN]
        for r in result[:3]
    ]
    logger.info(
        f"[Cache] ✓ HIT --- '{prompt[:TRUNCATE_LEN]}' 命中缓存 "
        f"({len(result)} 条结果, 剩余TTL={remaining:.0f}s)"
    )
    logger.info(f"[Cache]   Top results: {', '.join(titles)}")
    return result

回填机制

值得注意的是,这里的兜底机制不是简单 "单向兜底"。我特地做了 "双向同步" 的回填机制。因为在生产级应用中数据一致性显得尤为重要。回填机制的整体流程如下:

但是,我在测试中回填机制中发现一个 TTL 语义歧义 的问题:

  • 🌰:TTL = 100s
  1. T = 0s,首次写入,TTL_local = 100s,TTL_redis = 100s
  2. T = 50s, Redis 宕机,TTL_local = 50s,redis 不可用
  3. T = 60s,Redis 恢复,触发回填,TTL_local = 50s,回填后 redis 终止TTL,TTL_redis = 100s

原本本地 TTL = 50s 的数据在回填 redis 后变成了 100s,这个到底是不是问题呢?时效性变化的影响取决于时效敏感性有多强。其实,对于这个搜索项目还好,因为并不是强一致性 case。但是如果是价格、库存、股市 等这类时效性敏感的 case 就不能这么宽容处理了。

其实也简单,针对 TTL 做动态性传入:回写时不用完整的 DEFAULT_TTL,而是用 min(DEFAULT_TTL, 剩余TTL)

🤔🤔🤔:针对这个问题,在生产级应用里还有哪些更优的解决方案呢?

set_cached

set 这里和 get 不同的是:get 优先访问 redis,兜底使用本地缓存,但是 set 要始终将缓存内容写入了本地,以保障后续 Redis 失败时本地有数据可用。

Python 复制代码
def set_cached(prompt: str, count: int, result: list[dict]) -> None:
    """
    将搜索结果存入缓存。
    """
    key = _make_key(prompt, count)

    # 第一步:始终先写本地内存(兜底,保证当前实例可访问最新数据)
    with _fallback_lock:
        _fallback_cache[key] = (time.time() + DEFAULT_TTL, result)
    logger.info(
        f"[Cache] stored --- '{prompt[:TRUNCATE_LEN]}' "
        f"({len(result)} items, TTL={DEFAULT_TTL}s)→ 内存"
    )

    # 第二步:尝试写 Redis(共享,失败不影响主流程)
    r = _get_redis()
    if r is not None:
        # ── Redis 路径:SETEX 自带 TTL ────────────────────────────
        try:
            r.setex(key, DEFAULT_TTL, json.dumps(result, ensure_ascii=False))
            logger.info(
                f"[Cache] stored --- '{prompt[:TRUNCATE_LEN]}' "
                f"({len(result)} items, TTL={DEFAULT_TTL}s)→ Redis"
            )
        except Exception as exc:
            logger.warning(f"[Cache] Redis 写入失败 ({type(exc).__name__}): {exc}")
        return

clean_cached

清理逻辑很简单,没什么可说的。

Python 复制代码
def clear_cache() -> None:
    """
    清空所有缓存条目(用于测试)。
    """
    r = _get_redis()
    if r is not None:
        try:
            # SCAN 遍历所有 search_cache 前缀的 key 并删除
            cursor = 0
            deleted = 0
            while True:
                cursor, keys = r.scan(cursor, match=f"{REDIS_KEY_PREFIX}:*", count=100)
                if keys:
                    deleted += r.delete(*keys)
                if cursor == 0:
                    break
            logger.info(f"[Cache] cleared all {deleted} entries (Redis)")
        except Exception as exc:
            logger.warning(f"[Cache] Redis 清空失败 ({type(exc).__name__}): {exc}")
        return

    with _fallback_lock:
        count = len(_fallback_cache)
        _fallback_cache.clear()
    logger.info(f"[Cache] cleared all {count} entries (内存)")

cache_stats

就像我们之前做 iOS 开发,一个优秀 App 的产生,其代码必须具备可观测性 。同样我们也为我们的 search_cache 模块设计了观测函数,用于监控。

  • redis:看规模

    • total_entries:总共有多少热点数据。就好比 iOS App 的活跃用户
  • memory:看健康度

    • oldest_ttl:最老数据还有多久到期。如果这个值是 0,说明最老一批内存缓存可能要开始批量失效了,如果此时 Redis 也挂了,系统将面临"缓存雪崩"的风险。
      • 分布式系统,有的缓存可能近乎是同一时间创建的
      • 缓存雪崩:缓存批量失效后,短时间很多重复 query 集中打向 MCP,而MCP通常有QPS限流(比如每秒100次),直接就被打爆了
    • avg_results:平均每条缓存包含多少结果。如果这个值暴涨,可能预示着 MCP 返回了异常庞大的数据,可能导致网络拥堵或序列化失败
  • 监控作用

    • 故障诊断与警告
      • 🌰:凌晨 3 点,MCP 搜索 API 的调用量突然飙升
      • 看板诊断:cache_stats 异常 total_entries急剧下降,或者 backendredis 变成了 memory
      • 结论:Redis 连接闪断,系统自动降级。虽然整体服务没挂,但 API 成本在增加。需要安排人员迅速介入排查:重启 Redis 或检查网络。但是完全不用捞日志。
    • 容量规划与性能优化
      • Redis 容量 :通过 total_entries 的增长趋势,可以预估 Redis 的内存需求,提前扩容,防止因 Redis 内存满了触发 LRU 淘汰导致缓存命中率下降。
      • 内存风险 :通过 total_entries (memory) 和 avg_results,可以估算出每个 Pod 占用多少 MB 内存。如果 avg_results 很大,说明缓存对象过大,可能需要优化数据结构或压缩 JSON,防止 Pod 因 OOM(内存溢出)被 Kubernetes 杀掉。
    • 容灾(兜底降级)验证
      • 发布新版本,需要验证"降级机制"是否真的有效。
        • 1.手动切断 Redis 网络,然后调用 cache_stats()
        • 2.返回 {"backend": "memory", ...},降级代码正常走了,系统具备容灾能力。
          1. {"backend": "memory", ...},降级代码没走,需要 check 降级逻辑。
        • 如果没有这个接口,你只能等到真的发生故障才能知道降级是否有效(这不就是一个灾难级大坑🐴)。
Python 复制代码
def cache_stats() -> dict:
    """
    返回当前缓存统计信息(用于监控)。
    """
    r = _get_redis()
    if r is not None:
        try:
            # 统计 search_cache 前缀的 key 数量
            count = 0
            cursor = 0
            while True:
                cursor, keys = r.scan(cursor, match=f"{REDIS_KEY_PREFIX}:*", count=100)
                count += len(keys)
                if cursor == 0:
                    break
            return {
                "total_entries": count,
                "backend": "redis",
            }
        except Exception as exc:
            logger.warning(f"[Cache] Redis 统计失败 ({type(exc).__name__}): {exc}")
            return {"total_entries": "unknown", "backend": "redis"}

    now = time.time()
    with _fallback_lock:
        entries = []
        for key, (ts, res) in _fallback_cache.items():
            entries.append({
                "remaining_ttl": max(0, now - ts),
                "result_count": len(res),
            })
    return {
        # 缓存数
        "total_entries": len(entries),
        # 平均每条缓存包含多少结果
        "avg_results": sum(e["result_count"] for e in entries) / max(len(entries), 1),
        # 最老数据还有多久到期
        "oldest_ttl": min((e["remaining_ttl"] for e in entries), default=0),
        "backend": "memory",
    }

_get_redis

Python 复制代码
def _get_redis() -> redis.Redis | None:
    """
    获取 Redis 连接。不可用时返回 None。
    """
    global _redis_client, _redis_available
    if _redis_available is False:
        return None
    if _redis_client is not None:
        return _redis_client
    try:
        _redis_client = redis.from_url(REDIS_URL, decode_responses=True)
        _redis_client.ping()
        _redis_available = True
        logger.info("[Cache] Redis 连接成功,缓存跨实例共享")
        return _redis_client
    except Exception as exc:
        _redis_available = False
        _redis_client = None
        logger.warning(f"[Cache] Redis 不可用 ({type(exc).__name__}: {exc}),降级为进程内缓存")
        return None

缓存 key

典型的确定性映射的字典 key 设计。

Python 复制代码
def _make_key(prompt: str, count: int) -> str:
    """
    生成确定性的缓存 key。
    """
    # 防御性编程:去除首尾空白字符
    payload = f"{prompt.strip()}:{count}"
    return f"{REDIS_KEY_PREFIX}:{hashlib.md5(payload.encode('utf-8')).hexdigest()}"

cache 调用

我们在之前 WebSearchAgent-astep中查询的入口和 WebSearchMCP 返回结果的地方分别插入 get 和 set。

Python 复制代码
# WebSearchAgent.astep 函数内
...
# 优先访问缓存
count = kwargs.get("count", 10)
cache = get_cached(prompt, count=count)
if cache is not None:
    return cache

...
# 发起真正的 WebSearchMCP 请求
Python 复制代码
# 获取到令牌后,再切换到线程池执行阻塞的 HTTP 请求
# 此时当前协程让出 CPU,但 rate_limiter 的锁已释放,其他协程可申请令牌
response = await asyncio.to_thread(
    Application.call,
    api_key=api_key,
    app_id=app_id,
    prompt=step_prompt,
    biz_params=kwargs,
    )
response = self.extract_pages_from_mcp_response(response, None)

# 缓存结果
if response is not None:
    set_cached(prompt, count, result=response)

return response

至此,我们的 DeepRearchSystem 就具备了查询缓存机制。

更多 AI 技术干货 请订阅 AI技术手札专栏

相关推荐
一个处女座的程序猿1 小时前
AI之Interview:Claude Code之父Boris Cherny深度访谈—删除80%提示词、产品悬余、解缚思维与AI编程的范式转移
agent·claude·harness
AI大模型-小雄3 小时前
ChatGPT充值后Codex总改无关文件?用AGENTS.md限制项目修改范围
chatgpt·ai编程·codex·chatgpt plus·chatgpt pro·chatgpt充值
ZzT11 小时前
如何降低 Agent 生码不确定性?微软图表中间语言 Flint 有了答案
ai编程
星栈12 小时前
oh-my-pi工程级AI编码工使用体验
人工智能·后端·agent
AI大模型-小华12 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
周末程序猿12 小时前
LLM智能路由实践:通过 Harness 工程节约模型成本
人工智能·agent
玉鸯16 小时前
多 Agent 系统通信的实现原理与最佳实践
llm·agent·mcp
Tsonglew16 小时前
OpenWorker 代码解剖:一个 AI 同事"敢让它干活"的工程学
agent·ai编程