跳到主要内容

企业级增强:凭据保险库 · 结构化抓取 · 表单填充 · 混合脚本

30 秒导读: 前几章讲的是 Notte 的「裸循环」——感知推理执行。本章讲的是围着这条循环长出来的四组生产能力:让 agent 能安全登录(凭据不进 LLM)、能把网页抓成规整 JSON、能一次填完整张表单、能「该脚本就脚本、只在必要处才叫 AI」。它们都是可插拔的增强,不改循环骨架,只在关键节点插一手。


1. 这是什么:给「裸 agent 循环」补的四块生产短板

一个只会「看页面→想下一步→点一下」的 browser agent,拿去做 demo 没问题,但一碰真实业务就露怯。四个典型痛点:

痛点裸循环为什么做不了本章对应能力
要登录,但密码不能给 LLMLLM 的文本输入和截图都会「看到」密码① 凭据保险库 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 文本、也不进截图;落地时才换真值notte-core/.../credentials/base.pynotte-agent/.../agent.pynotte-browser/.../vault.py
② 结构化抓取markdown → 剪枝 → LLM 出 JSON → DataSpacenotte-browser/.../scraping/pipe.pyschema.pypruning.pynotte-core/.../data/space.py
③ 表单 / 2FA一个动作填整张表;2FA 自动出码notte-core/.../actions/actions.pynotte-browser/.../form_filling.py
④ 混合脚本确定步脚本化,AI 只补不确定处notte-agent/.../workflow.pynotte-browser/.../workflow_variables.py

下面逐块拆开。


3. Vault 凭据注入:让密码「LLM 看不见、落地才现形」

3.1 它要解决的小问题

agent 要替你登录 GitHub。可是:

  • 它每一步都把当前状态(包括你刚填进输入框的值)拼成文本发给 LLM
  • 如果开了视觉,它还把页面截图一起发给 LLM。

这两条路,密码只要走过任何一条,就等于把你的凭据交给了模型厂商。难点不在「怎么填密码」,而在「怎么让 agent 全程都碰不到真密码,直到真正落到那个 <input> 上的最后一刻」。

3.2 思路:占位符进出、真值只在最后一厘米出现

Notte 的解法是「占位符经济」:

  1. 全程 LLM 只见到假的占位符(如密码用 mycoolpassword、邮箱用 user@example.org);系统提示明确告诉它「填密码就用这个占位符,一个字都别改」。
  2. 真凭据存在 Vault 里,agent 代码持有,从不进 prompt
  3. 直到某个 FillAction 真要落地了,才在执行前一瞬把占位符换成真值。
  4. 反向也堵死:万一某次真值已经出现在页面、回流进了 LLM 输入或截图,再用一层 mask 把它抹回占位符 / 打码。

占位符清单本身就是一批 CredentialField 子类,各自带 placeholder_valuecredentials/base.py:107 起的 EmailFieldPasswordFieldMFAField、以及信用卡系列 CardNumberField 等。抽象基类是 credentials/base.py:289BaseVault

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_completioncredentials/base.py:538):一个装饰器,包住 structured_completion。每次调用前,把第 0 个参数(就是那坨 messages)序列化成 JSON,用 get_replacement_map()真值 → 占位符的全量字符串替换,再发出去。替换用的 recursive_replace_mappingbase.py:567)会递归走遍 dict/list/str;关键细节:遇到 type == "image_url" 的节点直接跳过(base.py:580),因为 base64 图不该被字符串替换污染。
  • 视觉防线 VaultSecretsScreenshotMasknotte-browser/src/notte_browser/vault.py):截图前用 JS evaluate_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:250action = await self.action_with_credentials(response.action)),也就是「拿到动作 → 落地」之间的最后一个钩子。

为什么要读 LocatorAttributes?因为要防止把真密码填错地方replace_credentialsbase.py:630)拿到占位符后,先按占位符反查出 CredentialField 类,再让它 validate_element(attrs) 核对:PasswordField.validate_element 要求 attrs.type == "password"base.py:157);信用卡类则用正则去匹配 autocompleteouterHTMLbase.py:171)。校验通过才把 action.value 换成真值(包成 ValueWithPlaceholder——一个 SecretStr 子类,credentials/types.py:15,天然不会被 pydantic 明文序列化)。

