数据截至 (上游 commit 6a6b6383eb6a)
Skyvern 2.0 规划器:从一句话目标到自主编排
30 秒导读: README 说 Skyvern 用「a swarm of agents(一群 agent)」去理解网页、规划并执行——这句话的真身就是本章讲的
task_v2。它不是真有很多进程在跑,而是一个外循环:拿到你一句话的大目标,每一轮先看当前页面,让 LLM 规划「下一步该干的小目标」,把这个小目标当场物化成一个工作流 block(导航 / 提取 / 循环)去执行,再把结果塞回历史、进入下一轮,直到 LLM 判定目标达成或不可能。
本章建立在前面几章之上,只讲新增的那层自治外循环,不重复它们的内部:
- 感知(把网页变成 LLM 能读的东西)见 01-perception-scraper.md
- 单步 Agent 循环与动作规划见 02-agent-loop-planning.md
- 动作如何精确落到真实元素见 03-action-execution.md
- Block 如何编排多步骤见 05-workflow-blocks.md
1. 这是什么(零基础也能懂)
一句话定义: task_v2(代码里也叫 observer cruise / Skyvern 2.0)是一个会自己写工作流的 agent——你只给一句话目标,它自己一步步把工作流「生长」出来并执行完。
它和 task_v1 的区别,是本章的核心。 用一句话说清:
- task_v1(单任务): 你给一个明确任务("在这个页面上填表并提交"),它在固定的 step 循环里把这一件事做完。任务边界是你划的。
- task_v2(自主规划): 你给一个大目标("帮我在这三家店里比一款笔记本的价格,选最便宜的"),它自己决定要分成哪些小任务、每个小任务是什么类型、按什么顺序做。任务边界是它自己划的。
给谁用 / 解决什么问题: 给那种「一句话说得清、但拆成步骤很烦」的网页活。你不想(也没法)预先画好工作流图,就把大目标丢给它,让它边看边想边做。
用起来什么样: 对外就是一句话 + 可选的起始 URL。
# 示意,非源码:task_v2 对外的最小心智模型
run = skyvern.run_task(
prompt="在 example-shop 上找到评分最高的三款机械键盘,比较价格,返回最便宜的那款",
engine="skyvern-2.0", # 走 task_v2 规划器,而不是 v1 单任务
)
# 剩下的拆解、导航、提取、比较,全由规划器自己完成
一句话直觉: 把它想 成一个边走边画流程图的人。他手上只有一句话目标,每走到一个新页面就停下来想「下一格该画什么框」,画完一个框(block)就立刻执行,看结果,再想下一格——而不是像 v1 那样照着一张已经画好的图走。
本节不碰底层。记住一件事就够了:v2 = 在工作流引擎之上,加一层「LLM 动态生成下一个 block」的自治外循环。
2. 顶层全景(它大概怎么转)
2.1 一张图看懂外循环
规划器的心脏是 run_task_v2_helper 里的一个 for i in range(max_iterations) 循环(skyvern/services/task_v2_service.py:864)。每一轮(iteration)都做同样四件事:
一句话大目标 (user_prompt)
│
▼
┌───────────────────────────────────────────┐
│ 每一轮 iteration(最多 50 轮) │
│ │
│ ① 观察 抓取当前页面 DOM + 截图 │
│ (scrape_website) │
│ │ │
│ ▼ │
│ ② 规划 LLM 读 页面+历史+大目标 │
│ → 判定 达成? 放弃? 还是 │
│ 下一个 mini goal + task_type │
│ (prompt: task_v2.j2) │
│ │ │
│ 达成 ──┴── 放弃 │
│ │ │ │
│ ▼ ▼ 否则继续 ▼ │
│ summarize terminate │
│ (完成) (终止) ③ 物化 │
│ 把 mini goal 变成 │
│ 一个 workflow block │
│ (_generate_*_task) │
│ │ │
│ ▼ │
│ ④ 执行 block │
│ (block.execute_safe) │
│ │ │
│ ▼ │
│ 结果写入 task_history │
│ 成功则再跑一次完成校验 │
│ (task_v2_check_completion) │
└──────────────────────────┬──────────────────┘
│ 回到 ① 进入下一轮
▼
达成 / 放弃 / 超步数 / 超轮数 → 终态
怎么读这张图: 从上往下是一轮的四步「观察 → 规划 → 物化 → 执行」,右边的分叉是三种提前退出(达成、放弃、超预算)。只要没退出,就回到 ① 重新观察,进入下一轮。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪里 |
|---|---|---|
initialize_task_v2 | 建空工作流 + workflow run 的壳,把 task_v2 挂上去 | task_v2_service.py:310 |
initialize_task_v2_metadata | 开跑前先让 LLM 推断起始 URL 和工作流标题 | task_v2_service.py:441 |
run_task_v2 | 外层入口:铺好 SkyvernContext、兜底异常、收尾清理浏览器 | task_v2_service.py:536 |
run_task_v2_helper | 核心主循环:观察→规划→生成→执行,逐轮推进 | task_v2_service.py:698 |
_generate_navigation_task | 把「导航类」mini goal 物化成 NavigationBlock | task_v2_service.py:2072 |
_generate_extraction_task | 把「提取类」mini goal 物化成 ExtractionBlock | task_v2_service.py:1838 |
_generate_loop_task | 把「循环类」mini goal 物化成 ForLoopBlock | task_v2_service.py:1604 |
_generate_goto_url_task | 生成一个纯跳转的 UrlBlock(第 0 轮开局用) | task_v2_service.py:2116 |
handle_block_result | 看 block 执行结果,决定工作流 run 是否要取消 / 刷新状态 | task_v2_service.py:1492 |
mark_task_v2_as_* | 五个终态落库:completed / failed / terminated / canceled / timed_out | task_v2_service.py:2226+ |
task_v2.j2 | 规划 prompt:读页面判定下一步、是否达成、是否放弃 | forge/prompts/skyvern/task_v2.j2 |
task_v2_check_completion.j2 | 完成校验 prompt:每次 block 成功后复核大目标是否真达成 | forge/prompts/skyvern/task_v2_check_completion.j2 |
2.3 主线走一遍(高层,不进代码)
- 你给一句话(+ 可选 URL)。
initialize_task_v2建一个空工作流——注意,此刻工作流里一个 block 都没有。 initialize_task_v2_metadata先让 LLM 猜「该从哪个 URL 开始、这活儿叫什么名字」。- 进入主循环。第 0 轮通常先生成一个跳转 block 把浏览器带到起始页。
- 之后每一轮:抓当前页 → 规划 prompt 吐出「下一个 mini goal + 它是 navigate/extract/loop 中的哪一种」→ 把它物化成对应 block 追加进工作流 → 执行 → 结果进历史。
- 规划 prompt 哪一轮说「user_goal_achieved=true」,就总结产出、收尾完成;说「should_terminate=true」就判定不可能、终止。
- 撞到轮数上限或步数上限,则失败收场。
关键认知:工作流是被这个循环一轮一轮「长」出来的,不是预先定义好的。 这就是 v2 相对 v1 的本质增量。
3. 核心原理(逐个机制,由浅入深)
3.1 开局:先建空壳,再让 LLM 补上 URL 和标题
要解决的小问题: 你可能只给了一句话,连从哪个网址开始都没说。得有人把「起点」补出来。
思路: 分两步。initialize_task_v2 先把承载执行的基础设施建好——一个空工作流 + 一次 workflow run(task_v2_service.py:368 的 create_empty_workflow、:378 的 setup_workflow_run)。真正的「起始 URL / 标题」留给 LLM 在 initialize_task_v2_metadata 里推断:
- 调
task_v2_generate_metadataprompt,喂大目标和当前浏览器 URL; - 拿回
url和title,装进TaskV2Metadata(task_v2_service.py:472-479); - 若既没用户给的 URL、LLM 也没推断出,直接抛
UrlGenerationFailure(:474)——没有起点就没法跑。
关键细节: 推断结果会回写工作流标题(原来是占位的 "New Workflow"),所以你在 UI 上看到的工作流名,是 LLM 现起的。
3.2 主循环:一轮 = 观察 + 规划 + 物化 + 执行
这是整章的骨架,全在 run_task_v2_helper(task_v2_service.py:698)。循环体 for i in range(max_iterations) 从 :864 开始。拆开看:
① 观察。 每轮开头重新拿浏览器页面并抓取(scrape_website,:964),把 DOM 元素树 + 截图备好。感知细节见 01-perception-scraper.md。
② 规划。 用 load_prompt_with_elements 组装 task_v2 规划 prompt(:993),喂进去的关键料是三样:
current_url:当前在哪user_goal:一句话大目标task_history:到目前为止做过的所有小任务及其结果
然后调 LLM(:1018),解析出这几个决策字段(:1033-1044):
user_goal_achieved → 大目标是否已经达成
should_terminate → 是否判定不可能、该放弃
plan → 下一个 mini goal 的文字描述
task_type → 这个 mini goal 属于 navigate / extract / loop 哪种
③ 分派与物化。 按 task_type 走不同分支,把 plan 这段文字变成一个真的 block:
| task_type | 物化成 | 生成函数 | 行 |
|---|---|---|---|
navigate | NavigationBlock | _generate_navigation_task | :1137 |
extract | ExtractionBlock | _generate_extraction_task | :1114 |
loop | ForLoopBlock | _generate_loop_task | :1151 |
navigate 分支还会用 MINI_GOAL_TEMPLATE 把 mini goal 包进大目标做上下文(:1136,模板见 constants.py:102),让底层单步 agent 知道「这一小步是为了哪个大目标」。
④ 追加进工作流并执行。 生成的 block 的 YAML 被 extend 进累积列表,然后整份工作流被重新创建(:1222-1227 的 create_workflow_from_request)——这就是工作流「一轮长一格」的物理动作。接着 block.execute_safe(:1231)真正执行。Block 的执行内部见 05-workflow-blocks.md。
⑤ 回灌历史。 执行结果(状态、失败原因、提取到的数据)打包成 task_history_record 追加进 task_history(:1271)。下一轮规划时,这段历史又会被喂回 prompt——这就是外循环「记得自己做过什么」的机制。
# 示意,非源码:一轮的骨架,对应 run_task_v2_helper
for i in range(max_iterations):
scraped = scrape(current_page) # ① 观察
plan = llm(task_v2_prompt(scraped, goal, history))# ② 规划
if plan.user_goal_achieved: summarize(); break # 达成→完成
if plan.should_terminate: terminate(); return # 放弃→终止
block = materialize(plan.task_type, plan.text) # ③ 物化成 block
result = block.execute() # ④ 执行
history.append(record(plan, result)) # ⑤ 回灌历史
# 重点看:history 每轮都被喂回 ② 的 prompt,这是自治的关键
3.3 规划 prompt:三种 task_type 与「循环优先」
要解决的小问题: LLM 怎么知道下一步该导航、该提取、还是该开一个循环?
思路: 全写在 task_v2.j2(规划 prompt)里,它给 LLM 定义了三种动作及其分工:
| task_type | 干什么 | 边界(prompt 明确划的) |
|---|---|---|
navigate | 到达正确页面 / 在页面上做操作(填表、点按钮、选项) | 不负责抓数据,别把 navigate 写成「…并提取数据」 |
extract | 从当前页面抓信息 | 只抓不导航,页面保持静止 |
loop | 对一批同形目标做同一件事(N 个链接、N 行、N 个商品) | 广度优先,一个 loop 顶 N 个串行任务 |
一个关键设计——「循环优先」(task_v2.j2:8-27)。 prompt 反复强调:只要目标是「对多个同形项做同一操作」,就用一个 loop,别拆成 N 个串行的 navigate/extract。原因很实在:串行 N 个会耗尽轮数和步数预算,常常没做完就超预算了;一个 loop 在一步规划里就覆盖全 部 N 项。
另一个精妙的反死循环设计(task_v2.j2:60-65)。 prompt 明确告诉 LLM:最终的总结/比较/推荐是达成时自动生成的,没有任何 task 负责写它。所以别去规划「把结果总结一下」这种 extract 任务——extract 只会一遍遍返回页面数据而非综合结论,这是「跑光步数预算」最常见的原因。只要各部分需要的数据都被 extract 抓到了,就该置 user_goal_achieved=true 收尾。
3.4 loop 的物化:最复杂的一支,两次 LLM 调用
要解决的小问题: 生成一个循环,得先知道「循环谁」(哪一批值),再知道「对每个值做什么」。
思路: _generate_loop_task(task_v2_service.py:1604)分两步,各调一次 LLM:
- 先跑一个提取 block 把「循环值」抓出来。 用
task_v2_loop_task_extraction_goalprompt 生成提取目标,schema 由_generate_data_extraction_schema_for_loop(:143)固定成{loop_values: [...], is_loop_value_link: bool}。这个 ExtractionBlock 当场执行(:1658),拿到要迭代的那批值。 - 再规划「循环体里那一个任务」。 用
task_v2_generate_task_blockprompt(:1742)生成循环内任务的navigation_goal/data_extraction_goal/data_schema,物化成一个TaskBlock,再包进ForLoopBlock(:1817-1830)。
关键细节 / 坑:
is_loop_value_link决定循环体怎么用值:是链接就每轮先跳过去(key 前缀task_in_loop_url_),不是链接就当参数注入(keytarget_)。- 非链接的参数 key 特意加了随机后缀(
:1639-1641),否则一个 run 里多个非链接循环都叫target,会撞「重复 key」校验。这类 corner case 是踩过坑后补的。
3.5 完成校验:block 成功后再复核一次大目标
要解决的小问题: 规划 prompt 在执行前说「还没达成」,但一个 block 成功执行之后,局面可能已经变了。不能只信执行前的判断。
思路: 每当 block success 为真,主循环会再跑一次独立的完成校验(task_v2_service.py:1293-1360):重新抓页面截图,喂 task_v2_check_completion prompt,专门问「就凭现在的 task_history + 截图,大目标达成没有 / 是不是不可能」。
这是一个「执行前规划、执行后复核」的双保险:
- 规划 prompt(
task_v2.j2)在轮首判断要不要继续; - 完成校验 prompt(
task_v2_check_completion.j2)在 block 成功后判断是不是可以收工了。
两个 prompt 都带同一套 严格约束:多部分目标必须每一部分都满足才算达成(required_subgoals 逐条核对);终止要极其保守,必须页面上有明确「不可能」的证据才 should_terminate。
3.6 思考记录:thought / plan 的可观测轨迹
要解决的小问题: 这么多轮 LLM 决策,用户和调试者得能看见它「想了什么」。
思路: 每次 LLM 决策前后,都往数据库写一条 Thought。类型和场景是枚举(schemas/task_v2.py:142 的 ThoughtType、:158 的 ThoughtScenario):
| 时机 | ThoughtType | ThoughtScenario |
|---|---|---|
| 规划下一步 | plan | generate_plan |
| 推断 URL/标题 | metadata | generate_metadata |
| 完成校验 | user_goal_check | user_goal_check |
| 抽取循环值 | plan | extract_loop_values |
| 生成循环内任务 | internal_plan | generate_task_in_loop |
| 终止 | termination | termination |
| 总结产出 | user_goal_check | summarization |
规划那条 thought 在 LLM 返回后被 update_thought 填上 thoughts(推理)、observation(页面观察)、answer(plan 文本)和结构化 output(task_v2_service.py:1050-1062)。UI 时间线就是读这些 thought 渲染出来的(get_thought_timelines,:2144)。
4. 深入实现(要读源码的人看这里)
4.1 两条预算线:轮数 vs 步数
v2 有两个独立的预算,任一耗尽都失败,别混淆:
| 预算 | 含义 | 默认 / 来源 | 检查点 |
|---|---|---|---|
max_iterations | 规划轮数:外循环最多转几圈 | DEFAULT_MAX_ITERATIONS = 50(task_v2_service.py:105) | for i in range(max_iterations)(:864);跑满走 for...else(:1454) |
max_steps | 总步数:所有生成的 block 里累计的底层 step 数 | settings.MAX_STEPS_PER_TASK_V2(:851) | 每轮末尾累加校验(:1408-1414) |
_resolve_max_iterations(:655)有个防退化设计:外部传入的 override 会和历史下限 DEFAULT_MAX_ITERATIONS 取 max,保证老工作流不会因为存了更小的旧默认值而倒退。
踩坑提示: 「规划得很聪明但每步很慢」会先撞 max_steps;「每步很快但方向乱兜圈」会先撞 max_iterations。两种失败的 failure_category_path 不同(v2_max_steps vs v2_max_iterations,:1439/:1476),排障时看这个标签能快速定位是哪条预算爆的。
4.2 五个终态
所有收场都归到一组 mark_task_v2_as_*(task_v2_service.py:2226+),它们都走同一个 _update_task_v2_status(:2192)落库、记时长指标、发 webhook:
| 终态函数 | 触发场景 | 行 |
|---|---|---|
mark_task_v2_as_completed | user_goal_achieved=true,总结后完成 | :2257 |
mark_task_v2_as_terminated | LLM 判定目标不可能(规划或完成校验任一) | :2301 |
mark_task_v2_as_failed | 无 task_type / 生成 block 失败 / 超步数 / 超轮数 | :2226 |
mark_task_v2_as_canceled | 工作流 run 被取消 | :2281 |
mark_task_v2_as_timed_out | 超时 | :2328 |
达成收尾走 _summarize_task_v2(:2680)→ task_v2_summary prompt 综合整段 task_history 产出最终交付物;失败也尽力产一份「部分结果」(_best_effort_failure_deliverable,:2653,永不抛异常,绝不干扰终态处理)。
4.3 navigate 的终端输出兜底(一个精细的容错)
navigate block 本该只导航,但有时底层 agent 的收尾 COMPLETE 动作里带了答案。主循环有段兜底:当 navigate 成功、但 extracted_information 为 null 时,去它的 COMPLETE 动作里捞回终端输出(_get_navigate_complete_output,:2480)。
这里有两个刻意的容错边界:
- 字符串输出超过
NAVIGATE_TERMINAL_OUTPUT_MAX_CHARS(2000)就截断(:2513)——因为 task_history 每轮都回灌 prompt,无界文本会撑爆上下文。 - 结构化输出(dict/list)没法安全截断,超过 10 倍上限就整个丢弃(
:2528-2537),逼规划器另开一个 extract 任务,而不是塞一坨脏数据进历史。
5. 巧妙之处(可借鉴的技术)
- 工作流「生长」而非「预定义」。 每轮把新 block 追加后整份重建工作流(
:1222-1227),让「动态规划」复用了「静态工作流引擎」的全部执行能力——规划器不必自己实现执行,只管生成 block。这是 v2 复用 ch05 的核心手法。 - 执行前规划 + 执行后复核 双 prompt。
task_v2.j2管「下一步」、task_v2_check_completion.j2管「收工没」,两次独立判断避免「以为做完了其实没做完」。 - prompt 层面的反死循环工程。 「循环优先」和「最终答案在收尾自动合成、别用 task 去写总结」两条规则(
task_v2.j2:8-27、:46-49),直接从设计上堵死了最常见的两种「跑光预算」死法。这是把踩过的坑固化进 prompt 的典范。 - 历史回灌 = 无状态循环的记忆。 每轮把 task_history 喂回 prompt,让本质无状态的 LLM 调用具备了「记得做过什么」的连续性,同时用字符/结构化上限守住上下文不膨胀。
6. 边界与局限
- 强依赖 LLM 判断力。 拆解质量、是否用 loop、何时收工,全押在规划 prompt 的输出上。方向判断错了,就在预算内空转到失败。
- 两条预算是硬墙。 复杂目标很容易撞
max_steps或max_iterations(默认 50 轮)。prompt 里反复劝用 loop、别拿 task 写总结,本质都是在省预算。 - 终止极度保守。 两个 prompt 都要求「必须有页面上明确的不可能证据」才 terminate,好处是不轻易放弃,代价是真正不可能的目标也可能兜到超预算才失败。
- loop 依赖「循环值」抽得准。
_generate_loop_task第一步的提取 block 若抓错/抓空那批值,整个循环就跑偏,且该失败在上层才被 mark(:1663-1667)。
7. 横向对比:task_v1 vs task_v2
同一个仓库里两代任务模型,取舍鲜明:
| 维度 | task_v1(单任务) | task_v2(自主规划) |
|---|---|---|
| 入口 | run_task(task_v1_service.py:110) | run_task_v2(task_v2_service.py:536) |
| 输入 | 一个明确任务 + 目标 URL | 一句话大目标(URL 可省,LLM 推断) |
| 循环 | 固定单任务,交给 executor 跑底层 step 循环(AsyncExecutorFactory...execute_task,task_v1_service.py:160) | 动态外循环,每轮 LLM 现规划下一个 block(run_task_v2_helper,:864) |
| 谁划任务边界 | 用户 | 规划器自己 |
| 产物 | 执行一个任务 | 边跑边「长」出一整个工作流 |
| 适用 | 边界清晰的单步活 | 需要多步拆解、事先画不出流程图的活 |
一句话: v1 是「照着你给的一张图走」,v2 是「你只给一句话,它边走边把图画出来」。v2 并不取代 v1——它在 v1/ch05 提供的感知、决策、执行、block 之上,只加了「LLM 动态生成下一个 block」这一层自治外循环。这就是 README 所谓「swarm of agents(一群 agent)」的工程真身(README.md:39)。
工程家族里 DOM 规划器与计算机使用(CUA)两种引擎的取舍见 04-engines-cua.md;task_v2 生成的 navigate block 底层可落到任一引擎执行。
8. 代码地图(导航索引)
| 主题 | 文件 | 符号 |
|---|---|---|
| 建空工作流壳 | skyvern/services/task_v2_service.py | initialize_task_v2 |
| 推断起始 URL/标题 | skyvern/services/task_v2_service.py | initialize_task_v2_metadata |
| 外层入口 / 上下文 / 收尾 | skyvern/services/task_v2_service.py | run_task_v2 |
| 核心主循环 | skyvern/services/task_v2_service.py | run_task_v2_helper |
| 轮数预算解析 | skyvern/services/task_v2_service.py | _resolve_max_iterations、DEFAULT_MAX_ITERATIONS |
| 物化导航 block | skyvern/services/task_v2_service.py | _generate_navigation_task |
| 物化提取 block | skyvern/services/task_v2_service.py | _generate_extraction_task |
| 物化循环 block(两次 LLM) | skyvern/services/task_v2_service.py | _generate_loop_task、_generate_data_extraction_schema_for_loop |
| 开局跳转 block | skyvern/services/task_v2_service.py | _generate_goto_url_task |
| block 结果处理 | skyvern/services/task_v2_service.py | handle_block_result |
| navigate 终端输出兜底 | skyvern/services/task_v2_service.py | _get_navigate_complete_output |
| 终态落库(×5) | skyvern/services/task_v2_service.py | mark_task_v2_as_completed/failed/terminated/canceled/timed_out |
| 达成总结产出 | skyvern/services/task_v2_service.py | _summarize_task_v2、_best_effort_failure_deliverable |
| 思考类型枚举 | skyvern/forge/sdk/schemas/task_v2.py | ThoughtType、ThoughtScenario |
| 规划 prompt | skyvern/forge/prompts/skyvern/task_v2.j2 | navigate/extract/loop 定义、循环优先、反死循环 |
| 完成校验 prompt | skyvern/forge/prompts/skyvern/task_v2_check_completion.j2 | required_subgoals、保守终止 |
| 循环内任务生成 prompt | skyvern/forge/prompts/skyvern/task_v2_generate_task_block.j2 | navigation_goal / data_extraction_goal / schema |
| 提取 schema 生成 prompt | skyvern/forge/prompts/skyvern/task_v2_generate_extraction_task.j2 | schema |
| mini goal 模板 | skyvern/constants.py | MINI_GOAL_TEMPLATE |
| v1 单任务入口(对比) | skyvern/services/task_v1_service.py | run_task |