如果你已经用Stagehand这类AI驱动的浏览器自动化工具跑通了几个流程,大概率会遇到同一个尴尬:功能是能跑,但慢、贵、偶尔还不稳定。我最早把Stagehand集成进模拟项目X做流程验证的时候,第一反应是它真的很聪明,页面结构变了,它会自己去找新路径。但跑了几百次重复任务之后,我发现真正的瓶颈根本不在AI“聪不聪明”,而在于每走一步,背后都要调一次模型接口。一次点击、一次输入、一次截图理解,全都是延迟和成本。
Stagehand的缓存机制与性能优化,恰好就是解决这个问题的胜负手。缓存命中的时候,重复执行同一类操作可以直接复用之前的解析结果和动作序列,速度能比冷启动快一个数量级,模型调用成本也能降下一大截。这篇文章不是复述文档,而是我从实际调优过程中总结出来的分层拆解、参数配置思路和排坑记录,适合那些已经能跑通脚本、正准备往生产级自动化方向打磨的开发者,也适合正在为“任务跑得慢、费用下不来”发愁的同学。
1. 先搞懂瓶颈在哪:AI自动化为什么慢且贵
1.1 传统自动化与AI驱动自动化的本质差别
传统自动化写死选择器,每次执行固定步骤,浏览器渲染完直接执行,几乎不引入额外判断。比如一个登录流程,就是定位输入框、填入账号密码、点击按钮、等待跳转,每一步都是确定的,所以延迟低、成本几乎为零。
AI驱动的自动化则完全不同。模型需要把用户给的指令翻译成浏览器动作,而翻译之前,还要先把当前页面状态打包成环境快照,包含DOM结构、可见文本、元素坐标、截图编码等。这些内容要作为上下文送给模型接口,由模型去理解页面、决定下一步该做什么。你可以把这想象成一位老司机接到指令后,先开一段路、停下来看看四周、再决定怎么走;传统自动化则更像铁轨上的列车,轨道铺好了就一路跑到底。
这个差异在简单流程里感觉不明显。但任务数量一多,或者页面结构复杂、交互层级深,每一次“停下来看看四周”都会放大延迟。尤其是页面里有弹窗、异步加载、动态刷新这些情况时,模型还需要重新判断页面状态,时间消耗会更明显。
1.2 成本和稳定性到底差在哪
先说延迟。一次动作指令的模型推理通常要几百毫秒到几秒,一个十步流程动辄十几次调用,冷启动整条链路下来十几秒是很常见的事。你体感上觉得“AI自动化好慢”,其实就是这些模型调用叠加起来的结果。
再说成本。模型接口一般按调用次数和输入token数计费,而页面快照里的大段DOM文本和截图编码,非常占输入空间。一个复杂表单页面的快照,发送过去的token可能比几十条普通文本对话还多。任务频率一高,费用会肉眼可见地上去。
还有稳定性。模型推理不是确定性计算,同一个页面、同一条指令,每次返回的动作序列可能有细微差别。这种随机性在探索场景里无所谓,在回归测试和定时巡检场景里就会带来麻烦——同一个用例,这次过了,下次可能因为模型换了个点击路径就挂了。
缓存机制的核心价值就在这里:把“已经算过答案”的结果存下来,下次直接查表。对AI自动化来说,就是把“理解页面、决定动作”这一整段从重复推理变成快速检索。用大白话讲,就是让这位老司机只认一次路,之后走同一条路时直接闭眼开过去,不再反复停下来辨认方向。
1.3 缓存机制适合谁,不适合谁
缓存不是银弹。它最适合的是重复度很高的任务,常见场景包括定时巡检、线上主流程回归、数据同步采集、批量表单处理。这些任务步骤固定,页面结构相对稳定,缓存命中率会很高。我在给某跨平台系统做定时巡检时,任务集固定为三十八个步骤,跑了一周看日志,缓存命中率能稳定在八成以上。
不适合的是探索型任务,比如用AI临时分析一个从未见过的页面、随机跳转抓取内容。这类任务每次输入都在变化,缓存几乎没有命中机会,强行开启反而要额外承担缓存序列化、目录维护的开销。我做过一个一次性的页面结构分析任务,命中率直接掉到个位数,最后干脆把缓存关了,逻辑反而更清爽。
2. 分层拆解:每一层缓存到底在缓存什么
2.1 会话级缓存:页面快照与DOM复用
会话级缓存是最容易理解也最容易被忽略的一层。它缓存的是浏览器环境里的页面快照、DOM解析结果、截图编码等数据。为什么要缓存这些?因为连续多个动作操作同一个页面时,不需要每步都重新抓取一遍完整DOM。第一次访问时生成一个页面签名,后续动作先比对URL和页面结构签名,没有变化就直接复用之前的解析结果。
这里最大的坑是动态内容。页面里经常有时钟、倒计时、随机推荐、token刷新之类的节点,这些都会改变DOM签名。我见过一个任务,页面右上角有个毫秒级的动态计时器,导致每次快照签名都不一样,会话级缓存完全失效。排查了很久才发现,是那个计时器节点在捣乱,后来在采集DOM时把这类节点剔除掉,命中率才恢复正常。
如果你也遇到“明明页面没变,缓存却不生效”的情况,可以先怀疑动态节点污染了签名。用一句话总结:会话级缓存的命中率,本质上取决于你对DOM签名做了多好的归一化处理。
2.2 动作级缓存:模型输出结果的复用
动作级缓存缓存的是“在一个具体页面上执行某个指令,模型给出的动作序列”。动作序列是一组有序的操作,比如点击按钮、填充输入框、等待元素出现、按下回车。
这一层的命中逻辑通常以指令文本、页面语义签名、上下文操作历史组合成的复合键作为索引。命中之后不需要再请求模型,直接执行已经验证过的动作序列就行。它有点像数据库里的查询计划缓存:同一条SQL第二次执行时,不需要重新计算执行计划,直接走已有计划,省掉了最昂贵的计算环节。
动作级缓存是节省模型调用最大的来源。我实际观察过,一组固定流程的任务,开启动作级缓存后,模型调用次数能从每任务几十次降到个位数。首次任务还是要付全量推理成本,但后面的重复执行基本都在走缓存。如果你关心费用优化,这一层值得优先研究。
2.3 LLM响应缓存:原始响应的整体复用
有些实现会把模型返回的原始响应整体缓存下来。好处是即使某个子模块没做动作级缓存,只要模型、系统提示词、用户输入完全一致,也可以直接返回原始响应,省掉一次网络请求和推理过程。
这一层最容易翻车的地方是提示词里带了动态字段。如果你把当前时间戳、随机流水号、会话ID这类内容拼进提示词,那就等于每次都在请求一个全新的输入,缓存永远也不会命中。我在排查一个“缓存不生效”的问题时,最后发现就是我在系统提示词尾部拼接了一个订单号参数。去掉之后,同一类请求的响应缓存立刻开始命中。
所以检查LLM响应缓存时,先别急着怀疑框架,把你自己的提示词模板从头到尾看一遍,看有没有“每次都会变”的字段。
2.4 持久化缓存:让缓存跨进程存活
会话级和动作级缓存默认在内存里,进程退出就丢了。持久化缓存会把可序列化的缓存结果写到本地文件目录或远端键值存储,进程重启后还能复用。
这个能力对定时任务和持续集成场景尤其重要。因为每次任务都是新起进程,如果只有内存缓存,相当于每次都要从零开始。我见过不少团队在容器环境里跑定时巡检,容器每次重建,缓存目录跟着一起消失,之前的命中积累全部作废。把缓存目录挂载成持久化卷,是必须做的一步。
持久化缓存带来的额外问题是存储容量的管理,这个放到后面踩坑部分详细讲。
2.5 缓存失效策略:命中之后的另一半逻辑
只关心命中率,不关心失效策略,迟早要付出代价。常见的失效方式有这么几种:
- TTL(存活时间):给缓存设置有效期,到期后自动失效。适合页面内容周期性变化的场景,比如每小时刷新的数据看板。
- 版本号:在缓存键前缀上挂一个版本号,业务规则或页面改版后手动提升版本号,旧缓存全部作废。适合发布新流程、新提示词的时候用。
- 页面结构变化驱动:DOM签名变化达到一定程度时主动废弃对应缓存。这个比较智能,但对签名算法要求高。
- 手动清理:用于排查问题或已知异常时强制清空缓存,属于保底手段。
失效策略的核心思路不是“让缓存永远有效”,而是“让缓存尽可能命中,同时一旦内容不该再被使用,能及时作废”。我个人的习惯是TTL保底、版本号主动控制,两者配合使用。
| 缓存层级 | 缓存内容 | 主要收益 | 失效风险 |
|---|---|---|---|
| 会话级 | DOM快照、截图编码 | 减少重复页面解析 | 动态节点导致签名失效 |
| 动作级 | 指令对应的动作序列 | 减少模型推理调用 | 页面语义变化后动作不适用 |
| LLM响应级 | 模型原始返回内容 | 减少网络请求与推理 | 提示词携带动态字段 |
| 持久化级 | 以上全部可序列化结果 | 跨进程复用 | 文件膨胀、版本过期 |
3. 实际调优:三步把缓存命中率拉上去
3.1 第一步:开启缓存并规划持久化目录
在初始化参数里找到缓存配置,一般需要指定缓存模式、失效时间、目录位置和命名空间。我手头用的版本大致长这样,具体字段名以你本地安装版本的类型声明为准:
const stagehand = new Stagehand({ browser: "chromium", cache: { mode: "persistent", // 可选 memory / persistent / hybrid ttlSeconds: 3600, directory: "./.cache/stagehand", namespace: "checkout-flow" } });有几个规划细节值得注意。缓存目录必须放进代码仓库的忽略清单,避免把本地产物提交进去。容器部署时要把目录挂载成持久化卷,否则每次容器重建就从头再来。如果同一套环境要跑多个业务线,建议用不同的namespace隔离开,防止缓存串数据。
这里要强调一下“混用模式”的价值。hybrid模式可以让热数据留在内存、冷数据落到磁盘,兼顾速度和容量。如果任务对延迟敏感,优先用这个模式;如果环境内存紧张,直接用persistent模式也行。
3.2 第二步:观察命中率,而不是只盯着耗时
只看“总耗时降了”就去庆祝,往往会错过真正的优化空间。我建议先给缓存模块加上可观测手段,至少要能看到两类数据:命中次数和未命中次数,以及每一次命中与未命中的耗时对比。
如果框架没有现成的统计面板,可以自己在外面包一层统计函数,对调用点做计数:
const runtimeStats = { hit: 0, miss: 0, error: 0, }; async function executeWithStats(task) { const start = Date.now(); try { const result = await task(); runtimeStats.miss++; return result; } catch (e) { runtimeStats.error++; throw e; } }这只是一个粗粒度的示例。真正重要的是养成看日志的习惯:日志里有没有缓存命中标记;同一步骤在冷热两条路径上的时间差是多少;模型调用计数有没有明显下降。命中率数据比一次两次的耗时波动更能说明问题。
3.3 第三步:把缓存键规范化,解决动态污染
缓存命中率的头号杀手,就是缓存键不一致。三个最常见的污染源是:URL带跟踪参数、DOM签名带着动态节点、提示词上下文里塞了随机值。
解决方法就是做规范化。URL要剔除跟踪参数和时间戳,DOM签名要过滤动态文本节点,提示词模板要保证稳定。一个简单的URL规范化函数大概是这样的:
function normalizeCacheInput(url, domFingerprint) { const u = new URL(url); u.searchParams.delete("utm_source"); u.searchParams.delete("timestamp"); u.searchParams.delete("session_id"); u.hash = ""; return `${u.origin}${u.pathname}?${u.searchParams.toString()}|${domFingerprint}`; }另外,多账号、多租户场景要注意缓存键的身份维度。如果不同账号的数据结构不同,却共用一个缓存键,轻则命中率低,重则数据串号。我自己在调试某跨平台系统时,就把账号ID放进了缓存键前缀,问题立刻清晰了。这里有个安全原则:缓存宁可miss,不可错hit。错命中比不命中更危险,因为它会让错误结果静默地出现,排查难度大得多。
3.4 性能效果评估:冷热缓存怎么对比
调优之后要有一套可复现的评估方法。我的做法是用同一批固定任务集,分两轮跑对比:第一轮清空缓存目录跑冷启动,记录模型调用次数、平均耗时、失败率;第二轮在相同条件下跑热缓存,记录同样的指标。
我实际做效果评估时,某跨平台系统的一组回归脚本,冷启动单条任务平均耗时大约十二秒,热缓存后降到约三秒;模型调用次数从每任务几十次降到个位数。需要提醒的是,AI推理延迟波动比较大,单次数据没有参考价值,一定要取多次运行的中位数。跑评估任务时,最好把任务列表固定住,页面环境保持一致,避免把环境差异误当成优化收益。
一个简单的对比维度可以参考下表:
| 维度 | 冷缓存 | 热缓存 | 变化 |
|---|---|---|---|
| 单任务平均耗时 | 约12秒 | 约3秒 | 下降约75% |
| 模型调用次数 | 几十次 | 个位数 | 大幅下降 |
| 任务失败率 | 基准 | 无明显上升 | 保持稳定 |
| 缓存命中率 | 0% | 80%以上 | 提升明显 |
4. 进阶提速:缓存之外还有哪些优化空间
4.1 把高确定性步骤搬出模型通道
缓存本质上是“存答案”,而性能优化还有一个方向是“不需要提问”。不是所有动作都需要模型判断。一些固定路径、固定选择器的操作,完全可以用底层浏览器API直接执行,跳过模型调用。
比如页面加载后要点击“下一步”按钮,如果这个按钮的稳定选择器你已经确认过,直接调用底层定位器点击就好,比让模型去识别快得多。Stagehand这类工具通常允许你混用两种方式,可以在流程里保留语义理解阶段给模型,其余高确定性环节走规则。我的建议是:核心路径上能用规则就用规则,只有真正需要语义理解的环节才走AI。这样缓存压力也小了,系统也更快、更稳。
这个思路在长流程任务里尤其明显。一个二十步的流程,如果只有五步真正需要模型判断,整条链路的延迟就会从“二十次模型调用”变成“五次模型调用加十五次本地执行”,体感差别很大。
4.2 压缩快照与上下文,降低模型输入负担
模型接口的延迟和输入长度强相关。页面快照里通常混杂着大量无关信息,比如隐藏的iframe、样式文本、脚本标签、折叠菜单里的内容。把这些东西过滤掉,只保留可交互元素和可见文本,推理速度和费用都会改善。
我一般会把过滤规则分成两层。第一层是通用过滤,把脚本、样式、注释节点直接去掉;第二层是按业务场景定制,比如登录页只保留输入框和按钮,列表页只保留表格行和分页控件。快照压缩在缓存开启前做还是开启后做,效果差异很大。一定是在压缩后的快照基础上去生成缓存键,否则原始快照里的噪声会导致键不一致。
上下文裁剪也很重要。有些任务会累积大量历史消息,把整场会话全部发给模型,既贵又慢。更好的做法是保留最近几轮的上下文,把早轮的内容摘要化,或者直接裁掉。上下文越短,推理越快,越不容易被无关信息带偏。
4.3 并发控制与预热技巧
同时开太多浏览器实例,资源开销会很大,模型接口也容易被限流。我建议用一个队列控制并发度:单浏览器实例内串行执行关键步骤,多实例之间并行跑不同任务。不要无脑追求并发数,稳定性和资源占用都要一起看。
预热是配合缓存非常好用的技巧。正式任务开始前,先让自动化把关键路径跑一遍,把缓存写满,生产任务再跑的时候基本全是命中。我在定时巡检场景里会加一个预热步骤,顺序是先执行一次最小路径的巡检,再开始真正的全量巡检。很多看起来是“模型变快了”的优化,其实就是预热把缓存提前填好了。
预热还有一种变体叫“按模块预热”,就是把历史任务里最常命中的前十个页面路径,在低峰期预先跑一遍。这样即使某些页面后续内容更新了,缓存里也保留了尽可能多的可复用部分。
4.4 成本估算公式:命中率如何兑换成费用
当你要向团队解释缓存的价值,最好给一个能算账的公式。粗略估算方式是这样的:当日模型调用总次数约等于任务数量乘以单任务平均模型调用次数。引入缓存后,实际发生计费的调用次数约等于(1减去缓存命中率)乘以原来的总次数。
举个例子。一个任务每天跑200次,每次任务平均15次模型调用,命中率80%。这意味着原始调用次数是3000次,引入缓存后实际调用次数降到了600次,减少了80%的调用量。具体单价每个平台不太一样,但比例是清楚的。所以建议在指标面板里同时记录调用次数和token消耗数,这两个数字比“感觉变快了”有力得多。
如果你用的是按token计费的模式,还要把输入token的平均大小考虑进来。快照压缩和上下文裁剪能直接影响每次调用的token数,这部分优化和缓存命中率是乘数关系,叠加起来效果更明显。
5. 踩坑实录:缓存最容易翻车的五个场景
5.1 缓存污染导致行为漂移
这是一个非常典型的生产事故。某次自动化任务原本是点击“下一页”查看报表,切换到另一个账号之后,执行结果却还是上一个账号的数据。排查到最后发现,缓存键里没有包含账号身份维度,命中到了别人的动作结果。
解决办法是按账号或租户隔离缓存空间。所有涉及不同身份、不同权限、不同区域的自动化任务,缓存键里必须有身份维度。宁可频繁miss,也不要错hit,因为错hit是静默错误,发生之后你甚至不知道结果是错的。这一点对任何数据敏感的业务场景都适用。
5.2 动态快照内容搞垮命中率
页面上的当前位置、倒计时、随机广告、验证码、滚动位置,都会让DOM签名不断变化。表现就是缓存命中率异常低,而且没有任何报错。我前面提到的计时器案例就是一个典型。
正确的排查方式是,先在日志里确认哪些节点的签名在两次抓取之间发生变化,然后针对性地过滤。这个工作有点像做页面性能分析里的“长任务拆解”,需要耐心地反复比较几次快照。不要指望一次把所有动态节点都找齐,先在主干路径上把最明显的几个处理掉,命中率通常就会有明显回升。
5.3 持久化缓存无限膨胀
长时间跑下来,缓存目录会越来越大。如果不加控制,磁盘空间被占满只是时间问题。尤其是页面结构经常变、每次变更都会产生新缓存键的场合,膨胀速度会很快。
对此我的处理策略有三步:给TTL设上限,超时的缓存文件定期清除;在业务低峰期做一次全量清理和重建;每天凌晨把超过三天未访问的缓存文件清理掉,控制目录体积。我用的清理脚本很简单,就是一个定时任务加一条删除命令,但效果很好,基本不会让缓存目录无限制膨胀。
5.4 发布新逻辑后旧缓存还在起作用
修改了系统提示词,或者调整了动作规则,结果线上任务还在用旧的缓存,表现和预期完全不符。这个问题的根因是缓存没有跟着业务版本一起更新。
解决办法有两种,二选一或者都用。第一种是提升缓存版本号,让旧缓存自然失效;第二种是在发布流程里加一个清缓存步骤,顺手把历史缓存清掉。我现在的习惯是,把缓存版本号作为一个环境变量管理,每次发布新流程的时候顺手改一下,这样缓存和代码始终保持同步,不会出现“新逻辑没生效”的尴尬。
5.5 排查问题:从日志到缓存键的三步法
遇到“怎么还是慢”或者“行为怎么不对”这类问题,推荐的排查顺序是这样的:
- 第一步看缓存命中日志,确定是miss还是hit。如果全是miss,问题大概率出在缓存键不一致上。
- 第二步,如果miss,对比同一步骤的缓存输入差异,检查URL、DOM签名、提示词上下文三个位置,哪个字段在变化。
- 第三步,如果hit了但结果不对,把缓存内容翻出来和当前页面核对,十有八九是键里带了身份维度或者动态字段,导致缓存串了上下文。
这套排查顺序帮我解决了绝大多数缓存相关的问题。很多时候,问题的答案不在框架里,而在你的输入数据上。
最后分享一点我个人的经验。缓存机制不是一个“开了就完事”的开关,它和业务逻辑是强耦合的。我踩过的坑,基本都集中在缓存键设计上。想清楚哪些输入变化会影响输出结果,把这些输入放进缓存键,其他无关字段一律规范化掉,命中率和安全性就能同时保住。前期多花半小时做键设计,后面能省下非常多的维护成本。
另外,如果你的自动化任务还有明显的提速需求,不妨再试试预热这个动作——让关键路径在正式任务前先跑一遍,效果往往立竿见影。这篇文章先写到这里,希望对你在Stagehand缓存机制与性能优化上的实践有帮助,也欢迎在评论区聊聊你在生产环境里碰到的缓存问题。