Featured image of post Lightpanda 源码级拆解:无头 Chrome 对 agent 贵在哪,以及「不渲染」换来和丢掉了什么

Lightpanda 源码级拆解:无头 Chrome 对 agent 贵在哪,以及「不渲染」换来和丢掉了什么

一个用 Zig 从零写的浏览器,没有 Chromium 分支,没有 WebKit 补丁,官方口径是比无头 Chrome 省 16 倍内存、快 9 倍。仓库 36.9k star,AGPL-3.0。

Lightpanda 的背景故事很直接:创始团队做了多年大规模网页抓取,撞上无头 Chrome 的成本和稳定性上限。实例开几百上千个,内存和 CPU 都吃不住,还得频繁重启才能稳住。

但如果你只把它读成「更省资源的无头 Chrome」,会漏掉它真正动刀的位置。这篇拆的是那一刀。

一、贵在哪:被像素定义的交互协议

这是我认为整篇里最重要的一段,因为它把因果顺序说反了就会理解错这个项目。

常见说法是「因为不渲染所以快」。真实顺序是反的:因为先把交互从像素上解耦了,才敢不渲染。

Lightpanda 官方文档自己点出了 CDP 的结构性缺陷。CDP 能在 DOM 上直接查询和修改,但它没有 DOM 级的用户交互命令。要点击一个元素,客户端必须:

  1. 查出这个元素的渲染像素坐标
  2. 把虚拟鼠标移到那个坐标
  3. 派发一个点击事件

三次往返,外加一个渲染依赖,去做一件概念上只有一个动作的事。文档的原话是,CDP 是为人坐在显示器前调试浏览器设计的,不是为「一个已经知道要操作哪个 DOM 节点的脚本」设计的。

结论就出来了:无头 Chrome 之所以必须把 style resolution、layout、paint、compositing 整条流水线跑起来,是因为它的交互协议是用像素定义的。 没有像素坐标,Playwright 点不了按钮。

Lightpanda 的做法是把交互改成 DOM 级,于是渲染流水线成了可删项。

二、删掉之后剩下什么

架构上留下的是自动化真正用到的那部分:网络层、HTML 解析器、JavaScript 引擎、Web API 和 DOM。

组件 实现
控制面 fetch、serve(CDP 或 WebDriver classic/BiDi)、agent、mcp、云端 HTTP API
网络层 libcurl、robots.txt、WebSocket
HTML 解析 把取回的 HTML 变成 DOM
JavaScript 引擎 V8,和 Chrome 同一个引擎
Web API 与 DOM 脚本读写和修改的内存文档

最后一行值得停一下:它没有自己造 JS 引擎,用的是 V8。这意味着跑不了的原因不会是「JS 引擎不支持某个语法」,而是缺某个 Web API、或者某个行为依赖真实布局。这个区别在排查问题的时候很有用。

进程模型上,它编译成单个原生二进制,运行时只有一个进程。这个进程持有一份跨整个运行周期共享的状态(代码里叫 App),浏览器实例按需创建,每个实例拥有自己的一个 V8 isolate。仓库结构直接对应这套分层:

路径 对应组件
src/cdp/、src/agent/、src/mcp/ 控制面
src/main.zig、src/App.zig App 与进程级服务
src/network/ App 的网络层(libcurl、robots.txt、WebSocket)
src/browser/ Browser、Session、Page、Frame

同一个引擎后面挂了五个入口,这是它设计上比较聪明的地方:

lightpanda fetch --dump html https://example.com   # 一次性取页,输出 HTML 或 Markdown
lightpanda serve --obey-robots                     # 起服务,默认 CDP,127.0.0.1:9222
lightpanda mcp                                     # MCP server(stdio)
lightpanda agent                                   # 交互式 agent REPL
lightpanda run script.js                           # 重放已保存的脚本,不需要 LLM

serve 的协议选择值得单独说:默认讲 CDP,加 --protocol webdriver 改讲 WebDriver(classic 走 HTTP,BiDi 走 WebSocket),而 --protocol cdp --protocol webdriver 可以在同一个端口上同时提供两套。对已经有一套 Selenium 测试栈的团队,这个兼容层省掉了重写。

日志是结构化的,一眼能看出它在干什么:

$time=1785335600862 $scope=app $level=note $msg="server running" address=127.0.0.1:9222
$time=1785335543068 $scope=browser $level=info $msg="executing script" src=.../script.js kind=javascript
$time=1785335543139 $scope=http $level=info $msg="request complete" source=xhr status=200 len=4770

三、代价:一组「看起来有、实际没有」的能力

这一节是我认为用它之前必须知道的,因为它的失败方式很特殊。

不渲染意味着没有真实布局。官方文档写得很清楚:元素尺寸是从 CSS 和 HTML 属性做的合理近似,而元素位置是从 DOM 深度和兄弟顺序模拟出来的(对应 calculateDocumentPosition),不是真实布局算出来的。

