6.1 断言体系:从「点了」到「点对了」


6.1 断言体系:从「点了」到「点对了」

本节摘要:本节建立浏览器智能体的判定层。第 2.1 节的循环只覆盖执行层报错(动作抛异常、页面返回错误),覆盖不了更危险的静默失败:点了但没生效、点到了但点错了、生效了但不是任务要的那个结果。先给三层断言:结构断言(代码查选择器、文本、元素状态,确定性、零模型成本)、视觉断言(截图比对或人工查看,覆盖排版与观感)、自然语言断言(模型判读页面状态,表达力最强但有误判概率)。三层对应验证分级口径:结构性用代码、感知性用模型、关键流程并用。再讲结果等价断言的思想:断言「目标达成」(列表里出现了那条待办)而不是「路径一致」(走了哪几步、点了哪个元素),这是 Agent 场景与传统脚本断言的分野。最后是断言失败的信息保真:截图、快照、日志三件套在失败瞬间落盘——失败的价值取决于它留下了什么证据。示例打在靶场上,Python 与 JS 双语。

学习目标

  • 区分执行层报错与静默失败,说出后者为什么更危险。
  • 用三层断言表按断言对象、成本、误判面做选型。
  • 解释结果等价断言与路径断言的差异。
  • 实现失败信息保真三件套(截图、快照、日志)的落盘。
  • 写出「结构断言打底、感知断言补盲」的双语验证函数。

一、静默失败:循环报成功,结果不存在

第 2.1 节的循环有一套错误回灌:动作执行报错时,错误文本送回模型,下一轮换个做法。这套机制的前提是「错误会被报出来」。但浏览器场景里大量失败是静默的:

失败形态 循环视角 任务视角
点了按钮但表单校验拦截 动作成功,无异常 提交没发生
点了相邻的同名按钮 动作成功,无异常 操作落在错误对象上
填了输入框但被脚本重置 无异常 数据没进去
页面局部报错但有兜底 UI 无异常 用户看到的已不是预期状态

这类失败的共同点:执行层一切正常,任务目标没有达成。循环自己发现不了,必须有人在循环外判定。这就是断言体系的位置——它不在循环之内,而在每个关键步骤(以及任务终点)之后:

执行层:动作抛异常了吗? 判定层:目标达成了吗? (循环自己管,第 2.1 节) (断言来管,本节) ✓ / ✗ ✓ / ✗ / 「看起来对」 失败可见,自动回灌 失败常常静默,需要显式检查 ​

二、三层断言:断言对象、成本、误判面

断言不是一件事,是三层叠加:

层 断言对象 实现形态 成本 误判面
结构断言 元素存在、文本内容、状态属性 代码(选择器、角色查询) 零模型调用 几乎不误判,只会随选择器脆断
视觉断言 排版、观感、像素级差异 截图比对或人工查看 截图存储与比对 像素噪声(字体渲染、动态元素)
自然语言断言 用户感知的状态语义 模型判读(如第 5.2 节 aiAssert) 每次一次模型调用 存在误判概率(断错或漏断)

三层的分工沿用第 5.2 节立下的口径并推广到全书:结构性问题用代码断言,感知性问题用自然语言断言,关键流程两者并用。「列表包含买牛奶」是结构信息,代码一行搞定;「页面主体可见、没有报错遮罩」传统断言写不清,交给模型;「支付成功页出现」是关键流程,代码断言元素存在,模型断言语义正确,双保险。

选层的判断题只有一个:**这个属性机器读得出来吗?**读得出来(文本、数量、状态)走代码;读不出来(观感、语义)才付模型的钱;介于两者之间(「表格数据对得上」)先试结构化抽取再断言抽取结果——第 4 章快照路线的思路在验证层复用。

三、结果等价断言:断言目标,不断言路径

传统脚本测试习惯断言路径:第一步点了什么、页面跳到哪、URL 是什么。Agent 场景这个习惯必须改掉,因为 Agent 的路径本来就是概率选择的——同一条任务,这次点侧栏入口、下次点顶栏入口,都是正确执行。断言路径等于把概率路径当成确定路径,用例必然时过时不过。

正确姿势是结果等价断言:只断言任务的最终状态等价类——「列表里出现那条待办」「订单状态变为已支付」「页面标题是待办清单演示」。至于中间走了哪条路、点了第几个候选,一概不断言。写用例时的自检问题:「换一条路径完成同一任务,这个断言还应该通过吗?」答案为是,才是结果等价断言。

这个思想与第 3.3 节固化并不冲突:固化的对象是高频稳定步骤(路径收敛后的产物),断言的对象是任务终点状态(不随路径漂移)。固化让执行变便宜,结果等价让验证变稳定,一动一静。

四、双语实现:验证函数与信息保真三件套

把上面的思想落成代码。Python 版:

