给 browser-use 加自定义动作:注册链路、动作过滤与取舍
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
加一个自定义动作在 browser-use 里只是几行装饰器,真正决定成败的是你判断哪些操作该被固化成一个入口、哪些该留给模型自己在页面上点。 注册器本身没什么玄学:它把你的函数签名翻译成一份 JSON schema,塞进模型每一步能选的清单里。难的是这份清单越长,模型选错的概率越高;而清单里每多一个带副作用的动作,模型就多一次可以在错误页面上误触的机会。
站内已有三篇相邻的文章:Agent 工具设计 讲的是工具边界怎么切,工具描述怎么写 讲的是描述文本本身的写法,pi 的工具层 讲另一个项目的工具层结构;这一篇只落在 browser-use 这个仓库上,逐行对着 browser_use/tools/ 讲注册与过滤的实现细节。
一、动作清单解决的是什么问题
模型看不见浏览器。它每一步看到的是一段页面表示,加一份「你现在可以做什么」的动作清单,然后输出一个结构化的选择。所以 browser-use 的核心数据结构不是浏览器封装,而是一张动作表。
这张表在 browser_use/tools/service.py 的 Tools 类里被填满。构造函数先调 _register_done_action(output_model) 注册收尾动作,然后把内置动作逐个用 @self.registry.action(...) 挂上去:search、navigate、go_back、wait、click、input、upload_file、switch、close、extract、search_page、find_elements、scroll、send_keys、find_text、screenshot、save_as_pdf、dropdown_options、select_dropdown、write_file、replace_file、read_file、evaluate。文件末尾还有一行 Controller = Tools,是给老代码留的别名。
关键点在于:内置动作和你的自定义动作走的是同一个入口。Tools.action(self, description, **kwargs) 的实现体就一句 return self.registry.action(description, **kwargs)。MCP 那条链路也一样——browser_use/mcp/client.py 把远端工具包成一个 wrapper 函数后,调的是 registry.action(description=description, param_model=param_model, domains=domains)(mcp_action_wrapper)。所以你理解了这一个装饰器,就同时理解了内置动作、自定义动作和 MCP 工具三者的注册方式。
二、一个函数怎么变成动作
最短的真实例子来自 examples/custom-functions/save_to_file_hugging_face.py:
class Model(BaseModel):
title: str
url: str
likes: int
license: str
class Models(BaseModel):
models: list[Model]
@tools.action('Save models', param_model=Models)
def save_models(params: Models):
with open('models.txt', 'a') as f:
for model in params.models:
f.write(f'{model.title} ({model.url}): {model.likes} likes, {model.license}\n')
装饰器落到 browser_use/tools/registry/service.py 的 Registry.action,签名是 action(self, description, param_model=None, domains=None, allowed_domains=None, terminates_sequence=False)。里面做四件事。
第一,名字。RegisteredAction 的 name=func.__name__,并且以 self.registry.actions[func.__name__] = action 的形式入表。也就是说函数名就是动作名,也是这张表的主键。同名后注册的会覆盖先注册的,没有告警。
第二,参数模型。有两种写法:传了 param_model 就是上面那种「第一个参数是 pydantic 模型」的形式;没传就从函数签名现场生成一个,create_model(f'{func.__name__}_Params', __base__=ActionModel, **params_dict)。内置的 wait 就是第二种写法,函数签名是 async def wait(seconds: int = 3),模型看到的参数名与默认值直接来自这里。
第三,特殊参数注入。_get_special_param_types 列出了一组保留参数名:context、browser_session、page_url、cdp_client、page_extraction_llm、available_file_paths、has_sensitive_data、file_system、extraction_schema。同一份清单以字段形式定义在 browser_use/tools/registry/views.py 的 SpecialActionParameters 里。你的函数只要声明了这些参数名,运行时就会被注入实例,它们不会出现在给模型看的 schema 里。类型标注写错会在注册时就抛错,报错文本是「conflicts with special argument injected by tools」。
第四,签名归一化。_normalize_action_function_signature 把函数包成只接受关键字参数的形式,顺带做两个硬性校验:函数里不许有 **kwargs(报错原文提示「Actions must have explicit positional parameters only.」,需要默认值请用专门的 param_model);同步函数也允许注册,包装层会走 asyncio.to_thread(func, *call_args),所以上面那个 save_models 用 def 而不是 async def 也能工作。
执行侧在 Registry.execute_action:先 action.param_model(**params) 做校验,再拼一份 special context 注入,最后 await action.function(params=validated_params, **special_context)。外层 Tools.act 负责把结果规整成 ActionResult——返回 str 会被包成 ActionResult(extracted_content=result),返回 None 得到空的 ActionResult(),返回别的类型直接抛 Invalid action result type。act 还套了一层 asyncio.wait_for,超时值优先读 BROWSER_USE_ACTION_TIMEOUT_S 环境变量,读不到或解析不出正的有限数就落回模块级常量 _ACTION_TIMEOUT_FALLBACK_S,也可以按次调用传 action_timeout 覆盖(同样走一遍正数校验)。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
Tools | 装内置动作、暴露 action / exclude_action / act | browser_use/tools/service.py | 建实例、加动作、裁掉不想要的动作时 |
Registry.action | 校验签名、生成参数模型、写入动作表 | browser_use/tools/registry/service.py | 注册报错、参数名被当成注入参数时 |
RegisteredAction | 存一条动作的名字、描述、参数模型、domains、terminates_sequence | browser_use/tools/registry/views.py | 想知道模型到底看到什么字段时 |
ActionRegistry._match_domains | 用 URL 判断某条动作在当前页是否可用 | browser_use/tools/registry/views.py | 动作在预期页面上没出现时 |
match_url_with_domain_pattern | 域名 glob 的真实匹配规则 | browser_use/utils.py | 写 domains 模式不生效时 |
ActionResult | 动作返回值的统一形状 | browser_use/agent/views.py | 决定结果进短期上下文还是长期记忆时 |
Agent._update_action_models_for_page | 每一步按当前 URL 重建可选动作 | browser_use/agent/service.py | 排查「这一步为什么少了个动作」时 |
三、动作过滤:它只在两个地方生效
registry.action 的 domains 参数(allowed_domains 是它的别名,两个同时传会抛 ValueError)就是过滤入口。examples/custom-functions/action_filters.py 里的写法是:
@registry.action(description='Trigger disco mode', domains=['google.com', '*.google.com'])
async def disco_mode(browser_session: BrowserSession):
cdp_session = await browser_session.get_or_create_cdp_session()
...
这条动作只会在 Google 域下出现在清单里。过滤在两处生效,都由 Agent 每一步驱动:Agent._update_action_models_for_page(page_url) 调 create_action_model(page_url=page_url) 重建结构化输出的 schema;紧接着 get_prompt_description(browser_state_summary.url) 生成一段文字描述,注入本步的浏览器状态消息。
两者的取舍规则值得记住。create_action_model 在 page_url is None 时只收 domains is None 的动作(这是构造 Agent 时的初始模型);给了 URL 就按 _match_domains 判断。get_prompt_description 反过来分工:page_url is None 时只输出无过滤动作(进系统提示词),给了 URL 时跳过无过滤动作、只列命中当前页的那些,注释写得很直白——无过滤动作已经在系统提示词里了,不重复占位。
域名模式的真实规则在 browser_use/utils.py 的 match_url_with_domain_pattern,几条与直觉不同的:模式不写 scheme 时默认按 https 匹配,所以 example.com 匹配不上 http://example.com;*.example.com 会顺带匹配裸域 example.com;*.*.domain 这类多通配、以及 example.* 这类通配 TLD 会被判为不安全直接返回 False(日志里是「Wildcard TLDs … are not supported for security」);新标签页由 is_new_tab_page 判定后返回 False。这里有个容易踩的分歧:action_filters.py 的文件头注释举例写了 example.co.*,而实现是拒绝通配 TLD 的——照注释抄会得到一条永不出现的动作。
至于比域名更细的条件,同一个示例文件里留了一句注释:page_filter 已经不被直接支持,做法是把条件判断放到函数体内、不满足就提前 return。use_the_force 就是这么写的,domains=['*'] 放行全部域,函数第一件事是调 is_login_page(browser_session),不是登录页就直接返回。代价也在这:这种自查发生在执行阶段,模型仍然会看到并可能选中它。
示例文件开头把过滤的三个目的写得比任何转述都清楚:避免模型选中与当前页不兼容的动作、限制决策疲劳、以及防止误触有状态动作或泄漏 secret。第三条尤其是自定义动作的重点——一条会写库、会发邮件、会用到凭据的动作,暴露面越窄越好。
四、什么该做成动作,什么留给模型自己点
先看仓库自己怎么划这条线。search_page 的描述里写明是「像 grep 一样搜页面文本、零 LLM 成本、瞬时返回」;find_elements 用 CSS 选择器批量取元素属性;evaluate 是 JavaScript 逃生口,描述里明确要求用 IIFE 包起来加 try-catch、只用浏览器 API、不许用 Node.js API,并且点带 [index] 标记的元素要用 click(index) 而不是绕道 evaluate。换句话说,页面级的通用探索能力已经被内置动作覆盖了,你再包一层同类动作只是加长清单。
值得固化成动作的,是下面这几类:
一是跨出浏览器的副作用。发通知、写库、调内部接口、落文件——examples/custom-functions/notification.py 就是把发邮件挂成动作。这类事模型没法靠点页面完成。
二是确定性远高于模型即兴操作的多步序列。一段固定的表单填法、一段固定的登录后跳转,做成一个动作可以把多轮试错压成一次调用,同时把失败模式收敛成一条你能读懂的错误。
三是结果需要结构化的提取。传 param_model 让模型直接按你的 pydantic 模型填字段,比让它自由写一段文本再解析要稳。如果要的是整个任务的最终结构化输出,走 Tools.use_structured_output_action(output_model)(它内部再调 _register_done_action),不要自己另写一个 done。
四是需要凭据的操作。execute_action 里的 sensitive_data 处理是域名感知的:新格式 {域名模式: {key: value}} 只在当前 URL 命中该模式时才把 secret 放进可用集合,替换的是 <secret>占位符</secret> 这种标记;占位符名以 bu_2fa_code 结尾时会用 pyotp 现算一个 6 位 TOTP。同时 special_context 里 has_sensitive_data 只在 action_name == 'input' 时为真,完整的 sensitive_data 也只会传给 input 这一个动作。你自己的动作拿不到它,这是有意的收口。
反过来,不该做成动作的:单纯的「点第三个按钮」「往这个框里打字」——这些内置动作已经带索引机制,包一层只会和模型的既有习惯打架;以及任何本质上是分支判断的东西——你把 if/else 塞进动作,模型就失去了看页面再决定的机会,遇到没预料的页面结构会直接卡死。
terminates_sequence=True 是个容易忽略的旋钮。RegisteredAction 的注释说明它标记「已知会改变页面」的动作,multi_act() 执行到这类动作后会放弃队列里剩下的动作。内置动作里带着它的是 search、navigate、go_back、switch、evaluate 这五个。你的动作如果会导致跳转或整页重绘,不设这个标记就会让后续排队动作作用在一个已经不存在的页面上。
五、边界与代价
动作名没有命名空间。 表的主键就是 func.__name__,注册同名动作会静默覆盖。notification.py 里那个 done 就是活例子——它顶掉了内置的收尾动作,因此必须自己返回 ActionResult(is_done=True, extracted_content='Email sent!'),而内置 done 携带的 success 语义、files_to_display 附件处理、下载文件自动挂附件那一套都不再生效。覆盖是有用的能力,但要清楚你顶掉了什么。
domains 不是权限边界。 它只影响「模型这一步能不能看见这条动作」,作用点在 create_action_model 和 get_prompt_description。execute_action 本身不做域名检查;Tools.__getattr__ 提供的 tools.navigate(url=..., browser_session=...) 这种直接调用路径同样不过滤。真正的访问控制得靠浏览器侧和账号侧,参考 最小权限设计 的思路,不要把过滤器当沙箱。
过滤只看 URL 的 scheme 与主机名。 match_url_with_domain_pattern 解析出的就是这两个部分,路径、查询串、页面内容、登录态、租户身份它都不看。「只在结算页可用」「只对已登录用户可用」这种条件,只能落到函数体内自查。
每一步重建 schema 有代价。 动作集合随页面变化,意味着发给模型的工具 schema 逐步不同。这会削弱提示词层面的稳定性,排查问题时也得连着当时的 URL 一起看,否则复现不出来。
它明确不管的事:目标站点的使用条款与 robots 策略、验证码与反自动化机制、账号被判为异常访问的风险,以及带登录态操作真实账号时的数据外泄面。仓库里 browser_use/browser/watchdogs/ 下 14 个 watchdog 里确实有一个 captcha_watchdog.py,但把「检测到了」理解成「能绕过」是危险的误读——遇到验证码与风控,正确处理是交回给人,参考 人在环中 那套做法。任何让 Agent 带着你的生产账号去第三方站点批量操作的方案,都得先过一遍条款与合规,这不是技术问题。
动作级的兜底超时不是重试机制。 它只保证 act 在有界时间内返回一个带 error 的 ActionResult 而不是挂死,错误文本里会提示浏览器可能失去响应;重试与恢复策略仍然要你自己在上层设计。
六、上手与避坑清单
- 动作名撞车。为什么会踩:主键是函数名,你写
async def done(...)或async def click(...)就覆盖了内置动作,注册时不报错、跑起来才发现行为变了。怎么避:给自定义动作加统一前缀;确实要替换内置动作时,用Tools(exclude_actions=[...])或tools.exclude_action('screenshot')显式表达意图,比靠同名覆盖清楚。 - 函数里写了
**kwargs。为什么会踩:签名归一化要靠明确的参数列表生成 schema,可变关键字参数无法映射,注册阶段直接抛错。怎么避:需要可选字段和默认值时定义一个 pydantic 模型传给param_model。 - 业务参数撞上保留名。为什么会踩:
context、page_url、file_system、cdp_client这些名字会被当成注入参数,你的同名参数不会出现在模型 schema 里,模型永远填不到它。怎么避:写完动作后回头对一遍SpecialActionParameters的字段列表再定参数名。 domains和allowed_domains同时传。为什么会踩:两者是同一个参数的别名,从不同文档片段抄代码很容易同时写上,结果是 ValueError。怎么避:项目内统一只用一个写法。- 模式漏了 scheme。为什么会踩:不带
://的模式默认按 https 匹配,内网或测试环境跑 http 就一条都命中不了,表现是动作「凭空消失」。怎么避:明确写成http*://example.com这类形式,并在目标 URL 上实测一次。 - 照注释写通配 TLD。为什么会踩:
example.*与多重通配会被安全检查判掉并返回 False,示例文件注释里的写法与实现不一致。怎么避:以match_url_with_domain_pattern的实现为准,把域名逐个列出来。 - 动作返回了别的类型。为什么会踩:
act只认ActionResult、str和None,返回 dict 或自定义对象会抛Invalid action result type。怎么避:统一返回ActionResult,并按需要区分extracted_content(可配合include_extracted_content_only_once只进一次上下文)和long_term_memory。 - 想让动作报「成功」。为什么会踩:
ActionResult有个校验器,success=True只允许在is_done=True时出现,普通动作这么写会直接抛异常。怎么避:普通动作把success留空,失败时才用success=False。 - 描述随手写一句。为什么会踩:
description就是模型选择动作的唯一依据,prompt_description()把它和参数名类型拼成一行给模型看,写得含糊等于让模型猜。怎么避:把触发条件、参数含义、不适用场景都写进描述里,别只留一个动词。 - 忘了标
terminates_sequence。为什么会踩:会跳转的动作后面还排着队列里的动作,它们会作用在旧页面的元素索引上,错误现象往往表现为「点错了元素」。怎么避:动作只要可能导致导航或整页重绘就设为 True。
收尾给一份自检:动作名会不会撞;参数名有没有踩保留字;domains 在真实 URL 上试过没有;返回值是不是 ActionResult;会不会引发跳转、要不要标 terminates_sequence;这条动作要用到的凭据是不是控制在最小范围。
接着往下读的顺序建议是:browser_use/tools/registry/service.py 的 _normalize_action_function_signature 看清签名怎么被改写,browser_use/tools/registry/views.py 的 ActionRegistry 看两条过滤分支,browser_use/utils.py 的 match_url_with_domain_pattern 把域名规则读完,最后翻 examples/custom-functions/ 那十个文件——它们比任何转述都短。项目采用 MIT 许可证,仓库里还有 browser_use/llm/ 下 15 个 provider 目录、browser_use/agent/system_prompts/ 下 8 份系统提示词、以及 examples/ 下 124 个文件,想理解一个开源项目的取向,这些目录结构本身就是资料。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 命令行怎么用 和 browser-use 结构化输出的 schema 约束与字段缺失排查。