browser-use 的两层 skills:技能目录与技能服务各管什么

2026-07-30

本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。

在 browser-use 里,「skills」是两个撞名的概念:仓库根目录 skills/ 下那几个目录是纯文本操作手册,读它的是你的编码代理;browser_use/skills/ 这个 Python 包是运行时服务,它把远端定义好的能力拉下来、转成参数模型、注册成 agent 可调用的动作。 两者代码零耦合,解决的问题也不重叠。把它们混为一谈,最常见的后果是你以为装了个 SKILL.md 就能让 Agent(skills=[...]) 生效,或者反过来,配了 API key 却发现你的编码代理还是不知道怎么开浏览器。

这篇就把两层分开讲清楚,顺带说明第二层里那个容易被忽略的细节:cookie 是从你本地浏览器会话里取出来、跟着参数一起发到远端执行的。

一、先看目录结构,两层根本不在一处

在仓库里 ls 一下就能看出来。根目录 skills/ 下面是六个目录:browser-usecloudopen-sourceqaremote-browserx402。每个目录里有一份 SKILL.md,有几个还带 references/ 子目录放分主题的参考文档(比如 skills/open-source/references/ 里按 agent.mdbrowser.mdtools.mdmodels.md 这样切开)。这些文件里一行 Python 代码都没有,全是自然语言指令加命令示例。

browser_use/skills/ 是一个正常的 Python 包:service.pyviews.pyutils.pyinstall.pybrowser_use.pyREADME.md,另外还有一个 browser-use/ 子目录里放着一份 SKILL.md

注意最后这个细节:包里那份 browser_use/skills/browser-use/SKILL.md 和根目录的 skills/browser-use/SKILL.md 内容逐字一致。原因在 pyproject.toml 里能找到 —— 打包时会收 browser_use/skills/**/*.md。也就是说包内那份是为了随 wheel 分发而存在的副本,browser_use/skills/browser_use.py 里的 skill_text() 就是先去读它:

def skill_text() -> str:
	"""Return the canonical Browser Use skill."""
	skill_path = Path(__file__).resolve().parent / 'browser-use' / 'SKILL.md'
	if skill_path.exists():
		return skill_path.read_text(encoding='utf-8')

这就是唯一的交叉点 —— 一个安装器,恰好住在 Python 包里,负责把第一层的文本文件搬到你的编码代理能读到的位置。除此之外两层互不知情。

二、第一层:写给编码代理读的操作手册

skills/browser-use/SKILL.md 的 frontmatter 只有 namedescription 两个键,描述是「Direct browser control via CDP for web interaction: automation, scraping, testing, screenshots, and site/app work.」。正文结构很直白:什么时候不该用它、怎么调用、本地 Chrome 出问题怎么办、远端浏览器、页面操作流程、录屏、交互技巧索引、设计取舍、坑、域名技能。

值得单独拎出来的是它开头那节 When Not to Use:如果一个普通 HTTP 请求就能读到的公开页面、API、文档,就别开浏览器,用 curl 或者你自己的抓取工具;只有当任务需要交互(点击、输入、导航)、需要用户的登录态、需要 JS 渲染,或者页面有机器人防护时才升级到浏览器。一份技能文档把「不要用我」写在最前面,这个取向本身就值得学 —— 它是在替模型省掉一整条昂贵路径。

调用方式是 CLI 加 heredoc:

browser-use <<'PY'
print(page_info())
PY

文档里明确写了辅助函数是预导入的,首次导航用 new_tab(url) 而不是 goto_url(url),导航后调 wait_for_load(),当前标签页失效或是内部页时调 ensure_real_tab()。定位元素优先走无障碍树而不是截图:cdp("Accessibility.getFullAXTree")["nodes"] 里每个元素都有 role、name 和 backendDOMNodeId,再用 cdp("DOM.getBoxModel", backendNodeId=n) 拿盒模型算中心点,交给 click_at_xy(x, y),最后用一次针对性的 js(...)page_info() 验证点中了。它在「设计取舍」一节解释了为什么默认坐标点击:CDP 鼠标事件在合成器层面穿透 iframe、shadow DOM 和跨域边界。

环境变量层面它暴露了几个开关:BH_DOMAIN_SKILLS=1 打开域名技能(默认关),打开后要先读 $BH_AGENT_WORKSPACE/domain-skills/<site>/ 下的文件再自己发明做法;BH_RECORD=1BH_RECORD=0 为单个进程覆盖录屏偏好;连接模型走默认守护进程、BU_NAMEBU_CDP_URLBU_CDP_WSstart_remote_daemon(...)。任务特定的辅助函数要求写进 $BH_AGENT_WORKSPACE/agent_helpers.py,核心辅助函数保持精简。

