browser-use 的 LLM 适配层怎么接国产模型与本地模型

2026-07-30

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

这层适配的真正难点不是把消息转成各家的请求格式,而是把「模型必须返回一个能被 Pydantic 校验通过的动作对象」这件事,在十几家 JSON Schema 支持程度参差不齐的服务上稳定复现。 你看 browser_use/llm/ 下那 15 个 provider 目录,绝大部分代码量都花在这件事上:谁支持 strict 模式的 json_schema、谁只认 json_object、谁必须绕成 function calling、谁连 minItems 都会报错。想清楚这一点,接模型时该改哪个参数就不用靠猜了。

站内已有几篇相邻的文章,分工不同:pi 的 provider 层怎么设计 拆的是另一个项目的抽象取向,Agent 内部的模型分层调度 讲的是跨项目通用的分派策略,国产 AI 编程工具对比 是工具选型视角。本篇只钉在 browser-use 这一个仓库的 browser_use/llm/ 目录里,讲它的代码是怎么组织的、以及你接一个它没有官方支持的模型时会撞到哪几行。

一、这一层要解决什么

browser_use/llm/base.py 开头留了一句注释,说明这个项目已经把代码从 langchain 切走了。这句话解释了整个目录的存在理由:它不打算依赖一个通用的模型抽象层,而是自己定义一个极窄的契约,然后为每家服务写一份实现。

契约本身很短。BaseChatModel 是一个 Protocol,而且加了 @runtime_checkable

@runtime_checkable
class BaseChatModel(Protocol):
	_verified_api_keys: bool = False

	model: str

	@property
	def provider(self) -> str: ...

	@property
	def name(self) -> str: ...

	@property
	def model_name(self) -> str:
		# for legacy support
		return self.model

要求就四样:一个 model 字段、两个字符串属性 providername,以及一个异步的 ainvokeainvokeoverload 声明了两种形态——不传 output_format 时返回 ChatInvokeCompletion[str],传了 Pydantic 模型时返回 ChatInvokeCompletion[T]

这里有个容易忽略的细节:base.py 里给 Protocol 实现了 __get_pydantic_core_schema__,返回 core_schema.any_schema()。注释写得很直白,是为了让这个 Protocol 能被塞进 Pydantic 模型里,从而给 agent 的设置做类型标注。用 Protocol 而不是抽象基类,意味着你自己写的类不需要继承任何东西,只要方法签名对得上就能传给 Agent

选择 Protocol 的代价也在这里:编译期没人替你检查语义。你的 ainvoke 返回了一个 ChatInvokeCompletion,但 usage 字段填 Nonestop_reason 不填,类型检查一样过,只是后面的 token 统计和截断判断会静默失效。

二、一次请求在这一层里流过哪些零件

ChatOpenAI 为例,ainvoke 内部的顺序是固定的:先把统一消息交给序列化器,再按是否需要结构化输出决定要不要构造 schema,发请求,最后把回包整形成统一结构。

组成部分它负责什么仓库位置你什么时候会碰到它
BaseChatModel 协议定义 model / provider / name / ainvoke 四项契约browser_use/llm/base.py自己写一个 provider,或做类型标注时
统一消息类型SystemMessage / UserMessage / AssistantMessage 与文本、图片、拒答三种内容块browser_use/llm/messages.py想改提示词组装、或排查图片有没有送进去
各家序列化器把统一消息翻成该服务的请求体browser_use/llm/deepseek/serializer.pybrowser_use/llm/ollama/serializer.py某家把多模态内容拼错、报 400 时
SchemaOptimizer把 Pydantic 的 $ref / $defs 全部内联展平,剔掉 additionalProperties,按需去掉 minItemsdefaultbrowser_use/llm/schema.py服务端抱怨 JSON Schema 里有它不认识的关键字
ChatInvokeCompletion / ChatInvokeUsage统一回包:completionthinkingusagestop_reasonstop_detailsbrowser_use/llm/views.py做用量统计、判断是不是被截断
三层异常ModelProviderError(502)、ModelRateLimitError(429)、ModelOutputTruncatedError(400)browser_use/llm/exceptions.py想区分「换个模型能救」和「重试也没用」
名字工厂get_llm_by_name('openai_gpt_4o') 这类按字符串取实例browser_use/llm/models.py想用配置字符串切模型时
懒加载入口_LAZY_IMPORTS 表 + 模块级 __getattr__browser_use/llm/__init__.py装了一堆 SDK 又不想全部 import 时

几个值得单独说的点。

SchemaOptimizer.create_optimized_json_schema 做的是展平:把 $defs 里的定义全部内联到引用点,同时保留完整的 description 不做截断——因为动作 schema 的 description 就是模型理解每个动作怎么用的唯一依据。它还有两个开关 remove_min_itemsremove_defaults,专门用来伺候那些不接受这两个关键字的服务。

