通过接入无头浏览器渲染页面并截图,让AI agent在报告完成前能看到实际UI效果,解决其只能通过测试却无法判断界面正确性的根本问题。
我的 AI 编程 Agent 擅长后端工作,在 UI 工作上却一直糟糕透顶——它能让所有测试通过,但页面看起来还是坏的。我通过在 Agent 的循环中接入一个无头浏览器来解决这个问题:它渲染页面、截图、读取自己的输出,然后才被允许说"完成了"。以下是这套 harness、实现它真正有效的 prompt 契约,以及五条经验教训——包括视觉反馈根本没有帮助的那些场景。
我平时用编程 Agent(Claude Code,v2.x,运行在 Node.js 22.x)做实际工作。在后端任务上它确实很强:有一套测试套件、一个类型检查器、一个 linter,每一个都是机器可读的 oracle。Agent 写代码、运行命令、读取失败信息、迭代。这个循环是自行闭环的。
前端工作彻底打破了这个循环。
Agent 会修改一个组件、运行测试套件、拿到 green、报告成功。然后我打开浏览器,发现一个模态框渲染在了页面内容后面,一个 flex 行悄无声息地变成了 column,或者一个按钮技术上存在于 DOM 中但视觉上在屏幕外四百像素。测试通过了,因为测试是对 DOM 做断言的——expect(screen.getByRole('button')).toBeVisible() 对一个人类看不到也点不了的元素会很满意。
我花了大约两周时间充当 Agent 的眼睛。它完成任务后,我会截图页面,粘贴回去,说"侧边栏叠在 header 上了"。它修好那个,又把 footer 弄坏了。每一次来回都需要我参与,这意味着 Agent 只能在有人盯着的时候才能工作——这完全违背了自主运行的初衷。
让这件事变得有趣的约束:我不想加视觉回归套件。快照 diff 工具在有稳定基线、设计不变的情况下很棒。我在构建新的 UI。每一张截图都应该和上一张不同。没有什么基线可以 diff。
我真正需要的不是回归检测,而是感知——Agent 需要看到它刚刚构建的东西,就像它已经能看到 stack trace 一样。
打破僵局的关键洞察:现代编程 Agent 是多模态的。如果一张截图作为图片进入 Agent 的上下文,它可以描述其中的内容。所以工作不是建一个比较引擎,而是建一个捕获步骤并让它变成不可跳过的。
一个渲染 harness,启动应用、导航到路由、在几个视口宽度下截图
一份结构化报告,Agent 与图片一起读取(控制台错误、失败的请求、布局指标)
一个 prompt 层面的契约,禁止 Agent 在没有当前代码的捕获的情况下声称完成
我用 Playwright,因为它已经在大多数项目中,而且很好地处理了"等到真正稳定"的问题。整个 harness 大约 60 行。承重部分:
// capture.mjs — run as: node capture.mjs /settings
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
const VIEWPORTS = [
{ name: 'mobile', width: 390, height: 844 },
{ name: 'desktop', width: 1440, height: 900 },
];
const route = process.argv[2] ?? '/';
const baseUrl = process.env.APP_URL ?? 'http://localhost:5173';
const browser = await chromium.launch();
const report = { route, viewports: [], consoleErrors: [], failedRequests: [] };
for (const vp of VIEWPORTS) {
const page = await browser.newPage({ viewport: { width: vp.width, height: vp.height } });
page.on('console', (m) => {
if (m.type() === 'error') report.consoleErrors.push(`[${vp.name}] ${m.text()}`);
});
page.on('requestfailed', (r) => {
report.failedRequests.push(`[${vp.name}] ${r.method()} ${r.url()}`);
});
await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' });
await page.waitForTimeout(300); // let CSS transitions land
await mkdir('.capture', { recursive: true });
const file = `.capture/${vp.name}.png`;
await page.screenshot({ path: file, fullPage: true });
// cheap layout signals the model can reason about without squinting at pixels
const overflow = await page.evaluate(() => {
const d = document.documentElement;
return { scrollW: d.scrollWidth, clientW: d.clientWidth };
});
report.viewports.push({
...vp,
file,
horizontalOverflow: overflow.scrollW > overflow.clientW,
overflowBy: overflow.scrollW - overflow.clientW,
});
}
await browser.close();
await writeFile('.capture/report.json', JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
Agent 运行 node capture.mjs /settings,在 stdout 获取 JSON,然后读取 .capture/desktop.png 和 .capture/mobile.png 作为图片。就这样。没有基线、没有 diff、没有需要维护的 golden 文件。
流程是这样的:
flowchart LR
A[Agent edits component] --> B[Run test suite]
B -->|green| C[node capture.mjs route]
C --> D[report.json:<br/>console errors,<br/>failed requests,<br/>overflow]
C --> E[PNG per viewport]
D --> F{Agent reviews<br/>its own output}
E --> F
F -->|looks wrong| A
F -->|looks right| G[Report done<br/>+ attach capture]
让它真正生效的部分
构建 harness 花了一个下午。让 Agent 真正诚实地使用它花了长得多的时间,这是如果我在读别人的帖子时会跳过的部分——所以我会说得具体一些。
我的第一次尝试是一条软性指令:"UI 改动后,运行 capture 脚本并审查截图。"Agent 大约一半的时间会这样做。当它深入一个任务且测试是 green 的时候,宣布胜利的拉力比配置文件里的礼貌建议更强。
三个改动解决了这个问题。
把捕获变成显式输出,而不是内部步骤。我要求 Agent 的完成消息必须包含原始的 report.json 和对每张截图看到的内容的一行描述。一个必须展示工作成果的步骤比一个可以悄悄决定不需要的步骤难跳过得多。
给它一个 checklist,而不是"审查截图"。"审查"不是一个模型可以失败的东西,这意味着它也不是一个模型可以通过的东西。我用具体的问题替换了它:
After running the capture, answer each of these explicitly:
- Is any text clipped, overlapping other text, or cut off at a container edge?
- Is `horizontalOverflow` false at every viewport? If true, name the element causing it.
- Are all interactive elements from this change visible inside the viewport bounds?
- Does the mobile capture show a layout, or a single collapsed column of unstyled content?
- Is `consoleErrors` empty? If not, treat each entry as a task blocker, not a warning.
If you cannot answer one of these from the capture, say so instead of assuming.
最后一行是我会尽力保留的那一条。没有它,Agent 会自信地描述一张它只瞄了一眼的截图。有了它,我会得到"模态框在移动端截图底部被截断了,我无法判断确认按钮是否可达"——这正是我需要的观察。
# in the "definition of done" check
CAPTURED=$(jq -r '.treeHash' .capture/report.json 2>/dev/null)
CURRENT=$(git stash create >/dev/null 2>&1; git rev-parse HEAD:./src)
[ "$CAPTURED" = "$CURRENT" ] || { echo "stale capture — re-run capture.mjs"; exit 1; }
一旦检查变成了机械性的而不是荣誉制度,遵守率基本达到了 100%。Agent 在这里并没有变得自律——只是循环没有其他方式可以闭合。
我一直把 Agent 自主性当作模型的一个属性来思考。实际上不是,它是仓库中可用反馈循环的一个属性。我的 Agent 在后端工作上是自主的,因为 pytest 和 tsc 是它可以在一秒内查询的 oracle。它在前端 UI 工作上依赖我,因为唯一的 oracle 是我的眼睛。自那以后,每次让 Agent 更有意义地变得更自主,都是通过把人类判断转换成一个 Agent 可以运行的命令——而不是更强的 prompt。
如果你想知道你的 Agent 会在哪里停滞,列出你项目中只有人类才能执行的检查。这就是清单。
我差点建了一个视觉回归系统,很高兴我没有建。快照 diff 回答"这次改变了吗?"——对成熟的 UI 是个好问题,对一小时前还不存在的屏幕毫无用处。多模态捕获回答"这是对的吗?",这是真正阻止我的问题。它们是不同的工具。Diff 是对已完成工作的守卫;感知是对未完成工作的循环。
我的 harness 中单价值最高的一行不是截图,而是 scrollWidth > clientWidth。移动端水平溢出大约占 Agent 提交的布局 bug 的三分之一,它可以用两个数字和零歧义检测出来。控制台错误也一样:React key 警告或失败的字体请求是一条字符串,而不是一个判断。截图是用来处理你无法简化为数字的 bug 的。先用数字——它更便宜、更可靠,模型永远不会误读它。
这在 UI 工作之外泛化得很好。任何我希望 Agent 真正执行的步骤,我现在都要求它从中产出一个产物——命令输出、文件路径、原始 JSON。不是声称它做了那个事。产物。每个任务多花几百个 token,但它消除了一整类"我运行了测试"(其实没有运行测试)。
这是诚实的限制。有了捕获循环,Agent 可靠地捕捉到破损的布局:重叠、裁剪、屏幕外的控件、坍缩的移动端视图、未样式化的闪烁。它捕捉不到间距与应用的其余部分不一致,或者层级结构是错的,或者空状态感觉未完成。
我花了一段时间试图用更好的 prompt 缩小那个差距——"评估视觉层级"、"检查间距一致性"——结果得到了自信的、泛化的设计反馈,与图片中的内容不符。那是一个真实的踩坑故事:大约花了一天发现一个模糊的问题无论模型多好都会产生模糊的答案。
所以我明确地划了那条线。循环的职责是"这是坏的吗?"——一个有可辩护答案的问题。"这是好的吗?"留给我自己。自从停止问第二个问题之后,我对第一个问题的信任度提高了很多。
我正在研究的两个方向:
交互捕获,而不只是页面加载。当前的 harness 截图一个静止状态的路由。我剩余的大部分 UI bug 在需要点击进入的状态中——打开的下拉菜单、加载骨架屏、错误 toast。我正在转向每个任务一个脚本,驱动到某个状态并在那里捕获,这主要意味着让 Agent 作为任务的一部分编写短的 Playwright 脚本,而不只是消费一个固定的脚本。
把"展示你的工作"契约扩展到无障碍。一个 axe-core 通过会产生一种结构化的、不可协商的输出,这种输出对控制台错误很有效——一份带有选择器的违规列表。同样的模式,应用到我目前手动执行的检查上,因此基本不做。
我一直重新学习的元经验:每次我感觉到自己在充当 Agent 的传感器,那就是去构建那个传感器的信号。
如果你的编程 Agent 擅长后端工作但不擅长 UI 工作,可能不是模型的问题。是你项目的一半有机器可读的 oracle,另一半只有你。一个无头浏览器、一张截图、和一条"完成"需要新鲜捕获的硬规则,在一下午的设置后为我关闭了大部分差距。
如果你尝试这个,在碰截图之前先从溢出检查和控制台错误开始。二十行代码,它能捕捉到的比你预期的多。
你有没有找到给你的 Agent 关于视觉工作的反馈方式——或者一个让你的 Agent 更有意义地变得自主的检查?我真的想听听;我正在收集这些。写在评论区,如果你想在发布交互捕获的后续文章时收到通知,就在这里关注我 on Dev.to。🚀