browser-use 的 LLM 适配层怎么接国产模型与本地模型
本文基于 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 字段、两个字符串属性 provider 和 name,以及一个异步的 ainvoke。ainvoke 用 overload 声明了两种形态——不传 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 字段填 None、stop_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.py、browser_use/llm/ollama/serializer.py | 某家把多模态内容拼错、报 400 时 |
SchemaOptimizer | 把 Pydantic 的 $ref / $defs 全部内联展平,剔掉 additionalProperties,按需去掉 minItems、default | browser_use/llm/schema.py | 服务端抱怨 JSON Schema 里有它不认识的关键字 |
ChatInvokeCompletion / ChatInvokeUsage | 统一回包:completion、thinking、usage、stop_reason、stop_details | browser_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_items 和 remove_defaults,专门用来伺候那些不接受这两个关键字的服务。
ChatInvokeUsage 的字段注释暴露了这层抽象的漏洞位置:prompt_cache_creation_tokens 标着 Anthropic only,prompt_image_tokens 标着 Google only,还有 prompt_cache_creation_5m_tokens、prompt_cache_creation_1h_tokens、pricing_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_schema 且 strict: True 是两种完全不同的实现路子,同一个动作 schema 在两边的成功率也不一样。
ChatDeepSeek 还有一个隐性行为:如果你的 base_url 以 /beta 结尾,它会把消息列表最后一条 assistant 消息加上 prefix: True,并允许透传 stop。这是官方的对话前缀续写能力,普通路径上用不到,但改 base_url 时得知道它会顺带改行为。
第二条是 OpenAI 兼容入口。 只要服务方提供 OpenAI 格式的 /v1/chat/completions,就可以直接用 ChatOpenAI 加 base_url。仓库 examples/models/ 下这类例子最多,qwen.py、modelscope_example.py、moonshot.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"}}]——参数对象被拍平成了字符串。注释给的建议是在提示词里补正确格式的具体例子。这类错误在日志里表现为校验失败,不是网络错误,别去查网络。
还有两个必须知道的边角:
一是名字工厂并不覆盖全部 provider。browser_use/llm/models.py 里 get_llm_by_name 支持的前缀只有 openai、azure、google、anthropic、mistral、oci、cerebras、bu 这几个,而且 oci 会直接抛 ValueError,让你去手工构造 ChatOCIRaw。DeepSeek、Groq、Ollama、OpenRouter 这些有目录但不在工厂列表里,只能直接实例化对应类。你如果打算用配置字符串统一切模型,这条线得自己补。
二是agent 会替你关掉视觉。browser_use/agent/service.py 里有一段带 TODO 注释的处理:模型名里包含 deepseek 就打印警告并把 use_vision 置为 False;grok-3 和 grok-code 同样处理。所以你传 use_vision=True 却发现截图没送出去,不是 bug。官方 DeepSeek 示例本来就显式写了 use_vision=False。这条判断是按模型名字符串做的,你用兼容端点跑一个名字里带 deepseek 的模型,也会被一起降级。
四、接本地模型:ChatOllama 省掉了什么
browser_use/llm/ollama/chat.py 是整个目录里最短的实现之一,参数只有 model、host、timeout、client_params、ollama_options。没有 api_key——本地服务不需要。examples/models/ollama.py 三行注释就是全部前置条件:装 Ollama、ollama serve、ollama 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=None。ChatDeepSeek 三条路径同样是 usage=None。而 ChatOpenAI 有一个 _get_usage 方法,会去读 prompt_tokens_details.cached_tokens。Agent 那边有 calculate_cost 参数和 TokenCost 服务,会给主模型、page_extraction_llm、judge_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_template 按 is_browser_use_model、is_anthropic_4_5 与 flash_mode 的组合挑文件,其中一支注释说明 Anthropic 4.5 系模型需要 4096 token 以上的提示词才能命中缓存。所以「换个 provider 只是换一个类」这句话不成立——提示词模板也可能跟着换。
参数默认值是为浏览器自动化调过的,不是中性值。 ChatOpenAI 默认 temperature=0.2、frequency_penalty=0.3(注释说是为了避免 4.1-mini 这类模型无限吐制表符)、max_retries=5(注释写明为自动化可靠性提高了重试次数)、max_completion_tokens=4096。命中 reasoning_models 列表的模型会改走 reasoning_effort,并把 temperature 和 frequency_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 语义。ChatOpenAI 在 response.choices 为空时抛的 ModelProviderError 消息里专门写了提示:如果你用 base_url 走代理,请确认它实现了 OpenAI 的 /v1/chat/completions 语义、且 choices 是非空列表,报错里还会把 base_url 打出来。避法:直接拿这个 base_url 用 curl 打一发原始请求,看回包结构。
三、结构化输出报 400 时,先在 schema 上做减法。
会踩是因为动作 schema 里有 minItems、default、anyOf 的组合,不是每家都吃。避法:按 moonshot.py 的路子逐项试 remove_min_items_from_schema、remove_defaults_from_schema,还不行再加 add_schema_to_system_prompt,让 schema 以文本形式进系统消息。README 里记的 Mistral 情况也是同一类——ChatMistral 会自动剔掉 minLength、maxLength、pattern、format 这些它不支持的关键字。
四、动作参数被拍平,是模型问题不是代码问题。
会踩是因为动作 schema 是嵌套的联合类型,中小模型容易把 {"navigate": {"url": ...}} 写成 {"navigate": "..."}。qwen.py 的注释记下了这个形态。避法:在提示词里给正确格式的具体例子;Agent 有 extend_system_message 参数,官方 DeepSeek 示例就是用它追加规则的。别指望靠调 temperature 解决。
五、视觉被静默关掉,先看模型名。
会踩是因为降级判断是对 self.llm.model.lower() 做子串匹配的,模型名里带 deepseek、grok-3、grok-code 都会被置为 use_vision=False,只在日志里留一条警告。避法:跑起来先看启动日志有没有那条警告,而不是去改序列化器。
六、本地模型别指望 usage 数据。
会踩是因为 ChatOllama 和 ChatDeepSeek 的返回都是 usage=None,TokenCost 拿不到东西,看起来像统计模块坏了。避法:需要横向比上下文消耗时,自己在调用外面量,或者只在有 usage 的 provider 上做对照。
七、配 fallback 时让两个模型的输出上限不同。
会踩是因为 ModelOutputTruncatedError 在同一模型上重试是无效的——它用 400 把自己排除出重试循环,指望的是切到一个上限更高的模型。避法:Agent 的 fallback_llm 别配一个参数几乎相同的孪生模型,否则切过去照样截断,而且注释写明切换只有一次机会。
八、拿真实账号跑之前先在隔离环境跑通。 会踩是因为模型的动作会真的点下去,误操作没有撤销键。避法:先用一个干净的浏览器配置、无登录态、目标是你自己可控的页面,把动作 schema 和模型配合走顺了,再考虑接触真实业务;碰到验证码就停下来交给人处理,别把它当成需要技术手段解决的障碍。
收个尾
接模型这件事在这个仓库里可以拆成三个判断:入口选哪条(专属类还是 ChatOpenAI 加 base_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.py 和 serializer.py 对照着读一遍。最后回到 examples/——整个示例目录一共 124 个文件,其中 examples/models/ 那一组里,很多注释记录的是维护者真实踩过的坑,比文档更值钱。项目采用 MIT 许可证,读、改、抄都不受限,但代码在高频变动,读之前先确认你手上的 commit。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 跑通第一个任务 和 browser-use 命令行怎么用。