ChatInvokeUsage 的字段注释暴露了这层抽象的漏洞位置:prompt_cache_creation_tokens 标着 Anthropic only,prompt_image_tokens 标着 Google only,还有 prompt_cache_creation_5m_tokensprompt_cache_creation_1h_tokenspricing_multiplier。也就是说统一回包并没有真的把差异抹平,而是把各家的特有维度都列进同一个结构里,谁不填就是 None

异常分三层不是为了好看。ModelOutputTruncatedError 的类注释写明了用意:它用 400 状态码把自己排除在同 provider 的重试循环之外,但 agent 的 fallback 切换会把它当作可恢复错误——因为换一个模型可能有更高的输出上限。Agent 的构造参数里有 fallback_llm,切换逻辑在 _try_switch_to_fallback_llm,注释说明一旦切过去就用到本轮结束,而且 401、402 这类密钥失效、余额不足也会触发切换。这个设计取向可以和 多模型 fallback 的设计方法 对照看。

三、接国产云端模型:先判断走哪条路

这层给你两条入口,选错了会白折腾。

第一条是专属类。 DeepSeek 有独立目录,browser_use/llm/deepseek/chat.py 里的 ChatDeepSeek 默认 base_url 就是官方地址:

llm = ChatDeepSeek(
	base_url='https://api.deepseek.com/v1',
	model='deepseek-chat',
	api_key=deepseek_api_key,
)

这段来自 examples/models/deepseek-chat.py。这个类的 ainvoke 写了三条互斥路径,注释里编了号:普通文本、function calling、以及官方 response_format 的 JSON 输出。需要结构化输出时它走的是 function calling——把 output_format.__name__ 当作工具名,把优化后的 schema 塞进 parameters,再用 tool_choice 强制模型必须调这个工具。这跟 ChatOpenAI 直接下 json_schemastrict: True 是两种完全不同的实现路子,同一个动作 schema 在两边的成功率也不一样。

ChatDeepSeek 还有一个隐性行为:如果你的 base_url/beta 结尾,它会把消息列表最后一条 assistant 消息加上 prefix: True,并允许透传 stop。这是官方的对话前缀续写能力,普通路径上用不到,但改 base_url 时得知道它会顺带改行为。

第二条是 OpenAI 兼容入口。 只要服务方提供 OpenAI 格式的 /v1/chat/completions,就可以直接用 ChatOpenAIbase_url。仓库 examples/models/ 下这类例子最多,qwen.pymodelscope_example.pymoonshot.py 都是这个写法。另外 browser_use/llm/openai/like.py 里有个 ChatOpenAILike,继承自 ChatOpenAI,docstring 写的就是「用 OpenAI API schema 对接任意 provider」。

走这条路要提前知道的是:兼容不等于等价。moonshot.py 这个例子把三个开关全打开了,而且每行都注明了原因:

llm = ChatOpenAI(
	model='kimi-k2-thinking',
	base_url='https://api.moonshot.ai/v1',
	api_key=api_key,
	add_schema_to_system_prompt=True,
	remove_min_items_from_schema=True,  # Moonshot doesn't support minItems in JSON schema
	remove_defaults_from_schema=True,  # Moonshot doesn't allow default values with anyOf
)

add_schema_to_system_prompt 的效果是把 schema 文本用 <json_schema> 标签追加进系统消息,相当于在协议层之外再靠提示词兜一遍。这三个开关的存在本身就是结论:接一个新的兼容端点,你大概率要在 schema 的严格程度上做减法。同类问题的通用应对可以看 结构化输出不稳怎么办

qwen.py 里那段注释更值得一读,它记录了真实的失败形态:只有 qwen-vl-max 跑通了,其他型号会把动作 schema 搞混,返回 [{"navigate": "google.com"}] 而不是 [{"navigate": {"url": "google.com"}}]——参数对象被拍平成了字符串。注释给的建议是在提示词里补正确格式的具体例子。这类错误在日志里表现为校验失败,不是网络错误,别去查网络。

还有两个必须知道的边角:

一是名字工厂并不覆盖全部 providerbrowser_use/llm/models.pyget_llm_by_name 支持的前缀只有 openaiazuregoogleanthropicmistralocicerebrasbu 这几个,而且 oci 会直接抛 ValueError,让你去手工构造 ChatOCIRaw。DeepSeek、Groq、Ollama、OpenRouter 这些有目录但不在工厂列表里,只能直接实例化对应类。你如果打算用配置字符串统一切模型,这条线得自己补。

