browser-use 结构化输出的 schema 约束与字段缺失排查

2026-07-30

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

browser-use 里的结构化输出不是一个开关,而是两条互不相通的链路:任务终局的形状由 output_model_schema 决定,它做的事是把 done 动作整个换掉;单页抽取的形状由 extract 动作的 output_schema 决定,它做的事是把一份 JSON Schema 现场编译成 Pydantic 模型。 字段拿不到时,第一步不是改提示词,而是先判断自己卡在哪条链路上——这两条链路的失败表现很像,修法完全不同。

站内已有三篇相邻内容:结构化输出为什么不稳 讲的是跨模型、跨调用的通用治理思路,Agent 输出约束怎么落地 讲的是约束该放在哪一层,Agent 参数校验 讲的是入参这一侧的防守;本篇不重复这些框架讨论,只做一件事——把 browser-use 这个具体仓库的两条链路读通,落到文件和字段名上。

一、先看你实际会遇到的现象

跑完一次任务,你拿到的是 AgentHistoryList。取结果的入口是 final_result(),它的实现很朴素:翻到最后一个历史项的最后一个 ActionResult,返回它的 extracted_content 字符串。也就是说,默认情况下你拿到的是一段自然语言,长度不定、字段不定,写解析代码等于赌运气。

仓库里 examples/features/custom_output.py 给出的做法是声明一个 Pydantic 模型,把它交给 Agent:

class Post(BaseModel):
	post_title: str
	post_url: str
	num_comments: int
	hours_since_post: int


class Posts(BaseModel):
	posts: list[Post]


agent = Agent(task=task, llm=model, output_model_schema=Posts)

history = await agent.run()

result = history.final_result()
if result:
	parsed: Posts = Posts.model_validate_json(result)

注意这段示例的关键细节:final_result() 返回的仍然是字符串,只是这次这个字符串是一段 JSON,所以能用 model_validate_json 吃下去。这不是巧合,而是下一节那套改装的直接结果。

二、终局约束:output_model_schema 换掉了 done

browser_use/agent/service.py 的构造函数里,Agent 先把 tools 装好,再去问 self.tools.get_output_model()。这里有三种情形:Agent 和 Tools 都设了输出模型且不是同一个类,只打一条 warning,然后以 Agent 传入的为准;只有 Tools 设了,就采用 Tools 的;确定下来之后,调用 self.tools.use_structured_output_action(self.output_model_schema)

use_structured_output_actionbrowser_use/tools/service.py 里只有两行,记下模型并转手给 _register_done_action。后者才是真正的改装现场:

@self.registry.action(
	'Complete task with structured output.',
	param_model=StructuredOutputAction[output_model],
)
async def done(params: StructuredOutputAction, file_system: FileSystem, browser_session: BrowserSession):
	# Exclude success from the output JSON
	# Use mode='json' to properly serialize enums at all nesting levels
	output_dict = params.data.model_dump(mode='json')

原来那个 done 动作的参数模型是 DoneAction,字段是 text / success / files_to_display;换装之后参数模型变成 StructuredOutputAction[output_model],字段是 success / data / files_to_display,其中 data 的类型就是你那个模型。约束因此发生在动作参数这一层:模型要收尾,就必须把结果填进 data,而 data 的形状由 Pydantic 校验。

StructuredOutputAction 定义在 browser_use/tools/views.py,它挂了一个 json_schema_extra=_hide_internal_fields_from_schema,作用是把 successfiles_to_display 从对外暴露的 JSON Schema 里 pop 掉——避免这两个内部字段和用户模型里同名字段撞车。所以模型眼里看到的 done 参数会比 Python 侧的定义干净一些。

写回那一侧也做了处理:done 返回的 ActionResult 里,extracted_content=json.dumps(output_dict, ensure_ascii=False)。这就解释了为什么 final_result() 拿到的是 JSON 字符串。

除了动作层,任务文本层也被动了一刀。_enhance_task_with_schemaoutput_model_schema.model_json_schema() 序列化后拼到任务描述尾部,形如 Expected output format: <类名> 加上完整 schema JSON。约束因此是双份的:一份写在任务里给模型看,一份压在动作参数校验上。

读回结果还有一个容易踩的点。AgentHistoryList 上有个 structured_output 属性:

final_result = self.final_result()
if final_result is not None and self._output_model_schema is not None:
	return self._output_model_schema.model_validate_json(final_result)

