跳到主要内容

决策层:单步 Agent 循环与动作规划

30 秒导读: 上一章(感知层)把网页压成了 LLM 能读的元素清单和截图。这一章讲决策skyvern_v1 引擎下,Skyvern 怎么下"一步棋"——把页面事实和目标拼成一段 prompt,调一次 LLM,让它回一批动作,解析成强类型对象,执行,再判定该收工、该重试还是该继续。动作具体怎么落到真实元素留给执行层(第 3 章),CUA 引擎细节留给第 4 章


1. 这节讲什么(先建直觉)

一次浏览器任务不是一锤子买卖,而是一步一步走:看一眼页面 → 决定这一步做什么 → 做 → 再看一眼 → 再决定……直到目标达成或放弃。

Skyvern 把"走一步"这件事拆成两个函数,职责分明:

函数白话职责位置
execute_stepstep 的生命周期管家:查取消/超时、算步数上限、调用真正干活的那个、根据结果决定收尾还是递归走下一步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

主线走一遍(高层,不进代码):

  1. execute_step 先做一圈守卫检查(任务/工作流是否已取消、超时),没问题才往下。
  2. agent_step 感知当前页面,把元素树、截图、目标、历史拼成 extract-action prompt。
  3. 把 prompt + 截图丢给 LLM,拿回一段 actions JSON。
  4. parse_actions 把 JSON 逐条变成强类型 Action;非法的丢弃。
  5. 逐个执行动作。任一动作硬失败就停下,把这一 step 标 failed
  6. 全部成功就标 completed
  7. 回到 execute_step,根据 step 是 completed 还是 failed,分别走 handle_completed_step / handle_failed_step,决定:任务完成 / 任务失败 / 新建下一 step(然后递归调回 execute_stepagent.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 = 10skyvern/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 起):failedhandle_failed_stepcompletedhandle_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)干两件事:

  1. 抓页面。SCRAPE_TYPE_ORDER = [NORMAL, NORMAL, RELOAD]skyvern/constants.py:108)依次尝试:先普通抓两次,还失败就 reload 再抓(agent.py:3367)。这是一条三级容错的抓取链,一次抓不到不至于整步崩。
  2. 拼 prompt。 非 CUA 引擎才拼(agent.py:3421),委托给 _build_extract_action_promptagent.py:3714)。

_build_extract_action_prompt 会按任务类型选模板(agent.py:3737-3777):

任务类型用的模板说明
generalextract-action(本章主角)常规导航/操作任务
validationdecisive-criterion-validate只判断"判据是否满足"
actioninfer-action-type 推断,再选 single-click/input/upload/select-action单动作任务

模板选定后,把一堆运行时变量塞进去 render(agent.py:3960load_prompt_with_elements):导航目标、用户资料 payload、当前 URL、数据抽取目标、动作历史、错误码映射、当前时间、以及一串控制"提供哪些动作类型"的开关(多标签页、新规划器动作等)。元素树本身由 scraped_page 提供(可能是精简版 lean tree,agent.py:3814)。

一个巧思:动作历史进 prompt。 变量 action_historyagent.py:3862)把前几步做过什么、结果如何塞回 prompt,并在模板里明确提示"上一步没起作用就换招"(extract-action.j2:66)。这让无状态的 LLM 调用之间有了"记忆",避免在同一个坑里反复横跳。

4.2 决策:调一次 LLM 拿动作 JSON

skyvern_v1(默认引擎,非 CUA、非注入、非纯抽取)走 agent.py:1487else 分支,核心就一次调用(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 / yutoriagent.py:1449-1485走各自的 CUA 生成器(见 ch04)
纯抽取任务有抽取目标、无导航目标agent.py:1488直接造一个 ExtractAction(见 §6)
PDF 页面抓到 <embed>/iframe 是 PDFagent.py:1531-1597DownloadFileAction 下载,不解析 LLM 动作
常规以上都不是agent.py:1606parse_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:1876record_artifacts_after_action),页面级 SCROLL 例外(怕破坏滚动驱动的 JS 状态,agent.py:1864-1868)。

4.5 标记:这一步 step 收尾