另外五个目录是不同用途的手册,分工靠 description 里的路由说明写死。skills/open-source/SKILL.md 是一张查表:想问 Agent 参数读 references/agent.md,问自定义工具读 references/tools.md,问 MCP server 与 skills 集成读 references/integrations.md;它的描述里直接写了「不要用它处理 Cloud API/SDK 用法 —— 那个用 cloud 技能;不要用它做 CLI 直接驱动浏览器 —— 那个用 browser-use 技能」。

另一个能看出设计意图的细节是权限声明。六份手册里只有 browser-use 那份的 frontmatter 是光板的 namedescription,其余五份都额外写了 allowed-tools,而且给的口子宽窄差得很远:cloudopen-source 这两份本质上是查表,就只给 Readqa 要真跑浏览器还要派子任务,给的是 Bash, Read, Taskx402 要在你项目里写配置和代码,给到 Bash, Read, Write, Editremote-browser 反而收得最紧,只给 Bash(browser-use:*),也就是除了这一个命令什么都不许跑。权限面是跟着这份手册真要干的活裁的,不是统一发一套 —— 这个思路值得抄:技能文档本身就是权限申请书,能少要就少要。

装载靠 browser_use/skills/install.py。它的入口在 CLI 里挂成 browser-use skill,两个子命令:show 把技能文本打到标准输出,install 写文件。目标目录是一张常量表,键是 agentsclaudecodexcopilotcursorgeminiopencode,前六个都拼成 ~/.<助手名>/skills/browser-use/SKILL.mdopencodeXDG_CONFIG_HOME(缺省 ~/.config)下的 opencode/skills/browser-use/SKILL.md,并且额外把 ~/.config/opencode/... 这条旧路径也加进候选清单做兼容。--target 默认值是 all,也就是一口气全写。--path 可以指定自定义目录或直接给一个 SKILL.md 路径。--force 这个参数的帮助文本写得很坦白:只为兼容而保留,因为 install 默认就覆盖已有的 SKILL.md

技能文本从哪来也有一层回退:install 默认会先跑 uv tool install --python 3.12 --upgrade --force browser-use(找不到 uv 就直接报错让你先装 uv,可以用 --no-install 跳过这步),然后去找 browser-harness 可执行文件 —— 先 shutil.which,再看 ~/.local/bin 下的 browser-harnessbrowser-harness.exe;找到了就执行 browser-harness skill 拿文本,找不到就退回读包内那份 SKILL.md

从 CLI 拿到的文本还要过一道改名。browser_use/skills/browser_use.py 里的 as_browser_use_skill() 会重写 frontmatter 的 namedescription,把正文标题 # browser-harness# Browser Harness 换成 # Browser Use,然后用一条带否定前视的正则 (?<!/)browser-harness 把正文里的品牌名全替换掉 —— 前面带斜杠的不换,这样 github.com/browser-use/browser-harness/... 这类仓库链接能原样保留。这个函数存在本身就说明了一件事:这层技能的正典源头是另一个叫 browser-harness 的工程,browser-use 这边做的是身份对齐后的分发。

三、第二层:把远端能力注册成 agent 的一个动作

browser_use/skills/service.py 里的 SkillService 是完全另一回事。它的构造函数收 skill_ids 和可选的 api_key,key 缺省从 BROWSER_USE_API_KEY 读,读不到直接抛 ValueError。也就是说这一层不是本地文件驱动的,它需要一个账号。

async_init() 做的事是拉取加缓存。客户端是 AsyncBrowserUse,调 list_skills(page_size=..., page_number=..., is_enabled=True)。这里有两条不同的分支值得记住:

skill_ids 里出现 '*' 时走通配符模式,只取第一页、最多 100 条,代码注释写的理由是「避免 LLM 工具过载」;如果这一页确实满了,会打一条 warning 提示你想要更多就显式写 ID。给显式 ID 时反而会翻页,一直翻到请求的 ID 全部找齐,或者某页返回条数不足一页为止,同时有 max_pages = 5 的安全上限,撞上限也会 warning。拉回来的条目还要过一道 skill.status == 'finished' 的过滤,请求了却没找到的 ID 会被单独 warning 列出来。

拿到的 SDK 对象经 Skill.from_skill_response() 转成 browser_use/skills/views.py 里的 Skill 模型,字段是 idtitledescriptionparametersoutput_schema。真正把这层和模型接上的是两个转换方法:parameters_pydantic(exclude_cookies=...) 把参数 schema 列表变成一个 pydantic 模型类,output_type_pydantic 在有输出 schema 时把它也变成模型。转换逻辑在 utils.pyconvert_parameters_to_pydantic() 里,把类型字符串映射成 Python 类型:stringstrnumberfloatbooleanboolobjectdict[str, Any]arraylist[Any]cookie 也按 str 处理;required 没写时默认视为必填。

