每隔几个月,总会冒出一个更快的 HTML 解析器,基准测试被反复转发,然后就有人宣布老牌工具已经过时。可真到了要选出所有包含某个词的段落,或者要拿到某个匹配节点的父节点时,你就会想起为什么 lxml 还开着在另一个标签页里。
lxml 是一个已经有 20 年历史的 libxml2 绑定库。它不花哨,也不新鲜。但对某一类任务来说——任何真正需要 XPath 的场景——主流 Python 生态里几乎没有别的工具能正面竞争。这篇实测评测会讲清楚它能做什么、它在哪些地方低调地赢了,以及哪些默认行为如果不了解,可能会踩坑。
一句话看懂 lxml:它到底是什么
lxml 是 Python 对 C 语言库 libxml2 和 libxslt 的绑定。它是解析器,也是序列化器,但不是爬虫,也不是浏览器——它能把标记语言转换成可查询、可编辑的树,再把这棵树重新输出成字节流。它提供兼容 ElementTree 的 API、完整的 XPath 1.0 引擎、XSLT 1.0 以及模式校验功能,由 Stefan Behnel 维护,官方定位是“Python 语言中功能最丰富、最好用的 XML 和 HTML 处理库”。
以下是截至 2026-07-14 从 GitHub 和 PyPI 快照整理的数据:
| 字段 | 数值 |
|---|---|
| 仓库 | lxml/lxml |
| Stars | 3,043 |
| Forks | 620 |
| Open issues | 16 |
| License | BSD-3-Clause |
| 创建时间 | 2011-02-11 |
| 最近一次推送 | 2026-07-02 |
| PyPI 稳定版 | 6.1.1 (2026-05-18) |
| 内置引擎 | libxml2 2.14.6 + libxslt 1.1.43 |
在开始之前先说明一点,免得有人说我在吹:这篇评测里没有什么“内部秘技”。lxml 历史足够久,文中提到的每个行为都能在 lxml 文档、libxml2 变更日志或者某个 Launchpad 讨论串里找到依据。我没有发现任何未公开的隐藏技巧,也不会凭空编一个出来。下面内容的价值在于:我把这些能力系统化、量化,并围绕 lxml 本身来组织,而不是简单地复述“它很强”。
测试设置(以及为什么有些耗时数据是借来的)
这篇评测的数据来自两类来源,而且来自两个不同地方,所以我先把它们说明白。
能力测试——也就是 XPath 行为、两种解析 API、命名空间、编码、节点生命周期——我是在一台机器上重新跑的:macOS arm64、Python 3.14.2、lxml 6.1.1、libxml2 2.14.6。那些 artifacts/raw/*.json 文件里的每个数值,都是脚本运行后算出来的,不是手工写进去的。能力测试是确定性的布尔值和枚举值,所以单次运行就足够稳定——机器负载不会影响 //a/@href 会不会返回属性字符串。
**耗时和内存占用数据不是这次新测的。**它们原封不动沿用了之前那套 selectolax 基准包里的结果——同一台机器、同一个虚拟环境、同一版 lxml 和 libxml2、基准截至 2026-07-13——这次我没有重新跑。这样做是有意为之。因为如果一边跑时序基准,一边跑一批能力脚本,很容易产生 CPU 争用,把复用的数据污染掉;而且那也是重复劳动:在那套基准里,lxml 本来就是已经完整测量过的对照库。复用同一批基准,能保证前后可比,而不会引入第二套细微不同的测量口径。所以你在下面看到毫秒级数值时,请理解为“同一测试环境、截至 2026-07-13”,而不是“我今天重新计时了”。
每条结论都会带一个置信标签:single-observation 表示确定性的能力测试,triple-run 表示复用的时序分布,hypothesis 则表示我提出了一个机制,但没有单独隔离验证。
XPath:selectolax 和 BeautifulSoup 真的没有的那一项
这是全文的重点,所以先说它。

我把 lxml 的 xpath() 放进了一个提前注册好的 37 项矩阵里测试——也就是说,每个用例的预期结果都在测试前写进了源码,所以我不可能在评分时“凭感觉加分”。测试覆盖了 10 种轴、9 种谓词形式、10 个内置函数、3 种标量返回类型,以及 5 个故意设置的陷阱用例,这些用例都使用 XPath 2.0 专有语法,而 lxml 的 1.0 引擎理应拒绝它们。
| 类别 | 覆盖项 | 结果 |
|---|---|---|
| 轴 | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 通过 |
| 谓词 | [1] / last() / position()<n / 属性相等 / 属性存在 / and / or / 嵌套 [.//a] / not() | 9/9 通过 |
| 函数 | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 通过 |
| 返回类型 | 布尔值 / 数值标量 | 3/3 通过 |
| 陷阱用例 | matches() / 序列 / if-then-else / except / 语法错误 | 5/5 正确拒绝 |
最终成绩是 37/37,而真正重要的是“陷阱”这一列。matches()、序列表达式、if/then/else 和 except 都是 XPath 2.0 语法,libxml2 的 1.0 引擎不会“支持一半”,而是直接抛出 XPathEvalError 拒绝执行,不会悄悄返回错误的节点集。所以这是一个先尝试破坏后仍然满分的结果,不是靠简单题堆出来的满分。这里的表现和 lxml XPath 文档描述完全一致,而这正是重点。
不过我也承认,测试框架自己有一处判断错了,而这恰恰是你可以放心相信的 37/37。//div[.//a[@href]] 这个用例,我最初预期会命中两个元素;实际运行只返回一个。我当时一度以为是 lxml 错了,大概 30 秒后回头检查 fixture,发现第二个元素其实是 <footer>,不是 <div>——错的是我的预期,不是引擎。我修正了预期集合,并把这个失误留在了源码注释里。责任归属就应该这样排:先怀疑自己的测试,再怀疑这套用了 20 年的 C 库。
XPath vs CSS:CSS 里根本表达不出来的东西
“XPath 更强”这句话太抽象,所以我把差距量化了。lxml 同时提供 .xpath() 和 .cssselect()(后者底层会把 CSS 翻译成 XPath)。我选了 10 个选择目标,检查 CSS 到底能不能表达。

| 目标 | XPath | CSS(cssselect) |
|---|---|---|
按文本内容筛选(contains(text(),"bargain")) | 可以 | 没有文本谓词 |
从子节点反向选父节点(//b/parent::p) | 可以 | 没有父选择器 |
返回属性值(//a/@href) | 可以 | 只能返回元素 |
返回文本节点(//p/text()) | 可以 | 没有文本节点 |
ancestor 轴(//td/ancestor::div) | 可以 | 不能向上遍历 |
按子元素数量筛选父节点(//ul[count(li)=4]) | 可以 | 没有 count 谓词 |
按文本长度筛选(string-length(text())>5) | 可以 | 没有长度谓词 |
nth-child / last-child / 相邻兄弟 | 可以 | 可以(3 个基础能力) |
10 个目标里,有 7 个根本没有 CSS 等价写法。按文本内容筛选、向上找父节点或祖先节点、把属性值或纯文本节点当结果直接返回、基于数量的谓词筛选——CSS 都做不到。只有 3 个(nth-child、last-child、相邻兄弟)两边都能处理。这就是“我为什么要用 lxml”这个问题的量化答案。selectolax 只支持 CSS,没有 xpath() 方法,所以这 7 类查询在那边要么得写成多步 Python 循环,要么根本做不到。如果你的抓取逻辑依赖这些能力,那选择其实已经很明确了。
(而且是的,测试框架这次又抓到我一次:我原本把 string-length(text())>5 预期成空集合,但实际上有两个 6 个字符的字符串匹配。修正的是预期,不是工具。)
三档严格模式:etree、recover、lxml.html
XPath 是你选择 lxml 的原因,而这三档严格模式,才是你继续留在 lxml 的原因。

大多数解析器对损坏输入只有一种处理方式,而 lxml 给你三种,而且足够可预测——我把 6 类畸形标记分别送进三种模式,并提前写好了每条路径应该如何表现。
| 畸形输入 | lxml.etree(严格) | etree + recover=True | lxml.html(宽松) |
|---|---|---|---|
未闭合标签 <root><a>x</root> | 抛错 | 自动修复 | 接受 |
错位嵌套 <b><i></b></i> | 抛错 | 自动修复 | 接受 |
未定义实体 | 抛错 | 自动修复 | 接受 |
裸 &(Tom & Jerry) | 抛错 | 自动修复 | 接受 |
多根节点 <a>1</a><b>2</b> | 抛错 | 自动修复 | 接受 |
| 结构正确的 XML | 接受 | 接受(0 个错误) | 接受 |
布尔属性 <input disabled> | 抛错 | 自动修复 | 接受 |
7 项里有 7 项都和预期一致。lxml.etree 会对这 6 类畸形输入全部抛出 XMLSyntaxError。同样的解析器加上 recover=True 之后,会吞掉错误并重建出可用树——这里最被低估的一点是,parser.error_log 会把它吞掉的每个错误都列出来。lxml.html 则会什么都接受,不抱怨。
这里“抛错 / 自动修复 / 接受”这个分类器,本身是根据运行时 error_log 的长度来判断的,而不是硬编码的。这就是为什么在 recover=True 下,结构正确的文档会被正确标成“接受”(日志为空),而不是“自动修复”。我的第一个版本只要 recover=True 就标成“自动修复”,结果把干净输入误判了;改成读取真实的 error_log 之后就正常了。
这在实际工作里意味着什么:如果你需要对损坏的数据源严格校验、出错就立刻失败,用 lxml.etree。如果面对的是脏兮兮的真实网页,只要能读出来就行,用 lxml.html。而很多工具做不到的中间态——“允许不完整,但把到底坏在哪告诉我,方便记录日志”——就用 recover=True,然后读错误日志。selectolax 只有宽松模式,没有严格模式,也没有错误日志。
iterparse:selectolax 完全没有的流式能力
这不是速度开关,这是能力边界。selectolax 只能一次性吃进完整字符串,没有增量接口。lxml 的 iterparse 会在元素闭合时逐个吐出节点,配合经典的 fast_iter 模式(边遍历边调用 elem.clear(),并删除前面的兄弟节点)之后,不管文档多大,内存都能保持平稳。

我直接测了内存特征——通过 ru_maxrss 看峰值 RSS,每个对象都在独立的新进程里跑,对象规模是 300,000 个 <record> 元素,总大小约 26.7 MB(26,744,801 字节)。
| 模式 | 峰值 RSS 增量 | 备注 |
|---|---|---|
iterparse + clear(fast_iter) | 约 1-2 MB | 边处理边释放,与数量无关,基本持平 |
iterparse 不 clear | 约 386 MB | 保留引用,和全量加载一样重 |
etree.parse(全量加载,对照) | 约 386 MB | 已知是高占用,说明内存计量器确实能看到量级差异 |
这个有界模式把峰值 RSS 增量控制在大约 1-2 MB,而全量加载约 386 MB,差了两个数量级以上,约为 0.3-0.4%。而且第一个 record 事件甚至在文件还没读完时就触发了,所以这是真正的增量流式处理,不是假装流式。最有教育意义的是中间那一行:用同一段 iterparse 循环,但把 clear() 去掉,内存又会涨回到约 386 MB,因为你把所有引用都留住了。真正的收益来自 clear(),不是 iterparse 本身。全量加载的对照值明显更高,也证明 RSS 计量器确实能识别这个数量级差异,而不是盲读。(这个内存测试是我在这次基准包里重新跑的——它属于占用测量,和前面借来的耗时数据不同。)
现实世界里,真正的含义是:如果你有一个几十 GB 的 XML 导出文件,内存根本放不下,那 selectolax 没有任何流式路径可走。要么用 lxml 的流式解析,要么换一种语言。
命名空间:RSS、SVG,以及默认命名空间陷阱
我测了 12 个命名空间案例,覆盖了 RSS 的三个命名空间、带默认命名空间和 xlink 的 SVG,以及默认命名空间 XML。12 个全都通过。
lxml 可以从 RSS 源里按 //dc:creator/text() 精确拿到 ["Alice", "Bob"];同一份文档里也能跨三个不同命名空间解析 //atom:link/@href 和 //content:encoded;在 SVG 的第二命名空间里处理 //s:rect 和 //s:use/@xlink:href 也没问题;还能用 QName 拆分 Clark 记法 {uri}local 的名字,并通过 nsmap 做自省。这些都是官方维护且有文档的行为,而这正是 selectolax 完全碰不到的维度,因为 selectolax 只支持 HTML5,不处理任意 XML 命名空间。
不过这里有一个必须记住的文档化陷阱。XPath 根本没有“默认命名空间”这个概念。把 //book 用在声明了 xmlns="urn:..." 的文档上,结果会是 0 命中——XPath 里空前缀是未定义的,lxml 文档也明确说明了这一点。你必须绑定一个人为前缀(比如 //c:book,并传入 namespaces={"c": "urn:..."},这样能找到全部 3 个),或者退回到 //*[local-name()='book'](同样 3 个)。这不是 bug,而是 XPath 规范的忠实实现。只是它会让每个人都恍然大悟一次。
真实脏网页:11 个实际抓取样本的还原度
合成测试都很干净,但网页不是。于是我复用了 selectolax 基准包里 11 个真实抓取页样本(截至 2026-07-10,只读),并以 lxml 为主体重新跑了一遍 lxml.html。
| 样本 | 大小 | 链接数 | libxml2 恢复的错误数 | 严格 XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | raised |
| wiki_scraping.html | 227 KB | 460 | 0 | raised |
| gov_whitehouse.html | 289 KB | 154 | 0 | raised |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | raised |
| forum_reddit.html | 129 KB | 318 | 0 | raised |
| docs_python.html | 80 KB | 341 | 2 | raised |
| ecommerce_books.html | 51 KB | 94 | 0 | raised |
| news_hackernews.html | 35 KB | 229 | 0 | raised |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | raised |
| spa_quotes_js.html | 6 KB | 5 | 0 | raised |
这 11 个样本都能被 lxml.html 解析成功,而且链接数、标题数、图片数都和 selectolax 基准包里复用的 lxml 统计完全一致——交叉校验结果是 true。正是这种一致性告诉我,这次复用是名副其实的“可比对照”,而不是两套不同测量结果碰巧用了同一个标签。
附带发现是:严格 XML 解析器对这 11 个页面里的 10 个都抛错了。真实网页几乎都不是格式良好的 XML,这也正是 libxml2 HTML 恢复模式存在的原因——它就是用来吞这些网页的。唯一的例外是 BBC News,它由 Next.js 渲染,结构足够规整,连严格 XML 解析都能通过。并不是所有标着“HTML”的东西都必须走恢复路径。
还有一个很容易混淆的计数点。在 docs_python.html 上,//a[@href](属性存在)统计为 343,而 selectolax 基准包里的 if n.get("href")(值为真)统计为 341。多出来的两个是空的 href="" 链接。这是计数口径差异——属性是否存在 vs 属性是否非空——不是 lxml 行为差异;只要把谓词对齐,数量就能重新对应上。做抓取时这点很值得注意:空 href 算不算,取决于你的过滤条件,不取决于解析器。
看起来像 bug、其实不是的深度上限
selectolax 基准包曾记录到 lxml 在 1,000 层和 5,000 层嵌套 <div> 标记时会丢掉最深层内容,并把它描述成“lxml 静默丢失了最深内容”。我想搞清楚机制,于是把默认解析器和 huge_tree=True 做了对比。

| 请求深度 | 默认解析器实际到达 | huge_tree=True 实际到达 |
|---|---|---|
| 300 | 253(后续丢弃) | 299(恢复) |
| 1000 | 253(后续丢弃) | 999(恢复) |
| 5000 | 253(后续丢弃) | 2045(仍然丢弃) |
默认解析器会在大约 253 层时截断,并把更深层的内容悄悄丢掉。这不是 bug,而是 libxml2 的 DoS 防御机制——一个大约 256 层的嵌套上限,用来防止恶意文档把栈撑爆;这一点在 lxml 的 Launchpad 讨论里也有说明。设置 huge_tree=True 之后,300 层和 1,000 层都能完整恢复。但 5,000 层即便开了 huge_tree 也只能到 2,045 层——说明 libxml2 还有第二道更硬的递归上限,而 huge_tree 并不能解除它。
所以操作建议很明确:如果你要解析来自可信来源的深层标记,使用 lxml.html.HTMLParser(huge_tree=True)。这次基准包在复用观测之上补充了机制解释(这是安全上限,不是数据损坏)、修复方式(huge_tree),以及这个修复依然够不到的第二层上限。
DOM 读写、序列化、编码
lxml 是完整的读写树,不是只读提取器,我把编辑接口逐项测了一遍。8 个 DOM 操作全部通过:SubElement、insert、remove、replace、strip_tags(去标签保留文本)、strip_elements(标签和文本都去掉)、drop_tree(lxml.html 独有),以及让新手最容易混淆的 text/tail 双槽模型——在 <p>head<b>bold</b>tail</p> 里,p.text 是 "head",b.text 是 "bold",b.tail 是 "tail"。
序列化 5 项全通过:XML 和 HTML 模式下的 tostring(HTML 会正确保留 void 元素而不自闭合)、pretty_print、C14N 规范化(method="c14n",这是另一个 lxml 独占特性)以及一次干净的往返转换。
编码处理则是 lxml 很安静但很重要的优势之一。给它非 UTF-8 字节,比如把 "<p>café éè</p>".encode("latin-1") 交给 lxml.html.fromstring,它能完整还原出 café éè,不会出现 U+FFFD 替换字符,也不会丢字节。这直接复现了它在 selectolax 基准包里的“清洁参考”角色;在那里,相同输入在另外两个引擎里都会静默损坏(Lexbor 会生成替换字符,Modest 则直接丢字节)。在字符集识别这件事上,lxml 依赖 libxml2,表现就是更稳。
但反过来,如果你声明了不规范的编码,它也会很严格。XML 声明里写 encoding="latin-1" 会抛出 XMLSyntaxError: Unsupported encoding: latin-1;而 IANA 标准写法 encoding="ISO-8859-1" 则可以正常解析并返回 café。libxml2 只接受标准编码名,不接受别名——这点在很早的 launchpad #613302 里就说明过了。不知道时会很烦,知道后其实很简单。
最后是节点生命周期。我在隔离子进程里跑了 3 个“悬空句柄”场景(如果真崩溃,进程退出码会非零):在树对象被垃圾回收后继续持有节点、在 drop_tree() 之后继续读句柄、以及在 remove() 之后继续使用节点。三种情况都没有段错误——lxml 会保留节点对树的引用,避免 use-after-free。同样,这个测试里 selectolax 也拿到了干净的结果。
速度和内存(复用数据,但如实说明)
这一节的所有数据都复用了 selectolax 基准包的结果,截至 2026-07-13。本包没有产出任何新的时序数值,我宁可把这句话说两遍,也不想让你误以为我重新计时了什么。
| 维度 | lxml 数值 | 解读 |
|---|---|---|
| 纯解析 p50(10 MB) | 77.9 ms | 比 selectolax-Lexbor 快约 33-34% |
| 全量解析 + 提取 p50(1 MB / 10 MB) | 14.18 ms / 172.9 ms | 小数据规模下与 Lexbor 基本持平 |
| 10 万节点 CSS 吞吐量 | 3,002,646 节点/秒 | 三个 C 语言引擎里最快档 |
| 10 MB RSS 增量 | 128.9 MB | 六个解析器里最省内存,约比 BeautifulSoup 轻 1.7 倍 |
| 冷启动导入时间 | 14.1 ms | 比 parsel 风格导入快约 2.3 倍 |
纯解析和吞吐量的数据表现很强,lxml 也是这 6 个解析器里最省内存的。不过线程表现要加一个说明。复用数据里,4 线程的墙钟速度提升只有 1.21 倍,被标记为结论不充分——但那是“共享默认解析器”路径的结果。lxml FAQ 明确说明:只有当每个线程使用自己的解析器(或一个复制出来的默认解析器)时,解析过程才会释放 GIL;共享同一个解析器会串行化访问。我已经从结构上验证了正确用法的 API 是否存在(XMLParser.copy()、get/set_default_parser、带内部锁的 XPathEvaluator 都在),但没有测每线程独立解析器的加速比——那会是一组新的时序测量,而这次基准包不产出这类数据。所以这里的 “1.21x” 请理解为“在天真的共享路径下”,不要把它当成 lxml 的线程上限。
这组数字也有一个星号:它们都是单平台数据,macOS arm64。lxml 的纯解析速度快过 Lexbor,这一点和通常“Lexbor 后端才是最快”的共识是有冲突的,所以在任何人把它当定论之前,确实还需要在 Linux x86_64 上复核一次。
许可证:一个低调但很实用的胜利
lxml 采用 BSD-3-Clause 许可,它内置的 C 库——libxml2 和 libxslt——都采用 MIT 许可证。这是一条完全宽松、没有任何 copyleft 的链条,一旦你要再分发产品,这点就很重要。相比之下,selectolax 的 wheel 会捆绑 LGPL-2.1 的 Modest 和 Apache-2.0 的 Lexbor,所以如果你要把它们放进闭源产品里,lxml 的故事更省心。
安装层面也有实际好处:lxml 发布了预编译 wheel,并且静态链接了 libxml2 和 libxslt,所以通常你 pip install lxml 时,不需要系统里先装 libxml2,也不需要编译器——这和从源码构建的体验完全不同。
lxml 适合哪里,以及 AI 提取层会接管哪里
这里要把边界说清楚,因为很容易概念混淆。lxml 是解析库。它给你一棵树和一套非常强的查询引擎,而围绕这棵树的其他事情仍然要你自己负责:抓页面、渲染 JavaScript、绕过反爬、写并维护 XPath,以及整理结果。这和托管式提取服务属于不同层级,两者更像邻居,而不是竞争对手。
对于那些不想自己维护“抓取-渲染-选择器-维护”整条链路的开发者来说,上层就轮到像 Thunderbit 这样的工具发挥作用了——而对这类用户来说,真正有用的是 API、MCP 服务器和 CLI,不是浏览器插件。Thunderbit Open API 提供 POST /distill,可以把页面提炼成干净的 Markdown,也提供 POST /extract,能按照 JSON Schema 提取结构化数据,还带有 renderMode 开关和批量任务来处理大规模需求。同一套引擎也能作为 MCP 服务器(thunderbit_suggest_fields、thunderbit_distill、thunderbit_extract)供 agent 和代码助手使用,也可以通过 npx @thunderbit/thunderbit-cli 直接在终端运行。它内置处理 JS 渲染、反爬和 CAPTCHA,并返回和 schema 匹配的 JSON——这已经是解析之上的一层,而不是替代解析本身。
理解方式很简单:如果你掌控整条流水线,并且希望对已理解的树结构进行精确的 XPath 控制,那就选 lxml;如果你根本不想维护选择器和渲染,那就选 AI 提取 API。现实里很多系统会两者并用——lxml 处理你自己可控的结构化数据源,提取服务处理那些难搞、长尾、你不想亲自维护的页面。
这篇评测没有测试什么
这是一篇阶段性评测,不是最终总分表,所以也要说清楚它没覆盖什么。
所有时序和内存数据都来自复用结果,且都只是在单一平台(macOS arm64,Python 3.14)上得到的,并继承了那套基准的限制——“lxml 在纯解析上更快”这个结论和主流共识相反,必须在 Linux x86_64 上重新验证。每线程解析器的线程加速比没有测,因为那需要新的时序数据。iterparse 的内存测试只测了 30 万条记录,没有测 GB 级真实 XML,也没有测 HTML 与 XML 的 iterparse 对比,更没有长时间 soak 测试。lxml 的 XSLT 1.0、RelaxNG / XMLSchema / DTD 校验以及 EXSLT 扩展都没有在这里测试——这些能力面很大,但已经超出解析与选择的核心范围。我观察到了 2,045 层的第二道深度上限,但没有进一步精确定位 libxml2 的递归常量。这里测的是稳定版 6.1.1,不是 7.0.0 alpha。Windows、源码构建以及 free-threaded 3.14t 版本也都没有测试。再往 XPath 里看,我覆盖了内置函数,但没有覆盖 XPath 变量、自定义 Python 扩展函数,或者预编译的 etree.XPath 对象复用。
结论
lxml 不是最新最快的那个工具,而这恰恰就是它的价值。它是一个有二十年历史的 libxml2 绑定,带着完整的 XPath 1.0 引擎——主流 Python 生态里没有别的工具能完全替代——还有三档可预测的解析严格度,中间那档带错误日志;它能真正流式处理放不进内存的文档;它对多命名空间和编码的处理都很稳;而且许可证完全宽松。少数几个尖锐边界——大约 253 层的深度上限,以及共享解析器时的线程表现——都已经有文档、可配置,而且现在也解释清楚了。
如果你自己掌控抓取流水线,并且重度依赖 XPath,那 lxml 依然是你该优先拿起的解析器。如果你不想自己维护选择器和渲染,那就交给 Thunderbit API、MCP 和 CLI 这样的 AI 提取层——这是职责分工,不是竞争。无论你站在哪边,都请把这些数据当作暂定结果,在你自己的平台上复测后再写进设计文档。
试试 Thunderbit 做网页数据提取 Get Started Free
常见问题
lxml 是网页爬虫吗?
不是。lxml 是解析器和序列化器——它是一个对 libxml2/libxslt 的 Python 绑定,用来把标记语言转换成可编辑、可查询的树。它不会抓页面、不会渲染 JavaScript,也不会处理反爬;这些都要由你提供请求层(比如 requests、httpx、无头浏览器或抓取服务),再把字节交给 lxml。
什么时候该用 lxml,而不是 BeautifulSoup 或 selectolax? lxml 适合你需要 XPath 的场景。BeautifulSoup 虽然可以把 lxml 当作后端解析器,但它本身不提供原生 XPath;selectolax 只支持 CSS,而且在它擅长的窄领域里速度更快。如果你的选择逻辑需要按文本内容过滤、向父节点或祖先节点遍历、提取属性或文本节点,或者基于数量做谓词筛选,lxml 的 XPath 引擎就是主流 Python 里唯一能直接表达这些需求的方案。
为什么 lxml 会悄悄丢掉特别深的嵌套内容?
因为默认解析器把嵌套深度限制在大约 253 层——这是 libxml2 为防御恶意文档而设置的 DoS 保护,不是 bug。把 huge_tree=True 打开(例如 lxml.html.HTMLParser(huge_tree=True))后,它可以完整恢复 300 层和 1,000 层的深度。但要注意还有一道更硬的递归上限,大约在 2,045 层,huge_tree 也绕不过去。
lxml 在多线程解析时会释放 GIL 吗? 只有在正确条件下才会。lxml FAQ 说明,只有当每个线程使用自己的解析器,或者使用一个复制出来的默认解析器时,解析过程才会释放 GIL;如果多个线程共享同一个解析器,访问会被串行化。这里复用数据里 4 线程的 1.21 倍加速,只反映了“共享解析器”的朴素路径,不代表每线程解析器的上限,那部分这次没有测。
到 2026 年,lxml 还在维护吗? 是的。稳定版 6.1.1 已于 2026-05-18 发布,仓库最近一次推送是 2026-07-02,而且 7.0.0 alpha 也在推进中。GitHub 上大约有 3,000 个 stars,底层 libxml2 也仍在积极维护,所以它依然是一个活跃、受支持的当前库,而不是“遗产项目”。