一个巧妙的纠错:如果这是个 MFA 凭据,但 agent 误用了普通 FillAction,代码会当场把它换成 MultiFactorFillActionbase.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 它要解决的小问题

裸循环产出的是「动作」,不是「数据」。可很多任务的目标本身就是数据——「把这页所有酒店的城市/价格/链接抓成一个列表」。难点有二:

  1. 怎么让 LLM 按你要的形状输出(而不是自由发挥一段话);
  2. 网页文字里塞满噪声——尤其是长长的 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:64DataScrapingPipe。它的 forwardpipe.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.maskpruning.py:70)用正则把 [文本](url) 里的 url 替换成 link1link2… 这类短占位符,原 URL 存进映射表。抓完再 unmask_pydanticpruning.py:129)把占位符还原成真 URL。这样喂给 LLM 的文本短得多。_calculate_url_percentagepipe.py:22)专门量化「这页有多少内容其实是 URL」,超阈值就提醒你开 use_link_placeholders
  • LLM 出 JSONSchemaScrapingPipe.forwardschema.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)。
  • 结果容器 DataSpacenotte-core/src/notte_core/data/space.py:103,同时持有 markdownstructured 两栏。StructuredDataspace.py:51)用校验器强制「成功必有 data、有 error 必须 success=False」的不变式(space.py:68-78),杜绝「说成功却没数据」的自相矛盾。

4.4 手法三:抓取结果如何「回灌」进循环

抓完的 DataSpace 不是丢给用户就完了——它要变成 agent 下一步能看到的上下文。这靠 FalcoPerception.perceive_datanotte-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_resultfalco/perception.py:111)调用,把抓取结果拼进「这一步执行成功/失败」的消息里,于是下一轮 get_messages 时,agent 就在历史里看到了刚抓到的 JSON——数据抓取因此成了循环的一等公民,而不是旁路。注意它优先只回灌 structured,抓取失败时明确回报失败原因而不是让 agent 误把错误信息当数据。


5. 表单 / 2FA 填充:一个动作填完整张表

5.1 它要解决的小问题

登录、注册、结账,往往是一整张表:姓、名、邮箱、地址、城市、邮编、卡号、CVV……让 agent 一格一格 fill,既慢(每格一次 LLM 决策)又脆(少填一格全盘重来)。理想是一个动作把整张表填掉。

5.2 手法:FormFillAction + 选择器字典

Notte 把「整表填充」做成了一种动作 FormFillActionnotte-core/src/notte_core/actions/actions.py:197)。它的 value 是一个字段名 → 值的字典,字段名是一批固定枚举(first_nameemailcc_numbertotp…):

# 源码节选 actions.py:197(用法示例)
session.execute(type="form_fill", value={"email": "user@example.com",
"first_name": "John", "last_name": "Doe"})

执行时走到 controller.py:141 的分支,交给 FormFillernotte-browser/src/notte_browser/form_filling.py:25):

  • FIELD_SELECTORSform_filling.py:27)是一张大字典,每个语义字段配一串候选 CSS 选择器(按 autocompletenameidplaceholder → 甚至德语 vorname/nachname 逐个兜底)。find_fieldform_filling.py:363)逐个试,命中即用;试不到再退而按 <label> 文本关联去找。
  • 拟人化细节fill_formform_filling.py:477)填每格时 press_sequentially 带随机延时、先 hover、遇 address1 还会等自动补全弹层再按 Esc——降低被反爬识别的概率。
  • 冲突消解:若页面同时有「全名」和「姓/名」两种字段,_detect_name_field_conflicts 会把 first+last 合成 full_name 优先填,避免重复。

5.3 2FA:占位符 + 自动出码

2FA 是凭据体系和表单体系的交汇点:

  • LLM 只见到 MFA 占位符 999779,并被指示「用专门的 MultiFactorFillActionactions.py:1148)而非普通 fill」。
  • 真正取值时,get_credentials_asyncbase.py:490)发现是 MFAField,就用存的 secret 实时算一个 TOTPbase.py:508TOTP(cred).now())——即用即算,vault 里存的是 secret 而非会过期的一次性码。
  • 云端 personasnotte-sdk/.../endpoints/personas.py)更进一步:给每个数字身份配独立邮箱/手机号,list_emails/list_sms 能读回收到的验证码,让「注册→收码→填码」这条 2FA 全链路自动化。

