一个用 Zig 从零写的浏览器,没有 Chromium 分支,没有 WebKit 补丁,官方口径是比无头 Chrome 省 16 倍内存、快 9 倍。仓库 36.9k star,AGPL-3.0。
Lightpanda 的背景故事很直接:创始团队做了多年大规模网页抓取,撞上无头 Chrome 的成本和稳定性上限。实例开几百上千个,内存和 CPU 都吃不住,还得频繁重启才能稳住。
但如果你只把它读成「更省资源的无头 Chrome」,会漏掉它真正动刀的位置。这篇拆的是那一刀。
一、贵在哪:被像素定义的交互协议
这是我认为整篇里最重要的一段,因为它把因果顺序说反了就会理解错这个项目。
常见说法是「因为不渲染所以快」。真实顺序是反的:因为先把交互从像素上解耦了,才敢不渲染。
Lightpanda 官方文档自己点出了 CDP 的结构性缺陷。CDP 能在 DOM 上直接查询和修改,但它没有 DOM 级的用户交互命令。要点击一个元素,客户端必须:
- 查出这个元素的渲染像素坐标
- 把虚拟鼠标移到那个坐标
- 派发一个点击事件
三次往返,外加一个渲染依赖,去做一件概念上只有一个动作的事。文档的原话是,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。
它叠了三层:
- 浏览器:加载页面、执行 JavaScript、处理 DOM,和 CDP server 用的是同一个引擎
- 运行时:一组原生工具,
goto、click、fill、extract、evaluate、search、detectForms、selectOption、waitForState等,每个都有对应的斜杠命令 - LLM:把你的自然语言请求翻译成工具调用,这一层是可选的
第三层可选这点很有意思。--no-llm 之后它退化成只有斜杠命令的 REPL,你仍然可以手工驱动浏览器。也就是说 LLM 在这里是「把自然语言编译成工具调用」的便利层,不是必需品。
provider 的解析顺序是四级,值得记一下因为它决定你第一次跑会不会卡住:
- 记住的:上次用
/provider选的,按目录持久化在.lp-agent.zon里,前提是它的 key 还在 - 自动探测:环境变量里第一个找到的 key,按固定优先级顺序
- 本地:没有云端 key 时,探测本机在跑的 Ollama 或 llama.cpp。本地服务不会被自动探测到,必须显式指定
--provider ollama或--provider llama_cpp - 没有:
--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。
相关阅读:
- 《Neko 源码级拆解:把浏览器变成一条视频流》:姊妹篇。这篇讲的是把浏览器压到最轻、砍掉一切与人有关的部分;那篇讲的是反过来,把浏览器整个变成一条给人看的流。
- 《browser-use 114k⭐ 称王!浏览器 Agent 三强争霸》:把这篇放进那个坐标系里看,Lightpanda 属于「驱动层」,和三强不是同一层的东西。
- 《OpenCode 被「蒸馏攻击」打穿》:同样涉及浏览器与凭据,那篇的教训是凭据放在哪比工具选哪个更值钱。