对 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 名片|万粉千文,百无一用