全部动作跑完没硬失败,就标 completedagent.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_actionagent.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_stagestr对"目标是否达成"的推理说明
user_goal_achievedbool目标是否已完成
action_planstr这一批动作的计划摘要;若目标达成,须在 actions 里放 COMPLETE
actionsarray动作数组,每个元素结构见下

user_goal_stage/action_plan 在精简输出模式 slim_outputsafe/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_float0.0–1.0 的信心值
action_type动作类型枚举(见 5.3)
id要操作的元素 ID,必须来自元素清单;无目标动作(WAIT/COMPLETE 等)填 nullextract-action.j2:25
text仅 INPUT_TEXT 用
option仅 SELECT_OPTION 用:{label, index, value}
key / repeat仅 KEYPRESS 用(允许 Enter/Tab/Escape/箭头等)
direction仅 SCROLL 用(up/down)
file_urlUPLOAD_FILE 必填;CLICK 触发上传时也可有
click_context仅 CLICK:single_option_click 标记"是否唯一选择"
contextINPUT_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_idparse_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

根基类 Actionactions.py:108 是个胖 Pydantic 模型,把所有动作可能用到的字段都摊平在一起(element_idtextoptionfile_urlerrorsreasoningintention 等,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 ...

两条为什么这么分很重要:

  1. WebActionactions.py:247)把 element_id 收紧成必填——凡是要"落到某个真实元素"的动作都归它,执行循环里正是靠 isinstance(action, WebAction) 来建元素链表、查重复元素(agent.py:1716:1764)。
  2. DecisiveActionactions.py:251)代表"这一步拍板了"——COMPLETE/TERMINATE。执行循环对它特殊对待:即便它"失败"也不打断、不重试(agent.py:1918),因为它的失败本身就是一种结论。

动作类型的总枚举skyvern/webeye/actions/action_types.pyActionType(StrEnum),其中 is_web_action() 定义了哪些算 web 动作(CLICK/INPUT_TEXT/UPLOAD_FILE/SELECT_OPTION/CHECKBOX/HOVER 等)。

执行结果用另一族模型 ActionResultskyvern/webeye/actions/responses.py:8)表达:ActionSuccess:61)、ActionFailure:80)、ActionAbort:102)。关键字段是 successstop_execution_on_failureskip_remaining_actions——正是 §4.4 那套"失败即停"判定读的字段。

纯抽取动作的特殊造法: 当任务只有抽取目标时,不走 LLM 动作规划,而是 create_extract_actionagent.py:6217)直接造一个 ExtractActionreasoning 取自一次"抽取摘要"预取(prefetched_summary_task),confidence_float=1.0


8. 善后:判定这一步的命运(完成 / 失败 / 继续)

agent_step 只负责标 step 是 completed 还是 failed"任务级"的命运判定回到 execute_step,分两条路。

8.1 step 完成 → handle_completed_step

真身 agent.py:5702,返回 (是否任务完成, 末步, 下一步) 三元组。判定优先级:

handle_completed_step (agent.py:5702)

├─ 需要 complete 验证 且 目标尚未标达成?
│ → 走并行验证 _handle_completed_step_with_parallel_verification (agent.py:4896)

├─ step.is_goal_achieved()? (models.py:114)
│ → 任务 completed,抓 extracted_information (agent.py:5751)

├─ step.is_terminated()? (models.py:153)
│ → 任务 terminated + 失败分类 (agent.py:5766)

├─ 是 ActionBlock 且 step 成功?
│ → 任务 completed(ActionBlock 无 COMPLETE 动作)(agent.py:5805)

├─ step.order + 1 >= max_steps_per_run?
│ → 任务 failed(到步数上限) (agent.py:5830)

└─ 否则 → 新建 order+1、retry_index=0 的下一 step (agent.py:5878)