_output_model_schema 是私有属性,Agent 在 run 收尾时才回填。仓库对此给了一个替代入口 get_structured_output(output_model),方法的文档字符串写得很直白:在沙箱执行等场景下访问结构化输出请用它,因为 _output_model_schema 这个私有属性在序列化过程中不会被保留。历史存过盘再读回来,structured_output 会是 None,这不是 bug。

三、抽取那一步:schema_utils 做的是编译

extract 动作是另一条链路。它的参数模型 ExtractActionqueryextract_linksextract_imagesstart_from_charalready_collected,以及本节的主角 output_schema。这个字段的类型标注是 SkipJsonSchema[dict | None]——意味着它不会出现在暴露给模型的动作参数 schema 里。

那它从哪来?browser_use/tools/service.py 里有一行注释和判断说清了:

# If the LLM didn't provide an output_schema, use the agent-injected extraction_schema
if output_schema is None and extraction_schema is not None:
	output_schema = extraction_schema

extraction_schema 是 Agent 构造参数,经 browser_use/tools/registry/service.pyexecute_action 组装进 special_context,再按参数名注入到动作函数里。所以实践含义是:想让抽取吐结构化数据,正常路径是你在 Agent 上显式给一份 JSON Schema,而不是期待模型自己想出一个 schema。

拿到 schema 之后,schema_dict_to_pydantic_model 负责把它变成一个运行时 Pydantic 模型。这个函数在 browser_use/tools/extraction/schema_utils.py,规则不多但每条都会影响你的字段:

顶层必须是 type: "object" 且至少有一个 property,否则抛 ValueError。组合与引用类关键字一律不支持,_UNSUPPORTED_KEYWORDS 是一个明确列出的集合:

_UNSUPPORTED_KEYWORDS = frozenset(
	{
		'$ref',
		'allOf',
		'anyOf',
		'oneOf',
		'not',
		'$defs',
		'definitions',
		'if',
		'then',
		'else',
		'dependentSchemas',
		'dependentRequired',
	}
)

_check_unsupported 在顶层和每个递归节点都会跑一遍,命中就抛错。枚举的处理是刻意放松的——'enum' in schema 时直接返回 str,源码注释写着 Literal would be stricter but LLMs are flaky。数组递归解析 items,对象递归建嵌套模型,没有 properties 的对象退化为 dict

默认值那一段最值得盯:字段在 required 里就用 ...(必填);有 default 用它;标了 nullableNone;三者都不满足时,按类型给零值——字符串 ''、浮点 0.0、整数 0、布尔 False、数组 [];枚举和嵌套对象没法凭空构造,就把类型改成 T | None 并默认 None。生成的模型继承 _StrictBase,它的 model_config 里带着 extra='forbid',多出来的键会被拒。

编译失败不会让整个动作崩,而是降级:

except (ValueError, TypeError) as exc:
	logger.warning(f'Invalid output_schema, falling back to free-text extraction: {exc}')
	output_schema = None

这是本篇最想让你记住的一处行为。schema 写得不合规,抽取会安静地退回自由文本路径,日志里只有一条 warning,而你的下游解析代码会拿到一段散文。

编译成功则走结构化路径:系统提示词明确要求只提取页面上存在的信息、不许猜、响应必须严格符合给定 schema,找不到的值用 null 或空字符串、空数组;调用时把编译出的模型作为 output_format 传给 page_extraction_llm.ainvoke;返回的 response.completion 是模型实例,model_dump(mode='json') 后既包进 extracted_content<structured_result> 段,也写进 ActionResult.metadata,结构是 browser_use/tools/extraction/views.py 里的 ExtractionResultdataschema_usedis_partialsource_urlcontent_stats

长页面由 chunk_markdown_by_structure 按结构切块,一次只处理第一块,块内有 has_more 就把 truncated 置真,content_stats 里补上 next_start_charchunk_indextotal_chunks,同时 ExtractionResult.is_partial 为真。翻页去重靠 already_collected,把上几页收集到的名称或 URL 传进去,提示词里会以 <already_collected> 段落要求跳过重复项。

把这两条链路涉及的零件摊开对照一遍,下表里的位置都是上面翻过的真实路径:

