IronClaw 流式 QA 实战:三个观测时刻,抓住流式渲染的真节奏
IronClaw 流式 QA 实战:三个观测时刻,抓住流式渲染的真节奏
【免费下载链接】ironclawIronClaw is an Agent OS focused on privacy, security and extensibility项目地址: https://gitcode.com/gh_mirrors/iro/ironclaw
你点了发送,请求返回 200,右上角 toast 闪了一瞬,你合上笔记本。可用户的屏幕上,IronClaw 的第一个 token 迟到了整整 8 秒。响应到达 ≠ 流式可用。流式渲染验证要量的是页面 DOM 的生长,不是网络层那张回执。
一个数字说了算
读完这节,你能说清为什么抓包抓到的 SSE 帧数永远说服不了评审,而 DOM 突变次数能。
整个方法论可以收束成一个数字:采样窗口内,页面上段落文本发生变化的次数。我们叫它突变次数(mutation count)。
采样器每约 100ms 读一次主区域所有段落的纯文本,把它们拼成一个"签名"。签名一变,就记一笔:什么时候变的、变到多长、末尾是哪一句。
为什么是它,而不是抓包?因为评审要判断的是用户看到的节奏。SSE 帧到达、WebSocket 事件投递,都发生在"线"上。用户看到的,是这些帧经过 JSON 解析、状态更新、React 重渲染之后,才落到段落里的那些字。前端 SSE 连接、看门狗和退避逻辑,就写在 useSSE.ts 里。
线层再健康,前端一卡,屏幕就是空的。抓包抓到 200 帧,可能一帧都没渲染出来。突变次数直接落在渲染结果上,它测的正是"屏幕到底动没动"。
所以这个数是真相指标:它不可伪造,没法用网络日志背书,只能在真实浏览器里量出来。
三个观测时刻
读完这节,你不用看源码,就能凭一条流式曲线判断它是健康还是病态。
我们把一次流式对话切成三个"用户会真正感知的时刻"。每个时刻各问一个问题:启动了吗?稳不稳?收得住吗?
时刻 A「第一口气」🌬️:提交后多久开始长
设计意图:用户最受不了的是发完消息后那一两秒的空白。第一口气只问一件事:提交之后,页面多久开始变长?到底有没有开始变长?这也是 fresh-thread 提示词被刻意做成"多工具、分阶段、长回答"的原因——就是让这段空白足够长,长到能测出来。它内嵌三条硬性意图:
- 至少三个工具:制造多次"调用→回流"的停顿,节奏才分得开;
- 工具阶段之间插一句进度更新:多打一个渲染脉冲;
- 最终答案至少十段短段落:把渲染窗口拉长,55 秒还够用。
脚本要点:基线必须在点击发送之后再抓。你提交的那条用户消息、以及提交后出现的静态引导文案,都不算流式证据。采样器先记下"提交后"的段落签名,之后每一次与基线不同的增长,才计入突变次数。
健康 vs 异常信号:健康曲线里,第一条突变记录出现在提交后一两秒内。若 count 很小,或首条记录迟迟不出,说明流式没启动起来,或启动被卡住了。
时刻 B「稳定的鼓点」🥁:中段渲染匀不匀
设计意图:用户能感知"一顿一顿"还是"匀速流淌"。稳定的鼓点回答:中段渲染速率是否均匀?有没有卡顿,或者憋很久后一次性倾倒?
脚本要点:follow-up 场景就是把这个鼓点放进已有上下文的对话里复测。长上下文更容易卡顿或一次吐完,所以这里专门再跑一遍。提示词做了降级:工具从"至少三个"降到"至少两个",最终答案从"十段"降到"八段"——追问本身就背着上下文,不用同等体量也够观察。
注意输入框定位名换了:追问态的 composer 用的是chat.followUpPlaceholder,文案是 "Ask for follow-up changes"。脚本必须拿这个名字去 fill,否则上一轮刚结束的输入框会定位失败。同时把变量全改成railwayFollowup*前缀,避开上一段脚本的绑定冲突。
健康 vs 异常信号:健康曲线中段节奏稳定,突变间隔大致均匀。异常是长时间静默后突然出现一大跳——那往往不是内容多,而是重连后一次倾倒,或前端把缓冲帧攒着没刷。
时刻 C「收尾」🏁:段落数收得住吗
设计意图:55 秒采样窗口走完,不等于流式结束。收尾问的是终端状态:段落数还长不长?输入框复位没有?答案收得完整吗?
脚本要点:完成态检查一次性看四样东西——composer 是否复位、活动入口按钮是否出现、段落数是否收敛、末尾是否完整收尾。
var snap = await page.locator("main").innerText(); var buttons = await page.getByRole("button").allTextContents(); var paras = await page.locator("main p").allTextContents(); console.log(JSON.stringify({ terminalComposer: snap.includes("Ask for follow-up changes"), activityButtons: buttons.filter(t => t.includes("Activity -")), paragraphCount: paras.length, tail: paras.slice(-10), }));看哪里:terminalComposer为 true 说明 composer 已复位成追问态;tail用一个完整句子收尾才算完整。
健康 vs 异常信号:三件事同时成立才算正常收工——段落数不再增长、tail以完整句收尾、composer 复位。若段落数还在涨、输入框却没复位,说明答案还在渲染,或前端状态机没切过去。
12 行脚本,三处不变
读完这节,你把这套探针搬到任何浏览器驱动上,要改的只有三处。
先看完整探针的核心循环,约十二行:
var start = Date.now(); var prev = (await page.locator("main p").allTextContents()).join("\n"); var changes = []; while (Date.now() - start < 55_000) { var parts = await page.locator("main p").allTextContents(); var sig = parts.join("\n"); if (sig !== prev) { changes.push({ t: Date.now() - start, len: sig.length, parts: parts.length, tail: sig.slice(-140) }); prev = sig; } await new Promise(r => setTimeout(r, 100)); } console.log(JSON.stringify({count: changes.length, first: changes.slice(0, 12), last: changes.slice(-20)}));看哪里:第 2 行是"提交后抓基线",while体里是"固定 100ms 轮询 + 签名比对",最后一行是"首 12、末 20 导出"。
三不变量,无论换什么驱动都不许动:
- 提交后取基线:基线永远在点击发送之后抓,之前的 DOM 一律不算数。
- 只计渲染层变化:只数
main p文本签名的变化,SSE / WebSocket 帧到达不算。 - 固定间隔轮询并记录时序:约 100ms 一次,每个突变点记下
t/len/parts/tail。
三处必改,换驱动时只翻译这三处:
| 三不变量(永远保留) | 三处必改(随驱动翻译) |
|---|---|
| ① 基线在提交后抓 | ① 会话/标签句柄 → 驱动 locator |
| ② 只计渲染层签名变化 | ② DOM 断言原语 → 驱动取文本/快照 API |
| ③ 固定 100ms 轮询、记时序 | ③ 等待 → 驱动原生 sleep |
探针什么时候会骗你
读完这节,当数据"看起来挺正常"时,你能一眼认出它被谁骗了。
陷阱一:陈旧构建(HEAD SHA 不一致)症状:曲线看着挺好,但测的是昨天的构建。 根因:preview 构建没跟上,你连的是旧headRefOid。 判定规则:railway_state必须是 success,且测试期间的headRefOid与记录值一致;否则本次观测作废。
陷阱二:wire 层 vs 渲染层混淆症状:网络日志里 SSE 帧源源不断,页面却半天不动。 根因:把"帧到达"当成了"渲染发生"。 判定规则:只认main p突变次数;帧数再多,渲染没动就是没动。
陷阱三:REPL 变量名冲突症状:第二次跑采样,count 突然变 0 或乱掉。 根因:Node REPL 里还留着上一段的同名绑定,新脚本覆盖或读到了旧值。 判定规则:每段脚本用全新前缀变量(如railwayFollowup*),换名后再跑。
陷阱四:容量控制反复拒绝 → 曲线跳变症状:采样曲线中段出现大段静默,后突然跳一大截。 根因:服务端 sse_capacity.rs 把连接反复拒绝——每调用方并发上限 3、单连接 5 分钟生命周期,连续拒绝达到退款上限后不再退款。前端重连,把缓冲帧一次性刷出。 判定规则:出现"长静默 + 大跳变"先怀疑重连,而不是内容本身。
从探针到证据
读完这节,你知道一条流式曲线怎么从"我觉得挺顺"变成 PR 里站得住的证据。
证据要成立,先过门禁。preview_state.sh 查出的railway_state若不是 success,这次浏览器观测直接作废,只能发 BLOCKED 证据。绝不允许在陈旧构建上做流式断言。
证据是分级的,从强到弱:
- 可见的增量变化(SSE 增量渲染验证 / DOM 突变采样)——最高档;
- 点击成功 + HTTP 200 + toast;
- 原始 wire 事件——最低,只算补充。
流式曲线平滑,也只能证明"渲染动了"。如果 PR 声称的是某个具体契约变更,你还得确认采样旅程真的走了目标契约:真实 provider / model、路由、角色、工具、起始状态。否则这个用例只是 supplemental,升不到 PASS。
发布走单一规范评论,带隐藏标记railway-test:evidence head=<HEAD_SHA>。所有证据(count/first/last、完成态断言结果)先拼成完整评论正文,再用gh pr comment或gh api --method PATCH发布或更新。发布前做清理:删railway-test-前缀的临时数据,关闭标签页,最后再复核一次headRefOid。Bearer 凭据、敏感浏览器状态,一个字都不能出现在证据里。
收束
流式 QA 测的不是"消息发出去没有",而是每一次工具回流、每一个进度更新、每一段答案,是否都以可感知的节奏长到了屏幕上。
关键文件
- useSSE.ts:前端 SSE 连接、30 秒活跃看门狗与抖动退避重连。
- sse_capacity.rs:服务端 SSE 并发上限、5 分钟生命周期与拒绝退款机制。
- streaming-cadence.md:本方法论的原始 QA 配方(fresh / follow-up / 完成态三段)。
- api.ts:
eventStreamRequest事件流请求与 WebSocket 事件通道。
【免费下载链接】ironclawIronClaw is an Agent OS focused on privacy, security and extensibility项目地址: https://gitcode.com/gh_mirrors/iro/ironclaw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考