二是agent 会替你关掉视觉browser_use/agent/service.py 里有一段带 TODO 注释的处理:模型名里包含 deepseek 就打印警告并把 use_vision 置为 Falsegrok-3grok-code 同样处理。所以你传 use_vision=True 却发现截图没送出去,不是 bug。官方 DeepSeek 示例本来就显式写了 use_vision=False。这条判断是按模型名字符串做的,你用兼容端点跑一个名字里带 deepseek 的模型,也会被一起降级。

四、接本地模型:ChatOllama 省掉了什么

browser_use/llm/ollama/chat.py 是整个目录里最短的实现之一,参数只有 modelhosttimeoutclient_paramsollama_options。没有 api_key——本地服务不需要。examples/models/ollama.py 三行注释就是全部前置条件:装 Ollama、ollama serveollama pull llama3.1:8b,然后:

llm = ChatOllama(model='llama3.1:8b')

省掉的部分才是重点。

结构化输出这条路它走的是 Ollama 自己的 format 参数,而且传进去的是 output_format.model_json_schema()——原始 schema,没过 SchemaOptimizer。这意味着 $ref 没有被展平,additionalProperties 没被剔掉,也没有 remove_min_items 之类的开关可用。本地小模型面对一个带嵌套引用的动作联合类型,出错概率比云端旗舰高得多,而你在这条路上少了一层可调的余地。

用量统计也省了。ChatOllama 的两条返回路径都写死 usage=NoneChatDeepSeek 三条路径同样是 usage=None。而 ChatOpenAI 有一个 _get_usage 方法,会去读 prompt_tokens_details.cached_tokensAgent 那边有 calculate_cost 参数和 TokenCost 服务,会给主模型、page_extraction_llmjudge_llm 逐个 register_llm。回包不带 usage,这套统计对该模型就是空的——本地跑不花钱,但你想比较不同本地模型的上下文消耗,得自己在外面量。

图片的处理路径不一样。browser_use/llm/ollama/serializer.py 把文本和图片分开抽:文本拼成字符串,图片单独提取成 Image 列表,data URL 会被 base64 解码成字节,普通 URL 则原样交给 Ollama 自己去下载。这和 OpenAI 系把图片作为 image_url 内容块混在 content 里是两种结构。本地模型没视觉能力时,先确认你换的模型标签本身支持多模态,再去怀疑序列化。

错误信息也粗一档。ChatOllama 用一个 except Exception 兜住全部异常,统一包成 ModelProviderError。它不会区分限流和连接失败——本地场景下限流意义不大,但连接不上、模型没 pull、显存不够,在日志里会长得一模一样。

五、边界与代价:这层明确不管的事

它不做跨家能力对齐。 官方 README 列的正式支持范围是 OpenAI、Anthropic、Google、Groq、Ollama、DeepSeek、Mistral、Cerebras;LangChain 那条路被明确标注为 NOT OFFICIALLY SUPPORTED,代码放在 examples/models/langchain/ 下,README 建议你把那份代码抄走当参考。换句话说,超出正式支持范围的组合,出问题得你自己扛。

provider 差异会漏进上层。 browser_use/llm/messages.py 里的 cache 字段注释直接写了「只对 Anthropic 模型生效」。更明显的是系统提示词:browser_use/agent/system_prompts/ 下有 8 份 md,browser_use/agent/prompts.py_load_prompt_templateis_browser_use_modelis_anthropic_4_5flash_mode 的组合挑文件,其中一支注释说明 Anthropic 4.5 系模型需要 4096 token 以上的提示词才能命中缓存。所以「换个 provider 只是换一个类」这句话不成立——提示词模板也可能跟着换。

参数默认值是为浏览器自动化调过的,不是中性值。 ChatOpenAI 默认 temperature=0.2frequency_penalty=0.3(注释说是为了避免 4.1-mini 这类模型无限吐制表符)、max_retries=5(注释写明为自动化可靠性提高了重试次数)、max_completion_tokens=4096。命中 reasoning_models 列表的模型会改走 reasoning_effort,并把 temperaturefrequency_penalty 弹掉。你把这些默认值照搬到别的用途上,行为可能不是你要的。

API key 校验基本是空壳。 _verify_and_setup_llm 的实现只做了一件事:如果 _verified_api_keys 已为真或者配置里开了 SKIP_LLM_API_KEY_VERIFICATION,就标记为已验证并返回。别指望它在启动时替你拦下一个写错的 key。