# verify_suite.py —— 结构断言与失败信息保真三件套(写法示意,以官方文档为准) import json from pathlib import Path from playwright.sync_api import sync_playwright def structural_assert(page) -> dict: """结构断言:结果等价口径,只查终态不查路径""" facts = { "title": page.title(), "add_button": page.get_by_role("button", name="添加").count(), "items": page.get_by_role("listitem").all_inner_texts(), } assert "待办" in facts["title"], f"标题不含关键词: {facts['title']}" assert facts["add_button"] >= 1, "添加按钮不存在" assert any("买牛奶" in t for t in facts["items"]), "列表未出现目标条目" return facts def save_evidence(page, run_id: str, step_no: int, reason: str) -> dict: """信息保真三件套:截图、快照、日志,失败瞬间落盘""" out = Path("runs") / run_id out.mkdir(parents=True, exist_ok=True) page.screenshot(path=str(out / f"step{step_no}.png"), full_page=True) snapshot = page.locator("body").aria_snapshot() # 无障碍快照,形态以官方文档为准 (out / f"step{step_no}.snapshot.txt").write_text(snapshot, "utf-8") log = {"url": page.url, "reason": reason, "step": step_no} (out / "log.jsonl").open("a", encoding="utf-8").write( json.dumps(log, ensure_ascii=False) + "\n" ) return log def main() -> None: with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto("http://127.0.0.1:8080/demo_todo.html") page.get_by_label("新待办").fill("买牛奶") page.get_by_role("button", name="添加").click() try: print("断言通过:", structural_assert(page)) except AssertionError as exc: print("断言失败,证据落盘:", save_evidence(page, "run-001", 1, str(exc))) browser.close() main() ​

JS 版(ES2020,JSDoc 标注类型):

// verify_suite.mjs —— 结构断言与失败信息保真三件套(写法示意,以官方文档为准) import { mkdirSync, appendFileSync, writeFileSync } from "node:fs"; import { chromium } from "playwright"; /** * 结构断言:结果等价口径,只查终态不查路径 * @param {import("playwright").Page} page 页面对象 * @returns {Promise<{title: string, items: string[]}>} 页面事实 */ async function structuralAssert(page) { const title = await page.title(); const items = await page.getByRole("listitem").allInnerTexts(); if (!title.includes("待办")) throw new Error(`标题不含关键词: ${title}`); if (!items.some((t) => t.includes("买牛奶"))) throw new Error("列表未出现目标条目"); return { title, items }; } /** * 信息保真三件套:截图、快照、日志 * @param {import("playwright").Page} page 页面对象 * @param {string} runId 运行标识 @param {number} stepNo 步骤序号 @param {string} reason 失败原因 */ async function saveEvidence(page, runId, stepNo, reason) { mkdirSync(`runs/${runId}`, { recursive: true }); await page.screenshot({ path: `runs/${runId}/step${stepNo}.png`, fullPage: true }); const snapshot = await page.locator("body").ariaSnapshot(); // 无障碍快照 writeFileSync(`runs/${runId}/step${stepNo}.snapshot.txt`, snapshot, "utf8"); appendFileSync( `runs/${runId}/log.jsonl`, JSON.stringify({ url: page.url(), reason, step: stepNo }) + "\n", "utf8" ); } const page = await (await chromium.launch()).newPage(); await page.goto("http://127.0.0.1:8080/demo_todo.html"); await page.getByLabel("新待办").fill("买牛奶"); await page.getByRole("button", { name: "添加" }).click(); try { console.log("断言通过:", await structuralAssert(page)); } catch (err) { await saveEvidence(page, "run-001", 1, err.message); console.error("断言失败,证据落盘:", err.message); } ​

三件套各司其职:截图回答「页面看起来什么样」(人工复盘第一入口);快照回答「语义树当时长什么样」(可 diff、可搜索,第 4 章的全部好处);日志回答「断言为什么失败、当时在哪个 URL」。三者用 run_id 加步骤号统一命名,失败现场可以原样归档——第 8.3 节的审计留痕会把这套机制扩展到每一个动作,不只是失败时刻。

五、断言的编排:查什么、何时查

断言不是越多越好。编排的两条纪律:

纪律 内容 反例
检查点少而关键 任务终点必查,不可逆动作前后各查一次 每步都断言,成本与脆断面双升
感知断言带复核 自然语言断言失败先出报告人工复核,不直接熔断 aiAssert 一挂就当生产事故拉警报

第一条的经济学:每个断言都是潜在的失败点,结构断言虽然零模型成本但随页面结构脆断(第 0.1 节老问题),检查点数量与维护面成正比。第二条是第 5.2 节误判口径的落地:模型断言存在误判概率,它的失败信号应该路由到「复核队列」而不是「告警熔断」——这个分级处置的思想,下一节展开成完整的失败恢复策略。

六、常见坑

坑 现象 排查与处理
断言跑在渲染前 时过时不过 断言前用自动等待或显式等待终态元素
断言路径而非结果 换路径即失败 自检「换路径还应通过」,改写终态断言
三件套只存截图 复盘时说不清语义状态 快照与日志一起落盘,三者互为索引
感知断言当熔断器 模型偶发误判引发误报 感知断言失败进复核队列,不进告警
断言写实现细节 查第三个 li 的类名 断言用户可感知状态(第 5.2 节口径)

本节要点回顾

  • 静默失败是循环的盲区:执行层正常、目标未达成,必须由判定层显式检查。
  • 三层断言:结构(代码、零成本、不误判)、视觉(截图、覆盖观感)、自然语言(模型、表达力强、有误判面);结构性用代码、感知性用模型、关键流程并用。
  • 结果等价断言:断言「目标达成」的不断言「路径一致」——Agent 的路径是概率的,终态才是要守的契约。
  • 失败信息保真三件套:截图、快照、日志,run_id 加步骤号统一命名,失败即证据。
  • 编排两纪律:检查点少而关键;感知断言失败走复核不走熔断。

判定层就位后,剩下的问题是「失败了怎么办」。下一节给失败一个分类学——瞬态网络、选择器漂移、指令歧义的正确反应完全不同——再按分类搭一条「重试、重定位、人工」的回退链,并用超时预算给救援行动画一条时间线。

作者与出处
原作者:灏天文库
整理人: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U