组成部分它负责什么对应仓库位置你什么时候会碰到它
output_model_schema 参数与去重逻辑决定任务终局形状,处理 Agent 与 Tools 双设时的取舍browser_use/agent/service.py想让 run() 的结果能直接反序列化
_enhance_task_with_schema把 schema JSON 追加进任务文本browser_use/agent/service.py排查模型为何忽略某字段
use_structured_output_action / _register_done_actionStructuredOutputAction[T] 重注册 donebrowser_use/tools/service.pydone 参数与你模型字段撞名时
StructuredOutputAction / _hide_internal_fields_from_schema承载 data 字段,隐藏内部字段browser_use/tools/views.py模型里有 success 之类字段时
ExtractAction.output_schema单页抽取的形状入口,对模型不可见browser_use/tools/views.py想让某一页吐结构化数据
extraction_schema 注入链把 Agent 上的 schema 送进动作参数browser_use/tools/registry/service.py抽取始终走自由文本时
schema_dict_to_pydantic_modelJSON Schema 编译成运行时模型,含降级browser_use/tools/extraction/schema_utils.py字段类型或可选性不符预期时
ExtractionResult记录本次抽取的数据、所用 schema、是否截断browser_use/tools/extraction/views.py怀疑结果只覆盖了半页
structured_output / get_structured_output从历史里解析出模型实例browser_use/agent/views.py历史存盘再读回后拿不到对象
SchemaOptimizer把 Pydantic schema 改写成各家能吃的形状browser_use/llm/schema.py可选字段被当成必填时

四、schema 到服务商手里还会被改写一次

你写的模型不会原样发出去。browser_use/llm/schema.pySchemaOptimizer.create_optimized_json_schema 会先展平所有 $ref$defs、跳过 additionalProperties、保留完整 description,然后跑两个收尾动作:把所有对象节点补上 additionalProperties: False,以及

@staticmethod
def _make_strict_compatible(schema: dict[str, Any] | list[Any]) -> None:
	"""Ensure all properties are required for OpenAI strict mode"""

它的实现就是逐层把 properties 的全部键塞进 required。这条改写有直接后果:在这条路上,你模型里的可选字段会被声明成必填。想表达”这个值可能没有”,靠的不是”不放进 required”,而是让类型允许 null(schema_utilsnullable 走的正是这条)或给一个默认值。

各家的差异也落在同一处。browser_use/llm/openai/chat.py 把它包成 response_format,名字是 agent_outputstrict: True,并且给了 remove_min_itemsremove_defaults 两个开关来兼容个别不接受这些关键字的服务;Gemini 那侧走 create_gemini_optimized_schema,函数文档写明保留调用方显式给出的 required 数组;Mistral 另有 MistralSchemaOptimizer 做二次兼容。browser_use/llm/ 下一共 15 个 provider 目录,同一份模型定义在不同 provider 上的边角行为并不一致,各家规则不同且会调整,以官方最新说明为准。

Agent 自己的输出模型也在这条链上。browser_use/agent/views.pyAgentOutput 覆写了 model_json_schema,硬把 required 设成 ['evaluation_previous_goal', 'memory', 'next_goal', 'action'];无思考与 flash 两个变体则在 schema 层 del 掉对应属性并改写 required。可以顺着这个思路理解一件事:这个项目对”约束”的取向是在 schema 层动手,而不是靠提示词反复叮嘱。

五、边界与代价:它明确不管什么

放弃了 JSON Schema 的表达力。 没有 oneOf 就没有联合类型,没有 $ref 就没有结构复用,重复的子结构只能复制粘贴。你的 schema 必须是扁平自包含的。顺带一个陷阱:直接把带嵌套模型的 Pydantic 类 model_json_schema() 丢给 extractoutput_schema,产出的 JSON 通常带 $defs$ref,会被 _check_unsupported 挡住。

放弃了枚举的严格性。 enum 一律退化成 str,模型返回集合外的值不会在这一层被拦,取值合法性得你自己校验。

“缺失”和”零值”分不开。 非必填、非 nullable、无默认的原始类型字段拿到的是 ''0,你无法区分”页面上确实是空”和”模型没找到”。要区分就在 schema 里显式声明 nullable

两条链路不能互相顶替。 仓库在 Agent 构造函数里专门写了注释,说明单页 extract 不能继承 output_model_schema:终局 schema 描述的是整个任务的结果形状(比如汇总加分步结果),拿它去约束单页抽取形状不对,而且在该项目的 gateway 上还会把 extract 误路由进 agent 动作协议而破坏它。

形状对不等于内容对。 这一点在源码措辞里反复出现:无结构化输出时的 DoneAction.text 描述要求只报本次会话里直接观察到的数据、不要用训练知识补空、不要声称完成了压缩记忆里的步骤;结构化抽取的系统提示词要求不许编造。这些是提示层的约束,不构成保证。JudgementResult 那套判定也只是又一次模型判断,verdictfailure_reasonimpossible_taskreached_captcha 都是模型填的字段。

