LobeHub 网关流式会话与 Tab 切换回归验证:200ms 状态时序探针与本地网关闭环 E2E 实践指南
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/server、src 中的源码交叉验证展开。
测试台要解决的问题:以可证伪的方式验证"流式回退"
这类缺陷的典型用户症状是:"消息在流式输出途中切走再切回,聊天记录退回到了很久以前的内容。" 仅靠人工观察很难判断是 store 层数据被覆盖,还是渲染层刷新不完整。因此该测试台确立了三条原则:
- 双视角采样:同时抓取 Zustand store(
chat.messagesMap)与渲染 DOM(聊天列表innerText),200ms 一次; - 事件打点:通过
window.__PROBE_EVENT('SENT')、AWAY_1、BACK_1等标记把用户动作与状态采样在时间轴上对齐; - 本地闭环:搭建一个能被应用本地 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_URL→getServerGlobalConfig→serverConfig.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.ts中isGatewayModeEnabled的判定刻意保持一致。
第二步:探针工具族文件一览
网关测试脚本全部集中在 .agents/acceptance/scripts/agent-gateway/ 目录,每个文件职责如下:
| 文件 | 角色 |
|---|---|
| local-gateway-setup.sh | 从应用 .env.local / gateway.env 写 agent-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 页面里的 WebSocket 与 fetch,用于区分网关 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
如何判读结果:分析器会打印三节——EVENTS、TIMELINE、REGRESSIONS。如果 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),用于把回归精确定位到某条消息;runOps与runOpTypes:仍在运行的操作数(execServerAgentRuntime等);domLen:渲染出的聊天列表区域总innerText长度;ind:页面上的可见 UI 指示器(Search pages、Crawled pages、Sending、Deeply 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 任一变小即视为回归候选),但显式白名单了三种预期回落,避免误报:
- topic 切换——关注点已移走,所有下降都是预期行为;
- reasoning 清零——assistant 想完开始调用工具/输出正文时,流式 reasoning 缓冲区被清空,而 finalized 的推理会被封进一个已完成子块,父级正在流动的 reasoning 归零不是 bug;
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 后消息回到了很早以前"这种模糊的用户报告,转化为一份可判定、可复现、可定位的证据链:
- 用 local-gateway-setup.sh + 兄弟仓库
agent-gateway/建立本地真实闭环,并用 local-gateway-probe.mjs 先做一次auth_success的可行性检查(把密钥错配问题隔离在最前面); - 注入 200ms 双视角探针,发送消息并用
__PROBE_EVENT打点; - 运行多轮 Tab 往返切换;
- 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 稳定性的重要一环。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00