browser-use 结构化输出的 schema 约束与字段缺失排查
本文基于 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_action 在 browser_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,作用是把 success 和 files_to_display 从对外暴露的 JSON Schema 里 pop 掉——避免这两个内部字段和用户模型里同名字段撞车。所以模型眼里看到的 done 参数会比 Python 侧的定义干净一些。
写回那一侧也做了处理:done 返回的 ActionResult 里,extracted_content=json.dumps(output_dict, ensure_ascii=False)。这就解释了为什么 final_result() 拿到的是 JSON 字符串。
除了动作层,任务文本层也被动了一刀。_enhance_task_with_schema 把 output_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 动作是另一条链路。它的参数模型 ExtractAction 有 query、extract_links、extract_images、start_from_char、already_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.py 的 execute_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 用它;标了 nullable 用 None;三者都不满足时,按类型给零值——字符串 ''、浮点 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 里的 ExtractionResult:data、schema_used、is_partial、source_url、content_stats。
长页面由 chunk_markdown_by_structure 按结构切块,一次只处理第一块,块内有 has_more 就把 truncated 置真,content_stats 里补上 next_start_char、chunk_index、total_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_action | 用 StructuredOutputAction[T] 重注册 done | browser_use/tools/service.py | done 参数与你模型字段撞名时 |
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_model | JSON 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.py 的 SchemaOptimizer.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_utils 里 nullable 走的正是这条)或给一个默认值。
各家的差异也落在同一处。browser_use/llm/openai/chat.py 把它包成 response_format,名字是 agent_output,strict: True,并且给了 remove_min_items 与 remove_defaults 两个开关来兼容个别不接受这些关键字的服务;Gemini 那侧走 create_gemini_optimized_schema,函数文档写明保留调用方显式给出的 required 数组;Mistral 另有 MistralSchemaOptimizer 做二次兼容。browser_use/llm/ 下一共 15 个 provider 目录,同一份模型定义在不同 provider 上的边角行为并不一致,各家规则不同且会调整,以官方最新说明为准。
Agent 自己的输出模型也在这条链上。browser_use/agent/views.py 的 AgentOutput 覆写了 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() 丢给 extract 的 output_schema,产出的 JSON 通常带 $defs 与 $ref,会被 _check_unsupported 挡住。
放弃了枚举的严格性。 enum 一律退化成 str,模型返回集合外的值不会在这一层被拦,取值合法性得你自己校验。
“缺失”和”零值”分不开。 非必填、非 nullable、无默认的原始类型字段拿到的是 '' 或 0,你无法区分”页面上确实是空”和”模型没找到”。要区分就在 schema 里显式声明 nullable。
两条链路不能互相顶替。 仓库在 Agent 构造函数里专门写了注释,说明单页 extract 不能继承 output_model_schema:终局 schema 描述的是整个任务的结果形状(比如汇总加分步结果),拿它去约束单页抽取形状不对,而且在该项目的 gateway 上还会把 extract 误路由进 agent 动作协议而破坏它。
形状对不等于内容对。 这一点在源码措辞里反复出现:无结构化输出时的 DoneAction.text 描述要求只报本次会话里直接观察到的数据、不要用训练知识补空、不要声称完成了压缩记忆里的步骤;结构化抽取的系统提示词要求不许编造。这些是提示层的约束,不构成保证。JudgementResult 那套判定也只是又一次模型判断,verdict、failure_reason、impossible_task、reached_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,可选性用 nullable 和 default 表达;上线前先在本地对这份 dict 调一次 schema_dict_to_pydantic_model,抛 ValueError 就说明线上会静默降级。
2. 抽取悄悄退回自由文本,你以为是模型不听话。 为什么会踩:降级只打一条 warning,日志级别一高就看不见。怎么避:把 logger 的 warning 留出来;更可靠的是查 ActionResult.metadata 里有没有 structured_extraction 标记和 extraction_result,有才是走了结构化路径。
3. 期待模型自己给 extract 传 schema。 为什么会踩:output_schema 是 SkipJsonSchema,模型看不到这个参数。怎么避:在 Agent 上传 extraction_schema,或从程序侧调用抽取。
4. 字段值是 0 或空串,误判成页面数据为空。 为什么会踩:非必填原始类型的默认值就是零值。怎么避:需要区分就声明 nullable;下游别用真值判断,用 is None。
5. 结果只覆盖了页面前半段。 为什么会踩:抽取一次只处理第一个内容块,长页面必然截断。怎么避:读 ExtractionResult.is_partial 和 content_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_action 和 browser_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 登你的账号。