首页
/ LobeHub 网关流式会话与 Tab 切换回归验证:200ms 状态时序探针与本地网关闭环 E2E 实践指南

LobeHub 网关流式会话与 Tab 切换回归验证:200ms 状态时序探针与本地网关闭环 E2E 实践指南

2026-09-07 18:27:36作者:秋泉律Samson

LobeHub(作为 Agent Operator 运行时)在网关模式(gateway mode)下通过 WebSocket 将 Agent 的流式消息推送给浏览器端。当用户一边等待流式输出、一边在多个会话 Tab 之间来回切换时,可能出现"切回 tab 后消息回到了很早以前"的状态回退类缺陷。本指南介绍 LobeHub 仓库内 .agents/acceptance 中针对该场景设计的一整套流式会话测试台(streaming + tab-switch test harness):它每隔 200ms 对 store 与 DOM 做一次双视角快照,并在辅助脚本搭建的本地 Agent Gateway 闭环上执行多轮 Tab 往返切换,最终用 Node 分析器判定"内容是否在同一 topic 上发生回退"。读完本文,你将掌握:如何用本地 worker 替代线上网关搭建可复现的浏览器↔网关闭环、如何注入采样探针与切 Tab 驱动器、以及如何解读三节式分析报告定位流式回归。

该文档是 .agents 目录下自动化验收体系的一部分,配套脚本位于 .agents/acceptance/scripts/agent-gateway,原始参考文档见 .agents/acceptance/references/agent-gateway.md。本文以该文档为骨架,并结合仓库内脚本与 apps/serversrc 中的源码交叉验证展开。

测试台要解决的问题:以可证伪的方式验证"流式回退"

这类缺陷的典型用户症状是:"消息在流式输出途中切走再切回,聊天记录退回到了很久以前的内容。" 仅靠人工观察很难判断是 store 层数据被覆盖,还是渲染层刷新不完整。因此该测试台确立了三条原则:

  1. 双视角采样:同时抓取 Zustand store(chat.messagesMap)与渲染 DOM(聊天列表 innerText),200ms 一次;
  2. 事件打点:通过 window.__PROBE_EVENT('SENT')AWAY_1BACK_1 等标记把用户动作与状态采样在时间轴上对齐;
  3. 本地闭环:搭建一个能被应用本地 JWT 信任的本地 Gateway,确保"服务端→网关→浏览器"全链路都真实发生在本机,从而能够判断回归是来自服务端推流还是客户端状态机。

探针虽然为 gateway-mode(网关模式)聊天而设计,但它的采样逻辑与话题无关,可用于任何 LobeHub 流式会话(Cloud Sandbox、组广播等 WebSocket 推流路径)。

第一步:为什么必须自建本地网关才能形成真闭环

适用场景:需要端到端验证 browser↔gateway 路径(gateway mode / Cloud Sandbox / WebSocket group broadcast)。以下探针脚本假定网关已经在向浏览器推流,本节解决的是"如何在本地获得一个正在推流的网关"。

线上网关在本地无法闭环的原因

线上网关(agent-gateway.lobehub.com)在 WS 握手阶段,会用生产环境应用的 JWKS 校验浏览器携带的用户 JWT。而本地 dev 实例是用自己JWKS_KEY 签发该 JWT 的,因此线上网关必然拒绝握手:

{"type":"auth_failed","reason":"signature verification failed"} → close 1008

值得注意的是:服务端→网关的 push 方向仍然返回 200。因为该方向的鉴权用的是静态的 AGENT_GATEWAY_SERVICE_TOKEN(而非 JWKS),所以事件其实在服务端侧流转正常,只是浏览器永远收不到——这正是"client / SSE / online-gateway 都不是真实本地闭环"的根源。文档给出的验证方法是:在页面里 hook WebSocket,读取 auth 发送后的第一个数据帧,即可复现上述 auth_failed 失败形态。

修复:在兄弟仓库里运行网关 worker 本身

网关 worker 位于 lobehub 仓库的同级目录(同一父目录下的 agent-gateway/ 兄弟仓库),是一个 Cloudflare Worker(wrangler dev,本地模式下使用 Durable Objects)。它的 verifyToken 使用可配置的 JWKS_PUBLIC_KEY 密钥来校验 JWT。只要把该密钥指向本地应用的公钥,本地网关就会信任本地 JWT → auth_success → 形成完整本地闭环。