最后是这层之外、但绕不开的代价。 这个项目驱动的是真实浏览器。你换模型只是换了决策者,动作还是落在真实页面上:如果你复用了带登录态的浏览器数据目录,模型就是拿着你的账号在操作;目标站点的使用条款是否允许自动化访问,需要你自己确认;验证码与反自动化机制会挡住流程,这是站点方的正当防线,别去想绕过它——账号被判为异常使用的后果由你承担。还有一条数据面的风险:页面上抓到的内容会随提示词发给模型服务方,页面里的手机号、订单、内部系统字段都在其中。接国产云端模型和接本地模型在这一点上的差别,比它们在结构化输出成功率上的差别更值得你先想清楚。各家服务对数据留存与训练使用的规则不同且会调整,以官方最新说明为准。

六、上手与避坑清单

一、先确认你的模型走哪条入口,再动手写代码。 会踩是因为 browser_use/llm/ 下有目录不等于 get_llm_by_name 认识它。按名字取实例只覆盖那 8 个前缀,DeepSeek、Ollama、Groq、OpenRouter 都得直接实例化类。避法:动手前直接打开 browser_use/llm/models.py,看 available_providers 那一行列的到底是哪几个。

二、报「missing or empty choices」时先查你的代理,别查模型。 会踩是因为很多聚合端点只实现了部分 OpenAI 语义。ChatOpenAIresponse.choices 为空时抛的 ModelProviderError 消息里专门写了提示:如果你用 base_url 走代理,请确认它实现了 OpenAI 的 /v1/chat/completions 语义、且 choices 是非空列表,报错里还会把 base_url 打出来。避法:直接拿这个 base_url 用 curl 打一发原始请求,看回包结构。

三、结构化输出报 400 时,先在 schema 上做减法。 会踩是因为动作 schema 里有 minItemsdefaultanyOf 的组合,不是每家都吃。避法:按 moonshot.py 的路子逐项试 remove_min_items_from_schemaremove_defaults_from_schema,还不行再加 add_schema_to_system_prompt,让 schema 以文本形式进系统消息。README 里记的 Mistral 情况也是同一类——ChatMistral 会自动剔掉 minLengthmaxLengthpatternformat 这些它不支持的关键字。

四、动作参数被拍平,是模型问题不是代码问题。 会踩是因为动作 schema 是嵌套的联合类型,中小模型容易把 {"navigate": {"url": ...}} 写成 {"navigate": "..."}qwen.py 的注释记下了这个形态。避法:在提示词里给正确格式的具体例子;Agentextend_system_message 参数,官方 DeepSeek 示例就是用它追加规则的。别指望靠调 temperature 解决。

五、视觉被静默关掉,先看模型名。 会踩是因为降级判断是对 self.llm.model.lower() 做子串匹配的,模型名里带 deepseekgrok-3grok-code 都会被置为 use_vision=False,只在日志里留一条警告。避法:跑起来先看启动日志有没有那条警告,而不是去改序列化器。

六、本地模型别指望 usage 数据。 会踩是因为 ChatOllamaChatDeepSeek 的返回都是 usage=NoneTokenCost 拿不到东西,看起来像统计模块坏了。避法:需要横向比上下文消耗时,自己在调用外面量,或者只在有 usage 的 provider 上做对照。

七、配 fallback 时让两个模型的输出上限不同。 会踩是因为 ModelOutputTruncatedError 在同一模型上重试是无效的——它用 400 把自己排除出重试循环,指望的是切到一个上限更高的模型。避法:Agentfallback_llm 别配一个参数几乎相同的孪生模型,否则切过去照样截断,而且注释写明切换只有一次机会。

八、拿真实账号跑之前先在隔离环境跑通。 会踩是因为模型的动作会真的点下去,误操作没有撤销键。避法:先用一个干净的浏览器配置、无登录态、目标是你自己可控的页面,把动作 schema 和模型配合走顺了,再考虑接触真实业务;碰到验证码就停下来交给人处理,别把它当成需要技术手段解决的障碍。

收个尾

接模型这件事在这个仓库里可以拆成三个判断:入口选哪条(专属类还是 ChatOpenAIbase_url)、结构化输出走哪条通道(json_schema 严格模式、json_object、还是被迫走 function calling)、以及这条路上你放弃了什么(usage 为空、schema 未经优化、视觉被降级)。三个判断都能在代码里读出答案,不需要试。

想继续往下挖,按这个顺序读文件效率最高:browser_use/llm/base.py 看契约有多窄,browser_use/llm/views.py 看统一回包留了哪些各家特有的口子,browser_use/llm/schema.py 看动作 schema 被怎么改造,然后挑一个和你的目标服务最接近的 provider 目录,把 chat.pyserializer.py 对照着读一遍。最后回到 examples/——整个示例目录一共 124 个文件,其中 examples/models/ 那一组里,很多注释记录的是维护者真实踩过的坑,比文档更值钱。项目采用 MIT 许可证,读、改、抄都不受限,但代码在高频变动,读之前先确认你手上的 commit。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 跑通第一个任务browser-use 命令行怎么用

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