ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IronClaw 流式输出 SSE 浏览器验证实操:把渲染节奏变成时序证据

IronClaw 流式输出 SSE 浏览器验证实操:把渲染节奏变成时序证据

IronClaw 流式输出 SSE 浏览器验证实操:把渲染节奏变成时序证据

【免费下载链接】ironclawIronClaw is an Agent OS focused on privacy, security and extensibility项目地址: https://gitcode.com/gh_mirrors/iro/ironclaw

当一次 PR 声称修好了 IronClaw 聊天页的流式卡顿,你点击发送、收到 HTTP 200、看到 toast 弹出、最终拿到完整答案——这些都不构成证据。真正的问题藏在时间轴里:每一段回答文本是什么时候出现在页面上的。本文基于仓库的 railway-test 技能(配方文档见 streaming-cadence.md),完整走一遍流式浏览器 QA:如何采样 SSE 流式输出的渲染节奏、如何判定完成态、如何把证据挂回 PR 评审。

点击成功为什么证明不了流式修好了

设想一个失败场景。PR 描述是"修复流式渲染卡顿"。你打开预览环境,发一条消息,请求成功,答案最终完整出现。旧的断言集合——元素存在、请求 200、toast 出现——全部通过。但页面实际的行为可能是:前 40 秒完全静止,最后 15 秒一次性吐出全部段落。

传统 UI 断言是状态断言,只关心"最终是什么"。流式质量是时间域属性,关心"随时间怎么变"。状态断言对时间域问题天然失明,原始网络帧的到达时间同样不够用——帧到达不等于用户看见了。所以配方文档把适用边界划得很窄:只有当 PR 改动聊天、SSE/WebSocket 投递、增量渲染或 live-update 对账时才走这条配方,主证据是"随时间测量可见 DOM 变化"。

流式 QA 的方法论三件套:基线、轮询、完成断言

整套采样逻辑只有三步,顺序不能换。

第一步,提交后取基线。点击发送之后,立即抓取一次主区域段落文本,作为签名基线。这一步相当于归零校准:用户消息本身、以及提交后固定出现的静态标记,都不是流式证据,必须先在基线里"扣除"。之后所有与基线的差异,才计为助手回复的渲染增长。

第二步,固定间隔轮询可见 DOM。以约 100ms 的间隔轮询main p(主区域的全部段落),把纯文本用换行拼成一个"签名"。签名变化时记一条记录:相对时间t、总长度len、段落数parts、末尾 140 字符tail。这就像给页面做脉搏记录,记的是渲染层的节拍,不是网络层的到帧。100ms 这个间隔是折中:过密会扰动渲染,过疏会漏掉逐段渲染的节拍。

第三步,完成态断言。采样窗口固定为 55 秒(防止脚本无限挂起,也足以覆盖一次长回答)。窗口结束后再单独检查页面是否进入了完成态,见下文。

只看渲染层还有一个硬理由:useSSE收到帧之后还要经过 JSON 解析、事件分发、React 状态更新,最终才表现为段落文本增长。用户感知的是最后一步。

采样脚本怎么写:新线程与 follow-up 两个场景

一次完整采样包含两个场景:在空白线程提交一条长提示词,再在既有线程上追问一轮。两个场景的差异如下表:

项目新线程(fresh thread)既有线程 follow-up
提示词规模至少 3 次工具调用,阶段间含进度更新,最终答案至少 10 个短段落至少 2 次工具调用,工具前一句进度,最终答案至少 8 个短段落
输入框定位名Ask IronClaw anything.(i18n 键chat.heroPlaceholder)Ask for follow-up changes(键chat.followUpPlaceholder,见 en.ts)
脚本变量前缀railway*railwayFollowup*
发送按钮Send messageSend message

follow-up 输入框的定位名不同,是因为完成上一轮后页面切入了"可继续追问"的 composer 状态,占位符文案随之切换;脚本必须针对当前 DOM 状态选对名字,否则填充会失败。变量前缀全部换新,是为了避开 Node REPL 里已存在的同名绑定。

两条测试提示词是刻意设计的:要求多工具、多阶段、长输出,把流式窗口拉长到 55 秒有意义。

新线程提示词:

Compare and contrast NEAR Protocol with Ethereum using current web research. Briefly tell me what you will research before using at least three tools, include another short progress update between tool phases, then write a detailed final answer of at least ten short paragraphs. I am testing smooth streaming, so stream naturally and do not make the answer terse.

follow-up 提示词:

Follow up by using at least two web searches to verify the newest roadmap milestones for both protocols. Give one short progress sentence before the tools, then a fresh final answer of at least eight short paragraphs. Keep the final answer flowing naturally so I can observe streaming.

脚本逻辑是散文式的:定位输入框、fill提示词、点击Send message按钮,然后进入轮询循环。核心骨架如下(句柄按你的驱动替换,下文移植节有说明):

// 提交后立刻取基线:之前的用户消息与静态标记不算流式证据 var started = Date.now(); var previous = (await page.locator("main p").allTextContents()).join("\n"); var changes = []; while (Date.now() - started < 55_000) { // 55 秒采样窗口 var parts = await page.locator("main p").allTextContents(); var signature = parts.join("\n"); // 可见 DOM 签名 if (signature !== previous) { // 只记录签名变化 changes.push({ t: Date.now() - started, len: signature.length, parts: parts.length, tail: signature.slice(-140) }); previous = signature; } await new Promise(r => setTimeout(r, 100)); // 约 100ms 轮询 } console.log(JSON.stringify({ count: changes.length, first: changes.slice(0, 12), last: changes.slice(-20) }));