搭建本地闭环的完整命令(对应 local-gateway-setup.sh):

# 1. 生成 agent-gateway/.dev.vars(从 JWKS_KEY 中提取公开 JWK +
#    复用 AGENT_GATEWAY_SERVICE_TOKEN)。
#    默认从 .records/env/gateway.env 读取来源,否则读 .env.local;
#    可通过 JWKS_SOURCE=<env file> 覆盖。
#    (受管 agent 测试运行环境里没有 .env.local。)
.agents/acceptance/scripts/agent-gateway/local-gateway-setup.sh

# 2. 另开终端启动 worker → http://localhost:8787
#    8787 是共享默认端口:同级 device-gateway/ 仓库的 worker 也会用它,
#    另一个会话可能已经占用。先检查再启动,尽量用自己的端口,而不是
#    重启别人的 worker —— .dev.vars 是共享的,如果按你的 JWKS 重新生成,
#    会破坏正跑在上面的人。
#      lsof -nP -iTCP:8787 -sTCP:LISTEN   # 查看占用进程
#      GATEWAY_PORT=8788 .../local-gateway-setup.sh && bunx wrangler dev --port 8788
#    重新生成前请备份 agent-gateway/.dev.vars,结束后恢复。
cd ../agent-gateway && bun run dev
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8787/health # → 200

# 3. 决定性检查 —— 本地网关是否接受应用的 JWT?
#    (不在 8787 端口时加 GATEWAY_WS=ws://localhost:<port>)
node .agents/acceptance/scripts/agent-gateway/local-gateway-probe.mjs
#    → RECV: {"type":"auth_success"}   ✅ 可行

# 4. 让 APP 指向本地网关并重启它的 dev server:
#      AGENT_GATEWAY_URL=http://localhost:8787   (client → ws://localhost:8787/ws)
#      AGENT_GATEWAY_SERVICE_TOKEN=<unchanged>   (与网关 .dev.vars 的 SERVICE_TOKEN 一致)
#      ENABLE_AGENT_GATEWAY=1                     (→ serverConfig.enableGatewayMode)

这条链路背后的密钥与调用链事实

上述 setup 之所以成立,依赖以下几点可在源码中核验的事实:

  • JWKS_PUBLIC_KEY 只是把 JWKS_KEY 剥掉私钥字段d,p,q,dp,dq,qi)后的公开 JWK 集合——kid 不变,因此签名校验可通过。剥字段的代码就在 local-gateway-setup.sh 的第 63 行:const { d, p, q, dp, dq, qi, ...rest } = k; return rest;
  • 网关 URL 的服务端链路AGENT_GATEWAY_URLgetServerGlobalConfigserverConfig.agentGatewayUrl;客户端构建 WS 地址时把 http(s)→ws(s) 并追加 /ws?operationId=…,所以直接配置一个 http://localhost:8787 即可。
  • 服务端只在不变量满足时启用网关推送:在 apps/server/src/modules/AgentRuntime/factory.ts 中,只有当 AGENT_GATEWAY_URL && AGENT_GATEWAY_SERVICE_TOKEN 同时被设置时,runtime 才会被 GatewayStreamNotifier 包裹。而 GatewayStreamNotifier 在注册每个 op 时通过 POST /api/operations/init 只携带 {operationId, userId}——不会为每个 op 上传公钥,这正是为什么网关必须预先通过 JWKS_PUBLIC_KEY 信任签名密钥。
  • 网关模式是"叠加开关":从 src/helpers/gatewayMode.ts 的判定谓词可见,仅配置了 agentGatewayUrl 并不够,还必须 enableGatewayMode === true 且用户/Agent 未通过 disableGatewayMode 主动退出,发送才会真正走上网关路径——该谓词与派发侧 gateway.tsisGatewayModeEnabled 的判定刻意保持一致。

第二步:探针工具族文件一览

网关测试脚本全部集中在 .agents/acceptance/scripts/agent-gateway/ 目录,每个文件职责如下:

文件 角色
local-gateway-setup.sh 从应用 .env.local / gateway.envagent-gateway/.dev.vars
local-gateway-probe.mjs 用应用的私钥签一个 JWT,断言本地网关返回 auth_success
probe.js 注入 200ms 采样器 + __PROBE_EVENT 打点 + __switchTab 系列 helper
probe-dump.js 停止采样器,返回 {events, samples} JSON 字符串
tab-switch.js 在两个 Tab 之间跑 N 轮往返切换,并为每一步打事件标记
analyze.mjs Node 后处理器:输出时间线 + 回归检测
run.ts 把 TS 版探针(probe-events.ts / probe-dump.ts)用 esbuild 打包注入 CDP 浏览器,并落盘到 .agent-gateway/(gitignored)

其中 .mjs 探针与 JWT 探测是"轻量注入版",而 run.ts 是面向夹具复用的"TS 打包版"——它还会额外 patch 页面里的 WebSocketfetch,用于区分网关 WS(operationId= 连接)与直接 /api/agent/stream 两条不同的推流通道。

第三步:标准工作流(七步走)

文档给出的端到端流程如下,全部命令以仓库根目录为基准:

# 1. 以 CDP 方式启动 Electron
.agents/acceptance/scripts/electron-dev.sh start

# 2. 进入某个会话,把运行时切到 Cloud Sandbox(gateway 模式)

# 3. 安装探针与 helper
agent-browser --cdp 9222 eval --stdin \
  < .agents/acceptance/scripts/agent-gateway/probe.js

# 4. 发送一条 tool-call 消息 —— 手动发送,或 type+press 自动发送
agent-browser --cdp 9222 eval "window.__PROBE_EVENT('SENT')"

# 5. 运行多轮切换驱动器(自动把当前激活 Tab 选为 BACK、最右侧非激活
#    Tab 选为 AWAY —— 如需不同节奏,改文件里的 ROUND_TRIPS / DWELL_MS)
agent-browser --cdp 9222 eval --stdin \
  < .agents/acceptance/scripts/agent-gateway/tab-switch.js

# 6. 等流式结束后 dump
agent-browser --cdp 9222 eval --stdin \
  < .agents/acceptance/scripts/agent-gateway/probe-dump.js \
  > /tmp/probe.json

# 7. 分析
node .agents/acceptance/scripts/agent-gateway/analyze.mjs /tmp/probe.json

如何判读结果:分析器会打印三节——EVENTSTIMELINEREGRESSIONS。如果 REGRESSIONS 非空,意味着在同一个 topic 上 content / reasoning / childN 发生了回落——这正是用户描述的"切回 tab 后消息回到很早以前"的底层症状形态。若采用 TS 打包版,可用 run.ts 的三个子命令替代第 3/6/7 步:

bun run .agents/acceptance/scripts/agent-gateway/run.ts install        # 注入事件探针
bun run .agents/acceptance/scripts/agent-gateway/run.ts dump chat-run  # 落盘 .agent-gateway/chat-run-<ts>.json
bun run .agents/acceptance/scripts/agent-gateway/run.ts analyze        # 分析最近一次 dump

探针到底在采集什么:两层数据模型的关键

探针在页面里通过 window.__LOBE_STORES.chat() 读取聊天 store,其核心难点在于 LobeHub 消息的数据结构是两层的

  • chat.messagesMap 只存放顶层 assistantGroup 的"外壳"消息;
  • 真正被流式写入的正文、推理(reasoning)与工具调用,全部存活在 assistantGroup.children: AssistantContentBlock[]
  • 因此任何只读 m.content / m.reasoning 的探针,在整个流式过程中都会读到全 0,从而漏掉所有关键信号。

probe.js 会同时遍历这两层并求总,每个采样点记录:

  • cT:content 总长度(顶层 + children 逐条累加);
  • rT:reasoning 总长度;
  • toolT:工具调用总数;
  • childN:content block 数量;
  • perMsg:逐条消息的明细(cLen / rLen / tools / chCount / chC / chR / chT),用于把回归精确定位到某条消息;
  • runOpsrunOpTypes:仍在运行的操作数(execServerAgentRuntime 等);
  • domLen:渲染出的聊天列表区域总 innerText 长度;
  • ind:页面上的可见 UI 指示器(Search pagesCrawled pagesSendingDeeply Thinking/Thought 等匹配计数)。