这类工具驱动的是真实浏览器,这几件事它明确不管。 它不替你判断目标站点的使用条款和 robots 政策——抓什么、频率多高、能不能存,是你的合规责任。验证码与反自动化机制不在它的解决范围:JudgementResult 里有 reached_captcha 字段,说明遇到验证码是被如实记录的结局之一;browser_use/browser/watchdogs/ 下 14 个 watchdog 里确实有一个 captcha watchdog,但那属于感知与上报,不是替你越过风控。带登录态跑意味着在操作真实账号,异常行为可能触发平台的风险判定,后果由账号持有者承担。数据外泄面同样要自己评估:抽取路径会把整页正文送进模型提示词;AgentHistory.model_dump 的敏感数据过滤按代码注释只作用于输入类动作的参数,ActionResult 里的内容是刻意不过滤的,因为那被视为对 agent 有意义的信息。权限该怎么收,参见 最小权限设计

六、上手与避坑清单

1. 你的 schema 带了 anyOf / oneOf / $ref 为什么会踩:多数人从 Pydantic 或 OpenAPI 里导出 schema,可选字段自然生成 anyOf,嵌套模型自然生成 $ref。怎么避:手写扁平 schema,可选性用 nullabledefault 表达;上线前先在本地对这份 dict 调一次 schema_dict_to_pydantic_model,抛 ValueError 就说明线上会静默降级。

2. 抽取悄悄退回自由文本,你以为是模型不听话。 为什么会踩:降级只打一条 warning,日志级别一高就看不见。怎么避:把 logger 的 warning 留出来;更可靠的是查 ActionResult.metadata 里有没有 structured_extraction 标记和 extraction_result,有才是走了结构化路径。

3. 期待模型自己给 extract 传 schema。 为什么会踩:output_schemaSkipJsonSchema,模型看不到这个参数。怎么避:在 Agent 上传 extraction_schema,或从程序侧调用抽取。

4. 字段值是 0 或空串,误判成页面数据为空。 为什么会踩:非必填原始类型的默认值就是零值。怎么避:需要区分就声明 nullable;下游别用真值判断,用 is None

5. 结果只覆盖了页面前半段。 为什么会踩:抽取一次只处理第一个内容块,长页面必然截断。怎么避:读 ExtractionResult.is_partialcontent_stats 里的 next_start_char,用 start_from_char 续抽;跨页收集时把已拿到的标识传进 already_collected 防重复。

6. 历史存盘再读回,structured_output 变成 None 为什么会踩:_output_model_schema 是私有属性,序列化不保留。怎么避:改用 get_structured_output(YourModel),显式把模型类传进去。

7. Tools 和 Agent 各设了一个输出模型。 为什么会踩:这种情况只有 warning,不报错,运行时以 Agent 传入的为准,另一边的定义被静静忽略。怎么避:只在一处声明,代码评审时把这条写进检查项。

8. 拿到合法 JSON 就当核实过的事实往下游写。 为什么会踩:schema 校验只管形状。怎么避:结构化结果之后仍要做业务层校验(数值范围、URL 可达性、条目数与页面是否吻合),必要时二次核对来源页。这类返回值该怎么设计,可参考 Agent 工具返回值设计

把这条链路压成一句话:终局形状靠替换 done 的参数模型来约束,页面形状靠把 JSON Schema 编译成 Pydantic 模型来约束,两头都会再被 SchemaOptimizer 按各家服务商的口味改写一次。字段不对时,从”是不是降级了”往回查,比改提示词有效得多。

一份可以照着走的自检清单:本地能否用你的 schema dict 成功建出模型;metadata 里有没有结构化抽取标记;is_partial 是否为真;可选字段是否显式 nullable;读回结果用的是属性还是 get_structured_output;这次任务里有没有带上真实登录态。

接下来该读哪个文件,按你的目标分:想改终局形状读 browser_use/tools/service.py 里的 _register_done_actionbrowser_use/tools/views.py;想改抽取形状读 browser_use/tools/extraction/schema_utils.py,那一百多行基本就是全部规则;想搞清模型这边为什么”少给字段”,读 browser_use/llm/schema.py 与你所用 provider 目录下的 chat.py。这个项目采用 MIT 许可证,源码可以直接翻,一切以仓库最新代码为准。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 给 browser-use 加自定义动作browser-use 开源项目怎么让 Agent 登你的账号

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