数据截至 (上游 commit 911b51b0d148)
企业级增强:凭据保险库 · 结构化抓取 · 表单填充 · 混合脚本
30 秒导读: 前几章讲的是 Notte 的「裸循环」——感知 → 推理 → 执行。本章讲的是围着这条循环长出来的四组生产能力:让 agent 能安全登录(凭据不进 LLM)、能把网页抓成规整 JSON、能一次填完整张表单、能「该脚本就脚本、只在必要处才叫 AI」。它们都是可插拔的增强,不改循环骨架,只在关键节点插一手。
1. 这是什么:给「裸 agent 循环」补的四块生产短板
一个只会「看页面→想下一步→点一下」的 browser agent,拿去做 demo 没问题,但一碰真实业务就露怯。四个典型痛点:
| 痛点 | 裸循环为什么做不了 | 本章对应能力 |
|---|---|---|
| 要登录,但密码不能给 LLM | LLM 的文本输入和截图都会「看到」密码 | ① 凭据保险库 Vault |
| 要的是规整数据,不是一堆网页文字 | 循环只产出「动作」,不产出「结构化结果」 | ② 结构化抓取 |
| 要填一张十几个字段的表单 | 一格一格 fill 又慢又容易错 | ③ 表单 / 2FA 填充 |
| 每步都叫 LLM,又贵又慢又不稳 | 循环默认「凡事问模型」 | ④ 混合脚本 / workflow |
这四块的共同设计哲学:不重写循环,而是在循环的特定节点挂钩子。凭据挂在「LLM 调用」和「动作落地」两处;抓取挂在「执行结果」里回灌;表单是一个特殊动作;混合脚本则直接替换掉 observe_and_completion 那一步,能查表就不问模型。
本章聚焦「为什么难 + 关键手法」,源码只引短片段,全貌请按引用去克隆里看。
2. 顶层全景:四块增强挂在循环的哪里
先看一张图,理解它们各自「插」在循环的什么位置——这是读懂本章的地图。
NotteAgent 主循环(每一步)
┌───────────────────────────────────────────────────────────┐
│ 感知页面 ──► 拼 messages ──► 调 LLM ──► 拿到动作 ──► 执行 │
└────┬────────────┬──────────────┬──────────────┬────────────┘
│ │ │ │
②抓取回灌 ②schema注入 ①LLM打码 ①执行前换真凭据
perceive_data 把json schema patch_... action_with_
把结果塞进 拼进 task 完成+截图mask credentials
「执行结果」 ▲
│ │
└── ③表单填充:form_fill 是一种「动作」,执行时走 FormFiller 整表填
└── ④混合脚本:WorkflowAgent 直接替换「调 LLM」这一步——
能复用脚本步就复用,只在缺口处才真的问模型
四块能力对应的核心文件:
| 能力 | 干什么 | 核心文件 |
|---|---|---|
| ① Vault | 凭据既不进 LLM 文本、也不进截图;落地时才换真值 | packages/notte-core/src/notte_core/credentials/base.py、packages/notte-agent/src/notte_agent/agent.py、packages/notte-browser/src/notte_browser/vault.py |
| ② 结构化抓取 | markdown → 剪枝 → LLM 出 JSON → DataSpace | packages/notte-browser/src/notte_browser/scraping/pipe.py、schema.py、pruning.py、packages/notte-core/src/notte_core/data/space.py |
| ③ 表单 / 2FA | 一个动作填整张表;2FA 自动出码 | packages/notte-core/src/notte_core/actions/actions.py、packages/notte-browser/src/notte_browser/form_filling.py |
| ④ 混合脚本 | 确定步脚本化,AI 只补不确定处 | packages/notte-agent/src/notte_agent/workflow.py、packages/notte-browser/src/notte_browser/workflow_variables.py |
下面逐块拆开。
3. Vault 凭据注入:让密码「LLM 看不见、落地才现形」
3.1 它要解决的小问题
agent 要替你登录 GitHub。可是:
- 它每一步都把当前状态(包括你刚填进输入框的值)拼成文本发给 LLM;
- 如果开了视觉,它还把页面截图一起发给 LLM。
这两条路,密码只要走过任何一条,就等于把你的凭据交给了模型厂商。难点不在「怎么填密码」,而在「怎么让 agent 全程都碰不到真密码,直到真正落到那个 <input> 上的最后一刻」。
3.2 思路:占位符进出、真值只在最后一厘米出现
Notte 的解法是「占位符经济」:
- 全程 LLM 只见到假的占位符(如密码用
mycoolpassword、邮箱用[email protected]);系统提示明确告诉它「填密码就用这个占位符,一个字都别改」。 - 真凭据存在 Vault 里,agent 代码持有,从不进 prompt。
- 直到某个
FillAction真要落地了,才在执行前一瞬把占位符换成真值。 - 反向也堵死:万一某次真值已经出现在页面、回流进了 LLM 输入或截图,再用一层 mask 把它抹回占位符 / 打码。
占位符清单本身就是一批 CredentialField 子类,各自带 placeholder_value:credentials/base.py:107 起的 EmailField、PasswordField、MFAField、以及信用卡系列 CardNumberField 等。抽象基类是 credentials/base.py:289 的 BaseVault。
3.3 构造时布下两道「出站」防线
NotteAgent.__init__ 里,一旦传入了 vault,就给循环打两个补丁——notte-agent/src/notte_agent/agent.py:82-88:
# 源码节选 agent.py:82
if self.vault is not None:
# ① 包住 LLM 调用:发送前把真凭据替换回占位符
self.llm.structured_completion = self.vault.patch_structured_completion(
0, self.vault.get_replacement_map
)(self.llm.structured_completion)
# ② 给截图挂 mask:把含真凭据的输入框涂掉
self.session.window.screenshot_mask = VaultSecretsScreenshotMask(vault=self.vault)
- 文本防线
patch_structured_completion(credentials/base.py:538):一个装饰器,包住structured_completion。每次调用前,把第 0 个参数(就是那坨 messages)序列化成 JSON,用get_replacement_map()做真值 → 占位符的全量字符串替换,再发出去。替换用的recursive_replace_mapping(base.py:567)会递归走遍 dict/list/str;关键细节:遇到type == "image_url"的节点直接跳过(base.py:580),因为 base64 图不该被字符串替换污染。 - 视觉防线
VaultSecretsScreenshotMask(notte-browser/src/notte_browser/vault.py):截图前用 JSevaluate_all扫页面所有<input>,凡el.value命中「已用过的真凭据」集合的,就返回其 Locator 交给截图层涂黑。
一句话:构造时就把「凭据泄漏」的两条出站路径(文本、像素)都堵上了。
3.4 执行前的「最后一厘米」:action_with_credentials
出站防的是「别泄漏」,可 agent 终究得把真密码打进输入框。这一步发生在动作真正落地之前——agent.py:90-109:
# 源码节选 agent.py:90
async def action_with_credentials(self, action: BaseAction) -> BaseAction:
if self.vault is not None and self.vault.contains_credentials(action):
locator = await self.session.locate(action) # 先定位到目标元素
attrs = LocatorAttributes(type=None, autocomplete=None, outerHTML=None)
if locator is not None:
# 读元素属性:type / autocomplete / outerHTML —— 用来校验「这真是密码框吗」
attr_type = await locator.get_attribute("type")
autocomplete = await locator.get_attribute("autocomplete")
outer_html = await locator.evaluate("el => el.outerHTML")
attrs = LocatorAttributes(type=attr_type, autocomplete=autocomplete, outerHTML=outer_html)
if locator is not None or isinstance(action, FormFillAction):
action = await self.vault.replace_credentials(action, attrs, self.session.snapshot)
return action
它在 主循环 的默认执行分支里被调用(agent.py:250,action = await self.action_with_credentials(response.action)),也就是「拿到动作 → 落地」之间的最后一个钩子。
为什么要读 LocatorAttributes?因为要防止把真密码填错地方。replace_credentials(base.py:630)拿到占位符后,先按占位符反查出 CredentialField 类,再让它 validate_element(attrs) 核对:PasswordField.validate_element 要求 attrs.type == "password"(base.py:157);信用卡类则用正则去匹配 autocomplete 或 outerHTML(base.py:171)。校验通过才把 action.value 换成真值(包成 ValueWithPlaceholder——一个 SecretStr 子类,credentials/types.py:15,天然不会被 pydantic 明文序列化)。
一个巧妙的纠错:如果这是个 MFA 凭据,但 agent 误用了普通 FillAction,代码会当场把它换成 MultiFactorFillAction(base.py:692),让 OTP 走对的落地路径。
3.5 用起来什么样
对使用者,这一切是隐形的(examples/auth-vault-agent/agent.py):
# 源码节选:示例
with notte.Vault() as vault, notte.Session() as session:
vault.add_credentials_from_env("github.com") # 从环境变量读真凭据
agent = notte.Agent(vault=vault, session=session) # 把 vault 交给 agent
output = agent.run(task="Go to github.com, and login with your provided credentials")
你只管把凭据塞进 vault,agent 全程操作的都是占位符——真值从进入内存到打进输入框,从未路过 LLM。
4. 结构化抓取:把一页网页榨成规整 JSON
4.1 它要解决的小问题
裸循环产出的是「动作」,不是「数据」。可很多任务的目标本身就是数据——「把这页所有酒店的城市/价格/链接抓成一个列表」。难点有二:
- 怎么让 LLM 按你要 的形状输出(而不是自由发挥一段话);
- 网页文字里塞满噪声——尤其是长长的 URL,既烧 token 又干扰模型。
4.2 手法一:用 Pydantic schema 把「形状」注入任务
Notte 让你传一个 response_format=某个 Pydantic 模型。这个 schema 会被直接拼进任务文本——agent.py:299-300:
# 源码节选 agent.py:299
if request.response_format is not None:
request.task = f"{request.task}. \n Use the following response format to format your answer:\n```json\n{request.response_format.model_json_schema()}\n```"
也就是说,「你要什么形状」变成了任务描述的一部分,裁判验证 时也会拿这个 schema 去校对最终答案。
4.3 手法二:抓取管线 DataScrapingPipe 四步走
真正把「一页 HTML」变成「规整 JSON」的,是 notte-browser/src/notte_browser/scraping/pipe.py:63 的 DataScrapingPipe。它的 forward(pipe.py:106)是一条流水线:
原始页面
│ MarkdownifyScrapingPipe:HTML → markdown
▼
markdown 文本
│ MarkdownPruningPipe.mask:把每个链接/图片 URL 换成短占位符 link1/img1…
│ (_calculate_url_percentage:先算 URL 占比,>50% 就警告你开占位符省 token)
▼
剪枝后的干净文本
│ SchemaScrapingPipe.forward:连同 JSON schema 一起丢给 LLM,让它出结构化数据
▼
StructuredData(成功/失败 + data)
│ 装进 DataSpace(markdown + structured 两栏并存)
▼
DataSpace
逐段看关键手法:
- 剪枝去 URL 噪声:
MarkdownPruningPipe.mask(pruning.py:70)用正则把[文本](url)里的url替换成link1、link2… 这类短占位符,原 URL 存进映射表。抓完再unmask_pydantic(pruning.py:129)把占位符还原成真 URL。这样喂给 LLM 的文本短得多。_calculate_url_percentage(pipe.py:22)专门量化「这页有多少内容其实是 URL」,超阈值就提醒你开use_link_placeholders。 - LLM 出 JSON:
SchemaScrapingPipe.forward(schema.py:68)分两条路——给了response_format就用extract-json-schema/multi-entity提示、把 schema 塞进变量(schema.py:118);只给了自然语言instructions就用extract-without-json-schema。拿到结果后_response_format.model_validate(...)校验,验不过就返回success=False带错误原因,绝不硬塞脏数据(schema.py:127-153)。 - 结果容器
DataSpace:notte-core/src/notte_core/data/space.py:103,同时持有markdown和structured两栏。StructuredData(space.py:51)用校验器强制「成功必有 data、有 error 必须 success=False」的不变式(space.py:68-78),杜绝「说成功却没数据」的自相矛盾。
4.4 手法三:抓取结果如何「回灌」进循环
抓完的 DataSpace 不是丢给用户就完了——它要变成 agent 下一步能看到的上下文。这靠 FalcoPerception.perceive_data(notte-agent/src/notte_agent/falco/perception.py:65):
# 源码节选 falco/perception.py:65(简化)
def perceive_data(self, data, only_structured=True):
structured_data = data.structured
if structured_data is not None and structured_data.success and structured_data.data is not None:
return f"\nExtracted JSON data:\n```json\n{structured_data.data.model_dump_json()}\n```\n"
if structured_data is not None and not structured_data.success:
return f"Scraping failed with error: {structured_data.error}. Please try again..."
...
它被 perceive_action_result(falco/perception.py:111)调用,把抓取结果拼进「这一步执行成功/失败」的消息里,于是下一轮 get_messages 时,agent 就在历史里看到了刚抓到的 JSON——数据抓取因此成了循环的一等公民,而不是旁路。注意它优先只回灌 structured,抓取失败时明确回报失败原因而不是让 agent 误把错误信息当数据。