5.成本、缓存与可观测性

本篇目标:先降低向量检索和模型调用的成本,再安全地复用缓存,最后用日志、指标、追踪与评测看清系统是否正常、够快且答得可靠。

学习路线

  1. 向量侧:用维度、量化、批量和合理机器规格控制存储与检索成本。
  2. 调用侧:用精确缓存、语义缓存、模型路由和 Token 预算减少重复模型调用。
  3. 可靠性与可观测性:加入安全、限流、重试、日志、指标、链路追踪和质量评测。

本篇重点是成本、缓存与监控。容量计算和扩容决策见 02_向量容量与扩容.md;检索、HNSW 和 BM25 见 01_RAG基础与检索.md;Query 改写与上线实践见 04_工程实现与上线.md


1. 向量检索与 RAG 的成本优化策略

成本优化的目标不是单纯把机器配小,而是在检索质量、查询延迟、存储成本和运维复杂度之间取得可验证的平衡。常见策略如下:

策略 做什么 主要节省什么 实施难度 关键注意点
降低向量维度 例如从 1,536 维改为 512 维 向量存储、RAM、索引体积 低~中 必须使用模型支持的降维方式,并重新建库、评测召回。
量化(Quantization) 例如 float32 → int8 向量内存、索引和部分计算成本 可能降低召回;有些方案会保留原始向量用于重排。
批量处理(Batching) 把多条离线写入/Embedding 请求合并 网络与请求开销、吞吐效率 通常不减少相同文本的 Token 总量;实时查询不能为了凑批而明显增加等待。
缓存(Caching) 缓存高频或语义相近的问题及结果 重复检索、重复重排、重复 LLM 调用 节省取决于命中率;必须隔离权限、知识库版本、提示词和模型版本。
合理选型(Right-size) 根据真实负载选择机器规格 闲置 CPU、RAM、磁盘和托管资源费用 通过 QPS、P95 延迟、CPU/RAM 利用率和增长趋势决定,而不是凭感觉预配。

1.1 策略一:降低向量维度

向量的原始存储与维度成正比:

text 复制代码
原始向量大小 = 向量数量 × 维度 × 每维字节数

对于 float32

text 复制代码
1,536 维:1,536 × 4 = 6,144 bytes/条
  512 维:  512 × 4 = 2,048 bytes/条

若模型和数据库支持将 1,536 维降为 512 维,原始向量部分理论上会减少:

text 复制代码
1 - 512 / 1536
= 66.7%

**校正:**不能随意把已有向量数组"截掉后面的维度"就认为完成降维。应使用嵌入模型正式支持的维度参数或专门的降维方案;文档和查询必须采用同一方案重新生成向量并重建索引。降维后必须比较 Recall@k、MRR/nDCG 和最终回答质量。

1.2 策略二:量化(Quantization)

量化是降低每个维度数值所占字节数:

text 复制代码
float32:每维 4 bytes
float16:每维 2 bytes
int8 / uint8:每维 1 byte

float32 → int8 为例,原始向量部分理论可从 4 bytes/维降到 1 byte/维,即减少约 75%。但总成本不一定减少 75%,因为还存在 HNSW 图、ID、元数据、原文、备份和服务资源;部分产品还会保留 float32 原向量,在 Top-k 候选上重排以补偿精度。

推荐流程:

text 复制代码
float32 建立基线 → 对评测集记录 Recall@k 与 P95 延迟
                 → 尝试 float16 / int8 / 产品量化方案
                 → 对比召回、延迟、RAM、磁盘和成本
                 → 达标后再上线

1.3 策略三:批量处理(Batching)

批量最适合离线建库。例如一次对 100 个片段发起 Embedding 请求,而不是连续发 100 次单片段请求:

text 复制代码
逐条请求:100 次网络往返 + 100 次请求处理开销
批量请求:  1 次或少量网络往返 + 更高吞吐

批量可降低网络与请求调度开销、提高建库速度,也可能获得服务商提供的异步批处理折扣;但在相同模型、相同文本下,它通常不会自动减少输入 Token 总量。对于面向用户的实时检索,不应为了凑批而增加明显等待时间。

1.4 策略四:缓存(Caching)

缓存可分为两层:

text 复制代码
L1 精确缓存:相同的规范化请求直接复用结果
L2 语义缓存:意思足够相近且上下文一致的问题复用结果

