FunTester RAG 应用测试:性能与质量双门禁

FunTester · 2026年08月03日 · 33 次阅读

对 RAG 应用做性能测试,需要设置两道独立的质量门:一道检查速度,另一道检查答案质量。传统负载测试工具可以测量响应时间,却无法发现幻觉问题——模型可能很快返回答案,但答案基于捏造的上下文,而非检索到的文档,且事实有误。

本文使用 k6 测量端到端延迟,使用 DeepEval 通过大语言模型作为裁判的方式评估答案忠实度和答案相关性。将两道门接入 GitHub Actions CI/CD 流水线后,无论性能还是输出质量发生回归,每次拉取请求都会在进入生产环境前自动发现问题。

如果你和我一样有 JMeter 或 k6 背景,面对一个 RAG 接口时,第一反应大概是直接压测并检查响应时间。这只解决了一半问题。RAG 应用可能又快又自信地给出完全错误的答案,单纯的负载测试不会告诉你这一点。你需要两个测试面,而不是一个:性能与质量。下文始终使用同一个示例:一个面向小型知识库的文档助手,回答如何以非 GUI 模式运行 JMeter。

RAG 颠覆传统压测

传统 API 会返回完整响应,你测量的是一次往返耗时。RAG 接口在回答前要完成两项昂贵操作:先从向量存储或搜索索引检索上下文,再逐 Token 流式生成响应。第二项尤其重要。

一次请求可能在数秒内流式输出数百个 Token。因此,将请求耗时视为单一指标,会掩盖两个完全不同的问题:模型多久开始回答,以及开始后生成速度有多快。

启动慢、生成快的系统,对正在聊天界面中输入内容的用户来说仍像是坏掉了。启动快、生成慢的系统,回答简短问题还可以,但汇总长文档时会很痛苦。把两者平均起来并不能提供有价值的信息。

性能与质量双门禁

我把 RAG 测试看作两道针对同一接口、但彼此独立的门禁。

性能要回答的是:它有多快,负载升高时是否仍能支撑?这是 k6 的工作,和其他 API 的负载测试相同,只是增加了面向 LLM 的指标。

质量要回答的是:答案是否真正基于检索到的上下文,还是模型编造了内容?这正是 DeepEval 的用武之地:它用 LLM 作为裁判,为每次响应评估忠实度和相关性。

任何一道门都无法单独说明全貌。一个速度很快却会产生幻觉的 RAG 应用,比一个慢但准确的应用更糟;而一个完全有依据、却要花 8 秒才能响应的应用,无论答案多正确,都会失去用户。

关键指标

性能指标

指标 含义
TTFT(首 Token 时间,Time to First Token) 用户面对空白屏幕、等待第一个内容出现的时长
ITL(Token 间延迟,Inter-Token Latency) 生成开始后 Token 流输出是否顺畅
Tokens/sec 生成速度,对长回答尤其重要
p95 / p99 延迟 尾部用户体验,而非平均体验

TTFT 是整个系统中对用户最可见的指标,也是传统负载工具最不擅长单独测量的指标。传统工具针对的是原子化的请求/响应周期,而不是流式响应。

质量指标

指标 含义
Faithfulness(忠实度) 答案是否有检索上下文支撑,还是被模型编造
Answer relevancy(答案相关性) 答案是否回答了实际问题,还是只是听起来合理
Context precision(上下文精确率) 检索是否返回了正确分片,且排序是否正确
Context recall(上下文召回率) 检索是否遗漏了答案所需的信息

这四个指标承担了 RAG 评估中的大部分诊断工作。忠实度和答案相关性属于生成侧;上下文精确率和召回率属于检索侧。当忠实度低、但上下文召回率高时,说明检索器已经完成了自己的工作,问题在于模型忽略了上下文;先区分提示词问题与检索问题,再开始调优。

DeepEval 检测幻觉

这里选择 DeepEval 而不是 RAGAS,主要因为 DeepEval 将评估包装为带有通过/失败阈值的 pytest 测试用例,非常适合作为 CI/CD 门禁。它还接受任意 LLM 作为裁判模型,因此即使示例应用使用 Gemini,也不会被某一家供应商锁定。

下面是针对 JMeter 文档助手示例的测试用例:

from deepeval.metrics import FaithfulnessMetric, AnswerRelevancyMetric
from deepeval.test_case import LLMTestCase
from deepeval.models import GeminiModel