接下来是这一层最该看懂的一段。browser_use/agent/service.py 里的 _register_skills_as_actions() 会遍历所有技能,给每个技能算一个 slug 当动作名(_get_skill_slug 把标题去标点、转小写、空格和连字符换下划线,文档字符串里的例子是 [Cloned] Github Stars Tracker 变成 cloned_github_stars_tracker;重名时追加 skill.id[:4]),动作描述拼成技能描述加上带引号的标题,参数模型则用 skill.parameters_pydantic(exclude_cookies=True),最后走 self.tools.registry.action(...) 注册。这个注册动作发生在 run() 启动阶段、browser_session.start() 之后。

注意那个 exclude_cookies=True注册给模型看的参数表里是不含 cookie 参数的,模型根本不知道有这回事。 cookie 由处理函数在执行时补:它先 await self.browser_session.cookies() 从当前浏览器会话取,execute_skill() 里再把 cookie 列表拍平成 name -> value 的字典填进参数。缺了必填的 cookie 就抛 MissingCookieException,异常自带 cookie_namecookie_description 两个属性,动作层把它翻成 Missing cookies (名字): 说明 这样的错误串回给模型,让模型知道该引导用户去登录哪个站点。

参数在发出去之前会用技能自己的 pydantic 模型校验一遍,ValidationError 会被拼成逐字段的可读错误再抛 ValueError。远端调用失败时 execute_skill 不抛异常,而是返回一个 success=False 的响应对象。整条链路最终落回 ActionResult:成功就填 extracted_content,失败就填 error

用户侧的入口就一行。Agent 同时接受 skill_idsskills 两个参数,后者在源码里被注释成前者的别名,传了就以它为准;两个都传会直接 raise ValueError,报错文本还明说了让你用 skills。仓库里 examples/models/skills.py 给了最小示例,任务是问 browser-use 仓库有多少 star,技能位置传的是一个 UUID:

	agent = Agent(
		task='How many stars does the browser-use repo have?',
		flash_mode=True,
		skills=['502af156-2a75-4b4e-816d-b2dc138b6647'],
	)

一个 UUID —— 这就是两层最直观的区别。第一层的技能标识是文件路径,第二层的技能标识是远端资源 ID。

四、两层对照

组成部分它负责什么对应仓库位置你什么时候会碰到它
技能手册目录用自然语言告诉编码代理该怎么开浏览器、什么时候别开、卡住去读哪份参考skills/browser-use/SKILL.md 等六个目录让 Claude Code、Cursor 这类代理帮你操作浏览器时
手册分发副本随 wheel 一起分发的同一份文本,供 skill_text() 回退读取browser_use/skills/browser-use/SKILL.md只装了包、没克隆仓库时
技能安装器把手册写进各家助手的技能目录,必要时先升级 CLI 再从 browser-harness skill 取正典文本browser_use/skills/install.py执行 browser-use skill install
身份对齐层重写 frontmatter 与正文品牌名,保留仓库链接不动browser_use/skills/browser_use.py排查装出来的 SKILL.md 为何与上游措辞不同时
技能拉取服务凭 API key 从远端列出技能、按 ID 或通配符筛选并缓存browser_use/skills/service.pyAgent(skills=[...]) 传 UUID 时
技能模型与转换把参数 schema 转成 pydantic 模型,处理 cookie 类型参数与输出 schemabrowser_use/skills/views.pybrowser_use/skills/utils.py参数校验报错、想知道类型怎么映射时
动作注册与执行给每个技能起动作名、藏掉 cookie 参数、执行时补 cookie 并把结果转成 ActionResultbrowser_use/agent/service.py模型调了技能却拿到 Missing cookies

五、边界与代价

第一层放弃的是确定性。它是提示词,不是代码 —— 模型可以不读、读错、或者读了不照做。手册里那些「首次导航用 new_tab」「导航后 wait_for_load()」的规矩没有任何运行时强制,写得再清楚也只是概率上更容易被遵守。它也不做版本协商:install 默认覆盖已有的 SKILL.md,所以你在装出来的文件里手写的补充,下一次安装就没了 —— 手册自己给的出口是把任务特定的东西写到 agent_helpers.py,而不是改 SKILL.md

第二层放弃的是自持。它需要 BROWSER_USE_API_KEY,技能定义在远端而不在你的仓库里,这意味着技能内容变了你的代码不会有任何 diff,回放和归因都得靠日志。它也不管技能本身怎么写、怎么测 —— 从这个包的视角看,一个技能就是一份参数 schema 加一次远端调用。通配符 '*' 更是明摆着的取舍:只拿第一页、上限 100 条,宁可漏也不把模型的工具表撑爆。