如果 20% 的请求能安全命中缓存,且命中的请求不再执行"检索 + 重排 + LLM",这部分链路的计算量理论最多可减少约 20%。实际节省还要扣除缓存查询、语义向量生成和缓存失效管理的成本。

在问题重复度较高、缓存边界设计合理的生产应用中,30%~50% 的缓存命中率常可作为一个值得追求和验证的经验目标;达到这个范围通常能明显减少延迟与模型调用费用。但它不是所有业务的保证值:帮助中心、客服 FAQ、内部知识助手等重复提问多的场景更容易达到;长尾问题多、知识库变化频繁或权限隔离很细的场景,命中率可能远低于此范围。

评估时不要只看"命中率"这个数字,还要同时看:

text 复制代码
安全命中率:命中的结果是否仍满足权限、版本和正确性要求
节省调用数:命中后实际跳过了多少次 embedding / 检索 / 重排 / LLM 调用
节省金额与 P95 延迟:缓存查询和失效维护成本扣除后,是否真的有收益
错误复用率:语义缓存是否把看似相近、实际不同的问题答错

缓存键或过滤条件至少应包含:

text 复制代码
用户 / 租户权限
知识库版本
模型与提示词版本
语言、地区和会话上下文(如会影响答案)

否则可能发生越权、返回旧答案或将错误答案大规模复用的问题。

生产级精确检索缓存:Redis + TTL + 版本化失效

functools.lru_cache 适合本地演示或单进程的小工具,但它只有当前 Python 进程可见,没有原生 TTL;多台 API 机器各自有一份缓存,知识库更新后也不会自动清除。因此,线上通常使用 Redis 这类共享缓存,并把「版本号」放进缓存键:知识库或权限规则变更时递增版本号,旧键自然不再命中,无需扫描并删除大量缓存键。

下面示例缓存的是检索结果,而不是最终 LLM 答案。它包含以下保护:

  • 缓存键包含查询、top_k、元数据过滤条件、租户、权限范围、知识库版本和检索配置版本;
  • Redis 的 EX 设置 TTL,避免内容无限期陈旧;
  • 权限过滤由服务端的 source_search 强制执行,不能只依赖缓存键;
  • 用短时分布式锁减轻高并发下同一个未命中请求同时穿透到向量库的风险;
  • Redis 故障时降级为直接检索,保证缓存不是单点故障。

安装依赖:

bash 复制代码
pip install redis langchain-core
python 复制代码
from __future__ import annotations

import hashlib
import hmac
import json
import os
import re
import secrets
import time
from dataclasses import dataclass, field
from typing import Any, Callable

from redis import Redis
from redis.exceptions import RedisError
from langchain_core.documents import Document


# 生产环境应从密钥管理服务或环境变量读取,不要把密钥写进代码仓库。
redis_client = Redis.from_url(
    os.environ["REDIS_URL"],
    decode_responses=True,
    socket_connect_timeout=0.2,
    socket_timeout=0.5,
)
CACHE_KEY_SECRET = os.environ["RETRIEVAL_CACHE_KEY_SECRET"].encode("utf-8")
CACHE_TTL_SECONDS = 300       # 结果最多缓存 5 分钟
LOCK_TTL_SECONDS = 3          # 防缓存击穿的短锁,必须小于一次检索的合理上限
CACHE_SCHEMA_VERSION = "v1"  # 修改序列化结构时递增


@dataclass(frozen=True)
class RetrievalRequest:
    query: str
    top_k: int
    tenant_id: str
    # 由服务端权限系统计算;同一权限可见范围应得到相同值。
    # 若每位用户都可能看到不同文档,应把 user_id 或其权限范围摘要纳入此字段。
    permission_fingerprint: str
    # 知识库内容、分块、向量或 ACL 发生变化时递增它。
    knowledge_base_version: str
    # 修改 embedding 模型、检索参数、重排策略等会影响结果时递增它。
    retriever_config_version: str
    # 仅接收服务端校验、白名单化后的过滤条件,不直接信任客户端传来的 JSON。
    metadata_filter: dict[str, Any] = field(default_factory=dict)


def normalize_query(query: str) -> str:
    """只清理首尾和连续空白;不要随意删中文标点或改写语义。"""
    return re.sub(r"\s+", " ", query).strip()


def canonical_json(value: Any) -> str:
    """排序后序列化,保证字典书写顺序不同仍得到相同缓存键。"""
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))