judge_model = GeminiModel(
    model="gemini-3.5-flash",
    api_key=os.getenv("GEMINI_API_KEY"),
)

faithfulness_metric = FaithfulnessMetric(threshold=0.75, model=judge_model)
answer_relevancy_metric = AnswerRelevancyMetric(threshold=0.8, model=judge_model)

def test_jmeter_non_gui_mode_answer():
    question = "How do I run JMeter in non-GUI mode?"
    result = query_rag_app(question)

    test_case = LLMTestCase(
        input=question,
        actual_output=result["answer"],
        retrieval_context=result["retrieved_chunks"],
    )

    for metric in [faithfulness_metric, answer_relevancy_metric]:
        metric.measure(test_case)
        status = "PASS" if metric.success else "FAIL"
        print(f"[{status}] {metric.__class__.__name__}: {metric.score:.3f}")

    failed = [m for m in [faithfulness_metric, answer_relevancy_metric] if not m.success]
    if failed:
        names = ", ".join(m.__class__.__name__ for m in failed)
        raise AssertionError(f"Metrics below threshold: {names}")

通过 pytest 运行这段测试,它会像普通测试一样通过或失败。重点就在于:它将 AI 听起来是否正确这个模糊问题,转变为清晰的二元 CI/CD 信号。

示例把忠实度阈值设为 0.75,把答案相关性阈值设为 0.8。这些数值适合作为门禁示例,但应结合自身应用的基线与业务容错范围校准,而不是直接照搬。

测试套件包含了处理 Gemini API 临时 503 错误的重试逻辑,采用指数退避,最多自动重试 3 次。DeepEval 同时生成 JUnit XML 与 HTML 报告,因此可以很方便地接入任何能理解 pytest 输出的 CI 系统。

k6 负载测试与 TTFT

如果你是来寻找干净的 TTFT 测量方案,这里会有些令人沮丧:k6 的 SSE 扩展 xk6-sse 不兼容 k6 v2。它面向 go.k6.io/k6 v1;在扩展更新之前,你只能在 k6 v2 的改进架构与正确测量流式指标的能力之间做选择。

因此,配套仓库采取了务实方案:使用 k6 内置的 http 模块测试 /chat/complete 接口,而不是 /chat 流式接口。不需要自定义二进制文件,也不需要扩展,直接使用标准 k6。

代价是无法获得真实的 TTFT,因为 /chat/complete 会等待完整响应生成后才返回。你能得到的是端到端延迟,仍然很有价值:它能告诉你系统是否变慢,但无法解释为什么慢。

测试代码如下:

import http from 'k6/http';
import { Trend, Counter } from 'k6/metrics';
import { check } from 'k6';

const totalDuration = new Trend('total_duration_ms', true);
const tokensPerSecond = new Trend('tokens_per_second');
const BASE_URL = __ENV.RAG_APP_URL || 'http://localhost:8080';