6. 混合脚本 / workflow:能脚本就脚本,只在必要处叫 AI

6.1 它要解决的小问题

裸循环最贵的地方是「凡事问模型」:哪怕「点一下已知的搜索框」这种确定动作,也要跑一次 LLM。可现实里,一个任务的大部分步骤是确定的(导航、填已知字段),只有少数节点需要临场判断。README 那句「脚本化确定部分、AI 只在需要时出手,降本 50%+」,落地机制就在这里。

6.2 思路一:手写混合脚本

最朴素的形态——直接把确定步写成 Playwright 式脚本,只在需要推理处插一个短 agent(README「Hybrid workflows」):

# 源码节选:README 示例
with client.Session() as session:
session.execute(type="goto", url="https://duckduckgo.com/") # 确定:脚本
session.execute(type="fill", selector="...", value="nottelabs") # 确定:脚本
agent = client.Agent(session=session, max_steps=3)
agent.run(task="Open nottelabs github repository") # 不确定:才叫 AI
data = session.scrape(instructions="Extract number of stars") # 确定:脚本

因为动作体系和 Playwright 高度兼容(见 动作与执行),脚本步和 agent 步能在同一个 session 里无缝穿插。

6.3 思路二:WorkflowAgent——把「问模型」这一步替换成「查表」

更自动化的形态是 WorkflowAgentnotte-agent/src/notte_agent/workflow.py)。它继承 NotteAgent只重写 observe_and_completion 这一步——也就是循环里「调 LLM 拿动作」的那一步:

# 源码节选 workflow.py:33(简化)
async def observe_and_completion(self, request):
current_step = len(self.trajectory)
if current_step == 0 or current_step >= nb_workflow_steps \
or self.trajectory.last_result is None or not self.trajectory.last_result.success:
# 没脚本可用 / 脚本走完 / 上一步失败 → 退回真正问模型
return await super().observe_and_completion(request)
step = self.workflow.steps[current_step]
logger.info(f"💨 Workflow - reusing workflow action '{step.action.type}'")
# 有脚本步可复用 → 直接返回,不花一次 LLM
return AgentCompletion.from_completion(step, TimedSpan.empty())

这就是降本的核心:一个 Workflownotte-browser/src/notte_browser/workflow_variables.py)本质是「一次成功运行录下来的动作序列」。回放时,只要脚本步还对得上、上一步没失败,就直接复用录好的动作,一次模型都不叫;只有当脚本对不上(页面变了、失败了、脚本用完了)才优雅退回到真 agent 推理。确定步几乎零成本,AI 只补缺口。

6.4 让脚本「可参数化」:变量抽取

录下来的脚本若把「搜索词」写死就不通用了。WorkflowVariablesPipeworkflow_variables.py)用一次 LLM 分析整条轨迹,判定每一步的值是常量还是变量VariableIdentifier),把变量步的 action.value 替换成 {search_query} 这样的模板。运行时 Workflow.fillworkflow_variables.py:30)再把真实变量灌回去:

# 源码节选 workflow_variables.py:30
def fill(self, variables):
if variables is None:
return self
for step in self.steps:
value = getattr(step.action, "value", None)
if value is not None and value in variables.keys():
step.action.value = variables[value] # 把模板占位换成本次真实值
return self

于是一条录一次的 workflow,能用不同参数反复跑——「录制→参数化→回放」把一次性 agent 运行沉淀成了可复用、近乎确定、廉价的自动化。


