
1. 起点被Chrome多开搞崩的内存才让我开始找替代方案1.1 场景一下开20个标签机器直接卡到鼠标都挪不动我最近的工作流里依赖一个很反直觉的组合一边是Chrome相关网页自动化工具一边则是轻量Agent任务调度。之前用Chrome做网页自动化时光开20个标签页就能让32G内存的机器卡成PPT切成Agent方案后同一批任务跑完内存峰值从2.8G降到了不到400M。这个差距让我决定把整个数据采集和表单提交流程全部重构了一遍。事情的起因其实很朴素。上个月接了一批电商比价的数据采集任务需要同时抓取二十多个商品页面的价格、库存和评价数。最初我想得特别简单用Chrome开二十个标签页每个页面跑一段脚本等它们自己加载完再统一提取数据。结果试了一轮机器直接卡到鼠标都挪不动任务管理器里Chrome的进程列表拉了整整两屏。更要命的是这种卡顿不是偶发现象只要标签页数量超过十五个系统就会进入假死状态连强制结束进程都要等好几分钟。当时我并没有意识到问题出在哪只觉得是电脑配置不够。直到我把同样的任务换成了Agent方案——用一个浏览器上下文实例串行加载这二十个页面内存占用直接从2.8G掉到了不到400M。这个结果让我开始认真思考一个此前一直被忽略的问题Chrome的网页自动化到底把内存花在了哪里1.2 几十个WebDriver进程同时跑内存是怎么被吃光的要理解Agent方案为什么省内存得先弄明白传统Chrome自动化的开销在哪。Chrome从很早就采用了多进程架构每一个标签页对应一个独立的渲染进程外加网络进程、GPU进程、扩展进程等一堆辅助进程。这样做的好处是单页面崩溃不会拖垮整个浏览器隔离性和稳定性很好但代价就是内存开销成倍上涨。更可怕的是当时我为了提高采集吞吐量不止开标签页还用了WebDriver方式的浏览器实例。也就是说二十个标签对应的不是同一个浏览器里的二十个标签而是二十个完全独立的浏览器进程。每个进程都要加载完整的内核框架、JavaScript引擎、渲染管线、缓存数据光进程底座的占用就是几百MB起步。二十个进程累加起来内存自然直奔3G以上这还不包括页面本身的内容。这种模式下每个页面之间没有任何共享重复的资源被一次又一次加载。网页里的公共JS库、公共样式、公共图片缓存理论上只需要一份的东西被硬生生复制了二十遍。我在任务管理器里实测过同样的一个页面单独用无头模式跑基础内存大概是180MB左右但只要浏览器进程数量到了两位数整个系统的内存就开始指数级失控——因为每个新进程不只是新增一份页面内存还要新增一套进程运行环境。2. Agent方案为什么能在同一件事上省下85%内存2.1 核心思路不渲染不需要的东西Agent方案省内存的第一个关键是把做任务和完整渲染网页这两件事解耦了。传统Chrome自动化本质上是让浏览器把页面当成人类用户正在看的样子去加载图片要解码、视频要预加载、广告要渲染、动画要播放、懒加载的图片也要按滚动位置慢慢去触发。这些东西对采集任务来说绝大多数是纯粹的内存浪费。而我使用的Agent方案底层控制的是同一个浏览器引擎实例但在任务执行层面做了大量减法。它通过直接调用浏览器调试协议接管页面生命周期不启动额外的可视化窗口不去解码非必要的图片资源也不等待那些对任务没有意义的渲染帧。任务脚本的核心逻辑是定位元素、提取文本、点击按钮、提交表单而不是把页面完整地画出来给谁看。可以这么理解Chrome的完整渲染像是在电影院看一场2小时电影每一帧画面都要呈现而Agent方案做的是把电影字幕、台词、关键剧情节点抽取成一份摘要你要的是信息不是画面。信息拿到了画面自然不用渲染。省内存不是靠压缩或者黑科技而是靠主动砍掉任务中用不到的能力。2.2 复用浏览器上下文把整活从进程级降到任务级Agent方案省内存的第二个关键是上下文复用。传统的WebDriver方案每创建一个Driver对象背后就是一个完整的浏览器实例而Agent任务队列只用了一个浏览器实例二十个页面全在这个实例里串行处理每处理完一个页面就把它关闭并清理DOM然后加载下一个。这种复用带来的内存收益是巨大的。浏览器内核代码、V8引擎、网络栈、缓存数据库这些底层设施只需要一份。二十个任务共享同一套运行底座而不是每人背上一个完整浏览器。打个比方传统方案是二十个人每人开一辆车去送货Agent方案是一辆货车依次把所有货送完。前者的车辆本身就有巨大的固定成本后者只多付了一点油耗。不仅是浏览器实例复用连接级别的复用也很关键。一个上下文实例内部会维护完整的Cookie、LocalStorage、会话数据任务与任务之间切换时不需要重新握手、重新登录、重新建立会话这部分省下来的不仅是内存还有时间。特别是那些需要登录态的采集任务传统方案里每个Driver都要走一遍登录流程多出来的内存和时间都是隐形成本。2.3 这套方案的适用边界说到这里必须泼一盆冷水Agent方案不是在所有场景下都能省85%内存。它最擅长的是批量、确定性、低交互的任务比如数据采集、表单批量提交、定时巡检、UI回归测试。这些任务的共同特征是目标明确页面结构基本不变不需要人类实时介入也不需要对页面进行复杂的视觉判断。反过来如果你的使用场景是开着网页看视频、做复杂调试、盯着页面视觉效果调样式或者需要同时对比多个页面内容那Agent方案就不太合适了。省内存的代价是放弃了完整渲染和并发可视能力这是架构决定的取舍。我后来在实际项目里也会保留一个完整Chrome窗口用于调试正式跑批任务才切到Agent模式。两条路线各有各的位置非要互相替代反而会踩坑。所以准确地说比Chrome省85%内存这个数据成立的前提是同样一批低交互的自动化任务传统方式用多个浏览器实例多开来完成Agent方案用复用上下文的队列来完成。脱离这个条件谈内存节省没有任何意义。3. 实测对比同一批采集任务两种方案的内存账单3.1 实验条件任务一样、机器一样变量只有执行方式为了不让自己凭感觉下结论我把两种方案放在同一台机器上做了三轮对比测试。实验机器是32G内存的Linux服务器CPU是8核任务内容是抓取20个不同的商品详情页提取标题、价格、库存三个字段。每轮任务完全相同唯一的区别是执行方式。方案A用的是模拟人工操作的Chrome多标签方式启动一个可见Chrome窗口依次打开20个标签页用DevTools协议脚本抓取数据。方案B用的是Agent任务队列一个浏览器上下文实例复用串行加载20个URL每页抓完即关闭。为了保证数据可对比每一轮测试前都会清空系统缓存记录任务运行全程的峰值总内存包括浏览器进程、内核缓存、页面渲染等所有相关开销同时记录总耗时。每轮测试跑三次取中位数尽量排除偶发因素。这里要说明的是普通用户使用Chrome时的内存统计口径会和后台进程有一些差异但我尽量做到两种方案在同等条件下对比目的在于看相对差距而不是追求绝对值完美。3.2 实测数据85%这个数字是怎么来的三轮测试下来数据非常稳定。方案A的内存峰值平均在2.8G左右方案B的平均峰值在390M到430M之间。按照峰值口径计算内存节省比例大概在85%上下。指标方案AChrome多标签方案BAgent任务队列差值20个页面总内存峰值2.8G410M节省约85%任务总耗时4分12秒5分36秒增加约33%单页平均耗时12.6秒16.8秒增加约4.2秒是否需要人工干预不需要不需要相同这里有个很关键的数据任务总耗时增加约33%。原因不难理解传统方式20个页面是并发加载的四个标签同时转圈总时间基本由最慢的那批页面决定Agent方案是串行加载一个页面等完才轮到下一个总时间是所有页面耗时之和。省内存的代价是多花了时间这是必然的取舍。但注意一个细节总耗时增加的幅度远没有内存节省的幅度大。在实际生产任务里如果能接受串行等待这33%的时间换85%的内存对一个长期跑的采集任务来说是相当划算的。特别是当任务规模从20个页面扩展到2000个页面时传统方案根本就不是内存预算问题而是直接把服务器搞挂了。Agent方案在这种规模下反而成了唯一能跑得起来的方案。3.3 省内存省在哪里多花的时间又花在哪里省下的主要是两部分一是进程数量二是渲染资源。传统方案20个标签意味着至少20个进程同时存在这本身就是巨大的基数Agent方案全程只有一个进程任务切换时释放的内容会回收到系统峰值的增长有限。渲染资源部分Agent方案默认不加载页面里的图片和视频这让单页内存开销大为下降。多花的时间则集中在页面加载等待上。串行执行时一个页面卡住后面的任务全部排队整体时间会明显拉长。我不建议在一开始就把并发数调得过高更稳妥的做法是先以1到2个并发跑小规模任务确认稳定性以后再慢慢提并发。并发每加1内存开销就会增加不少你需要在这两者之间找一个平衡点。4. 从零搭一个最小可用的Agent网页自动化任务队列4.1 环境准备Python Playwright 就够了如果你也想复现上面这套方案最轻量的组合是Python、Playwright和一个简单任务队列。Playwright底层通过浏览器调试协议与Chromium内核通信支持无头模式、上下文复用、元素定位和截图用来当Agent网页自动化的底座非常顺手。安装过程非常简单先装好Python 3.9以上版本然后装Playwright库再让它拉取一套浏览器内核。整个环境准备下来大约需要几分钟依赖也少。相比安全权限管理、容器隔离那一套东西这个起步门槛已经很低了。pip install playwright playwright install chromium我推荐刚开始只在无头模式下跑不要开有头窗口。无头模式下省的内存非常可观而且Agent任务本来就不依赖视觉展示。如果你后面需要调试定位规则再临时改成有头模式看效果即可。4.2 核心代码任务队列 上下文复用我把最核心的骨架代码贴在这里。它做的事情很简单持有一个浏览器实例循环处理URL列表每个URL加载后提取数据处理完就关闭页面然后进入下一个任务。import asyncio from playwright.async_api import async_playwright TASKS [ {url: https://example.com/product/1, name: 商品1}, {url: https://example.com/product/2, name: 商品2}, # ... 更多任务 ] async def extract_product(page, task): # 等待关键元素出现而不是固定写死 sleep await page.wait_for_selector(#product-title, timeout10000) title await page.text_content(#product-title) price await page.text_content(#product-price) stock await page.text_content(#product-stock) return {name: task[name], title: title, price: price, stock: stock} async def run_agent_queue(): async with async_playwright() as p: # 核心只创建一个浏览器实例所有任务共享 browser await p.chromium.launch(headlessTrue, args[--disable-gpu]) context await browser.new_context( viewport{width: 1280, height: 720}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) ) results [] for task in TASKS: page await context.new_page() try: await page.goto(task[url], wait_untildomcontentloaded, timeout20000) data await extract_product(page, task) results.append(data) except Exception as e: print(f任务失败: {task[name]}, error: {e}) finally: await page.close() # 关键处理完立即关闭页面 await asyncio.sleep(0.5) # 给GC一点时间 await browser.close() return results if __name__ __main__: data asyncio.run(run_agent_queue()) print(data)这段代码有几个细节值得注意。第一page.close()必须在每次任务后执行否则上下文里会堆积大量页面对象内存只增不减跑了一段时间以后照样会爆。第二wait_for_selector比固定sleep要靠谱得多它能在页面元素真正渲染出来时立刻返回既不会傻等也不会因为页面慢而误判失败。第三viewport设置得小一些可以节约一些渲染内存但如果是移动端页面你还是需要按真实设备尺寸来设置。4.3 跑起来之后先看这几个指标脚本跑起来以后不要只盯着输出结果一定要同时监控三个指标内存峰值、任务成功率和单页耗时。内存峰值可以用ps命令或者系统监控工具看成功率要看日志里有没有任务失败单页耗时则能帮你判断页面加载是否存在瓶颈。第一轮跑的时候建议不要加并发先把串行基线跑出来。在这个基线上观察内存数据确认和预期一致再尝试把循环改成asyncio.gather或Semaphore限流的并发模式。这种渐进式做法能避免一上来就被各种偶发问题淹没排查起来也容易很多。还有一个很容易被忽略的指标是失败重试次数。生产环境里网络抖动、页面改版、临时封禁都会导致任务失败。初期任务规模小失败了手动补跑就行任务量一大就必须在代码里加入重试机制。我的建议是每个任务最多自动重试两次两次都失败就写入异常队列最后统一看日志处理。重试次数设置过高反而会掩盖真实问题。5. 实际投入生产后踩过的坑和对应解法5.1 登录态和Cookie失效上下文复用的副作用上下文复用的代价之一是Cookie和登录态也被复用了。这听起来是好事省去了重复登录但实际生产里坑得很。我遇到过一次比较诡异的情况任务队列跑到第87个任务时突然开始集体失败而且不是超时是被目标网站强制登出所有需要登录态的页面全部跳转到登录页。排查了半天原因是某个页面在被关闭前执行了残留脚本把该站点在这个上下文里的Cookie给清了。由于所有任务共享同一个上下文这一个页面的故障直接污染了后面所有任务。解决方法有两种一是在提取任务完成后先把关键Cookie备份到内存里发现登出后立刻恢复二是改用独立上下文组比如每10个任务新建一个上下文把故障隔离在较小的范围内避免全队列被拉下水。Cookie这玩意儿一旦涉及共享就要格外小心。我后来干脆在进入队列前主动登录一次把认证Cookie序列化存成文件每次任务开始前先校验是否仍有效无效就直接从文件恢复。虽然麻烦一些但比整批任务挂掉要省心得多。5.2 JS渲染与懒加载导致拿不到数据用Agent方案跑任务时经常会出现一种看似成功、实则没抓到数据的假象。页面加载完成选择器也找到了但取回来的字段是空的。我一开始以为是选择器写错了反复调试之后才发现是页面里有懒加载逻辑——有些数据是等用户滚动到某个位置才通过异步请求加载的页面加载完成时这些元素压根还没有内容。解决思路有两个层面。第一层是模拟滚动。在提取数据前先把页面滚动到底部触发懒加载请求等数据到位后再提取await page.evaluate(window.scrollTo(0, document.body.scrollHeight)) await page.wait_for_timeout(1000)但这招不是万能的有些页面会做滚动监听滚到底不是真实用户行为会被风控识别。第二层更可靠直接拦截网络请求等关键接口的响应返回后再提取数据。我曾用这种方式解决过一个问答网站的内容抓取那个页面的回答内容全部由后端接口下发页面脚本拿到数据后渲染到DOM如果只看DOM就很容易抓到空字符串。拦截请求后我先等接口响应完成再去页面里取数据问题立刻消失。5.3 内存是省了CPU和网络反而成了瓶颈连续跑了几个通宵任务后我又发现了新的瓶颈内存占用确实稳住了但CPU和网络带宽开始报警。这个问题特别典型——省内存的本质是复用复用意味着同一时间只有一个页面在工作但这个页面加载时要解析的JS、要执行的脚本、要下载的资源一点都不会少。遇到CPU瓶颈时我第一个想法是把无头模式下的GPU和软件渲染关掉减少不必要的计算。后来发现更大的大头是页面本身的JavaScript执行开销这没法直接砍。我的做法是控制同一时刻只允许一个页面执行任务避免CPU时间片被多个半加载页面来回切换浪费。另外网络带宽这块我养成了给所有图片请求加过滤规则的习惯await context.route(**/*.{png,jpg,jpeg,gif,webp}, lambda route: route.abort())禁用图片请求后单页加载时间平均能缩短20%到30%网络开销也下来了。这里要注意的是如果目标页面里关键信息是用图片形式展示的比如验证码或者商品主图上挂了价格标签那图片就不能一概禁掉需要按例外规则放行。5.4 页面卡死时的重试策略别死等一个任务任务队列跑久了经常会遇到某个页面加载异常卡住的情况。页面加载超时是常事真正烦人的是那种既没超时也一直不触发的场景。有一次跑一个数据报表页面代码里所有等待都正常通过了但后续的任务一直不执行日志里也看不到任何报错。最后定位发现页面里有个死循环的JS脚本导致事件循环被占满页面永远停在半加载状态。解法是在每个任务的外层包一个总超时保护而不是只在单个等待操作上加时间。我给每个任务设定的总执行上限是40秒超过就直接关闭页面并在日志里标记超时然后进入下一个任务。这个总超时的设置需要参考之前跑出来的单页平均耗时取它两倍左右比较合适太大容易拖垮队列太小则误杀正常的慢页面。6. 沿着这个方向我还能往哪里扩展6.1 把任务定义从脚本变成意图现在的任务队列虽然是Agent网页自动化的形态但本质上还是脚本驱动的——每一个步骤、每一个选择器都写死了。如果目标网站改版选择器失效任务就得人工修。这个阶段再往后走就该把任务定义从脚本昇级成意图了。所谓意图是只告诉Agent我要这个商品的标题和价格然后让Agent根据页面结构自己找。比如通过可访问性树、元素标签、视觉位置这些信息智能匹配页面里的关键字段。我在一些内部实验里已经试过用这种方式替代部分固定选择器。效果是同样的任务在页面改版后仍能自动适配因为提取逻辑不再依赖某个具体的CSS类名。这个转型的工程量不小但方向很明确。如果你现在接手的是一个非常稳定、短期的采集项目我建议还是老老实实用选择器省得过度设计但如果你是要长期维护一套自动化流程提前引入意图识别能省掉后面大量维护成本。6.2 和AI能力结合让Agent自己决定点击什么扩展的第二个方向是让Agent具备自我规划能力。传统任务队列是定义好每一步然后执行AI增强后的Agent是给定一个目标自己规划出执行步骤。我在一个表单自动填写的实验里试着让大模型根据页面描述决定先点什么、再填什么、最后提交什么整体流程跑通的概率已经相当可观。不过这里要提醒一句AI规划的能力目前还做不到完全可靠尤其是在页面语义复杂、有多个相似按钮的情况下。我现在的做法是让AI只在分支决策时介入比如页面里出现A情况走方案一出现B情况走方案二其余路径依然保持确定性脚本。这个混合模式兼顾了效率和稳定性。6.3 给后来者的三条建议第一不要一开始就追求复杂的框架。先把一个简单的串行任务用无头模式跑通把数据抓下来把日志整理清楚然后再逐步加并发、加AI能力、加自动适配。地基不牢后面所有扩展都是在沙子上面盖楼。第二内存监控要从第一天就开始。不要等到任务挂了才去看指标。把内存峰值、成功率、单页耗时记录到日志里每天看一眼趋势很多坑在真正爆发前就已经有征兆了。第三对省85%内存这类结论保持清醒。它不是普适定律而是特定任务形态下的实验结果。你的任务类型、页面复杂程度、是否限制图片加载、并发设置都会显著影响最终数字。最好的做法是把自己场景里的数据和结果记录成文档形成属于你自己的基线结论。最后再说一个操作层面的小技巧如果你想让上下文复用得更彻底可以考虑让多个Agent任务共用同一个浏览器实例的不同上下文而不是每个任务新建浏览器。浏览器实例本身的开销远大于上下文能省则省。我最早就是踩了这个坑每个任务都新建浏览器虽然比WebDriver已经好很多但数据量上去之后内存又开始涨。后来改成共享浏览器实例加独立上下文内存峰值又降了一截。这个细节值得你实际跑一把看看差距。