DOM 侧信号与 store 侧信号成对出现,是为了能区分"store 层数据回退"与"渲染层未刷新"这两类不同的回归——如果 store 计数单调递增而 domLen 骤降,问题多半在渲染层;如果 store 计数本身就回落,问题则在消息状态机。

分析器的判定规则:白名单与阈值

analyze.mjs 的时间线默认约 1 秒一条(外加事件点 ±110ms 附近的采样),列格式为:

t(ms) | runOps | msgN | childN | content | reasoning | tools | domLen | search | crawl | topic | event

它对相邻采样间、同一 topic 上的单调回落报警(content / reasoning / tools / childN / msgN 任一变小即视为回归候选),但显式白名单了三种预期回落,避免误报:

  1. topic 切换——关注点已移走,所有下降都是预期行为;
  2. reasoning 清零——assistant 想完开始调用工具/输出正文时,流式 reasoning 缓冲区被清空,而 finalized 的推理会被封进一个已完成子块,父级正在流动的 reasoning 归零不是 bug;
  3. msgN_new 占位 topic 收缩到真实 topicId 时的消息数下降。

此外,domLen 因为工具调用标签里的计数器(如 "(10)")会随结果到达抖动几个字符,因此只有当 domLen 下降 超过 100 字符时分析器才标记——该阈值位于 analyze.mjs 的回归判定循环中。

实操中的五个坑位(Gotchas)

1. 乐观新建 topic 状态。 在首个 chunk 到达之前,消息存在于 <scope>_new 键下,id 为 tmp_* 且没有 topicId 字段。因此当 activeTopicId 为 null 时,probe.js 会回退去扫所有 *_new 结尾的键做采集——只按真实 topicId 过滤会在流式首包前漏采。

2. reasoning 归 0 不是缺陷。 前文白名单已说明原理;如需严苛断言,可在后处理时人工过滤这一形态。

3. DOM 长度的小幅抖动。 工具调用标签的计数会随结果到达而变化,只有超过 100 字符的 domLen 下降才值得调查。

4. 绝不要用 innerText 识别 Tab。 激活 Tab 的文本内嵌了 · <agent name> 后缀——例如搜索 'LobeHub Growth' 时,恰好当前 Agent 也叫 LobeHub Growth,就会匹配到本来就激活的那个 Tab,然后"点了一个你已经在上面的 Tab"。因此 probe.js 使用稳定的 data-contextmenu-trigger 属性(它是每个 Tab 一个的 React useId() 值,跨焦点变化存活)加 data-active="true" 来标记当前 Tab,暴露的 helper 有:

__listTabs() / __clickTabByKey(key) / __clickTabByIndex(i) / __activeTabKey()

5. tab-switch.js 是 fire-and-forget 的。 IIFE 只负责把异步循环踢起来然后立即返回,这样 agent-browser CLI 的 eval 不会撞上它默认的 25s 超时。因此 dump 之前必须先等 SWITCH_LOOP_DONE 事件标记出现;如果上一个循环仍在飞行中,重复运行会被拒绝——重叠运行产生的混沌数据不值得调试。

小结:从一次"可疑回退"到一份可判定的报告

把这套测试台串起来看,它的价值在于把"切回 tab 后消息回到了很早以前"这种模糊的用户报告,转化为一份可判定、可复现、可定位的证据链:

  1. local-gateway-setup.sh + 兄弟仓库 agent-gateway/ 建立本地真实闭环,并用 local-gateway-probe.mjs 先做一次 auth_success 的可行性检查(把密钥错配问题隔离在最前面);
  2. 注入 200ms 双视角探针,发送消息并用 __PROBE_EVENT 打点;
  3. 运行多轮 Tab 往返切换;
  4. dump 后用 analyze.mjs 检查 REGRESSIONS 是否为空。

若报告显示同 topic 下 content / reasoning / childN 回落,则回退发生在消息状态机层;若仅 domLen 骤降,则问题在渲染层。这套方法与 LobeHub 中 GatewayStreamNotifier(见 apps/server/src/modules/AgentRuntime/GatewayStreamNotifier.ts)的推流实现、以及客户端网关传输层(src/store/chat/slices/agentRun/actions/transports/gateway/gateway.ts)相互印证,是验收 gateway-mode 稳定性的重要一环。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389