首页
/ 测量 LibreChat 的 Agent 启动延迟:Playwright E2E 延迟基准测试实战指南

测量 LibreChat 的 Agent 启动延迟:Playwright E2E 延迟基准测试实战指南

2026-09-05 17:45:47作者:魏献源Searcher

本文基于仓库中的 e2e/benchmarks/README.md 及其配套实现,完整讲解 LibreChat 内置的 Agent 启动延迟基准测试:如何运行这个非阻断(non-gating)的 Playwright 基准,如何理解 submitToAckMssubmitToFirstContentMsackToFirstContentMs 三项核心指标,以及如何用 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_TOKENMOCK_LLM_CHUNK_DELAY_MS 默认 1e2e/playwright.config.benchmark.ts)。回复 token 极短、chunk 间隔极小,使模型输出本身几乎不贡献延迟,测得的是请求链路与启动路径的成本。
  • 模型是进程内 fake model:mock 配置通过 LIBRECHAT_TEST_RUN_HOOK 指向 e2e/setup/fake-model.js,它加载 @librechat/agentsFakeChatModel 并在 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-memoryE2E_LATENCY_TURN 只接受 first / follow-up,非法值会直接抛错终止运行(spec 第 46–52 行)。E2E_LATENCY_WARMUPSE2E_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 在点击提交前向页面注入三段观察逻辑:

  1. 起点 startedAt:在消息输入框上以捕获阶段(capture: true)监听 keydown,当且仅当按键是 Enter 且非 Shift+Enter 时,记录 performance.now() 作为起点。这精确对应用户"按下回车"的瞬间,而不是 JS 表单处理之后。
  2. ACK 点 acknowledgedAt:用 PerformanceObserver 观察 resource 条目,等待同源的 POST /api/agents/chat/agents 响应,取该条目的 responseEnd 作为 ACK 时刻——即响应体完整到达浏览器网络栈的时刻。该路由挂载在 api/server/routes/agents/chat.jsrouter.post('/:endpoint', controller),挂载前缀为 /api/agents/chat)。
  3. 首内容点 firstContentAt:用 MutationObserver 观察整个 document.bodychildList/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.tse2e/benchmarks/mongoose-latency-hook.cjs 共同实现:

  1. benchmark 配置检测到 E2E_LATENCY_MONGO_DELAY_MS > 0 时,把 --require=<mongoose-latency-hook.cjs> 追加到 NODE_OPTIONS,让每个 Node 进程(包括被测服务器)在加载 mongoose 时先加载该 hook;
  2. hook 文件对 mongoose.Query.prototype.execmongoose.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 方法论,要点如下:

  1. 依赖与基准文件必须完全一致——对比的两个修订使用相同的 package-lock 安装结果和同一份 benchmark 源码,只允许被测代码不同;
  2. 交替分块运行,顺序为 base / HEAD / HEAD / base——用首尾对称的块设计抵消机器状态随时间漂移(风扇热噪、内存碎片、后台任务)带来的系统性偏置;
  3. 排除 cold 样本——冷启动包含进程级一次性成本,不反映稳态请求成本;
  4. 同时报告每个块的中位数(block median)与合并后的中位数(pooled median)
  5. 不要从本来有效的块中剔除离群值——剔除会让比较失去统计基础;
  6. 运行期间避免构建、测试 worker 等其他 CPU 密集任务——报告里自带的 host.loadAverageBefore/AftercpuUtilizationPct 就是为事后甄别被污染的块而存在的。

配合 E2E_LATENCY_LABEL(如 base-block1head-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 的边界声明同样适用于此点);
  • 想深入阅读,建议按 READMEspec 实现benchmark 配置mock 配置MCP fixture 配置 的顺序查看,每一层都有源码注释说明设计动机。
登录后查看全文
热门项目推荐
相关项目推荐