1. 为什么我把日常脚本重写成了Paperclip这样的任务处理器
先交代一下背景。我以前有个很典型的痛:公司里每天有大量网页操作是重复的——早上把供应商后台的报价拉下来整理成表格,中午去几个数据平台把关键指标截图存下来,下午再逐个填一堆表单。这些事我最早都用Playwright写死脚本,每个网站一套选择器,每次页面改版脚本就废,光维护就有几十个版本。
后来我把这套东西整个重写了,内部代号就叫Paperclip。名字的灵感其实就是办公桌上那个回形针——它没有任何魔法,但能把一堆散落的单据夹成一条清楚的链。我的Paperclip做的事情也一样:把用户一句大白话指令(比如“去后台把今天的新增订单导出成Excel”),拆解成浏览器里的一个个动作:打开哪个页面、点击哪个按钮、等哪个元素出现、抓哪段数据,然后自动执行完。
它本质上是一个跑在你自己机器或服务器上的任务处理器,底层接了大模型API来负责“读懂指令和拆动作”,用浏览器自动化框架负责“动手点击”,再用一个任务队列来保证长任务不丢、失败能重试。
这篇内容适合两类人看。一类是被重复网页操作折磨的普通办公族,你可以不写代码,把Paperclip当成一个能听懂人话的网页机器人;另一类是写过一些自动化脚本但总在维护选择器上翻车的开发者,你会更关心我这套分层设计和踩过的坑。
先说清楚边界:Paperclip不是RPA厂商那种企业级平台,也不是什么万能爬虫,它解决的是自己可控的网站和公开页面的日常重复操作。登录态、验证码这类硬骨头,我的做法是尽量绕开或者让人工介入,而不是硬刚。想清楚了这一点,后面所有设计才不会跑偏。
2. 三层架构:任务队列、浏览器控制层、LLM调度层各干各的
Paperclip一开始我写得很粗暴,就是把“大模型返回的JSON动作”直接塞给Playwright去跑。结果跑一个长任务就出问题:网络断了任务没了、浏览器崩了不知道从哪继续、LLM偶尔抽风生成了非法动作,整个流程就卡死了。
后来我老老实实把系统拆成了三层,各自管各自的事。
2.1 任务队列层:把“人话需求”变成不丢失的持久化任务
这一层对应的是数据库和消息队列。用户说一句话之后,我先把它转成一个标准的任务对象,落到SQLite里。结构大概是这样的:
{ "task_id": "tsk_20250312_001", "user_intent": "去供应商后台把今天的报价单下载到本地", "status": "pending", "actions": [], "retry_count": 0, "max_retries": 3, "created_at": "2025-03-12 09:00:00" }任务状态我用了一套有限状态机:pending(排队中)、running(执行中)、waiting_confirm(需要人工确认)、succeeded(完成)、failed(失败),每个状态变化都写进SQLite。为什么非要落库而不是放在内存里?因为LLM调度一个长任务往往要好几分钟,中间浏览器随时可能崩。落库之后,进程重启可以从running状态恢复执行,而不是从头再来。
2.2 浏览器控制层:所有动作的真实执行者
这层我基于Playwright实现,但你完全可以用Selenium或者别的框架替换,核心在接口抽象。它对外暴露的只有几个动作接口:open(url)、click(selector)、fill(selector, value)、wait_for(condition)、extract(selector)、download(dir)。每个接口内部处理等待元素、处理弹窗、处理失败重试这些脏活。
关键点在于,浏览器控制层是“无脑执行”的,它不负责判断“该不该点这个按钮”。所有决策都交给上层LLM调度层,这一层只管把动作做稳定。这样设计之后,换浏览器框架只需要改这层的实现,不会动到上层的决策逻辑。
2.3 LLM调度层:负责“思考”,把指令翻译成动作序列
这一层是整个系统的脑子。用户那句大白话进来之后,我先把网页当前的可用信息整理成一段上下文(包括当前URL、页面标题、可见按钮和输入框的语义描述),然后让大模型基于这段上下文输出一个动作序列。
动作序列是结构化的JSON,格式如下:
[ {"action": "open", "value": "https://supplier.example.com/dashboard"}, {"action": "wait_for", "value": "text=今日报价"}, {"action": "click", "value": "text=导出"}, {"action": "download", "value": "/data/reports"} ]为什么一定要LLM输出JSON动作而不是直接让LLM写一段Python代码去执行?我试过后者,风险太大——LLM生成的代码很容易包含奇怪的文件操作、网络请求、甚至无限循环,等于把一台浏览器权限的机器交给了不可控的代码。JSON动作是固定的、白名单式的、可校验的,LLM再怎么跑偏,也只能在这几个动作里选,出格的话我就拒绝执行。
这一层我把OpenAI、国内几个主流大模型都试过,结论是:任务拆解能力比代码生成能力重要得多。一个能把步骤拆清楚、路径想明白的模型,比一个能写花哨代码但经常想当然的模型靠谱得多。
3. 技术选型里最让我纠结的几个决定
往下层讲之前,先把我个人踩过的选型坑写出来。这些决定看似简单,实际上直接决定了Paperclip的上限和运维成本。
3.1 浏览器控制:Playwright、Selenium、原生CDP怎么选
这个选择我做了个对比,直接上表格:
| 维度 | Playwright | Selenium | 原生CDP |
|---|---|---|---|
| 元素等待机制 | 内置自动等待,几乎不用手写sleep | 需要显式等待,写起来啰嗦 | 自己实现,麻烦 |
| 多浏览器支持 | Chromium/Firefox/WebKit | Chrome/Firefox/Edge等 | 只支持Chromium系 |
| 调试手段 | trace viewer录像回放,问题复现能力强 | 截图为主 | 需要自己封装 |
| 并发多开 | 支持多context隔离 | 支持但资源控制弱 | 支持 |
| 学习成本 | 中等 | 最低 | 高 |
我最后选了Playwright,最核心的原因就是它的自动等待。LLM调度层给出的动作里经常有“点击某按钮”“等待某数据加载”这种模糊指令,如果选择器给得不够精确,Playwright的自动等待能帮我兜住很多延迟问题,而Selenium通常需要我手动写一堆expected_conditions,代码量直接翻倍。
另外Playwright的trace录像帮我排除了无数个“到底哪一步错了”的疑问。出了问题直接打开回放,能看到每一步鼠标落在哪里、页面是什么状态,这对和LLM调度层联动排查问题特别重要——你可以直接看到LLM是不是给了一个离谱的点击目标。
3.2 LLM API选择:稳定性和结构化输出比聪明更重要
我试了一轮主流大模型API,最后定的选择标准就三条:
第一,必须支持稳定的JSON结构化输出。很多模型支持function calling或者response_format,但实际用起来偶尔会多出解释文字或者丢掉字段。我要的是每一次返回都能被pydantic严格校验通过,偶尔一次解析失败还能重试,而不是概率性的“偶尔能用”。
第二,上下文窗口要够大。因为Paperclip要把整个操作历史传给LLM做下一步决策,如果历史一长就溢出,那任务稍复杂一点就废了。我实际跑下来,长期任务的历史压缩策略比窗口大小更关键,这个后面说。
第三,延迟要低。拆解一步动作如果耗时超过10秒,整个交互体验就毁了。我是先在云端API上跑通,后来对隐私要求高的任务切到本地部署的轻量模型,效果还可以,但需要把网页上下文压缩得足够干净。
3.3 本地跑还是云端跑:我的落地方式
最终我的部署方式是任务队列和调度层跑在一台云服务器上,浏览器控制层用Playwright的headless模式跑在同一个环境里。为什么不在自己电脑上跑?因为定时任务、长任务执行到一半电脑休眠就全完了。上服务器之后稳定了很多。
费用方面,纯调用云端大模型API跑日常任务,一个月大概几十块钱,这个成本能接受。真正贵的是浏览器常驻内存——headless浏览器每个实例大概占500MB内存,并发三个任务就需要近2GB,选服务器的时候一定要按这个预算来。
4. 核心模块实现:从一句指令到真实点击的全流程
选型定了之后,我重点打磨了三个核心模块。这一部分我拆开讲,每一步都给出实际能跑的东西。
4.1 任务解析流程:把意图固化成可校验的动作序列
用户输入一句话之后,Paperclip不会直接开始执行,而是先走一个“意图解析”环节。这一步的Prompt设计很关键,我用自己的话描述一下大概的模板结构:
你是一个网页操作助手。请把用户的最终目标拆解为不超过10步的动作序列。 每步动作只能是以下类型之一:open, click, fill, wait_for, extract, download, screenshot。 当前页面状态描述:{当前页面的语义描述,包含链接文本和按钮文本} 用户目标:{用户输入} 只输出JSON数组,不要输出其他内容。有个细节,我会把页面的语义描述提前用程序生成好,而不是让模型直接读HTML。因为HTML太长,塞给LLM很容易让模型迷失重点。我用一个页面抽析模块,把页面里的a标签文本、button文本、input的placeholder、表单label全提取出来,整理成结构化文本。这样一来上下文很短,模型定位元素的准确率大幅上升。
4.2 动作执行的可靠性:选择器策略是核心中的核心
这一步是Paperclip翻车率最高的地方。最初我图省事,让LLM直接返回CSS选择器,结果悲剧了——类名带着hash后缀的网站一改版就全废,更别提一些前端框架每次构建都会重新生成随机class。
我的改进方案是往Prompt里明确禁止使用class选择器和id选择器,强制让LLM从页面语义信息里挑选定位方式,优先级是这样:
| 优先级 | 定位方式 | 适用场景 |
|---|---|---|
| 1 | get_by_role | 按钮、链接、复选框这种有明确ARIA角色的元素 |
| 2 | get_by_text | 页面里唯一文本,比如“导出”“确认提交” |
| 3 | get_by_placeholder | 输入框依据placeholder定位 |
| 4 | get_by_label | 表单控件依据label文本定位 |
| 5 | 备用CSS(仅限数据表格) | 实在没有语义特征才用 |
举个例子,原来让LLM点击“导出”按钮,它可能返回css=.export-btn,改版后就失效。现在我会在页面语义描述里把这个按钮标成role=button, text=导出,LLM返回click(text=导出),代码层再转成get_by_text(“导出”),稳定得多。
在API层我还加了元素找不到时的兜底策略:第一次点击失败,不是直接报错,而是重新抓取当前页面语义、再让LLM重新给一次选择器。实测这个“重新审视”机制能把单步成功率从80%左右拉到95%以上。
4.3 任务循环:成功、失败、还是求助
整个调度循环我写成了一个大循环。伪代码如下:
while task.status == "running": page_state = extract_page_semantics(page) actions = llm.decide_next_actions(page_state, task.original_intent, action_history) actions = validate_actions(actions) # pydantic校验 for act in actions: success = browser.execute(act) if not success: # 如果连续失败两次,拉高状态 task.retry_count += 1 if task.retry_count > 2: task.status = "waiting_confirm" notify_user(f"任务卡在第{step}步:{act.summary}") break actions = llm.re_decide(page_state) # 带着失败原因重新决策 else: action_history.append(act) else: task.status = "succeeded"有个判断很重要:什么时候让LLM继续处理,什么时候直接举手求助。我的经验是,同一个动作连续失败两次,或者网络重试三次还是超时,就不要再折腾了,直接标记为waiting_confirm并通知人工。因为LLM在浏览器环境里其实是个“睁眼瞎”,一旦信息不足,它越重试越容易陷入自己编造的循环里。
5. 上线后踩过的坑才算数:我把事故修成了策略
这部分我挑几个最有代表性的写,都是真实翻过车的。
5.1 新标签页和iframe导致点击目标丢失
第一次跑通全流程那天,我让它自己登录进系统点开一个报表,结果它点了半天都点不对。看回放才发现:点击“查看报表”之后,系统是在新标签页打开的,而我的浏览器控制层一直盯着原来的页面找元素,当然什么都找不到。
解法是在browser层统一做一次“标签页感知”:每次执行动作前检查当前活跃标签页,如果页面跳转了URL,就自动switch过去。iframe更恶心,表单嵌在iframe里,Playwright的locator默认是探测不到iframe内部元素的。我的做法是给元素定位加一个全局兜底,先看主文档找不找得到,找不到再遍历当前页面的iframe逐个找。
5.2 弹窗和确认框:一次手滑提交了不该提交的申请
有一次测试一个申请表单,流程最后弹了一个原生确认框,Playwright默认弹框会拦截,而我的动作序列里没有处理confirm的指令,结果是任务卡了半小时,所有后续动作全在跟一个不存在的弹窗较劲。
之后我加了弹窗事件监听,遇到confirm、alert、prompt默认全部先拒绝,然后把这个弹窗内容作为一条“页面状态”反馈给LLM,让它决定要不要点确定。这个策略虽然保守,但至少保证了不会在流程不明的时候把不该提交的内容提交出去。所有需要确认的操作,我现在都在Prompt里明确要求必须先返回wait_for(弹窗文本),再返回click(确定),宁可多一个步骤也不赌。
5.3 登录态失效:长任务跑一半被踢下线
跑一个需要20分钟的批量任务时,系统每隔10分钟踢一次登录态,任务卡在一次跳转登录页上。这玩意靠自动化硬解成本高,我最终的方案很朴素:用Playwright的storage_state把登录后的cookie持久化到本地文件,每次任务启动前加载一遍,快过期时再安排一次“续期动作”——先自动去首页走一圈,看是否被重定向到登录页,是的话就通知人工扫码,不是的话正常继续。
这类验证码、风控、动态token的问题,我的经验是不要试图靠Prompt或者脚本去破解。主动把边界画清楚,让系统在遇到这些情况时第一时间通知人,比训练一个“破解版自动化”靠谱得多,也更稳妥。
5.4 动作历史过长导致LLM开始胡说八道
这个坑最有意思。任务一旦长到上百步,我把全部action_history都塞给LLM,结果模型经常在中后段开始重复之前做过的动作,或者忘了原始目标是什么,开始自由发挥。查trace发现它把第10步的点击动作又在第80步重放了一遍,直接把页面带偏了。
我的解法是做了动作历史的滑动窗口压缩:保留最近10步的完整动作,更早的步骤则每5步合成一条摘要,让LLM只看到“已经完成了什么”而不必纠结每个细节。设置向量归档也可以,但实际用下来滑动窗口+摘要已经够用,成本还低。
5.5 幂等性是个大事:重复提交不可接受
最严重的一个问题:因为网络超时,浏览器控制层以为点击没有生效,实际服务器已经收到了请求。于是任务队列触发了重试,同一笔申请被重复提交了两次。
这个问题的根子在“动作执行成功”没有唯一标识。我后来在动作层给每个动作生成一个request_id,点击表单提交前先记录这个任务的关键数据(比如订单号、URL),执行完之后强制查一次页面状态确认是否出现成功提示,没有成功提示才允许重试,否则一律按成功处理。这套幂等判断让重复提交事故基本清零。
6. 实测效果与更适合往里塞的业务场景
最近我让Paperclip连续跑了一周的日常任务,放几个实测数据。
最稳定的是数据归集类:每天上午从三个平台抓行情数据、清洗、写入表格,七天成功率100%,平均每个任务耗时4分钟,之前人工做要半小时。其次是表单代填类,只要页面不弹验证码,成功率在95%以上。最不稳定的是那种需要大量判断的复杂审批流程,涉及多个角色、多种状态流转,成功率掉到70%左右,这种场景我现在不敢全自动。
用下来我觉得这几个场景是最适合往里面塞的:
- 定时巡检:每天早上自动打开各个后台看告警数量,异常时截图加汇总发出来
- 数据归档:把周期性生成的报表按固定命名规则下载存档
- 跨平台录入:从A系统读数据再填到B系统,中间做格式转换
- 页面功能冒烟:改版之后自动把核心路径点一遍,确认没挂
不太适合的是:涉及支付、强实名认证、强风控审核的流程。这类流程一旦自动操作出问题,填坑成本比节省的时间高得多。
我自己把Paperclip当成了那个办公桌上不停加夹子的回形针架子——今天夹一个巡检,明天夹一个归档,后天夹一个新需求。本质上它不是什么新发明,只是把“网页操作”这件事从硬编码脚本的易碎状态,换成了“有脑子的指令翻译+可靠的手脚执行”。你要是也有一堆类似的重复网页操作,可以按我这个三层分工开始搭,先把任务队列做扎实,再把选择器策略固化掉,最后才接大模型——这个顺序反了,后面就是无穷无尽的坑。