更要紧的是这一条:

Page.captureScreenshot 返回一张占位图,不是真实渲染。

原文的收尾是一句提醒:需要真实像素的时候,请改用完整浏览器。

把这两条放在一起看,会得到一个不太妙的性质:它在这类能力上不会报错,它会返回一个看起来正常的替代品。 截图请求成功返回了 200 和一张图片,只是图里没有你的页面。

对 agent 来说这比报错危险。一个拿到占位图的 agent 不会停下来,它会继续基于那张图往下推理,然后给你一个基于空白内容得出的结论。

所以边界要自己划清。以下这些能力不在它的范围内:视觉回归测试、布局断言(元素到底在哪、有没有重叠)、截图给多模态模型看、验证码图像识别、PDF 生成。

四、省了多少:数字和它的适用边界

官方基准是在 AWS m5.large 上请求 933 个真实网页,通过真实网络:

指标 Lightpanda 无头 Chrome 差距
峰值内存(100 页) 123 MB 2 GB 约 16 倍
执行时间(100 页) 5 秒 46 秒 约 9 倍

这两个数字的意义不一样,我想分开说。

内存那 16 倍才是决定性的。 它决定的是「一台机器上能同时跑多少个实例」,也就是你的单页成本。做大规模抓取或者给每个 agent 会话开一个浏览器进程时,这个数字直接换算成账单。

执行时间那 9 倍在单页任务上你基本感知不到。 46 秒和 5 秒都是 100 页的累计值,摊到单页是零点几秒的差别,而网络往返往往比这更慢。这个数字真正起作用的地方是批量任务。

顺便提醒一句这两个数字的适用边界:它是爬取场景的基准(取页、执行 JS、拿 DOM),不是「跑一个多步交互的 agent 任务」的基准。后者的耗时更多取决于页面数量、等待策略和模型推理,浏览器本身省下的那部分会被摊薄。

五、agent 模式:把 LLM 放进了浏览器进程

lightpanda agent 这个入口的设计和常见的「agent 通过 CDP 驱动浏览器」不一样:它跑在浏览器同一个进程里,不走 CDP。

它叠了三层:

  1. 浏览器:加载页面、执行 JavaScript、处理 DOM,和 CDP server 用的是同一个引擎
  2. 运行时:一组原生工具,goto、click、fill、extract、evaluate、search、detectForms、selectOption、waitForState 等,每个都有对应的斜杠命令
  3. LLM:把你的自然语言请求翻译成工具调用,这一层是可选的

第三层可选这点很有意思。--no-llm 之后它退化成只有斜杠命令的 REPL,你仍然可以手工驱动浏览器。也就是说 LLM 在这里是「把自然语言编译成工具调用」的便利层,不是必需品。

provider 的解析顺序是四级,值得记一下因为它决定你第一次跑会不会卡住:

  1. 记住的:上次用 /provider 选的,按目录持久化在 .lp-agent.zon 里,前提是它的 key 还在
  2. 自动探测:环境变量里第一个找到的 key,按固定优先级顺序
  3. 本地:没有云端 key 时,探测本机在跑的 Ollama 或 llama.cpp。本地服务不会被自动探测到,必须显式指定 --provider ollama 或 --provider llama_cpp
  4. 没有:--no-llm 退化成基础 REPL,自然语言和 /login、/logout 这类由 LLM 驱动的命令不可用

还有一个反直觉的开关:--effort 设的是每轮推理预算(none 到 xhigh,REPL 里默认 low,一次性 --task 模式默认 medium)。官方给的说明是,更高的 effort 可能意味着每个任务更少的工具调用,因为模型规划得更好。所以它不单纯是「更慢更聪明」的滑块,而是一个真实的取舍。

--attach 可以把本地文件喂给模型,文本内联(单个上限 512 KiB),图片、音频、PDF 走 base64(单个上限 20 MiB),每个 --attach 带一个文件,可重复。

六、为什么 agent 模式要放进同一个进程

回到第一节那个 CDP 的缺陷,就能看出 lightpanda agent 那个设计选择的理由。

如果 agent 通过 CDP 驱动浏览器,那么每一个动作都要过一次协议:客户端发命令,浏览器执行,结果回来,客户端再决定下一步。第一节里那个点击的例子是三次往返;一个多步任务就是几十次。每次往返都是延迟、都是状态同步的机会、也都是「客户端和服务端对当前状态的理解可能不一致」的窗口。

而 agent 模式把运行时工具放进了浏览器进程里。goto、click、fill 这些工具直接操作内存里的 DOM,不过协议。省下的不只是那几次往返的延迟,更是一个根本性的简化:不再有两个进程各自维护一份「页面现在长什么样」的理解。

这也解释了为什么它的工具集里有 detectForms、selectOption、waitForState 这种语义级的操作。它们的存在前提就是能直接看到 DOM 结构。用像素坐标的协议做不出 detectForms,因为「表单」这个概念在像素层面不存在。