安全边界要说得更直白些。这类工具驱动的是真实浏览器,很可能带着你的真实登录态。第二层还会把浏览器里的 cookie 从会话取出、随参数发往远端执行 —— 这条数据流是你在读 Agent(skills=[...]) 这一行时看不见的,它藏在 exclude_cookies=True 后面。凡是涉及公司内网系统、支付、邮箱、社交账号的场景,都要先想清楚这个外泄面能不能接受,再想功能。

另外几件它明确不管:目标站点的使用条款要你自己判断,验证码和反自动化机制会拦你(手册的取向是遇到登录墙就停下来问用户,只在 Chrome 已登录时自动走 SSO,密码、多因素、授权确认、账号选择有歧义时一律停),账号被判为异常的风险由你承担;云端浏览器在停止或超时之前一直计费,手册要求任务做完直接问用户是否关闭,别默默留着。这些都不是缺陷,是它把边界写在了脸上 —— 而绕过风控这件事,不在任何一层的职责范围里。

六、上手与避坑清单

  • 想让编码代理会用浏览器,装的是第一层。 会踩的原因是两层同名,容易以为配了 key 就万事俱备。做法:跑 browser-use skill install,然后到对应助手的 ~/.<助手名>/skills/browser-use/ 下确认 SKILL.md 真落地了,再让代理读一遍。
  • 不想让它顺手升级你的 CLI,先加 --no-install 会踩的原因是 install 默认会跑一次 uv tool install --python 3.12 --upgrade --force browser-use,在锁了版本的机器上这是个意外变更。做法:--no-install 跳过升级、直接用现有命令或包内文本。
  • 不要在装出来的 SKILL.md 里手写补充。 会踩的原因是默认覆盖,且 --force 只是个兼容用的空参数,没有「不覆盖」这个选项。做法:把项目专属的东西放进 $BH_AGENT_WORKSPACE/agent_helpers.py,手册本身当只读。
  • --target all 会往目标表里的每个助手目录都写一份。 会踩的原因是默认值就是 all,而目标表有七个键、还额外带一条 opencode 旧路径,你可能只用其中一个。做法:显式写 --target claude 之类,或者用 --path 指到你自己的目录。
  • 第二层的 skills= 收的是 UUID,不是名字也不是文件路径。 会踩的原因是被第一层的目录名带偏。做法:传远端技能 ID;写错或技能状态不是 finished 时不会报错中断,只会在日志里 warning「未找到或不可用」,所以启动阶段务必看一眼日志有没有这条。
  • skillsskill_ids 只能传一个。 会踩的原因是文档里两个都出现过,容易图省事都写上。做法:统一用 skills,另一个当历史别名看。
  • 别用 '*' 上生产。 会踩的原因是它只取第一页、上限 100 条,还会把这一批全注册成动作,工具表膨胀直接影响模型的选择质量。做法:显式列出这次任务真需要的几个 ID,理由和给 agent 设计工具时控制工具数量是同一个。
  • 看到 Missing cookies (...) 别去改参数。 会踩的原因是这个错误看着像参数漏传,其实 cookie 参数根本没暴露给模型。做法:去让用户在浏览器里把对应站点登录好,让会话里真有那个 cookie,再重试。
  • 技能注册发生在浏览器会话启动之后。 会踩的原因是它要从会话取 cookie,所以在 run() 之前去查动作表是查不到技能动作的。做法:调试时把断点或日志放在启动之后,别在构造 Agent 那一刻找。

收束

这两层的分工可以压成一句话:第一层解决「代理知不知道该怎么做」,第二层解决「代理有没有一个现成的动作可以直接调」。前者的产物是上下文,后者的产物是工具表里的一项加一次远端调用。

站内另外三篇 —— Pi 的技能装载路径ECC 的技能检索机制superpowers 的技能文件契约 —— 讲的都是「文件式技能怎么被发现和加载」这一层的不同实现,可以横着对比;本篇的重点是 browser-use 这一个仓库里同名两层的切分,尤其是第二层那种「远端可执行技能被注册成 agent 动作」的另一种含义,那已经更接近动作参数怎么校验的话题。项目采用 MIT 许可证,上面每条都能自己打开核。

接下来该读哪个文件,取决于你要解决哪层的问题:卡在「代理不会开浏览器」就把 skills/browser-use/SKILL.md 整篇读完,它的密度足够当一份 CDP 操作备忘;卡在「装到哪去了」读 browser_use/skills/install.py 的目标目录常量表;卡在「技能怎么变成动作」就顺着 browser_use/skills/service.pyexecute_skillbrowser_use/agent/service.py_register_skills_as_actions 各读一遍,cookie 那条隐式数据流看一次就再也忘不掉了。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 拆 browser-use 的遥测与观测读 browser-use 的 beta 支线

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。