export const options = {
  scenarios: {
    rag_chat: {
      executor: 'ramping-vus',
      stages: [
        { duration: '30s', target: 10 },
        { duration: '1m', target: 10 },
        { duration: '30s', target: 0 },
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(95)<6000'],
    total_duration_ms: ['p(95)<6000'],
  },
};

export default function () {
  const startTime = Date.now();
  const res = http.post(
    `${BASE_URL}/chat/complete`,
    JSON.stringify({ query: 'How do I run JMeter in non-GUI mode?' }),
    {
      headers: { 'Content-Type': 'application/json' },
      timeout: '30s',
    },
  );

  const duration = Date.now() - startTime;
  check(res, {
    'status 200': (r) => r.status === 200,
    'has answer': (r) => JSON.parse(r.body).answer !== undefined,
  });
  totalDuration.add(duration);

  // Rough tokens/sec estimate from word count
  const words = JSON.parse(res.body).answer.trim().split(/\s+/).length;
  tokensPerSecond.add((words / duration) * 1000);
}

该测试会在 30 秒内从 0 增加到 10 个虚拟用户,保持 1 分钟,再在 30 秒内降回 0。http_req_duration 和自定义的 total_duration_ms 指标都设置为 p95 小于 6000 ms。

脚本中的 tokens_per_second 根据回答的词数估算,因此更适合观察同一场景下的相对变化,不能把它当作流式输出速度的精确替代指标。要分析首 Token 时间和 Token 间延迟,仍需要真实的 SSE 流式测量。

何时应该切回 SSE?关注 xk6-sse 仓库。等它支持 k6 v2 后,将接口从 /chat/complete 改为 /chat,在 Dockerfile 中添加 SSE 扩展,就可以测量真实 TTFT。在此之前,标准 k6 测量的是端到端延迟,而不是流式行为。

配套仓库的 Express 应用同时提供两个接口,准备就绪后可以切换:

接口 响应 状态
POST /chat SSE 流 等待 xk6-sse 支持 k6 v2 后使用
POST /chat/complete 完整 JSON 当前供 k6 与 DeepEval 使用

接入 CI/CD

当两类测试都能在本地运行后,接入 GitHub Actions 主要是流程编排:启动应用、等待健康检查通过,再并行运行 k6 与 DeepEval 门禁。两者彼此独立,因此可以并行运行。

name: RAG CI

on: [pull_request]

jobs:
  performance-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Write app env file
        run: |
          cat > app/.env << EOF
          GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}
          GEMINI_MODEL=gemini-3.5-flash
          FILE_SEARCH_STORE_NAME=${{ secrets.FILE_SEARCH_STORE_NAME }}
          PORT=8080
          EOF
      - name: Start RAG app
        run: docker compose up -d --build app
      - name: Wait for health
        run: |
          timeout 60 bash -c 'until curl -f http://localhost:8080/health; do sleep 2; done'
      - name: Run k6 load test
        run: docker compose --profile perf run --rm k6

  quality-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Write app env file
        run: |
          cat > app/.env << EOF
          GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}
          GEMINI_MODEL=gemini-3.5-flash
          FILE_SEARCH_STORE_NAME=${{ secrets.FILE_SEARCH_STORE_NAME }}
          PORT=8080
          EOF
      - name: Start RAG app
        run: docker compose up -d --build app
      - name: Wait for health
        run: |
          timeout 60 bash -c 'until curl -f http://localhost:8080/health; do sleep 2; done'
      - name: Run DeepEval tests
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
        run: docker compose --profile quality run --rm deepeval

两个作业都会在每次拉取请求时运行。无论某个 PR 拖慢了响应时间,还是悄悄让模型开始产生幻觉,都会在进入评审者视野之前被捕获,更不用说进入生产环境。

要让这个工作流通过,你需要在 GitHub 仓库中添加两个密钥:

密钥
GEMINI_API_KEY https://aistudio.google.com/apikey 获取的 Gemini API 密钥
FILE_SEARCH_STORE_NAME setup-store.js 中的存储名称,格式为 fileSearchStores/your-store-id

设置 SLO

这里不会给出一个通用的延迟目标。对于聊天式 RAG 应用,有的建议将目标定在 1 秒以内;对于更复杂的文档分析,有的建议预留 3~5 秒。适合你的数值完全取决于检索后端、模型以及用户实际任务。

先针对自己的基线运行负载测试,再根据基线设置阈值,而不是套用博客中的数字,包括本文中的数字。

示例仓库以 p95 小于 6000 ms作为起点,因为测试中的 Gemini File Search RAG 应用在使用 gemini-3.5-flash、10 个并发用户时可以达到该水平。实际情况会因以下因素而有很大差异:

  • 模型选择:Flash 或 Pro,以及实际使用的上下文窗口大小。
  • 检索后端:向量数据库查询时间、检索的分片数量。
  • 文档规模与复杂度。
  • 到 LLM 供应商的网络延迟。

无论具体目标是多少,都应持续跟踪以下内容:

  • p95 和 p99 延迟,而非仅看中位数。用户抱怨的是尾部体验。
  • 预期并发度下的延迟,而非仅 1 个用户时的延迟。RAG 应用常因检索瓶颈而在负载下出现非线性退化。
  • 忠实度和答案相关性的长期趋势,而非单次运行的通过/失败。某个指标长期为 0.90、随后降至 0.78,即使仍高于 0.75 的阈值,也应视为信号。

总结

RAG 性能测试需要同时运行性能门禁与质量门禁:一门是带有 LLM 感知指标的经典负载测试,另一门是传统负载测试工具从未被设计来完成的 LLM 作为裁判质量评分。两者都要设为门禁,才能捕获只做速度测试会直接漏掉的回归问题。

当前的工具链并不完美:如果不自己编写 SSE 客户端,就无法通过 k6 v2 测量 TTFT;LLM 作为裁判的评分也有一致性方面的局限。但它已经足以在生产环境之前捕获回归问题,这正是 CI/CD 门禁的意义。


相关阅读

##### FunTester 名片|万粉千文,百无一用

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册