代价是耦合:这套工具只有 lightpanda agent 能用,你在自己的 agent 框架里通过 CDP 连过去是拿不到的。想用就得把任务交给它的 REPL,或者用 --task 跑一次性任务。

七、迁移一份已有的 Playwright 脚本要改什么

对已经有自动化栈的团队,Lightpanda 的迁移成本比较低,因为它的策略是「在你已经用的接口上等你」:Puppeteer、Playwright、Chromedp 通过 CDP 连它的方式和连 Chrome 一样,Selenium 走 WebDriver BiDi。把客户端指向它的端点,脚本基本不用动。

但有几类操作会出问题,这是迁移时该先盘一遍的清单:

你的脚本里有什么 在 Lightpanda 上会怎样 处理方式
截图 / 视觉断言 拿到占位图,不报错 这段留在 Chrome 上
依赖真实布局的定位(按位置、重叠判断) 位置是模拟值 改成 DOM 语义定位
PDF 生成 不支持 留在 Chrome 上
文件上传 不支持 留在 Chrome 上
多标签页流程 一条 CDP 连接一个页面 拆成多次运行,或改用 goto_url
依赖冷门 Web API 的页面 取决于该 API 有没有实现 报错就回退完整浏览器

看这张表的方式是:能过一遍 DOM 语义完成的部分交出去,剩下需要像素的部分留给自己。 这也是混合部署的划分线。

八、PandaScript:被低估的那一环

这个设计我认为在 agent 工具里不常见,值得单独讲。

流程是这样的:你用自然语言让 agent 把一件事跑通,然后 /save 把它导出成一个普通的 JavaScript 重放脚本。之后:

lightpanda run script.js    # 没有 LLM 调用,不需要 API key

它做的事是把 agent 的探索成本摊薄成脚本的运行成本。第一次调用的代价是模型推理加试错,第二次开始是纯执行。

在 agent 流水线里这是一种少见的「确定性降级」:一个任务一旦被探索明白,就可以从「每次都要模型决策」降级成「一段可重放、可版本管理、可 CI 的代码」。稳定性、成本和可审计性三样一起改善。

官方也给了对应的警告:.js 脚本里可以包含 evaluate(...) 调用,那是在页面里执行任意 JavaScript,只跑你信任的脚本,标准跟对待 shell 脚本一样。

九、三个会咬人的细节

1. 一条 CDP 连接只持有一个页面。 第一次 new_tab(url) 正常,第二次会失败并报 TargetAlreadyLoaded。这不只是个 bug,它的会话模型没有跟上「agent 会开很多标签页」这个现实。用的时候要按「一个连接一个页面」来组织,或者用 goto_url 复用当前页。

2. AGPL-3.0 是架构决策,不是法务细节。 一个以「替换无头 Chrome」为定位的浏览器选了这个许可,直接决定它能不能被嵌进闭源产品。如果那件事是真的,AGPL-3.0 会关掉这条路径,把采用方式推向「自托管」和「用它的云服务」两端。评估它的时候,这一条应该和内存数字放在同一张表上。

3. robots.txt 要自己开。 官方给了 --obey-robots 这个参数,同时提了一句「尊重 robots.txt,避免对小站点高频请求」。要注意的是它默认不开,也就是默认行为不遵守 robots.txt。把抓取跑在别人的站点上时,这是一个需要主动处理的合规点,fetch 和 serve 都接受这个参数。

4. 真实部署形态通常是混合的。 Hermes Agent 的官方文档里写着 browser.engine: lightpanda,配的是自动 Chrome 兜底:navigate、snapshot、click、type、scroll、back、press、eval 走 Lightpanda,而截图、browser_vision、PDF 生成、文件上传,以及任何在 Lightpanda 上报错的操作,自动重试到 Chrome。这等于官方承认了那个边界的存在,并给出了工程上的处理方式。

写在最后

我的判断是 Lightpanda 的价值不在「更便宜的无头浏览器」这个定位上,而在于它把无头浏览器的成本结构归因清楚了。过去几年大家默认无头 Chrome 重是必然代价,它指出核心依赖是交互协议定义在像素上,于是渲染成了不可删项。删掉那个依赖,渲染就可以删。

代价同样清楚:没有真实布局,截图是占位图,而失败方式是返回替代品而不是报错。用它的正确姿势是按能力分层,需要像素的活儿交给完整浏览器。

最后交代一下本文的证据边界:以上全部来自 Lightpanda 的官方文档、仓库结构和源码目录,我没有实机跑过。要动手验证,最小的一组是三条命令:lightpanda fetch --dump html <url> 看它取回的是不是最终 DOM;lightpanda serve 之后用 Playwright 连上去,试一次 captureScreenshot 看返回的占位图长什么样;以及开第二个 new_tab 复现 TargetAlreadyLoaded。

相关阅读:

By AI博士 万戈