决策层:单步 Agent 循环与动作规划
30 秒导读: 上一章(感知层)把网页压成了 LLM 能读的元素清单和截图。这一章讲决策:
skyvern_v1引擎下,Skyvern 怎么下"一步棋"——把页面事实和目标拼成一段 prompt,调一次 LLM,让它回一批动作,解析成强类型对象,执行,再判定该收工、该重试还是该继续。动作具体怎么落到真实元素留给执行层(第 3 章),CUA 引擎细节留给第 4 章。
1. 这节讲什么(先建直觉)
一次浏览器任务不是一锤子买卖,而是一步一步走:看一眼页面 → 决定这一步做什么 → 做 → 再看一眼 → 再决定……直到目标达成或放弃。
Skyvern 把"走一步"这件事拆成两个函数,职责分明:
| 函数 | 白话职责 | 位置 |
|---|---|---|
execute_step | step 的生命周期管家:查取消/超时、算步数上限、调用真正干活的那个、根据结果决定收尾还是递归走下一步 | skyvern/forge/agent.py:630 |
agent_step | 真正下一步棋的人:感知 → 拼 prompt → 调 LLM → 解析动作 → 逐个执行 → 标记 step 状态 | skyvern/forge/agent.py:1316 |
一句话直觉:execute_step 是循环的骨架和守卫,agent_step 是循环体里那颗跳动的心脏。 本章几乎全部篇幅在讲这颗心脏,以及它和 LLM 之间那份"你必须回这种 JSON"的契约。
这一步棋的输入 / 输出很干净:
- 输入: 当前页面的元素树 + 截图(页面事实)、用户的导航目标 / 数据抽取目标、之前几步的动作历史。
- 输出: 一批带元素 ID 的强类型动作(点这个、往那个框输文字、选这个下拉项、或者 COMPLETE/TERMINATE),加上"这一步 step 是完成了、失败了还是要继续"的判定。
2. 顶层全景(一步棋怎么转)
怎么读这张图: 从上到下是一次 agent_step 的时间线;左边一列是它依赖的外部部件。命中 COMPLETE/失败会拐出主线去"善后"。
用户目标 / 抽取目标
│
▼
┌───────────────────────────────────────────────┐
│ execute_step (骨架 + 守卫) │ agent.py:630
│ · 任务取消 / 工作流取消 / 超时 → 直接收尾 │
│ · 算 max_steps_per_run(默认 10) │
│ · 调 agent_step;拿回 step 状态后决定下一步 │
└───────────────────┬───────────────────────────┘
│ 调用
▼
┌───────── ──────────────────────────────────────┐
│ agent_step (核心决策管线) agent.py:1316│
│ │
│ ① 感知 build_and_record_step_prompt ───────┼──▶ 感知层(ch01)
│ scrape 页面 + 拼 extract-action prompt │
│ │
│ ② 决策 llm_api_handler(prompt, 截图) ───────┼──▶ LLM
│ 拿回一坨 actions JSON │
│ │
│ ③ 解析 parse_actions(JSON) ──▶ [Action,...] │──▶ parse_actions.py
│ JSON → 强类型、非法动作丢弃 │
│ │
│ ④ 执行 for action: ActionHandler.handle ─────┼──▶ 执行层(ch03)
│ 逐个落到真实元素;失败即停 │
│ │
│ ⑤ 标记 update_step(completed / failed) │
└───────────────────┬───────────────────────────┘
│ step 状态
┌───────────┴────────────┐
▼ ▼
handle_completed_step handle_failed_step
agent.py:5702 agent.py:5203
· 目标达成? → 任务完成 · 达到重试上限? → 任务失败
· TERMINATE? → 任务终止 · 否则 → 新建 retry_index+1 的 step
· 到步数上限? → 失败
· 否则 → 新建下一 step
主线走一遍(高层,不进代码):
execute_step先做一圈守卫检查(任务/工作流是否已取消、超时),没问题才往下。agent_step感知当前页面,把元素树、截图、目标、历史拼成extract-actionprompt。- 把 prompt + 截图丢给 LLM,拿回一段
actionsJSON。 parse_actions把 JSON 逐条变成强类型Action;非法的丢弃。- 逐个执行动作。任一动作硬失败就停下,把这一 step 标
failed。 - 全部成功就标
completed。 - 回到
execute_step,根据 step 是completed还是failed,分别走handle_completed_step/handle_failed_step,决定:任务完成 / 任务失败 / 新建下一 step(然后递归调回execute_step,agent.py:1000和:1014)。
3. execute_step:循环的骨架与守卫
这节讲:为什么"下一步棋"之前要先过一堆检查。
execute_step 本身不做决策,它的价值在"别让 agent 在不该跑的时候跑"。真正的守卫有三类:
① 取消 / 超时 —— 随时可能被叫停。 每进一次 execute_step,它都重新查一遍状态:
- 工作流被取消 / 超时(
agent.py:669、:685)→ 把 step 标canceled,任务标对应状态,直接返回。 - 任务本身被取消(
agent.py:707)→ 同样收尾走人。
这不是一次性检查,而是每一步开头都查——因为一个任务可能跑几十步,用户中途点了停止,必须尽快感知到。
② 步数上限 —— 别无限走下去。 agent.py:729-735 按优先级算出这一趟最多走几步:
# 示意,非源码:上限来源的优先级链
max_steps = (
context.max_steps_override # 运行时临时覆盖
or task.max_steps_per_run # 任务 级配置
or organization.max_steps_per_run # 组织级配置
or settings.MAX_STEPS_PER_RUN # 兜底默认 = 10
)
真实兜底值是 MAX_STEPS_PER_RUN: int = 10(skyvern/config.py:129)。这个上限不在 execute_step 里直接拦,而是传到后面 handle_completed_step 判"到顶了就把任务标失败"(见 §7)。
③ 特殊短路 —— 纯 GOTO_URL 任务。 如果任务既没导航目标、也没抽取目标、也没完成/终止判据(agent.py:769),那多半只是个"跳到某 URL"的块,直接 goto 完就把 step 和任务标完成,不惊动 LLM。
守卫过了,才调用心脏(agent.py:830):
# 示意,非源码:execute_step 里调用 agent_step 的关键一行
step, detailed_output = await self.agent_step(
task, step, browser_state,
engine=engine, # 默认 skyvern_v1
complete_verification=complete_verification,
...
)
拿回结果后按 step 状态分流(agent.py:916 起):failed → handle_failed_step;completed → handle_completed_step;然后若需要继续,用返回的 next_step 递归调回自己(agent.py:999-1013 是重试路径,:1014 是正常推进路径)。整个"多步循环"就是这条递归尾调用撑起来的。
4. agent_step:核心决策管线
这是本章的主角。它很长(agent.py:1316 到 :2128),但骨架就是全景图里那五步:感知 → 决策 → 解析 → 执行 → 标记。下面逐步拆。
4.1 感知 + 拼 prompt(build_and_record_step_prompt)
agent_step 第一件事是拿到"页面事实 + 一段现成的 prompt"(agent.py:1422-1432):
# 示意,非源码
(scraped_page, extract_action_prompt, use_caching, prompt_name) = \
await self.build_and_record_step_prompt(task, step, browser_state, engine)
build_and_record_step_prompt(真身 agent.py:3267)干两件事:
- 抓页面。 按
SCRAPE_TYPE_ORDER = [NORMAL, NORMAL, RELOAD](skyvern/constants.py:108)依次尝试:先普通抓两次,还失败就 reload 再抓(agent.py:3367)。这是一条三级容错的抓取链,一次抓不到不至于整步崩。 - 拼 prompt。 非 CUA 引擎才拼(
agent.py:3421),委托给_build_extract_action_prompt(agent.py:3714)。
_build_extract_action_prompt 会按任务类型选模板(agent.py:3737-3777):
| 任务类型 | 用的模板 | 说明 |
|---|---|---|
general | extract-action(本章主角) | 常规导航/操作任务 |
validation | decisive-criterion-validate | 只判断"判据是否满足" |
action | 先 infer-action-type 推断,再选 single-click/input/upload/select-action | 单动作任务 |
模板选定后,把一堆运行时变量塞进去 render(agent.py:3960 的 load_prompt_with_elements):导航目标、用户资料 payload、当前 URL、数据抽取目标、动作历史、错误码映射、当前时间、以及一串控制"提供哪些动作类型"的开关(多标签页、新规划器动作等)。元素树本身由 scraped_page 提供(可能是精简版 lean tree,agent.py:3814)。
一个巧思:动作历史进 prompt。 变量
action_history(agent.py:3862)把前几步做过什么、结果如何塞回 prompt,并在模板里明确提示"上一步没起作用就换招"(extract-action.j2:66)。这让无状态的 LLM 调用之间有了"记忆",避免在同一个坑里反复横跳。
4.2 决策:调一次 LLM 拿动作 JSON
skyvern_v1(默认引擎,非 CUA、非注入、非纯抽取)走 agent.py:1487 的 else 分支,核心就一次调用(agent.py:1504-1520):
# 示意,非源码
llm_api_handler = LLMAPIHandlerFactory.get_override_llm_api_handler(...)
json_response = await llm_api_handler(
prompt=extract_action_prompt,
prompt_name=prompt_name, # "extract-actions"
step=step,
screenshots=scraped_page.screenshots, # 截图一起喂
system_prompt=task.workflow_system_prompt,
)
拿回的 json_response["actions"] 就是 LLM 给的"这一步该做什么"。注意几个在调 LLM 前后的分叉(都在 agent.py 的这段 if/elif 里),说明不是所有 step 都真的问 LLM:
| 分叉 | 触发条件 | 位置 | 做什么 |
|---|---|---|---|
| 注入动作 | prepare_step_execution 预置了动作(如主动解验证码) | agent.py:1441 | 跳过 LLM,直接用注入的 |
| CUA 引擎 | openai_cua / anthropic_cua / ui_tars / yutori | agent.py:1449-1485 | 走各自的 CUA 生成器(见 ch04) |
| 纯抽取任务 | 有抽取目标、无导航目标 | agent.py:1488 | 直接造一个 ExtractAction(见 §6) |
| PDF 页面 | 抓到 <embed>/iframe 是 PDF | agent.py:1531-1597 | 造 DownloadFileAction 下载,不解析 LLM 动作 |
| 常规 | 以上都不是 | agent.py:1606 | 调 parse_actions 解析 LLM 的 JSON |
4.3 解析:parse_actions 把 JSON 变强类型
见 §5 单独讲。产出一批 Action 对象。
若解析后动作数为 0(agent.py:1665),这一步没事可做,直接标 failed 返回——会触发上层重试。
4.4 执行:逐个动作落地
拿到动作列表后进入执行循环(agent.py:1732 起)。这里的逻辑很密,但决策层只需理解它的判定规则,具体怎么点/怎么输是执行层(ch03)的事:
- 执行前先建"元素 ID → 动作"的链表(
agent.py:1710-1724),用来处理同一元素被多个动作命中的情况。 - 过滤 WAIT。 如果动作列表里既有 WAIT 又有实际动作,跳过 WAIT(
agent.py:1692-1698)——因为 WAIT 被当成失败信号,会挡住后面的动作。 - 逐个交给执行层(
agent.py:1829):
# 示意,非源码
results = await ActionHandler.handle_action(
scraped_page=scraped_page, task=task, step=step,
page=current_page, action=action,
)
- 失败即停的判定(
agent.py:1892起)——这是执行循环的核心规则:
最后一个 result 成功?
├─ 是 → 记成功;若 result.skip_remaining_actions → 提前收尾
└─ 否 ┬─ 是 DecisiveAction(COMPLETE/TERMINATE)? → 记警告但不停不重试
├─ result.stop_execution_on_failure == False? → 记警告,继续下一个
├─ 该元素还有后续重复动作节点? → 继续(换个动作试同一元素)
└─ 否则 → 把 step 标 failed,return,触发重试 (agent.py:1959)
一句话:普通动作硬失败 → 整个 step 失败重来;决定性动作或"软失败"不打断整步。
每个动作执行后还会异步补录制品(截图等,agent.py:1876 的 record_artifacts_after_action),页面级 SCROLL 例外(怕破坏滚动驱动的 JS 状态,agent.py:1864-1868)。
4.5 标记:这一步 step 收尾
全部动作跑完没硬失败,就标 completed(agent.py:2076):
# 示意,非源码
completed_step = await self.update_step(
step=step, status=StepStatus.completed,
output=detailed_agent_step_output.to_agent_step_output(),
)
有一个特例要在标完成前处理:任务同时有导航目标和抽取目标、且这一步已达成目标(agent.py:2057),就顺手补跑一次抽取(create_extract_action + handle_action,agent.py:2068-2073),把数据抓下来再收工。
5. LLM 契约:extract-action.j2 的动作 JSON schema
这节讲最要紧的一份约定:prompt 里逐字告诉 LLM"你必须回成这个形状"。真身在 skyvern/forge/prompts/skyvern/extract-action.j2(模板名常量 EXTRACT_ACTION_TEMPLATE = "extract-action",agent.py:169;调用时的 prompt_name = "extract-actions",agent.py:192)。
模板开头先给 LLM 立规矩(extract-action.j2:1-6):只用给定 ID 的元素、别凭空想象元素、必须输出合法 JSON(不许有注释、尾逗号、多余引号)。然后要求回这样一个对象:
5.1 顶层字段
| 字段 | 类型 | 含义 |
|---|---|---|
user_goal_stage | str | 对"目标是否达成"的推理说明 |
user_goal_achieved | bool | 目标是否已完成 |
action_plan | str | 这一批动作的计划摘要;若目标达成,须在 actions 里放 COMPLETE |
actions | array | 动作数组,每个元素结构见下 |
(user_goal_stage/action_plan 在精简输出模式 slim_output 为 safe/terse 时会被省掉,extract-action.j2:14-17。)
5.2 单个 action 的关键字段(extract-action.j2:19-58)
| 字段 | 用途 |
|---|---|
reasoning | 为什么选这个动作类型、为什么选这个元素(须与具体用户数据无关) |
user_detail_query / user_detail_answer | "把动作意图写成一道 Jeopardy 问答":query 问需要什么信息(不含具体用户数据),answer 才填具体值。这是 Skyvern 把"意图"和"隐私数据"分离的巧招 |
confidence_float | 0.0–1.0 的信心值 |
action_type | 动作类型枚举(见 5.3) |
id | 要操作的元素 ID,必须来自元素清单;无目标动作(WAIT/COMPLETE 等)填 null(extract-action.j2:25) |
text | 仅 INPUT_TEXT 用 |
option | 仅 SELECT_OPTION 用:{label, index, value} |
key / repeat | 仅 KEYPRESS 用(允许 Enter/Tab/Escape/箭头等) |
direction | 仅 SCROLL 用(up/down) |
file_url | UPLOAD_FILE 必填;CLICK 触发上传时也可有 |
click_context | 仅 CLICK:single_option_click 标记"是否唯一选择" |
context | INPUT_TEXT/SELECT_OPTION:字段名、是否必填、是否搜索框、是否日期、日期格式等 |
5.3 action_type 枚举与 COMPLETE / TERMINATE 语义
枚举值随开关动态增删(extract-action.j2:24),基础集合:CLICK / HOVER / INPUT_TEXT / UPLOAD_FILE / SELECT_OPTION / WAIT / SOLVE_CAPTCHA / COMPLETE / TERMINATE / KEYPRESS / SCROLL;开了新规划器动作还会加 GOTO_URL / RELOAD_PAGE / EXTRACT_INFORMATION,开了多标签页加 NEW_TAB / SWITCH_TAB / CLOSE_PAGE。
两个决定性动作的语义是整份契约的关键(extract-action.j2:24):
COMPLETE:当"完成判据满足"或"用户目标达成"、且(若有抽取目标)能从当前页拿到数据时才返回。模板反复强调"目标没达成绝不返回 COMPLETE"。TERMINATE:判断目标不可能达成时用它带失败终止。一旦返回 TERMINATE,同批其它动作全被忽略。模板提醒"如果等待可能推进目标,就别 TERMINATE"。
为什么这份契约值得逐字读: 它是 Skyvern 全部智能的"最后一公里"——模型再聪明,只要吐的 JSON 不符合这里的字段名和枚举,下游
parse_actions就接不住。模板里那些絮絮叨叨的约束("captcha 令牌 2 分钟过期,所以先填表最后再 SOLVE_CAPTCHA"、extract-action.j2:24)都是踩坑攒出来的。
6. parse_actions:JSON → 强类型 Action
这节讲:LLM 的 JSON 怎么变成程序能安全执行的对象,以及"垃圾进、别让垃圾崩"。
入口是 parse_actions(复数,skyvern/webeye/actions/parse_actions.py:347),它遍历 actions 数组,对每条调 parse_action(单数,parse_actions.py:70),再补上 task_id/step_id/action_order 等归属字段。
核心设计:逐条容错,坏一条不牵连全体。 每条解析都包在 try 里(parse_actions.py:357-401):
| 异常 | 处理 |
|---|---|
UnsupportedActionType | 记 error,跳过这条 |
ValidationError / ValueError | 记 warning,跳过这条 |
其它 Exception | 记 error,跳过这条 |
也就是说 LLM 回了 5 条动作、其中 1 条非法,另外 4 条照样执行。这比"整批作废"鲁棒得多。
parse_action 内部就是一棵大 if/elif 树,按 action_type 造对应类型(parse_actions.py:123 起)。几个关键规整动作值得点名:
- 大小写 + 别名归一(
parse_actions.py:116-122):action_type.upper();老名PRESS_ENTER映射到KEYPRESS;prompt 里对外叫EXTRACT_INFORMATION,内部映射回EXTRACT。 - 非 web 动作强制清空 element_id(
parse_actions.py:125-129):LLM 常给 WAIT/COMPLETE 也塞个元素 ID,这里统一抹掉(SCROLL 例外,它需要容器 ID)。 - KEYPRESS 白名单(
parse_actions.py:232-238):只允许Enter/Tab/Escape/ArrowDown/ArrowUp和单个字符,其余键直接降级成NullAction。 - URL 类动作安全校验(GOTO_URL
parse_actions.py:289-308、NEW_TAB:315-329):验证 URL 合法、且拦截私网/回环/内网主机(is_blocked_host)——因为导航目标可能被页面内容操纵,这是一道注入防线。 - 元素缺失检查(
parse_actions.py:407-418):解析完扫一遍所有 element_id,凡是不在scraped_page.id_to_element_hash里的记 warning(代码注释自嘲"这段可能不需要")。
7. Action 模型族:强类型的动作对象
这节讲:解析产物长什么样、类型层级怎么组织。定义在 skyvern/webeye/actions/actions.py。
根基类 Action(actions.py:108) 是个胖 Pydantic 模型,把所有动作可能用到的字段都摊平在一起(element_id、text、option、file_url、errors、reasoning、intention 等,actions.py:120-171)。子类只是给 action_type 定死默认值、并声明自己真正在乎的字段。
关键的类型分层(决定执行循环怎么对待它):
Action (actions.py:108 一切动作的基类)
├── WebAction (actions.py:247 element_id 必填 —— 要落到具体元素)
│ ├── ClickAction (actions.py:276)
│ ├── InputTextAction (actions.py:290)
│ ├── SelectOptionAction (actions.py:348)
│ ├── UploadFileAction (actions.py:318)
│ ├── CheckboxAction (actions.py:363)
│ └── HoverAction (actions.py:376)
├── DecisiveAction (actions.py:251 带 errors —— "拍板"类)
│ ├── CompleteAction (actions.py:386 verified 字段)
│ └── TerminateAction (actions.py:381 failure_categories)
├── ExtractAction (actions.py:392 抽取,无目标元素)
├── ScrollAction / KeypressAction / WaitAction / SolveCaptchaAction ...
└── GotoUrlAction / NewTabAction / SwitchTabAction / ClosePageAction ...
两条为什么这么分很重要:
WebAction(actions.py:247)把element_id收紧成必填——凡是要"落到某个真实元素"的动作都归它,执行循环里正是靠isinstance(action, WebAction)来建元素链表、查重复元素(agent.py:1716、:1764