def cache_key(request: RetrievalRequest) -> str:
    key_material = {
        "schema": CACHE_SCHEMA_VERSION,
        "query": normalize_query(request.query),
        "top_k": request.top_k,
        "tenant_id": request.tenant_id,
        "permission": request.permission_fingerprint,
        "kb_version": request.knowledge_base_version,
        "retriever_version": request.retriever_config_version,
        "filter": request.metadata_filter,
    }
    # HMAC 既使键长度固定,也避免在 Redis 键名中暴露用户的原始问题。
    digest = hmac.new(
        CACHE_KEY_SECRET,
        canonical_json(key_material).encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return f"rag:retrieval:{CACHE_SCHEMA_VERSION}:{digest}"


def dump_documents(documents: list[Document]) -> str:
    return json.dumps(
        [
            {
                "id": getattr(doc, "id", None),
                "page_content": doc.page_content,
                "metadata": doc.metadata,
            }
            for doc in documents
        ],
        ensure_ascii=False,
        default=str,  # 元数据应尽量只存 JSON 类型;这里防止意外对象导致缓存失败
    )


def load_documents(payload: str) -> list[Document]:
    return [
        Document(
            id=item.get("id"),
            page_content=item["page_content"],
            metadata=item.get("metadata", {}),
        )
        for item in json.loads(payload)
    ]


# 由具体向量库实现。此函数必须再次强制叠加 tenant/ACL 过滤,不能认为缓存键已经鉴权。
# 示例:在 Qdrant / Milvus / pgvector 中,将 request.tenant_id、权限可见范围和
# request.metadata_filter 组合为数据库对应的 server-side filter,再进行 similarity search。
SourceSearch = Callable[[RetrievalRequest], list[Document]]


def release_lock_safely(lock_key: str, token: str) -> None:
    """只释放自己持有的锁,避免超时后误删后来者的锁。"""
    redis_client.eval(
        """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
        """,
        1,
        lock_key,
        token,
    )


def search_with_cache(request: RetrievalRequest, source_search: SourceSearch) -> list[Document]:
    if not normalize_query(request.query):
        return []
    if not 1 <= request.top_k <= 100:
        raise ValueError("top_k 必须在 1 到 100 之间")

    key = cache_key(request)
    lock_key = f"{key}:lock"

    try:
        cached = redis_client.get(key)
        if cached is not None:
            return load_documents(cached)

        token = secrets.token_urlsafe(18)
        got_lock = redis_client.set(lock_key, token, nx=True, ex=LOCK_TTL_SECONDS)
        if got_lock:
            try:
                # 获锁前后都查一次,防止等待锁期间其他请求已经填充缓存。
                cached = redis_client.get(key)
                if cached is not None:
                    return load_documents(cached)

                documents = source_search(request)  # 此处必须做服务端权限过滤
                redis_client.setex(key, CACHE_TTL_SECONDS, dump_documents(documents))
                return documents
            finally:
                release_lock_safely(lock_key, token)

        # 已有请求正在填充缓存:短暂等待并重查;超时后宁可直接查询,也不要一直阻塞用户。
        for _ in range(3):
            time.sleep(0.05)
            cached = redis_client.get(key)
            if cached is not None:
                return load_documents(cached)
        return source_search(request)

    except RedisError:
        # Redis 不可用时服务仍可工作;应同时记录指标和告警。
        return source_search(request)

调用时,不要让客户端直接指定 tenant_idpermission_fingerprintknowledge_base_version;它们必须由已认证用户、权限服务和知识库服务在后端生成:

python 复制代码
request = RetrievalRequest(
    query=user_input,
    top_k=8,
    tenant_id=current_tenant.id,
    permission_fingerprint=current_user.document_scope_fingerprint,
    knowledge_base_version=knowledge_base.current_version,
    retriever_config_version="bge-m3-hybrid-rerank-v3",
    metadata_filter=validated_filter,  # 例如 {"category": "产品文档", "lang": "zh"}
)
documents = search_with_cache(request, source_search=authorized_vector_search)

知识库内容、文档权限、分块或索引发生变化时,递增对应的 knowledge_base_version;权限模型变更时同时更新 permission_fingerprint。这样新请求会使用新键,旧缓存会在 TTL 到期后自动淘汰。若遇到紧急下线、严重权限事故等场景,仍应按租户/知识库前缀主动删除 Redis 键,不能只等待 TTL。

这个例子是 L1 精确缓存:只有规范化后完全相同的请求才命中。语义缓存还需额外判断问题相似度、租户/权限/版本一致性和答案风险,建议先从这个精确缓存开始。

1.5 策略五:合理选型(Right-size)

不要只按"未来可能增长到很大"预先购买过大的机器。应根据监控逐步调整:

信号 处理建议
RAM 长期低、CPU 低、P95 延迟远低于目标 考虑降低实例规格。
RAM 接近 70%~80%,或索引频繁被挤出内存 增加 RAM,或评估量化/降维。
CPU 高峰持续饱和、查询排队 增加 CPU、读副本,或优化 efSearch / 重排。
磁盘、备份或副本费用占比高 检查原文 payload、快照策略、量化与冷热数据分层。

1.6 推荐实施顺序

text 复制代码
1. 记录当前 RAM、磁盘、QPS、P95 延迟、Recall@k 和月账单
2. 清理重复、过期、无权限或无价值数据
3. 按真实负载合理选择机器规格
4. 为安全可复用的高频请求增加缓存
5. 在评测集上测试降维和量化
6. 最后再考虑复杂的分片、跨集群或大规模架构改造

多种策略可以组合使用,但每次变更都应通过评测确认没有明显损害检索质量。

本节小结

先用"分块数量 × 维度 × 每维字节数"估算原始向量,再加 HNSW、元数据和余量。容量不够时先升级单机;容量或吞吐仍无法满足时再分片;如果只是读并发和高可用问题,优先考虑副本。


2. 模型调用成本、缓存与 Token 管控

生产环境需要控制成本,也需要防范异常超长输入、重试风暴和缓存越权。常见手段是:缓存、路由、预算与监控

2.1 模型智能路由(Model Routing)------不大炮打蚊子

核心思想:不是所有任务都需要调用最贵、推理最重的模型。

典型做法是用规则或轻量模型评估任务类型:

  • 简单任务:固定 FAQ、分类、格式化、简单提取,可用成本较低的模型;
  • 复杂任务:多步推理、复杂代码、深度分析,路由到能力更强的模型;
  • 不确定任务:可先尝试较便宜方案,再按质量规则升级。

**校正:**不要把特定模型价格、固定分类模型或"70% 请求、节省 80% 成本"写成通用结论。模型价格和能力会变化,路由错误也会损害质量;应以真实流量、失败率和用户反馈评估收益。

2.2 语义缓存(Semantic Caching)------复用相近问题的结果

两层缓存的常见结构:

text 复制代码
用户提问
  │
  ▼
[L1 精确缓存] ──命中──► 直接返回已验证结果
  │ 未命中
  ▼
[L2 语义缓存] ──语义足够接近且上下文一致──► 返回已验证结果
  │ 未命中
  ▼
[检索 + LLM 调用] ──► 记录结果,再按策略回写缓存
  • L1 精确缓存:同一规范化请求直接复用结果;
  • L2 语义缓存:问题意思相近时复用结果,通常用向量检索实现;
  • Redis/RediSearch 等工具可以实现向量索引,但并非唯一选择。

2.2.1 语义缓存应该放在哪里?

通常优先把语义缓存放进具备向量检索能力的 Redis (例如 Redis Stack / RediSearch,或云服务提供的等价能力)。普通 Redis 的字符串 Key-Value 能做 L1 精确缓存,但不能仅凭普通 GET 完成"按向量相似度找相近问题"。

推荐把系统分成三层,职责不要混在一起:

text 复制代码
L1 Redis 精确缓存
    key   = 规范化问题 + 租户/权限 + 知识库版本 + 检索配置
    value = 已验证的回答或检索结果

L2 Redis 语义缓存(带向量索引)
    存储 = 问题向量 + 原问题 + 回答/检索结果 + 权限/版本 + TTL
    查询 = 用新问题向量搜索历史缓存问题,并检查相似度阈值

知识库向量数据库(pgvector / Milvus / Qdrant / Pinecone 等)
    存储 = 长期存在的文档片段向量、元数据和索引
    查询 = 找与用户问题相关的原始知识库证据

完整请求链路可以写成:

text 复制代码
用户问题
  ↓
L1 Redis 精确缓存
  ├─ 命中 → 直接返回
  ↓ 未命中
L2 Redis 语义缓存(问题向量相似度)
  ├─ 相似度 ≥ 0.95,且权限、版本等均满足 → 返回缓存结果
  ↓ 未命中
知识库向量数据库 → 重排 → LLM
  ↓
把新问题、问题向量和结果按 TTL 写回 L1/L2 缓存

0.95 可作为语义缓存的起始实验阈值:相似度达到或超过它时,才考虑复用已有回答。它不是通用的正确答案,必须用真实问题集调优:阈值过低会把"看起来相近、实际条件不同"的问题误复用,损害正确性;阈值过高则很少命中,节省不了成本。应按 embedding 模型、语言、问题类型、是否涉及金额/日期/权限等高风险条件,分别评测并确定阈值。

Redis 适合优先承担语义缓存,因为它通常延迟低,并且适合设置 TTL、最大内存和淘汰策略;而缓存结果是短期、可重新生成的数据。知识库向量则需要长期保存、备份、重建索引和版本管理。因此,不要把"缓存问题向量"和"长期知识库文档向量"混在同一个集合中,否则过期淘汰、容量管理和权限变更会变得混乱。

下列情况可评估把 L2 语义缓存单独放入向量数据库:缓存条目规模很大、Redis 没有可用的向量检索能力、或团队已有高性能向量检索基础设施。即使如此,也常让 Redis 保存最终回答和 TTL 状态;需要保证 Redis 与向量库中的缓存条目能够一起失效。

**校正:**语义缓存并不是严格"0 Token、0 成本、固定 20ms"。即使命中,也可能需要嵌入计算和缓存检索。更重要的是缓存键/过滤条件要包含知识库版本、权限、租户、语言/地区、模型及提示词版本,否则可能返回过期或越权内容。

2.3 Token 预算与熔断(Token Budgeting)

Token 是模型处理文本的基本单位。应在请求的全生命周期管控:

  1. 请求前:限制与预估
    • 限制用户输入长度;
    • 限制检索片段数量和进入上下文的总 Token;
    • 设置允许的最大输出;
    • 超过业务预算时截断、要求用户缩短问题或拒绝请求。
  2. 请求后:记录真实用量
    • 记录输入、输出、缓存、重试和工具调用用量;
    • 计算单请求成本、平均成本和异常峰值;
    • 对限额、429、超时、格式解析失败进行告警。

2.4 不同模型的 Token 计算方式

不同厂商和模型拥有不同分词器,不能使用"一个汉字固定等于多少 Token"这种经验公式做精确预算。

厂商/模型类型 常见计数方式 注意事项
OpenAI tiktoken 可用于本地估算 实际 API 的消息格式、工具、图片等可能与纯文本估算有差异;以响应 usage 为最终记账依据。
Anthropic SDK 的 token 计数接口 以当前 SDK/文档支持的模型和消息格式为准。
Qwen 等开源模型 对应模型的 AutoTokenizer 或厂商 SDK 词表和聊天模板会影响计数。

OpenAI 估算示例:

python 复制代码
import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o-mini")
estimated_tokens = len(enc.encode(text))

**校正:**这是"文本估算",并不保证等于完整请求的最终计费量;不要把它描述为对所有 API 调用的绝对精算。


3. 可观测性、可靠性与质量评估

可观测性是通过系统输出和中间记录,理解内部实际运行状态的能力。不要只看最终答案,还要能持续回答四个生产问题:系统正常工作吗?够快吗?成本是否可控?哪里正在出错?

3.1 生产级 LLM / RAG 的四层保障

生产系统除了核心的"检索 + LLM 回答"流程,通常还需要四类横向能力。可以先用下面的分层理解:

text 复制代码
安全(Security)
  ↓
成本优化(Cost Optimization)
  ↓
错误处理(Error Handling)
  ↓
监控(Monitoring):包住并观察上面所有层和核心业务
常见手段 它负责解决什么问题
安全 输入清理、提示注入防护、PII(个人敏感信息)保护、权限过滤、Guardrails 不让恶意输入、越权数据或敏感内容进入不该进入的环节。
成本优化 模型路由、精确/语义缓存、Token 与金额预算、批处理 不为简单请求调用过贵模型,不为重复工作反复付费,防止异常请求烧穿预算。
错误处理 超时、有限重试、指数退避、熔断、降级/备用方案 API 限流、模型超时、向量库故障时,不让单次失败或重试风暴拖垮系统。
监控 结构化日志、指标、链路追踪、告警、评测 看清系统是否正常、够快、够便宜,以及一次故障具体发生在哪一步。

一次 RAG 请求可以粗略理解为:

text 复制代码
用户问题
  ↓
安全:检查输入、权限和敏感信息
  ↓
成本:检查缓存、选择模型、控制 Token 预算
  ↓
向量检索 → 重排 → LLM 调用
  ↓
错误处理:超时则有限重试;持续故障则熔断、降级或返回明确提示
  ↓
返回回答

监控之所以被称为"最外层",是因为它会记录上面每一步,例如"安全拦截了多少次""缓存是否命中""哪一步慢""模型调用花了多少钱""重试是否突然增多"。它本身应主要观察和记录,不直接决定回答内容;但监控触发的告警、人工处理、限流或自动扩容可能间接影响系统。

**校正:**这四层不是严格只能从上到下执行一次的流水线。安全检查可能同时出现在输入、检索、工具调用和输出阶段;成本控制与错误处理也会分布在多个步骤中。这个图更适合表达"职责分工",而不是固定代码顺序。

3.2 生产可用 API:功能清单与请求链路

把安全、成本、错误处理、监控和部署能力合在一起,才接近"可上线的 LLM / RAG API"。下面是一份初学者可用于检查项目缺口的清单:

功能 它做什么 主要解决的问题
LangSmith tracing 给每次请求记录 trace 和 metadata 能回看一次请求的模型、检索、耗时、Token、错误和路由过程。
输入清理 检查恶意输入和提示注入 防止"忽略之前指令""泄露系统提示词"等输入影响应用。
PII 检测/脱敏 在模型调用和日志记录前识别邮箱、证件号、银行卡等 减少敏感个人信息泄露给模型、日志或第三方系统的风险。
错误处理与重试 超时、有限重试、指数退避、模型降级 遇到 API 限流、网络错误或模型故障时,避免立即失败或重试风暴。
回答缓存 对安全可复用的重复请求返回已有结果 降低重复 LLM 调用的延迟和成本。
限流(Rate limiting) 限制单位时间内允许的请求数 防止恶意刷接口、流量突刺和预算耗尽。
结构化日志 输出 JSON 等固定字段日志 便于按错误、模型、耗时、请求 ID 等字段查询和审计。
指标收集 统计请求数、延迟、Token、成本、错误率等 支持仪表盘、告警、扩容和成本分析。
健康检查 提供如 /health 的健康接口 Docker、Kubernetes 或负载均衡器可判断实例是否仍可服务。
Docker 部署 将应用及依赖打包成容器 让本地、测试和生产环境的运行方式更一致。

一次用户请求的简化顺序如下:

text 复制代码
用户请求
  ↓
限流
  ↓
输入清理 + 敏感信息脱敏 + 权限检查
  ↓
LangSmith trace 开始
  ↓
缓存查询
  ├─ 命中 → 返回已验证结果
  ↓ 未命中
检索 / 重排 / LLM 调用
  ├─ 失败 → 有限重试、指数退避;持续失败则熔断或降级
  ↓
记录 JSON 日志、指标、Token 和成本
  ↓
返回回答

健康检查不属于单次问答链路,而是由外部平台定期调用:

text 复制代码
Docker / Kubernetes / 负载均衡器 → GET /health
服务正常 → 返回 HTTP 200
服务异常 → 告警、重启实例或切走流量

校正:"内存缓存 + 按 IP 限流"适合单机教学或早期原型,但不能直接当成完整生产方案。多实例应用通常使用 Redis 等共享缓存,并加入 TTL、权限、知识库版本和失效策略;限流也常同时按 IP、用户、API Key、租户、套餐和全局并发量控制。

类别 它回答的问题 建议记录内容
Logs(结构化日志) 某一次请求具体发生了什么、为什么失败? 脱敏后的请求事件、错误栈、request_id / trace_id、模型、检索数量、路由决策、审计记录。
Metrics(指标) 整体发生了多少、是否变慢/变贵/变不稳定? QPS、成功率、错误率、P50/P95/P99 延迟、缓存命中率、Token、成本、吞吐量。
Traces(链路追踪) 一次请求经过哪些步骤,慢或失败在哪里? 缓存、Embedding、向量检索、重排、工具调用、LLM 调用各步骤的开始/结束、耗时和父子关系。
Evals(评估) 系统答得好不好? 事实正确性、检索相关性、引用正确性、拒答质量、人工反馈、回归结果

**校正:**传统可观测性通常强调 Logs、Metrics、Traces。Evals 是 LLM 应用中同样必不可少的质量保障机制,但更准确地说它是"评测与回归测试",不等同于传统定义中的第三支柱。

3.3 Logs、Metrics、Traces 与 Evals 如何配合

text 复制代码
用户提问
  ↓
Redis 缓存 → 向量检索 → 重排 → LLM → 返回回答
  │             │          │       │
  └─────────────┴──────────┴───────┴─ 每步写入同一个 trace_id
                         ↓
日志:记录每一步的详细事件和异常
指标:汇总为延迟、错误率、Token、成本、命中率等趋势
追踪:把这些步骤串起来,定位"这一次为什么慢/错/贵"

3.3.1 结构化日志:记录"发生了什么"

日志应尽量使用固定字段(常见形式是 JSON),而不是只打印"调用失败"。例如:

json 复制代码
{
  "trace_id": "tr_123",
  "model": "gpt-4o-mini",
  "cache_hit": false,
  "retrieved_chunks": 6,
  "latency_ms": 820,
  "input_tokens": 650,
  "output_tokens": 230,
  "status": "success"
}

它适合追查单次问题,例如"用户这次为什么没有查到资料?"或"这次请求为什么超时?"。不要把 API Key、完整敏感原文、未脱敏的个人信息直接写入日志。

3.3.2 指标:记录"发生了多少"

指标用于看仪表盘、趋势和告警。RAG 的最小监控清单可从下面开始:

text 复制代码
请求数、成功率、错误率、限流/超时次数
P50 / P95 / P99 端到端延迟,以及缓存、检索、重排、LLM 各阶段耗时
缓存命中率、无检索结果率、平均检索片段数
模型名、输入/输出 Token、单请求成本、每日成本
QPS、RPM、TPS 是什么?

它们都用于衡量系统处理请求的速度或压力:

指标 全称 含义 常见场景
QPS Queries Per Second 每秒处理多少个请求 API、搜索、RAG 问答等请求吞吐。
RPM Requests Per Minute 每分钟处理多少个请求 服务商限额、低频业务统计。
TPS Transactions Per Second 每秒完成多少笔事务 支付、订单、库存扣减等有事务语义的业务。

不要把"每分钟 QPS"当作一个独立单位。它通常指的是"过去 1 分钟的平均 QPS"。例如过去 60 秒收到了 600 个请求:

text 复制代码
平均 QPS = 600 ÷ 60 = 10 QPS

对 RAG 而言,一次请求通常对应一次用户提问及其后的缓存、检索、重排和 LLM 调用。QPS 上升时,要同时观察 P95/P99 延迟、错误率、模型并发、向量数据库 CPU/RAM 和 Redis 命中率,不能只看请求数量。

text 复制代码
一句话记忆:QPS = 每秒请求数;RPM = 每分钟请求数;TPS = 每秒事务数。
P50、P95、P99 是什么?

P50、P95、P99 是业界常用的**延迟百分位数(Percentile)**指标,常用于接口、数据库、缓存和 LLM 调用的性能监控。把一段时间内的所有请求耗时从快到慢排序后:

指标 含义 通俗理解 RAG 中主要用来判断什么
P50 50% 的请求耗时不超过该值 中间水平,也就是普通用户通常等待多久 典型问答速度。
P95 95% 的请求耗时不超过该值 大多数用户能否接受等待时间 缓存、检索、重排和 LLM 串起来后是否够快。
P99 99% 的请求耗时不超过该值 最慢的那 1% 用户等待多久 是否存在限流、重试、慢查询、冷启动或异常排队等尾延迟问题。

例如统计 100 次请求后得到:

text 复制代码
P50 = 300ms
P95 = 1.2s
P99 = 5s

这表示一半请求在 300ms 内完成,95 次请求在 1.2 秒内完成,但最慢的少数请求可能需要 5 秒。只看"平均延迟"会掩盖这种少量但严重影响用户体验的慢请求;因此生产系统通常至少同时看 P50、P95 和 P99。

text 复制代码
一句话记忆:P50 看典型速度,P95 看大多数用户体验,P99 看最慢的一小部分请求。

3.3.3 链路追踪与 LLM 埋点:记录"经过了哪里"

"给 LLM 调用做埋点"(Instrumented LLM)不是传统三大支柱之外的第四种数据,而是指给每一次模型调用自动采集日志、指标和追踪信息。可记录模型名、提示词版本、耗时、输入/输出 Token、成本、响应状态和错误;再通过 trace_id 与缓存、检索、重排等步骤关联。LangSmith、OpenTelemetry 等工具都可帮助完成这类采集。

自定义业务埋点如何接入 LangSmith?

LangSmith 已能自动记录许多 LLM 层信息,例如调用链、模型、Token、成本、延迟和错误。但缓存命中、无答案、拒答、业务线、租户等级等,只有业务代码自己知道。因此常见做法不是"先算好一批汇总数字再整体上传",而是:每次请求发生时,把业务事实附加到这一次 trace;LangSmith 再按 trace 汇总、筛选和分组。

text 复制代码
业务代码判断本次发生了什么
  ↓
为当前 trace 写入 metadata / tags / feedback
  ↓
LangSmith 负责查看调用细节、按字段分组、展示趋势和告警
关注项 业务代码如何得到 常见写入方式
缓存命中率 缓存层知道本次是 hit 还是 miss trace metadata:cache_hit: true/false
无答案率 RAG 的证据判断发现资料不足 metadata:answer_status: "no_answer"
拒答率 安全策略或回答流程决定拒答 metadata:answer_status: "refused"refusal_reason
用户满意度 用户点赞/点踩、人工标注或自动评测 trace_id 调用 LangSmith create_feedback(),把评分关联到这一次请求。
按租户/业务线分析成本 后端从认证和业务上下文得到 metadata:tenant_tierbusiness_line,再结合 LangSmith 已记录的 Token/成本做分组。

用户反馈通常在回答后异步到达,链路如下:

text 复制代码
一次问答完成 → 保存/返回 trace_id
用户点击 👍 / 👎 → 前端把评分和 trace_id 发到后端
后端调用 create_feedback() → 评分关联回这一次具体请求

字段选择要注意"基数"(不同取值的数量):

text 复制代码
适合放入 LangSmith metadata:环境、模型版本、业务线、租户等级、是否命中缓存、回答状态
谨慎放入 metadata:每个 user_id、每个 document_id、完整原始问题、敏感个人信息
更适合 Prometheus/Grafana、日志平台或数仓:Redis/向量库 CPU、RAM、磁盘、网络、每个租户的精确财务账单

原因是每位用户、每篇文档都作为独立分组会产生高基数字段,使仪表盘难以使用,也可能带来隐私风险。对于精确的逐租户成本,通常同时写入自己的业务数据库或数仓;LangSmith 用于定位"哪类请求、哪条链路、哪个模型"导致成本上升。

3.4 链路追踪(Traces)------还原现场

可帮助回答:

  • 多智能体或工作流走了哪些节点;
  • 每步实际输入和输出是什么;
  • 调用了哪些 API/数据库/工具;
  • 为什么进入某个路由分支;
  • 最终答案引用了哪些上下文。

注意对敏感数据脱敏、控制日志保留期限,并根据权限限制谁能查看原始 Prompt 和文档片段。

3.5 指标度量(Metrics)------精打细算

关注:输入/输出 Token、每个节点的延迟、单次成本、错误率、429/超时率、缓存命中率和检索空结果率。使用 P50/P95/P99 等分位数,而不是只看平均值。

3.6 效果评估(Evals)------质量把关

评测至少应区分:

  • 检索是否找对证据;
  • 回答是否被证据支持;
  • 引用是否对应真实片段;
  • 无答案时是否诚实拒答;
  • 改模型、提示词、切分参数后是否出现回归。

用户点赞/点踩、人工抽检和线上失败案例都应沉淀回评测集。


参考:Token、用量与模型成本

相关推荐
大闸蟹u43 分钟前
图解 AI Agent:Agent = 模型 + Harness
人工智能
dunge20261 小时前
ChatGPT Plus / Pro 与 Codex 深度实战:2026年9月5日 从模型能力对比到代码生成工作流全解析
人工智能·chatgpt
阿拉斯攀登1 小时前
SpringCloudAlibaba微服务消息调用:跨服务业务异步解耦
人工智能
joinwell521 小时前
只发 GET,为什么还是写进去了?从公共 Wiki 到 Agent 工具门禁的效果边界
人工智能·http
zhuhai_xigedian1 小时前
源网荷储一体化柜的经济效益优化功能实现机制
大数据·运维·人工智能·重构·能源
jay神1 小时前
深度学习的损失函数怎么选?
人工智能·深度学习·计算机视觉·毕业设计·损失函数
zhaoali07091 小时前
深度学习系列实验-计算机视觉基础
人工智能·深度学习·计算机视觉
RisunJan1 小时前
AI 名词速查手册
人工智能
堕落年代1 小时前
uni-app x 蒸汽模式:实时流式语音识别(ASR)三大疑难杂症全记录
人工智能·uni-app·语音识别