测量 LibreChat 的 Agent 启动延迟:Playwright E2E 延迟基准测试实战指南
本文基于仓库中的 e2e/benchmarks/README.md 及其配套实现,完整讲解 LibreChat 内置的 Agent 启动延迟基准测试:如何运行这个非阻断(non-gating)的 Playwright 基准,如何理解 submitToAckMs、submitToFirstContentMs、ackToFirstContentMs 三项核心指标,以及如何用 mcp-memory profile、follow-up 回合、模拟 Mongo 延迟和 Redis 流模式来验证不同代码修订在 Agent 聊天关键路径上的真实性能差异。
基准测试在测量什么
该基准模拟一个真实用户操作:在 LibreChat 前端为某个 Agent 发起一次全新的 Agent 聊天,从用户按下 Enter 键开始计时,测量三个时间点:
| 指标 | 含义 |
|---|---|
submitToAckMs |
从提交(Enter)到 agent-chat 的 POST 请求响应结束的时间,即服务端"确认受理"(ack)耗时 |
submitToFirstContentMs |
从提交到 mock 助手回复 token 出现在消息 DOM 中(浏览器绘制之前)的时间,即用户可感知的"首字"耗时 |
ackToFirstContentMs |
上述两个事件之间的间隔,即请求被受理后到内容开始产出的处理时间 |
三项指标在 e2e/benchmarks/agent-startup.latency.spec.ts 中计算,全部基于浏览器内的 performance.now() 与 Resource Timing API 时间戳相减得到,而不是在 Node 端估算网络往返。
几个关键的测量设计保证了样本有效性:
- cold 样本单独报告:每个进程的第一次请求(冷启动,包含连接建立、页面状态初始化等一次性成本)与后续样本分开统计,避免污染均值;
- 每个 warmup 与样本各使用一个全新会话,且测量过的会话在采样结束后立即通过
DELETE /api/convos删除(见 deleteMeasuredConversation),从而避免历史消息增长对后续样本产生偏置; - 主机负载与 CPU 利用率随报告一起记录(逻辑 CPU 数、运行前后 load average、全程 CPU 占用百分比),让被构建任务、测试 worker 等干扰"污染"的运行可被事后识别。
基准测试是非门禁(non-gating)性质的:它不决定 CI 通过与否,而是为性能回归调查和 base/HEAD 对比提供可复现的数据。
运行默认基准
在仓库根目录执行:
npm run e2e:benchmark:agents
该脚本定义于 package.json:
"e2e:benchmark:agents": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.benchmark.ts agent-startup.latency.spec.ts"
其中 e2e:prepare 等价于 npm run frontend,即先构建前端产物,再用 e2e/playwright.config.benchmark.ts 这份专用配置运行 agent-startup.latency.spec.ts 这一个 spec。
默认组合是:内存流(in-memory streams)+ minimal profile(不带工具的极简 Agent)+ first 回合(测第一次请求)+ 5 次 warmup + 30 个计入统计的样本。
基准运行环境的构成
benchmark 配置继承自 mock 配置(e2e/playwright.config.mock.ts),并做了针对性收窄,理解这些细节有助于判断测量结果的可比性:
MOCK_LLM_REPLY默认BENCH_TOKEN、MOCK_LLM_CHUNK_DELAY_MS默认1(e2e/playwright.config.benchmark.ts)。回复 token 极短、chunk 间隔极小,使模型输出本身几乎不贡献延迟,测得的是请求链路与启动路径的成本。- 模型是进程内 fake model:mock 配置通过
LIBRECHAT_TEST_RUN_HOOK指向 e2e/setup/fake-model.js,它加载@librechat/agents的FakeChatModel并在createRun时替换真实模型。请求走的是完整的Run.create -> graph -> tool-node真实链路,但不会接触任何真实 LLM 供应商,因此整个基准无需任何 API 密钥。 - Agent 使用
Mock Provider A/mock-model-a:spec 通过POST /api/agents创建名为E2E Agent Startup Benchmark <时间戳>的 Agent(createAgent),随后在前端"Select a model"按钮下从 "My Agents" 选中它。测试结束(finally块)会清理该 Agent。 - 超时与重试:benchmark 配置将整体 timeout 设为 20 分钟、
retries: 0、reporter 用line;spec 自身还会按样本数动态设置test.setTimeout(Math.max(120000, (warmups + samples + 1) * 30000)),即按"每样本 30 秒"预留预算。 - webServer 只保留 mock 配置中的第一个服务:注释说明基准使用的是由 LibreChat 服务器从 e2e 配置加载的 stdio MCP fixture,而不是独立的 HTTP MCP fixture。
- mock 配置的环境净化:
neutralizeCredentialEnv会清空本地.env中形似凭据的变量(正则/(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS|CLIENT_ID|_KEY)$/i),并强制CHECK_BALANCE: false等覆盖项,保证基准在"无余额、无外部供应商"的干净环境下运行。
e2e 配置文件由 e2e/config/librechat.e2e.yaml 模板生成;其中定义了 benchmark 会使用的 stdio MCP 服务器:
mcpServers:
e2e-memory:
type: stdio
command: node
args:
- e2e/setup/fake-mcp-server.js
title: E2E Memory
description: Local MCP fixture used by mock end-to-end tests.
timeout: 30000
(见 e2e/config/librechat.e2e.yaml 中的 e2e-memory 段落,fake-mcp-server.js 位于 e2e/setup/fake-mcp-server.js。)
环境变量全表
README 列出的全部环境变量如下,默认值与取值约束已在 agent-startup.latency.spec.ts 中确认:
| 变量 | 默认值 | 用途 |
|---|---|---|
E2E_LATENCY_PROFILE |
minimal |
使用 mcp-memory 可额外验证 MCP 与 memory 工具的启动开销 |
E2E_LATENCY_TURN |
first |
使用 follow-up 则测量恒定单回合历史的请求 |
E2E_LATENCY_WARMUPS |
5 |
冷请求之后的不计数预热样本数 |
E2E_LATENCY_SAMPLES |
30 |
计入汇总统计的样本数 |
E2E_LATENCY_LABEL |
unlabeled |
在 JSON 报告中标识所测修订版本或对比块 |
E2E_LATENCY_GIT_SHA |
unknown |
在 JSON 报告中记录所测 git 修订号 |
E2E_LATENCY_STREAM_MODE |
in-memory |
在报告中标注流式后端(内存或 Redis) |
E2E_LATENCY_MONGO_DELAY_MS |
0 |
为每条 Mongoose 查询注入受控延迟 |
E2E_LATENCY_OUTPUT |
未设置 | 设置后将完整报告写入该路径 |
源码中的取值校验:E2E_LATENCY_PROFILE 只接受 minimal / mcp-memory,E2E_LATENCY_TURN 只接受 first / follow-up,非法值会直接抛错终止运行(spec 第 46–52 行)。E2E_LATENCY_WARMUPS 与 E2E_LATENCY_SAMPLES 通过 parseCount 解析,非整数或低于下界时回退到默认值而不是报错。
mcp-memory profile:带 MCP 与 memory 的 Agent
当 profile 为 mcp-memory 时,创建的 Agent 会附带三个工具(spec 第 53–58 行):
const MCP_SERVER_NAME = 'e2e-memory';
const MCP_TOOLS = [
'memory',
`sys__server__sys_mcp_${MCP_SERVER_NAME}`,
`remember_fact_mcp_${MCP_SERVER_NAME}`,
];
创建后 spec 会断言 agent.tools 包含这三个工具、agent.mcpServerNames 包含 e2e-memory,确保"带工具的 Agent 启动路径"确实被覆盖到了。这条路径会真实启动前文提到的 stdio MCP 进程,因此测得的延迟包含了 MCP 服务器拉起与工具装配的成本。
follow-up 回合:固定单回合历史
first 回合测量会话中第一次请求;follow-up 回合(prepareConversation)则在每个被测样本之前先进行一次不计入统计的种子对话:填入 agent startup latency seed <n>、等待 BENCH_TOKEN 出现且 stop 按钮消失后,再测量下一条请求,最后删除该会话。这样既验证了会话历史读取的真实路径,又通过"每样本一删一新"保证历史长度在样本间恒定,不让历史增长跨样本累积。
浏览器端计时的实现细节
理解三个指标如何采集,有助于正确解读报告。installBrowserObservers 在点击提交前向页面注入三段观察逻辑:
- 起点
startedAt:在消息输入框上以捕获阶段(capture: true)监听keydown,当且仅当按键是Enter且非Shift+Enter时,记录performance.now()作为起点。这精确对应用户"按下回车"的瞬间,而不是 JS 表单处理之后。 - ACK 点
acknowledgedAt:用PerformanceObserver观察 resource 条目,等待同源的POST /api/agents/chat/agents响应,取该条目的responseEnd作为 ACK 时刻——即响应体完整到达浏览器网络栈的时刻。该路由挂载在 api/server/routes/agents/chat.js(router.post('/:endpoint', controller),挂载前缀为/api/agents/chat)。 - 首内容点
firstContentAt:用MutationObserver观察整个document.body的childList/characterData/subtree变化,当包含回复文本的.message-render .agent-turn .message-content元素数量比提交前多出一支时,记录performance.now()。这是 DOM 层面的"内容已写入"时刻,先于浏览器实际绘制(paint),因此 README 将其描述为"before browser paint"。
随后 Playwright 通过 waitForFunction 等待三个时间戳齐备(超时 30 秒),再校验回复计数恰好加一、stop 按钮消失,才把该样本计入并删除会话。任何一个时间戳缺失都会让测试失败而不是产出半残数据。
报告:结构与产出位置
每次运行生成一份 JSON 报告,包含以下字段(report 构造):
{
"label": "unlabeled",
"gitSha": "unknown",
"streamMode": "in-memory",
"profile": "minimal",
"turn": "first",
"simulatedLatency": { "mongoQueryMs": 0 },
"cold": { "submitToAckMs": 0, "submitToFirstContentMs": 0, "ackToFirstContentMs": 0 },
"warmups": 5,
"samples": 30,
"host": {
"logicalCpus": 8,
"loadAverageBefore": [0.1, 0.2, 0.3],
"loadAverageAfter": [0.2, 0.2, 0.2],
"cpuUtilizationPct": 35.2
},
"raw": {
"submitToAckMs": [ "...每个样本的原始值..." ],
"submitToFirstContentMs": [ "..." ],
"ackToFirstContentMs": [ "..." ]
},
"summary": {
"submitToAckMs": { "p50": 0, "p95": 0, "mean": 0, "min": 0, "max": 0 },
"submitToFirstContentMs": { "p50": 0, "p95": 0, "mean": 0, "min": 0, "max": 0 },
"ackToFirstContentMs": { "p50": 0, "p95": 0, "mean": 0, "min": 0, "max": 0 }
}
}
(数值仅为字段示意;raw 保留全部样本原始值,便于自行复算。)
报告的三个产出通道:
- 控制台:打印单行
AGENT_STARTUP_LATENCY <json>,方便 CI 日志中 grep 提取; - Playwright 附件:以
agent-startup-latency.json附加到测试结果(saveReport); - 文件:设置了
E2E_LATENCY_OUTPUT时写入指定路径(自动创建父目录)。
统计方法值得注意:p50/p95 采用线性插值百分位(percentile() 按 (n-1)*p 位置在相邻两个排序值之间加权),而非"取最近邻",小样本下比取整索引更接近真实分位数。
模拟数据库延迟:E2E_LATENCY_MONGO_DELAY_MS
这是 README 中特别强调的"受控 I/O"实验工具。其机制由 e2e/playwright.config.benchmark.ts 与 e2e/benchmarks/mongoose-latency-hook.cjs 共同实现:
- benchmark 配置检测到
E2E_LATENCY_MONGO_DELAY_MS > 0时,把--require=<mongoose-latency-hook.cjs>追加到NODE_OPTIONS,让每个 Node 进程(包括被测服务器)在加载 mongoose 时先加载该 hook; - hook 文件对
mongoose.Query.prototype.exec与mongoose.Aggregate.prototype.exec打补丁:执行前先setTimeout指定的毫秒数,再调用原方法,并用Symbol.for('librechat.e2e.mongooseLatencyPatched')防止重复打补丁:
prototype.exec = async function delayedExec(...args) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
return originalExec.apply(this, args);
};
README 对此有明确的纪律性要求:该 profile 用于揭示请求异步关键路径上的变更(即每次数据库往返在关键路径上出现的次数是否变化),它必须与零延迟的本地结果分开标注、分开报告——它是受控工作负载,不能据此声称生产数据库延迟。
切换流式后端:Redis streams
默认的 in-memory 模式测量的是进程内流式队列;若要覆盖 Redis 流式后端,需要为 E2E 服务器指定一个可丢弃的 Redis 实例(README 示例使用 16379 端口):
USE_REDIS=true \
USE_REDIS_STREAMS=true \
REDIS_URI=redis://127.0.0.1:16379 \
E2E_LATENCY_STREAM_MODE=redis \
E2E_LATENCY_PROFILE=mcp-memory \
npm run e2e:benchmark:agents
服务器侧会强制校验流式后端确实落在 Redis 上:e2e/setup/start-server.js 中的 requireRedisStreams/verifyRedisStreams 会通过 ioredisClient.ping() 探活,并检查 GenerationJobManager.isRedis,若服务器静默回退到内存模式则直接报错终止,而不是带着错误的后端跑完一轮基准。E2E_LATENCY_STREAM_MODE=redis 只是把这个后端事实写进报告,便于两组报告对照。
做 base 对 HEAD 的对比
README 给出了一套可操作的 A/B 方法论,要点如下:
- 依赖与基准文件必须完全一致——对比的两个修订使用相同的
package-lock安装结果和同一份 benchmark 源码,只允许被测代码不同; - 交替分块运行,顺序为 base / HEAD / HEAD / base——用首尾对称的块设计抵消机器状态随时间漂移(风扇热噪、内存碎片、后台任务)带来的系统性偏置;
- 排除 cold 样本——冷启动包含进程级一次性成本,不反映稳态请求成本;
- 同时报告每个块的中位数(block median)与合并后的中位数(pooled median);
- 不要从本来有效的块中剔除离群值——剔除会让比较失去统计基础;
- 运行期间避免构建、测试 worker 等其他 CPU 密集任务——报告里自带的
host.loadAverageBefore/After与cpuUtilizationPct就是为事后甄别被污染的块而存在的。
配合 E2E_LATENCY_LABEL(如 base-block1、head-block1)与 E2E_LATENCY_GIT_SHA 为每个块打标,再用 E2E_LATENCY_OUTPUT 把四份报告落盘,即可完成一组可追溯的对比实验。
小结与适用前提
- 该基准衡量的是从浏览器 Enter 到服务端 ACK、再到首内容写入 DOM 的端到端启动延迟,覆盖 Agent 请求校验、MCP/memory 工具装配(
mcp-memory)、会话历史读取(follow-up)等真实代码路径,同时用 fake model 消除了真实 LLM 网络与生成耗时的干扰; - 适用前提:本地或 CI 内运行,需要先
npm run e2e:prepare构建前端;结果反映的是本地/受控环境下的相对差异,不能直接外推为生产延迟数字(README 对模拟延迟 profile 的边界声明同样适用于此点); - 想深入阅读,建议按 README → spec 实现 → benchmark 配置 → mock 配置 → MCP fixture 配置 的顺序查看,每一层都有源码注释说明设计动机。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00