拆解 browser-use 的动作注册表:Python 函数怎么变成模型可选的动作
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
这层的关键判断是:browser-use 的注册表不是一张”工具清单”,而是一个把 Python 函数签名编译成 Pydantic 模型的编译器,而且这个编译结果每一步都会按当前页面 URL 重新算一遍。 想清楚这一点,后面所有细节都有了归属:为什么动作函数不许写 **kwargs、为什么参数名撞上 file_system 会直接报错、为什么同一个 agent 在两个页面上看到的动作数量不一样。
站内已经写过三篇相邻的文章,分工先说清楚:Pi 的工具层怎么组织 讲另一个项目在同一位置上的取舍,工具描述该怎么写 讲描述文本本身的写法,工具数量到多少会开始互相干扰 讲规模带来的选择噪声。这篇只钻一件事:browser-use 这个具体仓库里,从函数定义到模型 schema 之间那段代码到底做了什么。
一、它要解决的问题:函数签名和模型 schema 之间的落差
让模型操作浏览器,落到工程上是两个不匹配的东西要对上。一边是 Python 函数:有类型注解、有默认值、有你自己注入的依赖。另一边是模型:它只能看到一段文本描述加一份 JSON Schema,填出来的东西必须能被反序列化回参数。
browser-use 把这段落差压在 browser_use/tools/registry/service.py 的 Registry 类里,入口是 action() 装饰器。它支持两种写法。
第一种是显式传参数模型,仓库里内置动作基本都是这么写的:
@self.registry.action('Go back', param_model=NoParamsAction, terminates_sequence=True)
async def go_back(_: NoParamsAction, browser_session: BrowserSession):
第二种是不传 param_model,直接把参数摊在函数签名上,让注册表自己去推:
@self.registry.action('Wait for x seconds.')
async def wait(seconds: int = 3):
两种写法在 _normalize_action_function_signature() 里合流。这个方法先遍历函数签名,把参数分成两堆:名字命中”特殊参数”集合的归一堆,其余归”动作参数”那堆。分堆用的名字集合来自 registry/service.py 里的 _get_special_param_types()——一份手写的”名字到期望类型”映射,它的键跟 browser_use/tools/registry/views.py 里 SpecialActionParameters 的字段一一对应。那个类把可注入的东西列得很直白:context、browser_session、page_url、cdp_client、page_extraction_llm、file_system、available_file_paths、has_sensitive_data、extraction_schema。
分完堆,只有”动作参数”那堆会变成模型能填的字段。第二种写法下,注册表按类型注解和默认值现场造模型:
for param in action_params:
annotation = param.annotation if param.annotation != Parameter.empty else str
default = ... if param.default == Parameter.empty else param.default
params_dict[param.name] = (annotation, default)
param_model = create_model(f'{func.__name__}_Params', __base__=ActionModel, **params_dict)
这段有个细节值得记住:注解缺失时兜底成 str。你写 async def foo(count) 不报错,但模型看到的是字符串字段。
接着它用 functools.wraps 包一个 normalized_wrapper,把签名重写成”只收关键字参数”:一个 params,加上这个函数真正声明过的那些特殊参数,再加一个 **kwargs 吞掉多余的注入。装饰器最后返回的是这个 wrapper,不是你写的原函数——所以直接调用被装饰过的函数时,传参方式也变了。同步函数也能注册,wrapper 里用 asyncio.to_thread 把它丢到线程去跑。
二、几个组成部分,各管一段
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
Registry.action() | 装饰器入口,做排除判断、签名归一化、写入注册表 | browser_use/tools/registry/service.py | 注册任何自定义动作时 |
_normalize_action_function_signature() | 拆分特殊参数与动作参数、生成 _Params 模型、包 wrapper | browser_use/tools/registry/service.py | 签名写法不合规、注册期报错时 |
SpecialActionParameters | 声明所有可注入依赖的字段名与类型 | browser_use/tools/registry/views.py | 给动作要 browser_session、file_system 时 |
RegisteredAction | 一条动作的元数据:名字、描述、参数模型、域名过滤、是否中断动作序列 | browser_use/tools/registry/views.py | 想读注册表内容、判断动作能力时 |
create_action_model() | 把可用动作编译成给模型的 schema(单动作模型的联合体) | browser_use/tools/registry/service.py | 排查模型为什么看不到某个动作时 |
get_prompt_description() | 把动作渲染成提示词里的文本行 | browser_use/tools/registry/views.py | 调描述、看提示词实际内容时 |
execute_action() | 执行期校验参数、注入依赖、替换敏感数据占位符、统一包装异常 | browser_use/tools/registry/service.py | 动作报错、看错误信息来源时 |
Tools.__init__ 里的内置动作 | click、input、navigate、extract、scroll、write_file 等的具体实现 | browser_use/tools/service.py | 想照着内置动作抄写法时 |
读代码时注意一个容易认错的地方:registry/service.py 里还留着一个 _create_param_model(),生成的模型名后缀是 _parameters,而装饰器实际走的是 _normalize_action_function_signature(),生成的后缀是 _Params。别顺着前者去理解主链路。
三、参数校验分在两道关卡上
第一道在注册期,也就是导入你的代码那一刻就会炸。
**kwargs 直接拒:签名里出现 VAR_KEYWORD 类型的参数,抛 ValueError,错误话术只说”动作必须只用显式的位置参数”;至于”要带一堆默认值该怎么写”,答案写在这段校验上方的代码注释里——用专门的 param_model。理由不难想——**kwargs 没有可导出的 schema,模型无从下手。
参数名撞车也在这里拦。如果你的动作参数叫 file_system 但注解成了别的类型,_get_special_param_types() 给出的期望类型会跟你的注解比对,比对逻辑允许精确相等、子类、以及 list[T] 对 list 这几种情况,Optional[T] 会先剥掉 None 再比。不兼容时抛出的错误措辞是 conflicts with special argument injected by tools。反过来,名字撞了而类型也对得上,那这个参数就会被当成注入项——模型永远填不到它。
第二道在执行期,落在 execute_action() 开头:
先查动作是否存在,不存在抛 Action {action_name} not found。然后拿模型填的字典去实例化参数模型,失败的话包成 Invalid parameters ... for action ...,把原始异常类型和信息都带上。这层校验强度直接由 ActionModel 的配置决定,它在 views.py 里写的是 extra='forbid'——多传一个字段就是校验失败,不会静默丢弃。
个别参数模型会故意反过来放松。browser_use/tools/views.py 里的 NoParamsAction 配的是 extra='ignore',还带一个可选的 description 字段,注释写明是因为 Gemini API 在 response schema 里遇到空对象会报错。这类兼容补丁挺说明问题:schema 一旦要过多个模型服务商的接口,就得给具体实现让路,各家规则不同且会调整,以官方最新说明为准。
依赖缺失是第三种失败,报错话术很具体:Action {name} requires browser_session but none provided.、requires page_extraction_llm but none provided、requires file_system but none provided。前两条字符串还被 execute_action() 的 except ValueError 段落反过来做了一次子串匹配:命中就原样抛出,保住原话;file_system 那条没进匹配名单,会被统一包成 Error executing action。看到这类信息,方向是”注入链路没接上”,不是”参数填错了”。
四、模型看到的东西:schema 和文本两个通道
create_action_model() 的做法值得单独看。它没有造一个”所有动作各占一个可选字段”的大模型,而是给每个动作单独造一个只有一个字段的模型,字段名就是动作名,字段类型是该动作的参数模型,字段描述取 action.description,模型名形如 ClickActionModel。然后把这批模型用 Union 拼起来,套进一个 RootModel 子类 ActionModelUnion,并把它的 __name__ 和 __qualname__ 都改写成 ActionModel。
这个绕法解决的是”模型只能选一个动作”的表达问题:联合类型天然排他,比一堆可选字段更难填错。代价是 RootModel 会挡住外层调用惯用的方法,所以 ActionModelUnion 里手工把 get_index()、set_index()、model_dump() 转发到 self.root。没有可用动作时它返回一个空的 EmptyActionModel;只有一个动作时直接返回那一个,不套联合。
文本通道是另一条。RegisteredAction.prompt_description() 读参数模型的 model_json_schema(),把属性逐个拼成 param=type (description),最终格式是 action_name: Description. (param1=type, param2=type)。有意思的是,browser_use/tools/service.py 里有几个内置动作的 description 传的就是空字符串——search、navigate、upload_file、send_keys、dropdown_options 都是。也就是说这些动作的语义不靠这行文本承载,而是靠动作名、参数字段上的 Field(description=...),以及 browser_use/agent/system_prompts/ 下那 8 份系统提示词。反过来说,自定义动作没有那份提示词兜底,描述留空就等于把语义全押在函数名上。
五、动作变多时,靠什么控制暴露面
注册表里的动作数量和模型每一步实际看到的数量不是一回事。收窄的手段有几条,性质各不相同。
排除名单。 Registry.__init__ 收 exclude_actions,action() 装饰器在注册前先查一遍,命中就原样返回函数、不入表。另有 exclude_action() 方法能在初始化之后动手:既追加进排除名单防止重新注册,也从 self.registry.actions 里 del 掉已注册的那条。browser_use/agent/service.py 里两条路都走了:构造 Tools 时传 exclude_actions = ['screenshot'] if use_vision != 'auto' else [],紧接着又补一次 self.tools.exclude_action('screenshot'),注释写明是为了在用户自带 tools 实例的情况下也强制生效。注意两处填的都是函数名,不是描述。
域名过滤。 action() 接 domains(allowed_domains 是它的别名,两个同时传会抛错)。匹配逻辑在 ActionRegistry._match_domains(),走的是 glob 通配,RegisteredAction 的注释里给的例子是 ['*.google.com', 'www.bing.com', 'yahoo.*']。
过滤的语义要仔细读,schema 通道和文本通道在无 URL 时一致、给了 URL 之后就分岔:page_url 为 None 时两边都只收 domains is None 的动作;给了 page_url,create_action_model() 收的是”无过滤器的”加”过滤器匹配上的”两拨,而 get_prompt_description() 只渲染带过滤器且匹配上的那一拨,因为无过滤器的那批已经进了系统提示词、不必再重复一遍。带域名过滤的动作会被 browser_use/agent/prompts.py 塞进 <page_specific_actions> 标签,跟浏览器状态一起送给模型。browser_use/agent/service.py 的 _update_action_models_for_page() 每步都会用当前 URL 重建 ActionModel。
白名单。 create_action_model(include_actions=[...]) 只收指定的几个。仓库里的用法是 include_actions=['done'],单独编译一个只含收尾动作的模型。
换参数模型。 Tools._register_click_action() 会先把注册表里已有的 click 删掉再重新注册:开了坐标点击就用带 coordinate_x/coordinate_y 的 ClickElementAction,没开就用只有 index 的 ClickElementActionIndexOnly。同一个动作名,schema 宽窄不同。这是收窄”参数空间”,不是收窄”动作数量”。
跟这层相邻但不同的是 terminates_sequence。它是 RegisteredAction 上的一个布尔字段,navigate、search、go_back、switch、evaluate 都标了 True。browser_use/agent/service.py 的 multi_act() 执行到这类动作后会中断剩下的排队动作,同时还有一层运行时检测:比对动作前后的当前 URL 和聚焦目标,变了就中断。它管的是”一次输出多个动作时哪些还能接着执行”,跟”模型能看到什么”是两回事。想把这类编排约束跟参数校验分开想,可以对照 参数校验该放在哪一层。
六、边界与代价:这个设计放弃了什么
放弃了静态可读性。 内置动作绝大多数定义在 Tools.__init__ 内部的闭包里,剩下 click 和 done 挪进了 _register_click_action() 与 _register_done_action()——同样是闭包,只是换了个位置;browser_use/tools/service.py 因此长到两千多行。好处是能直接闭包捕获 self(比如 self._click_by_index),代价是想知道”到底有哪些动作、参数长什么样”,得读构造函数或运行时打印注册表,靠 IDE 跳转拿不到全貌。
放弃了一部分签名自由。 **kwargs 不许用;参数名跟注入项集合冲突就报错;返回值类型也被 Tools.act() 卡住——只接受 str、ActionResult、None,别的类型直接 raise ValueError(f'Invalid action result type: ...')。
schema 体积随动作数量线性涨。 每个动作一个独立模型,联合起来送给模型,每步还要按 URL 重建一遍。动作多的时候,这份 schema 本身就在占上下文预算。这也是域名过滤存在的实际理由——examples/custom-functions/action_filters.py 的文件头注释把它写成”限制决策疲劳”,把动作只暴露在有意义的页面上。
域名过滤只到域名这一层。 它是 glob 不是正则,看的是 URL 的域名部分。同一个域名下”只在登录页可用”这种需求,action_filters.py 里的做法是在函数体里自己判断当前 URL、不满足就直接返回。别指望过滤器帮你做路径级的门禁。
它明确不管的事,值得单独列:
- 不管权限与审批。注册表没有”这个动作需要人确认”的概念,任何注册进去的动作模型都可以选、选中就执行。要人工确认,得自己在动作实现里做。
- 不管幂等与重放。同一个动作被连续选两次,注册表不会去重,只有
multi_act()的页面变化守卫会在页面发生变化时中断后续排队动作。 - 不管目标站点的使用条款。这些动作会驱动真实浏览器去访问第三方站点,条款是否允许自动化访问、访问频率是否合规,注册表一概不判断,责任在你。
- 不管验证码与反自动化机制。真实站点上遇到人机校验、行为风控、账号异常判定,是这类工具的常态;
navigate动作在页面内容为空时给的错误信息里就把 “anti-bot measures” 列成了可能原因之一。合规的做法是换途径(官方 API、授权的数据接口)或者转人工,不是去研究怎么绕过。 - 敏感数据只在参数层做了一件事。
execute_action()会调_replace_sensitive_data(),把参数里<secret>label</secret>形式的占位符替换成真值;新格式{domain_pattern: {key: value}}会先按当前 URL 匹配域名,旧格式{key: value}的注释写得很坦白:对所有域名可见,仅为兼容保留。占位符名以bu_2fa_code结尾时,它会用pyotp现算一个 6 位 TOTP 码。替换之后这些明文就进了页面输入框和后续的 DOM 状态,注册表管不到。带登录态跑 agent 前,先想清楚这条数据路径的暴露面,可以对照 最小权限怎么落到 agent 上。
七、上手与避坑清单
动作函数里别写 **kwargs。 会踩是因为写惯了转发式的工具函数。注册期就抛 ValueError,不是运行时才发现。想给一堆可选参数带默认值,就显式定义一个 param_model,把默认值放在 Pydantic 字段上。
给参数取名前先过一遍注入项集合。 会踩是因为 context、page_url、file_system 这些名字太顺手。撞上而类型不同会在注册期报 conflicts with special argument injected by tools;撞上而类型也一致,参数会被注入而不是被模型填,表现是”模型怎么都不传这个值”。改名或者确认你真的想要注入。
每个动作参数都标类型注解。 会踩是因为注解缺失不报错。缺注解时兜底成 str,模型会给你送字符串,数值计算的地方就在运行时炸。
自定义动作的描述别留空。 会踩是因为看内置动作里 search、navigate 传的就是空字符串,以为可以照抄。它们靠 system_prompts/ 那 8 份提示词兜底,你的动作没有。描述会同时进 schema 的字段描述和提示词文本行,两处都靠它。
动作参数不要多塞字段测试。 会踩是因为 ActionModel 配了 extra='forbid',多一个字段就是整条动作校验失败,错误信息是 Invalid parameters,容易误判成类型不对。要放松只能在自己的参数模型上配 extra='ignore',像 NoParamsAction 那样。
带 domains 的动作在系统提示词里找不到,是设计如此。 会踩是因为它看起来像”没注册成功”。无 URL 场景只渲染无过滤器的动作,带过滤器的那批要等到实际页面 URL 匹配上,才会出现在 <page_specific_actions> 里。验证方式是先把 agent 开到目标域名的页面上再看。
exclude_actions 传的是函数名。 会踩是因为提示词里看到的是描述文本,容易拿描述去填。它比对的是 func.__name__,写错了不会报错,只是静默无效。
动作里别返回自定义对象。 会踩是因为返回个 dict 看起来很自然。Tools.act() 只认 str、ActionResult、None,其余抛 Invalid action result type。要带结构就用 ActionResult 的 metadata 字段。
长耗时动作注意单动作超时。 会踩是因为超时不是在你的动作里配的。browser_use/tools/service.py 有一个全局的单动作墙钟上限,默认值定义在 _ACTION_TIMEOUT_FALLBACK_S,可以用环境变量 BROWSER_USE_ACTION_TIMEOUT_S 或者 tools.act(action_timeout=...) 覆盖;两个入口都会拒绝非有限值和非正值并回落到默认。超时后返回的是带 error 的 ActionResult,agent 还能继续,不会卡死。
测试时可以绕开模型直接调动作。 Tools.__getattr__ 做了转发:属性名命中注册表里的动作时,返回一个包装器,它自己造一个临时的动作模型再走 act(),所以错误处理和结果归一化跟正式链路一致。想给动作写单测,这是现成的入口。
收束
这层设计的取舍其实挺清楚:用签名内省和 Pydantic 动态建模换掉手写 schema 的重复劳动,用”每步按 URL 重编译”换取暴露面可控,代价是静态可读性和一部分签名自由。是否值得,取决于你的动作集有多大、有多少动作只在特定站点有意义。
接着往下读,建议按这个顺序:先通读 browser_use/tools/registry/views.py,它篇幅最短,是整层的地基;再读 browser_use/tools/registry/service.py 的 _normalize_action_function_signature() 和 create_action_model();然后挑 browser_use/tools/service.py 里的 click(看依赖注入)、extract(看 LLM 参与的动作)、write_file(看不碰浏览器的动作)三个内置动作看范式;最后看 examples/custom-functions/action_filters.py,那是域名过滤的完整示例。
自检三问:你的每个自定义动作,参数是否都有类型注解和有意义的描述?有没有参数名撞进注入项集合?有没有哪个动作应该加 domains 却没加,正在无谓地占着每一步的 schema 预算?
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 的 actor 层 和 browser-use 的 MCP 双向设计。