complete verification(完成校验)是这里的精华。 光靠 LLM 在动作里回一个 COMPLETE 并不可信——它可能幻觉"我完成了"。所以当开了 complete_verification、且这一步没有决定性动作时(agent.py:2034-2048 先埋 enable_parallel_verification),善后阶段会再单独问一次 LLM"目标到底达成没有":

  • _handle_completed_step_with_parallel_verificationagent.py:4896)并行做两件事:一边 check_user_goal_complete 校验目标(agent.py:2950),一边投机性地预抓下一步_speculate_next_step_plan),赌"没完成"以省一次抓取延迟。
  • check_user_goal_complete 内部调 complete_verifyagent.py:2804)重新抓页面、再问 LLM。返回 CompleteVerifyResult,其三态语义定义在 skyvern/webeye/actions/actions.py:50-86is_complete / is_terminate / is_continue 三个 property,分别对应 complete / terminate / continue)。
  • 校验说"达成" → 造一个 verified=TrueCompleteActionagent.py:2986),任务收工;说"该终止" → TerminateAction;说"没完成" → 返回 None,用预抓的数据接着走下一步。

is_goal_achievedmodels.py:114)本身也有讲究:它只认"最后一个动作是 COMPLETE 或 EXTRACT 且成功";而且带导航目标时,一个中途的 EXTRACT 不能算完成,必须同一步里存在成功的 COMPLETE(models.py:132-141)——防止规划器随手 EXTRACT 就误判整个导航任务完成。

8.2 step 失败 → handle_failed_stepretry_index

真身 agent.py:5203,返回"下一 step 或 None":

handle_failed_step (agent.py:5203)

├─ 有用户自定义的终止级错误(terminal_user_errors)?
│ → 任务直接 failed,不重试 (agent.py:5213)

├─ step.retry_index >= max_retries_per_step? (agent.py:5243)
│ → 任务 failed,总结失败原因 + 分类
│ (兜底 MAX_RETRIES_PER_STEP = 5, config.py:144)

└─ 否则 → 新建同 order、retry_index + 1 的 step (agent.py:5308)
这个 step 被 execute_step 递归执行(agent.py:1000)

关键区分 orderretry_index(都在 Step 模型,models.py:58):

字段含义谁 +1
order第几步棋(推进)handle_completed_step 建下一步时 +1(agent.py:5878
retry_index同一步棋的第几次重试handle_failed_step 重试时 +1(agent.py:5308

所以"走 10 步"和"某步重试 5 次"是两个独立预算:前者受 max_steps_per_run(默认 10)管,后者受 max_retries_per_step(默认 5)管。一步失败先原地重试,重试到顶才让整个任务失败。


9. 贯穿始终的数据模型

这节把前面反复出现的两个模型收在一处。

Stepskyvern/forge/sdk/models.py:58 是决策循环的状态单元。核心字段:

  • statusStepStatusmodels.py:12,枚举 created/running/failed/completed/canceled),带合法状态转移表can_update_tomodels.py:19)——例如 failed/completed 是终态,不能再改。
  • order / retry_index:见 §8.2。
  • outputAgentStepOutput,落库的动作与结果。
  • 三个判定方法:is_goal_achieved:114)、is_success:145)、is_terminated:153)——善后逻辑全靠它们读。

DetailedAgentStepOutputskyvern/webeye/actions/models.py:17agent_step 返回的厚版调试快照,字段几乎覆盖了整条管线的中间产物:

字段装的东西
scraped_page感知层抓的页面
extract_action_prompt拼给 LLM 的 prompt
llm_responseLLM 回的原始 JSON
actions解析出的强类型动作
actions_and_results[(动作, [结果]), ...] 一一对应
cua_responseCUA 引擎的原始响应(若走 CUA)

不落库(注释明说"只给 Jupyter 调试用",models.py:19),落库前经 to_agent_step_output()models.py:76)瘦身成 AgentStepOutput,顺带抽出用户自定义错误、附上浏览器元数据。


10. engine 分支:本章只点名

agent_step 里那串 if engine == ...agent.py:1449-1485)说明 Skyvern 不止一个"下棋引擎"。全枚举在 skyvern/schemas/run_enums.py:14

engine决策方式本章立场
skyvern_v1(默认,"skyvern-1.0"DOM 元素树 + extract-action prompt → 动作 JSON本章主角
skyvern_v2"skyvern-2.0"一句话目标 → 自主编排第 6 章
openai_cua / anthropic_cua / ui_tars / yutori_navigator计算机使用(看截图直接给坐标动作)第 4 章

判 CUA 用元组 CUA_ENGINESrun_enums.py:23)。CUA 引擎在本章管线里的差异只需知道两点:它们不拼 extract-action promptagent.py:3421)、抓页面时只要 1 张截图不滚动agent.py:3253-3255),且跳过 complete verificationagent.py:656,因为 CUA 一步只出一个动作、幻觉完成的概率低)。


