跳到主要内容

03 · 行动层:动作注册表与动态 schema

本章讲 LLM 的「手」是怎么被定义和约束的:动作怎么注册、怎么按当前页面拼成一个只能选一个动作的 pydantic schema 喂给模型(从语法上杜绝模型编造动作)、动作怎么验参和执行,以及任务怎么靠 done 结束。

3.1 它要解决的小问题

LLM 输出是自由文本,但我们要的是可执行、参数合法的动作。两个风险:

  1. 模型编一个不存在的动作(teleport_to(url));
  2. 模型给的参数类型/字段不对(click(index="the blue button"))。

解决思路:用类型系统当护栏——把「这一步允许的所有动作」编译成一个 pydantic schema,交给 LLM 的结构化输出(structured output / tool calling),让模型在语法层面就只能产出合法动作。

3.2 动作怎么注册:@action 装饰器

所有内置动作在 Tools(tools/service.py:441)里用 @self.registry.action(...) 登记。装饰器(tools/registry/service.py:290 Registry.action)做三件事:把函数包装成统一签名、生成参数模型、存进 registry.actions[name]:

# 示意,非源码;贴近 tools/service.py 里 click 的注册方式
@self.registry.action('Click element by index.', param_model=ClickElementActionIndexOnly)
async def click(params: ClickElementActionIndexOnly, browser_session: BrowserSession):
return await self._click_by_index(params, browser_session)

每个动作带:

  • description(给 LLM 看的说明,会进 schema);
  • param_model(pydantic 参数模型);
  • 可选 domains(只在某些域名可用);
  • 可选 terminates_sequence(标记会换页,§01 的护栏 1 用它,如导航/go_backTrue,tools/service.py:461)。

注册结果是一个 RegisteredAction(tools/registry/service.py:313),存进 self.registry.actions[func.__name__]

3.3 核心巧妙:create_action_model 动态拼 schema

每一步,Registry.create_action_model(tools/registry/service.py:507)按当前页面 URL 现拼一个 schema:

遍历所有注册动作:
├─ 不在 include 列表 → 跳过
├─ 该动作有 domains 限制且当前 URL 不匹配 → 跳过
└─ 保留
对每个保留的动作 name:
create_model(f'{Name}ActionModel', __base__=ActionModel, **{name: (param_model, Field(描述))})
# 即:一个只含「这一个动作字段」的模型
把这些单动作模型 Union 起来:
Union[ClickElementActionModel, InputActionModel, DoneActionModel, ...]

关键在 tools/registry/service.py:541-567:为每个动作单独建一个「只含它自己一个字段」的模型(serializer 注释:这样模型只装当前用的动作,而不是「所有动作大部分为 None」),再用 Union[tuple(individual_action_models)] 合起来,包成一个 RootModel(tools/registry/service.py:569 ActionModelUnion)。

这个 Union 就是给 LLM 的护栏:结构化输出要求模型从这个 Union 里选恰好一个分支,既不能编动作名(不在 Union 里),也不能给错参数(每个分支的 param_model 强类型校验)。

按域名过滤(tools/registry/service.py:531)还顺带实现了「某些动作只在特定网站出现」——比如某个站点专属的技能动作,只有在那个域名下才进 schema。

3.4 schema 怎么挂到 LLM 输出上

LLM 返回的整体结构是 AgentOutput(agent/views.py:388):

# 示意,非源码;字段对应 agent/views.py 的 AgentOutput
class AgentOutput(BaseModel):
thinking: str | None # 推理过程(可关)
evaluation_previous_goal: str # 上一步干得怎么样
memory: str # 要记住的东西
next_goal: str # 这一步目标
action: list[ActionModel] # 要执行的动作(至少 1 个)

AgentOutput.type_with_custom_actions(agent/views.py:418)把上面那个动态 Union 塞进 action: list[<那个Union>] 字段,生成本步专用的输出类型。然后 get_model_output(agent/service.py:1937)把它当 output_format 传给 llm.ainvoke:

# 示意,非源码;对应 agent/service.py:1944
kwargs = {'output_format': self.AgentOutput, 'session_id': self.session_id}
response = await self.llm.ainvoke(input_messages, **kwargs)
parsed: AgentOutput = response.completion # 已是校验过的强类型对象

BaseChatModel.ainvoke(llm/base.py:35,Protocol)有重载:传了 output_format 就返回 ChatInvokeCompletion[T],各家适配器(llm/openai/chat.py 等)负责把它翻成自己 API 的 structured-output / tool-calling 调用。

3.5 动作怎么执行:验参 + 注入上下文

模型返回的 ActionModelmulti_act(§01),逐个交给 Tools.act(tools/service.py:2164),后者再调 Registry.execute_action(tools/registry/service.py:330)。execute_action 做:

  1. 验参:action.param_model(**params),失败抛带原始参数的清晰错误(tools/registry/service.py:349);
  2. 替敏感数据:若配了 sensitive_data,把占位符换成真实值(_replace_sensitive_data,tools/registry/service.py:364)——这样真实密码/token 不进 LLM 上下文,只在执行时注入;
  3. 注入「特殊上下文」:把 browser_sessionpage_extraction_llmfile_systemcdp_clientpage_url 等按 handler 需要的参数注入(tools/registry/service.py:367)。动作函数声明哪个参数,就注入哪个。

Tools.act 还给每个动作套了超时(asyncio.wait_for(..., timeout=timeout_s),默认 180s,tools/service.py:2206)——防止某个 CDP 调用因 WebSocket 静默而无限挂起,超时则返回错误让 agent 自救(tools/service.py:2229)。

3.6 done 动作:怎么结束

任务完成靠模型主动调 doneTools 里注册了两种 done(tools/service.py:2002 / 2039):带结构化输出 schema 的 StructuredOutputAction 版,和普通 DoneAction 版。doneActionResultis_done=Truesuccesstext(给用户的最终答复)、files_to_display

约束(在 ActionResult 校验器,agent/views.py:342):success=True 只能is_done=True 时设——防止模型在没结束时谎报成功。主循环看到 last_result[-1].is_done 就退出(§01)。

3.7 关键细节/坑

  • 坐标点击是可选档。 默认只开放「按编号点」;set_coordinate_clicking(tools/service.py:2138)仅对支持坐标的前沿模型(claude-sonnet/opus、gemini-3-pro、browser-use/* 等,见 tools/service.py:2144 注释)开启,届时 click 既能 index 也能 coordinate_x/y。这是 DOM 路线对纯视觉路线的一点妥协。
  • 空页面兜底。 没有可用动作时返回 EmptyActionModel(tools/registry/service.py:557)。
  • 敏感数据只对需要的动作给。 只有 input 动作才拿到 sensitive_data(tools/registry/service.py:377)。

3.8 代码地图

主题文件符号
注册动作tools/registry/service.pyRegistry.actionRegisteredAction
动态 schematools/registry/service.pyRegistry.create_action_modelActionModelUnion
输出结构agent/views.pyAgentOutputtype_with_custom_actionsActionResult
取模型输出agent/service.pyAgent.get_model_output
执行+注入tools/registry/service.pyRegistry.execute_action
分发+超时tools/service.pyTools.act
donetools/service.py两个 done 注册(StructuredOutputAction / DoneAction)