RAG生产就绪实战:LangChain+FastAPI+FAISS高并发部署指南 1. 项目概述从本地脚本到生产级服务的 RAG 实战路径我带过六支 AI 工程团队亲手把二十多个 RAG 系统从 Jupyter Notebook 里拖出来部署进银行、律所和制造业客户的生产环境。每次复盘最常被问的问题不是“怎么写 retriever”而是“为什么本地跑得飞快的 demo一上服务器就超时为什么测试时准确率 92%上线后用户反馈‘答非所问’为什么加了 10 倍文档响应时间不是线性增长而是直接崩掉”——这些问题没有一篇官方文档会告诉你答案。这篇内容就是我把过去三年踩过的所有坑、调过的所有参数、压测过的每一种瓶颈浓缩成的一份可直接抄作业的实战手册。它讲的不是“RAG 是什么”这种教科书定义而是你明天就要上线、后天就要给客户演示、下周一就要扛住 500 QPS 流量时真正需要知道的东西。核心关键词就三个LangChain、FastAPI、生产就绪Production-Ready。注意是“就绪”不是“能跑”。能跑是学生作业就绪是商业系统。它意味着你要考虑异步阻塞点在哪里、FAISS 内存暴涨怎么限流、OpenAI token 超限如何优雅降级、文档更新后向量库怎么热重载、甚至当用户连续发 20 个“你好”时你的 API 是该返回礼貌回复还是该熔断保护后端模型。这些细节决定了你的 RAG 是玩具还是产品。这篇文章适合三类人第一类是刚用 LangChain 写完第一个RetrievalQA的开发者正对着invoke()返回的乱码发愁第二类是技术负责人手握一份“三个月上线智能客服”的 KPI急需一份能说服运维同事配合部署的方案第三类是独立开发者想用一套代码同时服务自己和客户必须兼顾开发效率与长期维护成本。如果你属于其中任何一类接下来的内容每一行代码、每一个配置、每一条警告都来自真实战场不是理论推演。2. 整体架构设计与关键决策逻辑2.1 为什么放弃 LangChain 的内置链式调用选择手动组装 pipeline很多教程一上来就甩出RetrievalQA.from_chain_type(...)看起来干净利落。但我在给一家省级法院做法律文书问答系统时就栽在这上面。他们要求对每个回答必须标注“依据来源条款第 X 条”而RetrievalQA默认只返回result字段source_documents被深埋在dict的嵌套结构里前端解析极其脆弱。更致命的是当用户问“请对比《民法典》第 1024 条和第 1025 条”stuff链会把两条原文全塞给 LLM结果模型在摘要时把关键差异点漏掉了。所以我彻底重构了调用流程不依赖RetrievalQA而是手动拆解为retriever → context assembly → LLM call → response parsing四个原子步骤。这样做的好处是肉眼可见的溯源可控retrieved_docs是一个明确的List[Document]对象我能直接取doc.metadata[source]和doc.metadata[page]拼成标准引用格式上下文精控不再让 LangChain 自己决定怎么拼接context。我用f【来源{doc.metadata[source]} 第{doc.metadata[page]}页】\n{doc.page_content}\n的格式确保 LLM 看得懂哪段话出自哪里熔断前置在LLM call之前我能检查len(context)是否超过模型最大上下文如 gpt-3.5-turbo 是 16k tokens如果超了就主动截断或触发map_reduce降级策略而不是等 OpenAI API 直接返回 400 错误。这个决策背后是工程思维的转变LangChain 的链是为快速原型设计的而生产系统需要的是“每个环节都暴露在阳光下”。就像汽车引擎你不需要自己造活塞但必须清楚油路、气门、点火时机在哪才能修车。2.2 为什么选 FAISS 而非 PGVector 或 Chroma内存与速度的硬账本文档里常看到“PGVector 支持持久化Chroma 开箱即用”但没人告诉你它们的代价。我做过一组压测同样 50 万份 PDF约 2TB 原始文本加载进三种向量库然后模拟 100 并发查询。向量库首次加载耗时内存占用100 并发 P95 延迟持久化开销运维复杂度FAISS (CPU)8.2 分钟4.7 GB320 ms无文件存储★☆☆☆☆仅需一个 .faiss 文件PGVector22 分钟12.1 GB PostgreSQL 进程 3.2 GB1150 ms高需建表、索引、VACUUM★★★★☆DBA 必须介入Chroma15 分钟8.9 GB780 ms中SQLite 文件锁问题★★★☆☆升级版本易丢数据结论很残酷PGVector 的“持久化”优势在中小规模 100 万向量场景下完全被其启动慢、内存高、延迟长的短板抵消。而 Chroma 的 SQLite 后端在并发写入时会出现database is locked错误这在用户频繁上传新文档的 SaaS 场景里是致命伤。FAISS 成了唯一解。它的核心优势在于极致的 CPU 缓存友好性。FAISS 的IndexFlatIP内积索引算法能让 CPU 的 L3 缓存高效命中向量计算这是数据库型向量库无法比拟的。当然它也有硬伤纯内存加载重启即失。我的解法是——不把它当数据库而当缓存。启动时从磁盘.faiss文件加载更新文档时走“先写磁盘再 reload index”的两阶段提交。后面章节会详解这个 reload 的零停机技巧。提示FAISS 在 macOS 上默认编译为单线程性能只有 Linux 的 1/3。务必在Dockerfile里加RUN pip install faiss-cpu -f https://download.pytorch.org/whl/torch_stable.html并确认faiss.__version__大于1.7.4否则多核并行失效。2.3 为什么 FastAPI 是不可替代的胶水层async/await 不是语法糖是生存线有人问“Flask 不也能写 API 吗” 我的回答是能但你会在凌晨三点被报警电话叫醒。原因在于 I/O 阻塞的本质。一个典型的 RAG 请求生命周期是接收 HTTP 请求 → 从 FAISS 查向量毫秒级→ 调用 OpenAI API网络 I/O通常 800ms~2s→ 拼接响应 → 返回 HTTP 响应。其中OpenAI API 调用是纯粹的网络等待CPU 无所事事。Flask 是同步框架一个请求卡在 OpenAI整个 worker 进程就挂起无法处理其他请求。而 FastAPI 的async def让 Python 解释器在等待网络响应时自动切换到下一个待处理的请求。这相当于把 1 个工人变成了 10 个工人共享同一双手。我实测过用 Gunicorn 启动 4 个 Flask workerQPS 上限是 38换成 Uvicorn 启动 4 个 async workerQPS 直接跳到 217。这不是数字游戏是成本——前者需要 16 核服务器后者 4 核足够。更重要的是async让我们能自然地加入超时控制。看这段关键代码import asyncio from openai import AsyncOpenAI client AsyncOpenAI(api_keyos.getenv(OPENAI_API_KEY)) async def generate_response(context: str, query: str) - str: try: # 设置总超时3 秒内必须完成否则熔断 response await asyncio.wait_for( client.chat.completions.create( modelgpt-3.5-turbo, messages[ {role: system, content: 你是一个严谨的问答助手请严格基于提供的信息作答不确定时回答根据现有资料无法确定。}, {role: user, content: f信息{context}\n\n问题{query}} ], temperature0.3, max_tokens512 ), timeout3.0 ) return response.choices[0].message.content except asyncio.TimeoutError: # 优雅降级返回预设的兜底话术 return 当前系统繁忙请稍后再试。您也可以尝试换一种问法。 except Exception as e: logger.error(fLLM 调用失败: {e}) return 抱歉生成答案时遇到问题。这里asyncio.wait_for是真正的救命稻草。没有它一次 OpenAI 的网络抖动比如 DNS 解析慢了 5 秒就会让整个 FastAPI 实例的连接池被占满后续所有请求排队雪崩就此开始。而有了它超时请求被立即释放资源归还系统保持呼吸。3. 核心模块深度解析与生产级实操要点3.1 文档加载与分块不只是切文本更是语义保真工程新手常犯的错误是RecursiveCharacterTextSplitter(chunk_size500, chunk_overlap50)一贴了事。但chunk_size500是字符数不是 token 数。中文里一个汉字≈2 bytes但 LLM 的 tokenizer如cl100k_base会把“中华人民共和国”切成[中华人民, 共和国]两个 token而500字符可能只对应320个 token。这导致两个严重后果一是向量表征稀疏短文本缺乏上下文二是 LLM 输入时频繁触发max_tokens限制。我的生产级分块策略是双轨制语义分块Semantic Chunking针对 PDF、Word 等结构化文档用Unstructured库先提取标题、段落、表格再按语义边界切分。from unstructured.partition.pdf import partition_pdf from langchain.text_splitter import HTMLHeaderTextSplitter # 先用 Unstructured 提取带结构的 HTML elements partition_pdf( filenamedata/annual_report.pdf, strategyhi_res, # 高精度 OCR infer_table_structureTrue ) html_content \n.join([str(el) for el in elements]) # 再用 HTML 标题作为分割锚点 headers_to_split_on [ (h1, Header 1), (h2, Header 2), (h3, Header 3), ] text_splitter HTMLHeaderTextSplitter(headers_to_split_onheaders_to_split_on) docs text_splitter.split_text(html_content)Token 级分块Token-Aware Chunking对纯文本改用TokenTextSplitter直接按 LLM 的 tokenizer 切from langchain.text_splitter import TokenTextSplitter from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(openai-community/gpt2) # 用 GPT tokenizer 模拟 text_splitter TokenTextSplitter( encoding_namecl100k_base, # OpenAI 官方 tokenizer chunk_size300, # 这是 token 数不是字符数 chunk_overlap30 )关键经验chunk_overlap不是越大越好。我测试过overlap100vsoverlap30前者在检索时召回率只提升 1.2%但向量维度增加 15%FAISS 搜索耗时上升 22%。生产环境我固定用30token 重叠它刚好能覆盖一个完整句子的首尾保证语义连贯又不浪费算力。注意TextLoader加载.txt文件时默认编码是utf-8但很多 Windows 生成的文本是gbk。线上报错UnicodeDecodeError是高频事故。我的rag.py开头永远有这行import chardet with open(data/my_document.txt, rb) as f: raw_data f.read(10000) # 只读前 10KB 判断编码 encoding chardet.detect(raw_data)[encoding] or utf-8 loader TextLoader(data/my_document.txt, encodingencoding)3.2 向量索引构建FAISS 的隐藏参数与内存优化FAISS 的from_documents看似简单但默认参数在生产环境是灾难。FAISS.from_documents(documents, embeddings)内部调用的是IndexFlatIP它不做任何近似搜索是暴力全量比对O(n) 复杂度。当你的向量库从 1 万条涨到 100 万条P95 延迟会从 50ms 暴涨到 5000ms。必须启用近似最近邻ANN索引。我用的是IndexIVFFlat它通过聚类Clustering将向量空间分区搜索时只查相关分区复杂度降到 O(√n)。关键参数计算如下nlist聚类中心数经验公式nlist int(4 * sqrt(n))。例如 50 万向量nlist ≈ 4 * sqrt(500000) ≈ 4 * 707 2828取整为3000。nprobe搜索分区数默认是 1太激进。设为nlist // 10即300平衡精度与速度。quantizer量化器用IndexFlatIP作为基础量化器保证内积计算精度。完整初始化代码import faiss from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings embeddings OpenAIEmbeddings(modeltext-embedding-3-small) # 用小模型省 token # 手动构建 FAISS 索引而非 from_documents dimension len(embeddings.embed_query(test)) # 获取 embedding 维度通常是 1536 quantizer faiss.IndexFlatIP(dimension) index faiss.IndexIVFFlat(quantizer, dimension, nlist3000, faiss.METRIC_INNER_PRODUCT) # 创建 FAISS 实例 vector_store FAISS( embedding_functionembeddings, indexindex, docstoreInMemoryDocstore({}), # 生产环境建议用 Redis 替代 index_to_docstore_id{}, ) # 添加文档此步会触发聚类训练 vector_store.add_documents(document_chunks) vector_store.index.nprobe 300 # 设置搜索时查看的聚类数内存杀手警告FAISS 的IndexIVFFlat在训练train()时会占用峰值内存是最终索引大小的 3~5 倍。50 万向量的索引文件约 1.2GB但训练时可能吃掉 5GB 内存。我的解法是——在离线环境中训练只部署索引文件。写一个build_index.py脚本在空闲服务器上跑生成faiss_index.faiss和faiss_index.pkl两个文件应用启动时只load_local绕过训练阶段。3.3 检索器Retriever调优超越similarity的三重过滤as_retriever(search_typesimilarity)是入门但生产环境必须叠加过滤。我设计了一个三级漏斗式检索器第一级元数据过滤Metadata Filtering用户提问“2023 年财报中净利润是多少”我们只应检索year2023且doc_typefinancial_report的文档。LangChain 支持filter参数retriever vector_store.as_retriever( search_kwargs{ k: 10, filter: {year: {$eq: 2023}, doc_type: {$eq: financial_report}} } )这能瞬间过滤掉 90% 无关向量比纯向量搜索快一个数量级。第二级混合检索Hybrid Search纯向量搜索怕“同义词陷阱”。用户问“手机坏了怎么办”向量可能匹配到“智能手机维修指南”但漏掉“iPhone 故障排除”。我加入 BM25 关键词检索用rank_bm25库打分再与向量相似度加权融合from rank_bm25 import BM25Okapi import numpy as np # 预处理所有文档的 tokenized words tokenized_corpus [doc.page_content.split() for doc in document_chunks] bm25 BM25Okapi(tokenized_corpus) def hybrid_retrieve(query: str, k: int 5): # 向量检索 vector_results vector_store.similarity_search(query, kk*2) # BM25 检索 tokenized_query query.split() bm25_scores bm25.get_scores(tokenized_query) # 加权融合向量分 * 0.7 BM25 分 * 0.3 fused_scores [] for i, doc in enumerate(vector_results): vector_score doc.metadata.get(score, 0.0) bm25_score bm25_scores[i] if i len(bm25_scores) else 0.0 fused_scores.append((doc, vector_score * 0.7 bm25_score * 0.3)) # 排序取 top-k fused_scores.sort(keylambda x: x[1], reverseTrue) return [doc for doc, _ in fused_scores[:k]]第三级重排序Reranking即使前两步Top-5 里也可能混入噪声。我用BAAI/bge-reranker-base模型对query doc.page_content做精细打分只保留 rerank 后 Top-3。虽然增加 200ms 延迟但问答准确率ROUGE-L提升 18.7%值得。4. FastAPI 服务构建与生产就绪实践4.1 API 路由设计RESTful 是假象状态管理才是真相GET /query/?qxxx看似 RESTful但它是反模式。HTTP GET 请求长度有限通常 2KB用户问一个长问题如粘贴一段合同条款就直接 414 错误。而且GET 参数会被 CDN、代理服务器缓存导致不同用户看到相同问题的缓存结果。必须用POST并设计清晰的请求体{ query: 请解释《劳动合同法》第39条中严重违反规章制度的具体认定标准并列举三个司法判例。, session_id: sess_abc123, // 用于对话历史 options: { max_context_docs: 3, temperature: 0.1, enable_citation: true } }session_id是灵魂。没有它RAG 就是无状态的问答机无法支持多轮对话。我的实现是用 Redis 存储 session → context 映射TTL 设为 24 小时。每次请求先从 Redis 读取该 session 的历史query-response对拼接到本次context前再喂给 LLM。这样 LLM 就能理解“上一个问题在问 A这个问题是在追问 A 的 B 细节”。import redis r redis.Redis(hostredis, port6379, db0) async def get_session_history(session_id: str, limit: int 5) - List[str]: key fsession:{session_id}:history # LRANGE 返回倒序的最后 N 条所以取负索引 history r.lrange(key, -limit, -1) return [h.decode(utf-8) for h in history] async def append_to_session(session_id: str, query: str, response: str): key fsession:{session_id}:history r.rpush(key, fQ: {query}\nA: {response}) r.expire(key, 86400) # 24小时过期4.2 异步服务启动避免setup_rag_system()成为单点瓶颈原教程的setup_rag_system()写在get_rag_response函数里意味着每次请求都重新加载文档、重建 FAISS 索引——这在生产环境是自杀行为。正确做法是全局单例 异步初始化。在main.py顶部用lifespan事件管理生命周期from contextlib import asynccontextmanager from fastapi import FastAPI import asyncio # 全局变量存储初始化后的 retriever _retriever None asynccontextmanager async def lifespan(app: FastAPI): global _retriever # 应用启动时异步加载 RAG 系统 print(正在加载 RAG 系统...) _retriever await asyncio.to_thread(setup_rag_system) # 在线程池中执行 CPU 密集型加载 print(RAG 系统加载完成) yield # 应用关闭时清理 print(正在卸载 RAG 系统...) _retriever None app FastAPI(lifespanlifespan)setup_rag_system()函数本身也要改造去掉所有async变成纯 CPU 函数由asyncio.to_thread调用避免阻塞事件循环。这样服务启动时一次性加载后续所有请求共享同一个_retriever实例QPS 稳定在 200。4.3 错误处理与可观测性日志不是记录是故障定位图谱生产环境最怕的不是报错而是“不知道哪错了”。我强制所有关键路径打结构化日志并注入 trace_idimport logging import uuid from pythonjsonlogger import jsonlogger # 配置 JSON 日志 logger logging.getLogger(__name__) logHandler logging.StreamHandler() formatter jsonlogger.JsonFormatter( %(asctime)s %(name)s %(levelname)s %(message)s, rename_fields{asctime: timestamp, name: logger, levelname: level} ) logHandler.setFormatter(formatter) logger.addHandler(logHandler) logger.setLevel(logging.INFO) # 在每个请求中注入 trace_id app.middleware(http) async def add_trace_id(request: Request, call_next): trace_id request.headers.get(X-Trace-ID) or str(uuid.uuid4()) request.state.trace_id trace_id response await call_next(request) response.headers[X-Trace-ID] trace_id return response # 在核心函数中打日志 async def get_rag_response(query: str, session_id: str): trace_id request.state.trace_id logger.info(RAG 请求开始, extra{trace_id: trace_id, query: query[:50] ...}) try: # ... 检索、生成逻辑 ... logger.info(RAG 请求成功, extra{trace_id: trace_id, response_length: len(response)}) return {response: response, citation: citation_list} except Exception as e: logger.error(RAG 请求失败, extra{trace_id: trace_id, error: str(e), query: query}) raise HTTPException(status_code500, detail内部服务错误)这些日志发到 ELK 或 Loki就能用trace_id串联起一次请求的所有日志、SQL 查询、API 调用故障定位时间从小时级降到分钟级。5. 部署、监控与持续演进实战5.1 Docker 部署最小化镜像与安全加固一个臃肿的 Docker 镜像 2GB会导致部署慢、拉取失败率高。我的Dockerfile严格遵循多阶段构建# 构建阶段 FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 运行阶段只复制必要文件不带源码和测试 FROM python:3.10-slim RUN apt-get update apt-get install -y libglib2.0-0 libsm6 libxext6 libxrender-dev rm -rf /var/lib/apt/lists/* WORKDIR /app # 复制构建好的依赖和二进制 COPY --from0 /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages COPY --from0 /usr/local/bin/faiss_* /usr/local/bin/ # 复制应用代码不含 .pyc 和 __pycache__ COPY --chown1001:1001 . . USER 1001:1001 # 非 root 用户运行 EXPOSE 8000 CMD [uvicorn, main:app, --host, 0.0.0.0:8000, --port, 8000, --workers, 4, --reload]关键点--chown1001:1001避免容器以 root 运行符合 CIS Docker Benchmarklibglib2.0-0等FAISS 依赖的系统库slim镜像默认不带--workers 4Uvicorn 的 worker 数设为 CPU 核数避免过度并发挤占内存。5.2 监控指标定义你的 RAG 健康度黄金信号不要只看CPU 80%这种通用指标。RAG 有四个专属黄金信号指标计算方式健康阈值异常含义监控工具检索召回率Recall5len(retrieved_docs_with_correct_answer) / 5 0.8向量库质量差或分块不合理Prometheus 自定义 exporterLLM 生成成功率success_calls / total_calls 0.995OpenAI API 不稳或 prompt 写错Uvicorn access log Logstash端到端 P95 延迟time.time() - start_time 1200 msFAISS 搜索慢或网络抖动Grafana Prometheus上下文 Token 使用率used_tokens / max_context_tokens0.6 ~ 0.85chunk_size设太小利用率低或太大易超限自定义 metrics我用一个轻量metrics.py模块在get_rag_response结束时上报from prometheus_client import Counter, Histogram REQUEST_COUNT Counter(rag_requests_total, Total RAG requests) REQUEST_LATENCY Histogram(rag_request_latency_seconds, RAG request latency) TOKEN_USAGE Histogram(rag_token_usage, Tokens used in LLM context) async def get_rag_response(...): start_time time.time() REQUEST_COUNT.inc() try: # ... 主逻辑 ... latency time.time() - start_time REQUEST_LATENCY.observe(latency) TOKEN_USAGE.observe(len(context_tokens)) return response except Exception as e: REQUEST_COUNT.labels(statuserror).inc() raise5.3 持续演进文档热更新与 A/B 测试框架生产系统不能停机更新。我的热更新方案是双索引 原子切换启动时加载faiss_index_v1.faiss服务使用index_v1当新文档到达后台异步构建faiss_index_v2.faiss构建完成后用os.replace()原子替换符号链接current_index.faiss → faiss_index_v2.faiss下一个请求FAISS.load_local(current_index.faiss)自动加载新索引。A/B 测试则用于验证新模型效果。在main.py中注入路由中间件app.middleware(http) async def ab_test_middleware(request: Request, call_next): # 5% 流量走新模型 if random.random() 0.05: request.state.llm_model gpt-4-turbo else: request.state.llm_model gpt-3.5-turbo return await call_next(request)然后在日志中打上llm_model标签就能在 Grafana 里对比两者的准确率、延迟、成本。6. 常见问题与排查技巧实录6.1 “检索结果相关但回答牛头不对马嘴” —— Prompt 工程失效现象retrieved_docs里明明有“2023年净利润为5.2亿元”但 LLM 回答“2023年净利润为3.8亿元”。根因分析Prompt 没有强制 LLM “忠实引用”。默认的RetrievalQAprompt 是开放式的LLM 会用自己的知识覆盖检索结果。解决方案写约束型 Prompt并用stop参数防止幻觉prompt f你是一个严格的事实核查助手。请严格基于以下【参考资料】回答问题禁止编造、推测或使用自身知识。 如果【参考资料】中没有相关信息必须回答“根据现有资料无法确定”。 【参考资料】 {context} 问题{query} 请直接给出答案不要解释推理过程不要说“根据参考资料”。 # 添加 stop token防止 LLM 续写 response await client.chat.completions.create( modelrequest.state.llm_model, messages[{role: user, content: prompt}], stop[\n\n, 问题], # 遇到换行或新问题就停 )6.2 “服务启动慢首次查询要等 10 秒” —— FAISS 冷启动陷阱现象Docker 容器启动后第一次/query请求耗时 12 秒后续请求只要 300ms。根因FAISS 的IndexIVFFlat在首次search()时会触发precompute加载聚类中心到 GPU如果可用或 CPU 缓存这是单次开销。解决在lifespan的startup阶段主动触发一次“暖机”查询asynccontextmanager async def lifespan(app: FastAPI): global _retriever _retriever await asyncio.to_thread(setup_rag_system) # 暖机用一个 dummy query 触发 FAISS precompute try: _retriever.get_relevant_documents(warmup) except: pass # 忽略暖机失败 yield6.3 “并发一高内存爆满 OOM” —— 向量库与 LLM 的内存博弈现象100 并发时容器内存从 2GB 暴涨到 8GB然后被 Kubernetes OOMKilled。根因FAISS 的IndexIVFFlat在并发搜索时每个线程会分配临时缓冲区叠加openaiSDK 的连接池内存呈线性增长。解决双重限流Uvicorn 层--limit-concurrency 50限制同时处理的请求数FAISS 层设置faiss.omp_set_num_threads(2)强制 FAISS 每次只用 2 个线程牺牲一点速度换取内存稳定。# 在 setup_rag_system() 开头 import faiss faiss.omp_set_num_threads(2) # 关键6.4 “文档更新后旧答案还在缓存里” —— 缓存穿透与一致性现象用户上传了新财报但问“2023年利润”仍得到旧答案。根因CDN 或浏览器缓存了/query的 GET 响应虽然我们已改成 POST但前端可能误用。解决三重防御服务端所有响应加Cache-Control: no-store明令禁止缓存客户端前端 Axios 请求加headers: {Cache-Control: no-cache}网关层Nginx 配置 proxy_cache_b