7. 巧妙之处(值得带走的技术)

  • 占位符经济学:真凭据用「进站前替换回占位符 + 出站落地才换真值 + 截图 mask」三道钩子,把秘密压缩在「最后一厘米」。ValueWithPlaceholder 继承 SecretStr,连序列化都天然不吐明文(credentials/types.py:15)。
  • 误用自动纠偏:agent 把 OTP 当普通密码填?replace_credentials 当场把 FillAction 升级成 MultiFactorFillActionbase.py:692)。凭据体系不假设模型永远选对动作。
  • 抓取先剪枝再喂模型:URL 占位化(pruning.py:70)+ 占比度量(pipe.py:22),把 token 噪声按下去,抓完再无损还原。
  • workflow 的「退回」而非「硬刚」:脚本对不上就无缝退回问模型(workflow.py:33),既拿到脚本的便宜,又保住 agent 的鲁棒——这是「降本 50%+」不牺牲可靠性的关键。

8. 边界与局限(诚实)

  • 人类介入未实现HelpAction 直接判失败(agent.py:164)——需要人工兜底的流程走不通。
  • 表单选择器靠启发式FIELD_SELECTORSform_filling.py:27)是一大堆手写选择器,冷门/自定义字段可能匹配不到;FormFillAction 的字段名也是固定枚举,超出枚举的字段填不了。
  • 抓取吃 schema 校验:LLM 出的 JSON 过不了 model_validate 就整体判失败返回(schema.py:127)——稳,但复杂 schema 下可能反复失败。
  • workflow 对页面漂移敏感:录制的动作序列一旦页面结构变化对不上,就退回全量 agent 推理,省不到钱(workflow.py:33 的退回分支)。

9. 横向对比

关切Notte 的取舍与兄弟章 / 库
凭据安全占位符 + 双向 mask + 落地校验,凭据不进 LLM多数裸 agent 框架直接把值交给模型
结构化输出独立抓取管线 + Pydantic 校验 + 占位剪枝动作与执行 的动作产出对照
降本混合脚本「能脚本就脚本」,录制回放近零成本纯 agent 循环凡事问模型

对外如何以本地 / 托管 SDK、MCP 暴露这些能力,见 对外形态;整体循环骨架见 Agent 主循环


10. 代码地图(导航索引)

主题文件路径符号名
Vault 抽象基类packages/notte-core/src/notte_core/credentials/base.pyBaseVault
构造时挂两道防线packages/notte-agent/src/notte_agent/agent.pyNotteAgent.__init__
LLM 出站替换装饰器packages/notte-core/src/notte_core/credentials/base.pypatch_structured_completion / recursive_replace_mapping
执行前换真凭据packages/notte-agent/src/notte_agent/agent.pyaction_with_credentials
凭据落地 + 元素校验 + MFA 纠偏packages/notte-core/src/notte_core/credentials/base.pyreplace_credentials
各占位符字段packages/notte-core/src/notte_core/credentials/base.pyEmailField / PasswordField / MFAField / CardNumberField
截图打码packages/notte-browser/src/notte_browser/vault.pyVaultSecretsScreenshotMask
response_format 注入 taskpackages/notte-agent/src/notte_agent/agent.pyNotteAgent.get_messages
抓取管线packages/notte-browser/src/notte_browser/scraping/pipe.pyDataScrapingPipe / _calculate_url_percentage
URL 剪枝 / 还原packages/notte-browser/src/notte_browser/scraping/pruning.pyMarkdownPruningPipe.mask / unmask_pydantic
LLM 出结构化 JSONpackages/notte-browser/src/notte_browser/scraping/schema.pySchemaScrapingPipe.forward
结果容器packages/notte-core/src/notte_core/data/space.pyDataSpace / StructuredData
抓取结果回灌packages/notte-agent/src/notte_agent/falco/perception.pyFalcoPerception.perceive_data
整表填充动作packages/notte-core/src/notte_core/actions/actions.pyFormFillAction / MultiFactorFillAction
表单填充引擎packages/notte-browser/src/notte_browser/form_filling.pyFormFiller / FIELD_SELECTORS / fill_form
混合 workflow agentpackages/notte-agent/src/notte_agent/workflow.pyWorkflowAgent.observe_and_completion
workflow 变量抽取 / 回放packages/notte-browser/src/notte_browser/workflow_variables.pyWorkflow.fill / WorkflowVariablesPipe
数字身份 / 2FA 收码packages/notte-sdk/src/notte_sdk/endpoints/personas.pyPersonasClient.list_emails / list_sms