如何判定流式已完成:四个断言

55 秒窗口结束不等于流式结束,长答案可能还在渲染。完成态检查取四个值,各自覆盖一个维度:

断言取法判什么
terminalComposerDOM 快照是否包含Ask for follow-up changes占位符composer 复位,进入可继续追问的完成态
activityButtons按钮文本中匹配Activity -的列表流式结束后活动/运行记录入口出现
paragraphCountmain p段落总数与采样记录的parts对照,不再增长即渲染收敛
tail末尾 10 段文本人工核对最终答案完整收尾、无截断

组合判定:段落数稳定 +tail以完整句结束 + composer 复位,三者齐备才算流式正常结束。如果段落数还在涨而 composer 没复位,说明答案仍在渲染,或者 UI 状态机没切换。

从采样数据到 PR 证据:门禁与发布红线

先看数据怎么读。输出只有三个字段:count(总变化次数)、first(前 12 条)、last(末 20 条)。健康的曲线应该是早期变化密集(模型快速吐出开头)、中期节奏稳定、末期自然收敛。count过小或首条记录时间过晚,说明流式启动或持续渲染出了问题;中间出现长时间静默后突然跳变,则指向断线重连。

再前置一步门禁。流式断言必须建立在"确认过的 HEAD SHA 对应的构建"上。先跑scripts/preview_state.sh <PR> [REPO] [PREVIEW_URL],它通过 commit 作用域的状态端点报告railway_state,取值success/pending/failure/error/missing/query_failed,同时抓取前端资源指纹(形如assets/app-<hash>.js)。两条红线:railway_state不是success,或测试期间headRefOid与记录值不一致,本次浏览器观测作废,只能发布 BLOCKED 证据。

⚠️ 证据分级也有讲究。在 SKILL.md 定义的证据标准里,流式采样属于"可见的增量变化"这一档,明确高于点击成功、HTTP 200、toast、原始事件。即使曲线平滑,如果采样旅程没走目标契约(真实 provider/model、路由、角色、工具、起始状态),该用例只能算 supplemental,不能升级为 PASS。

发布阶段:所有证据(count/first/last、完成态断言结果)先拼成完整 PR 评论正文,再清理railway-test-前缀的临时数据、关闭标签页、复核headRefOid,最后以单一规范评论形式发布,评论带隐藏标记railway-test:evidence head=<HEAD_SHA>。Bearer 凭据和敏感浏览器状态不得出现在任何证据里。

移植到其他浏览器驱动:只改三处

配方脚本是为 Codex 内嵌浏览器写的,标签句柄绑定名railwayTab。换驱动时只翻译三处:

  1. 会话/标签句柄:railwayTab.playwright.getByRole(...)换成驱动自己的 locator API;
  2. DOM 断言原语:locator("main p").allTextContents()换成驱动的取文本接口,domSnapshot()换成page.content()或序列化的 accessibility tree;
  3. 异步等待:new Promise((resolve) => setTimeout(resolve, 100))换成驱动原生 sleep。

翻译完之后,方法论三件套原样保留:提交后取基线 → 固定间隔轮询可见 DOM 并记录签名变化 → 完成态断言。证据的可复现性来自这三个要点,而不是来自某一套驱动 API。

原理回望:为什么轮询渲染层才有效

采样器记下的每个签名变化,对应前端一整套流式管道的最终输出。按数据流从上游到下游看:

  • 事件获取:useSSE.ts 用EventSourcePlus向GET /v2/threads/<threadId>/events建立 SSE 连接,Bearer 令牌经 headers 函数按需注入,不进入浏览器历史与代理日志;
  • 帧解析与分发:onMessage解析 JSON 帧,把stream_error归一为error事件,再经onEvent回调驱动聊天状态更新;
  • 渲染:状态流转最终体现为main p文本增长——采样器观测的正是这一步;
  • 服务端容量控制:sse_capacity.rs 限制每调用方并发 3 条(DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER = 3)、单连接最长存活 5 分钟(SSE_MAX_LIFETIME),并带拒绝退款机制:连续拒绝达到REJECTION_REFUND_LIMIT = 5后不再退款。连接被反复拒绝重建,会直接打乱采样到的渲染节奏;
  • 连接状态机:useSSE内置 30 秒活跃看门狗、1s→30s 抖动退避,以及connection_id/connection_generation重连标识。流中断时前端进入RECONNECTING状态并调度重连。

这五条解释了采样曲线该怎么读:健康曲线里不该出现"大段静默后突然跳变",一旦出现,先怀疑重连。流式 QA 测的从来不是"消息发出去没有",而是每一次工具回流、每一个进度更新、每一段答案,是否都以可感知的节奏渲染到了页面上。配合完成态断言与 HEAD 门禁,"流式平滑"就从主观感受变成了可量化、可回归、可评审的工程事实。

【免费下载链接】ironclawIronClaw is an Agent OS focused on privacy, security and extensibility项目地址: https://gitcode.com/gh_mirrors/iro/ironclaw

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进