11. 边界与局限(诚实)

  • 一步一次 LLM 调用是主成本。 skyvern_v1 每步至少一次 extract-action 调用,开了 complete verification 还要再加一次校验调用(agent.py:2950)。并行投机预抓(agent.py:4938)是为省延迟,但赌错(其实已完成)时那次预抓白做。
  • 契约脆弱性。 整条链的可靠性押在 LLM 吐的 JSON 符合 extract-action.j2 的字段/枚举上。parse_actions 能容忍单条坏动作、非法键降级为 NullAction,但如果模型系统性地跑偏(比如整体不合法 JSON),这一步只能标失败重试。
  • 失败恢复靠"重试同一步",不是"回滚"。 一步里某个动作把页面带到了半路才失败,重试是在新页面状态上重新规划retry_index+1 的新 step 会重新抓页面),而不是撤销已做的动作——浏览器世界没有事务。
  • 预算是硬上限。 max_steps_per_run(默认 10)到顶即判失败并让 LLM 总结失败原因(agent.py:5839),不会自动放宽;复杂任务需要显式调大或改用工作流(ch05)/ Skyvern 2.0(ch06)。
  • 代码里能看出的 TODO/自嘲不少:parse_actions 末尾"这段可能不需要"(parse_actions.py:403)、CheckboxAction 被当 Click 处理更稳(actions.py:357-361),说明这块仍在演进。

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

主题文件路径符号
step 生命周期 + 守卫 + 递归推进skyvern/forge/agent.py:630ForgeAgent.execute_step
核心决策管线(感知→LLM→解析→执行→标记)skyvern/forge/agent.py:1316ForgeAgent.agent_step
抓页面 + 拼 extract-action promptskyvern/forge/agent.py:3267build_and_record_step_prompt
按任务类型选模板、render promptskyvern/forge/agent.py:3714_build_extract_action_prompt
造纯抽取动作skyvern/forge/agent.py:6217create_extract_action
step 完成后的命运判定skyvern/forge/agent.py:5702handle_completed_step
完成校验(并行 + 投机预抓)skyvern/forge/agent.py:4896_handle_completed_step_with_parallel_verification
再问一次 LLM 目标是否达成skyvern/forge/agent.py:2950check_user_goal_complete / complete_verify
完成校验结果(三态 property)skyvern/webeye/actions/actions.py:50CompleteVerifyResult / VerificationStatus
step 失败 → 重试或判失败skyvern/forge/agent.py:5203handle_failed_step
LLM 动作 JSON 契约(逐字 schema)skyvern/forge/prompts/skyvern/extract-action.j2:1模板 extract-action
JSON → 强类型(批量,逐条容错)skyvern/webeye/actions/parse_actions.py:347parse_actions
单条动作解析 + 规整 + 安全校验skyvern/webeye/actions/parse_actions.py:70parse_action
Action 基类与类型层级skyvern/webeye/actions/actions.py:108Action / WebAction / DecisiveAction
动作类型总枚举skyvern/webeye/actions/action_types.py:4ActionType
执行结果模型skyvern/webeye/actions/responses.py:8ActionResult / ActionSuccess / ActionFailure
step 状态机 + 判定方法skyvern/forge/sdk/models.py:58Step / StepStatus
单步厚版调试输出skyvern/webeye/actions/models.py:17DetailedAgentStepOutput
引擎枚举与 CUA 集合skyvern/schemas/run_enums.py:14RunEngine / CUA_ENGINES
步数 / 重试兜底常量skyvern/config.py:129MAX_STEPS_PER_RUN / MAX_RETRIES_PER_STEP
抓取容错顺序skyvern/constants.py:108SCRAPE_TYPE_ORDER

同组其它章:index · 01 感知层 · 03 执行层 · 04 引擎家族/CUA · 05 工作流 Block · 06 Skyvern 2.0 规划器