数据截至 (上游 commit eaf1040642b2)
Task 与类型安全结果:把"要什么类型"声明出来
30 秒导读:
Task是 Marvin 里最核心的数据模型——一个把「要模型做什么」(prompt)和「做到哪一步 了」(状态)装在一起的容器。本章讲清它的两个支柱:五态状态机 和result_type体系(你声明想要什么类型,Marvin 负责渲染进 prompt、并用 Pydantic 把模型的回答校验成那个类型)。本章只讲数据结构;这些 Task 是怎么被调度、被驱动跑起来的,留给 编排器与回合循环 和 end-turn 与 agentlet。
1. 这是什么(零基础也能懂)
一句话定义: Task 是一个「带类型声明的 AI 任务单」——你写下要模型做什么、期望结果是什么类型,它记录任务从待办到完成的状态。
它解决什么问题。 直接调 LLM 你拿到的永远是一坨字符串。要把它变成 int、变成一个 Pydantic 模型、变成「只能是 red/green/blue 之一」,你得自己写 prompt 说明格式、自己解析、自己校验、自己重试。Marvin 把这套「声明期望类型 → 渲染进 prompt → 校验模型输出」的循环固化进 Task 这一个对象里。
用起来什么样。 最小的例子——你只给指令和一个类型:
# 示意,非源码
from marvin import Task
# 想要一个整数
t = Task("2020 年美国总统大选,胜者得票率是多少个百分点?", result_type=int)
t.run() # 驱动模型,直到它交出一个能校验成 int 的结果
print(t.result) # 4 (一个真正的 int,不是 "4")
一句话直觉。 把 Task 想成一张报销单:单子上写清「报什么、要什么格式的凭证」(instructions + result_type),单子有状态戳(待审 / 审核中 / 通过 / 驳回 / 免审)。模型是那个填单子的人,Marvin 是那个坚持「凭证格式不对就打回去重填」的财务。
本章覆盖的两个支柱:
| 支柱 | 是什么 | 核心符号 |
|---|---|---|
| 状态机 | 任务的生命周期,五个状态 + 迁移方法 | TaskState、mark_*、is_ready |
| 类型体系 | 声明期望类型、渲染进 prompt、校验输出 | result_type、Labels、validate_result |
2. 顶层全景(Task 内部怎么组织)
一个 Task 实例内部大致分四块。下图从左到右是「你声明的 → Marvin 内部维护的 → 输出的」:
你在构造时声明的 Marvin 内部维护的 产出
┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────┐
│ instructions 指令 │ │ state 五态状态机 │ │ result │
│ result_type 期望类型 │─────▶│ id 8位十六进制 │─────▶│ 校验后的 T │
│ context 上下文 │ │ subtasks / parent │ │ 或 错误串 │
│ tools / memories │ │ depends_on 依赖集合 │ └─────────────┘
└──────────────────────┘ └──────────────────────┘
│ ▲
│ get_prompt() 经 task.jinja │
▼ │
渲染成 <task>…</task> 文本 ───────────┘
(含 result-type 说明,喂给模型)
Task 是一个 @dataclass,用了 Generic[T]——这里的 T 就是 result_type 的类型,让 task.result 在类型检查器眼里就是你声明的那个类型。定义见 src/marvin/tasks/task.py:75-77(class Task(Generic[T]))。
各字段一句话职责(挑与本章相关的):
| 字段 | 干什么 | 位置 |
|---|---|---|
instructions | 要模型做什么,唯一必填项 | task.py:84-87 |
result_type | 期望结果类型,默认 str | task.py:94-100 |
state | 当前状态,初始 PENDING | task.py:143-147 |
context | 附加上下文,会渲染进 prompt | task.py:124-127 |
result | 校验后的结果,或失败时的错误串 | task.py:188-195 |
_parent / subtasks / depends_on | 依赖图字段(本章只讲结构) | task.py:164-186 |
3. 支柱一:五态状态机
3.1 五个状态是什么
TaskState 是一个字符串枚举,五个值 —— src/marvin/tasks/task.py:62-69(class TaskState):
| 状态 | 含义 | 判定方法 |
|---|---|---|
PENDING | 已创建,还没跑(初始态) | is_pending() |
RUNNING | 正在被驱动 | is_running() |
SUCCESSFUL | 成功,result 是校验后的值 | is_successful() |
FAILED | 失败,result 是错误串 | is_failed() |
SKIPPED | 被跳过(需 allow_skip) | is_skipped() |
Marvin 把这五态再归成两组「完成度」概念——这是调度用得最多的两个谓词:
is_incomplete()—— 状态是PENDING或RUNNING(task.py:629-631)。is_complete()—— 状态是SUCCESSFUL/FAILED/SKIPPED(task.py:633-635)。
注意:FAILED 和 SKIPPED 都算「完成」。也就是说一个失败的任务不会卡住依赖它的下游——它已经有了终局。
3.2 状态怎么迁移
初始永远是 PENDING(构造函数里写死,task.py:292)。之后靠四个 mark_* 异步方法推进:
mark_running()
PENDING ───────────▶ RUNNING ──┬── mark_successful() ──▶ SUCCESSFUL
│ ├── mark_failed() ──▶ FAILED
│ └── mark_skipped() ──▶ SKIPPED
└──── mark_skipped() ───────────────────────────────▶ SKIPPED
这些方法不只是改一个枚举值,它们各自还干一件"副作用"活:
mark_running(task.py:582-595):置RUNNING,并把渲染后的 prompt 作为一条 user message 推进当前 thread(thread.add_user_message_async(self.get_prompt()))。这一步是任务"开口说话"的地方。mark_successful(task.py:549-567):默认先校验(validate_result,见 §4.4),再把校验后的值写进self.result,置SUCCESSFUL。mark_failed(task.py:569-580):把错误串写进self.result,置FAILED。mark_skipped(task.py:597-607):置SKIPPED。
关键细节:
mark_successful里result参数默认是None,且默认validate_result=True。所以就算模型"什么都没给",也会走一遍校验——这就是为什么result_type=None的任务能干净地表示"我不需要结果",而result_type=str(默认)时给None会被校验挡下(见 §4.4)。
3.3 is_ready:一个任务什么时候"可以跑"
is_ready() 回答调度器最关心的问题——这个任务现在能不能跑。规则很简洁(task.py:637-644):
# 真源码,task.py:642-644
return self.is_incomplete() and all(
t.is_complete() for t in (self.depends_on | self.subtasks)
)
翻译成白话:任务自己还没完成,且它依赖的一切(显式依赖 depends_on ∪ 子任务 subtasks)都已完成。 注意子任务也算前置——父任务要等所有子任务落定才轮到自己。至于调度器如何轮询 is_ready 来决定跑谁,是 第 2 章 的事。
4. 支柱二:result_type 类型体系
这是本章的精华。核心思想一句话:你声明"要什么类型",Marvin 把它翻译成给模型的说明、再把模型的回答校验回那个类型。 下面由浅入深拆四层。
4.1 默认是 str
result_type 字段默认值是一个哨兵 NOTSET(task.py:45、94-100)。构造时如果没传,就落到 str——见 task.py:279:
# 真源码,task.py:279
self.result_type = result_type if result_type is not NOTSET else str
用哨兵而不是直接默认 str,是为了区分"没传"和"显式传了 None"(None 表示"不需要结果",是合法值,见 §4.4)。
4.2 分类简写:['a','b'] 与 [['a','b']]
Marvin 给「分类任务」(classification,让模型从固定选项里选)设计了极简语法:直接传一个 list 就是单选,传"套一层的 list"就是多选。
| 你写的 | 含义 | 转成 |
|---|---|---|
result_type=['red','green','blue'] | 单标签:三选一 | Labels(['red','green','blue']) |
result_type=[['red','green','blue']] | 多标签:可多选 | Labels(['red','green','blue'], many=True) |
这个转换在构造函数末尾就地完成(task.py:315-332)。识别逻辑:
- 若是
list/tuple/set,且长度为 1 且唯一元素又是个序列 → 判定为多标签,取内层建Labels(..., many=True)。 - 否则若是
list/tuple/set→ 单标签Labels(...)。 - 空列表、
[[]]、或[['a'], ['b']]这类非法嵌套,直接raise ValueError(task.py:320-331)。
(同一段逻辑也以 _validate_result_type() 方法形式存在于 task.py:337-364,作为可复用的校验入口。)
4.3 Labels:分类的统一容器
Labels 是 src/marvin/utilities/types.py:30-143 里的一个 dataclass,它把"分类"这件事统一了——不管标签来自 list、Literal["a","b"]、还是 enum.Enum,都归一到同一个接口。__post_init__(types.py:73-85)负责把各种来源摊平成一个 _labels 元组。
Labels 最巧妙的设计:对模型只暴露"索引",不暴露"值"。 三个方法配合完成这件事:
get_type()(types.py:92-94):告诉校验器该期望什么——单选是int,多选是list[int]。模型交回 的是数字下标,不是字符串。get_indexed_labels()(types.py:133-143):产出{0: 'red', 1: 'green', 2: 'blue'}这样的映射,供 prompt 展示。validate(value)(types.py:96-131):把模型给的下标(或下标列表)映射回真实标签值。多选时还顺手查边界、查重复、查类型。
为什么用下标而不让模型直接吐标签字符串?因为下标是封闭整数空间,模型难以"编造"出不存在的类别——它顶多给个越界数字,而越界会被 validate 干净地拒掉(types.py:112-115、127-130)。这是把"幻觉"约束在类型层面的一招。
围绕 Labels 有两个自由函数(本章只需知道符号级作用):
is_classifier(type_)(types.py:192-241):判断一个类型是不是分类任务——涵盖Enum、Literal、任意序列,以及它们的list[...]多标签形式。Task.is_classifier()(task.py:407-409)直接委托给它。as_classifier(type_)(types.py:146-189):把一个已确认是分类的类型转成Labels实例。
4.4 三个出口方法:get_result_type / get_result_type_str / validate_result
有了上面的铺垫,Task 上三个方法就是把「类型」用在三个不同场合:
get_result_type()(task.py:411-418)—— 给校验器用的"有效类型"。分类任务返回 int 或 list[int](下标类型),否则原样返回你声明的类型。
get_result_type_str()(task.py:420-433)—— 给模型 prompt用的字符串说明。两种分支:
- 分类任务:产出
"Provide the integer indices of your chosen labels: {0: 'red', ...}"——直接告诉模型"报下标"。 - 其它类型:走 Pydantic
TypeAdapter,把类型的 JSON Schema 序列化进 prompt(json.dumps(type_adapter.json_schema()))。所以你传一个 Pydantic 模型,模型会在 prompt 里看到它的完整 schema。
validate_result(raw_result)(task.py:435-462)—— 校验的总闸门,按优先级三层:
1. 自定义校验器优先 ──▶ 若设了 result_validator,用它,出错抛 ValueError
2. 分类任务 ──▶ as_classifier(...).validate(raw) 把下标映射回标签
3. 普通类型 ──▶ Pydantic TypeAdapter.validate_python(raw)
第 3 层里有两个 None 的边界处理值得记(task.py:450-459):
result_type is None(你声明"不要结果")→ 只接受raw is None,否则报错。result_type不是None但raw is None→ 除非类型里显式含None(如str | None,靠get_args查NoneType),否则报错。
这就是为什么"声明了要 int 却啥都没给"会被挡下——校验层不放行。
5. result_type 是怎么进 prompt 的(task.jinja)
前面说的"渲染进 prompt",落地在模板 src/marvin/templates/task.jinja。Task.get_prompt()(task.py:464-473)用这个模板、以 task=self 渲染出一段 XML 风格文本,再拼上附件。
模板结构(task.jinja:1-37)把任务的几块信息包成 <task>:
| 模板片段 | 来源 | 行 |
|---|---|---|
<instructions> | task.instructions(缩进 8 格) | task.jinja:6-8 |
<context> 逐项 | 遍历 task.context.items() | task.jinja:9-15 |
<result-type> | task.get_result_type_str() | task.jinja:17-23 |
<parent-task-id> | task.parent.id(若有父任务) | task.jinja:24-26 |
<memories> | 各 memory 的 get_prompt() | task.jinja:29-36 |
一个关键的门控:<result-type> 只在 task.is_ready() 为真时才渲染(task.jinja:17)。也就是说——只有当任务真的可以跑(依赖都完成了),才会把"你该交什么类型"这段说明喂给模型。同一块里还附了一句硬提示(task.jinja:21):
Note: result types must be provided as part of a complete MarkTaskSuccessful payload.
这句话把"结果类型"和"结束回合的工具"绑在了一起——模型不是随便回一段话,而是要以一个 MarkTaskSuccessful 载荷来交结果。这个"把结束回合做成类型化工具"的机制,正是 第 3 章 的主题。
6. 依赖图字段(为第 4 章埋伏笔)
Task 自带三个字段描述任务之间的关系。本章只讲这些字段是什么数据结构,不讲调度器怎么用它们。
| 字段 | 类型 | 含义 |
|---|---|---|
_parent | Task | None | 父任务(经 parent property 读写) |
subtasks | set[Task] | 子任务集合 |
depends_on | set[Task] | 必须先完成的前置任务 |
父子关系是自动双向维护的。 parent 是一个 property(task.py:377-389):给一个 task 设 parent 时,setter 会同时把它加进新父的 subtasks、并从旧父的 subtasks 里移除。你只需设一头,另一头自动同步。
父任务还能从上下文自动推断。 构造时若没显式传 parent(哨兵 NOTSET),会取 _current_task 这个 ContextVar 的当前值(task.py:296-298)。而 Task 实现了 __enter__/__exit__(task.py:646-655)——用 with task: 把它设为"当前任务"。于是在 with 父任务: 块里新建的任务会自动挂成子任务:
# 示意,非源码
with Task("写一篇报告", result_type=str) as parent:
# 下面两个自动成为 parent 的 subtasks(无需手动指定 parent)
outline = Task("先列提纲")
draft = Task("再写正文", depends_on=[outline])
这些集合字段唯一在本章"用到"的地方,就是 §3.3 的 is_ready()——它把 depends_on | subtasks 合起来检查是否都 is_complete()。真正的依赖调度、多智 能体协作、记忆与持久化,见 第 4 章。
7. 边界与局限(诚实说)
- Task 本身不会"跑"。
run()/run_async()(task.py:490-501)只是把自己交给marvin.fns.run.run_tasks_async—— 真正的驱动逻辑在引擎里,不在 Task。Task 是数据模型,不是执行器。 - 分类靠下标,不靠字符串匹配。 模型必须回整数下标;若模型不遵守(回了字符串),
Labels.validate会拒(types.py:122-126)。这把可靠性押在"模型能正确报下标"上。 - 多标签要求非空、无重复、下标有序合法。 空
list、重复下标、越界都会被validate拒(types.py:101-117)。 None语义有两副面孔。result_type=None(不要结果)和result_type含None(如str | None,可以是 None)是两回事,靠 §4.4 的分支区分——传错容易困惑。
8. 横向对比
result_type 驱动的"声明式类型化输出"是 Marvin 相对其它 agent 框架最鲜明的取舍:多数框架让你自己写 prompt + 解析,Marvin 把"类型 → prompt 说明 → 校验"三步固化进 Task。它与本 shelf 里那些"以对话/工具循环为中心"的框架的差异,在总库对应原理页有横向展开。分类用"下标而非字符串"来抑制幻觉的做法,也可与其它框架的 structured-output 方案对照。
同组其它章:总览 · 编排器与回合循环 · end-turn 与 agentlet · 演员、线程与放大 · 高层门面 cast/classify/…。
9. 代码地图(导航索引)
| 主题 | 文件 | 符号 |
|---|---|---|
| Task 数据模型 | src/marvin/tasks/task.py | Task(Generic[T]) |
| 五态枚举 | src/marvin/tasks/task.py | TaskState |
| 默认 str / 哨兵 | src/marvin/tasks/task.py | NOTSET、Task.__init__ |
| 分类简写转换 | src/marvin/tasks/task.py | Task.__init__(315-332)、_validate_result_type |
| 有效类型 / prompt 串 / 校验 | src/marvin/tasks/task.py | get_result_type、get_result_type_str、validate_result |
| 状态迁移 | src/marvin/tasks/task.py | mark_running、mark_successful、mark_failed、mark_skipped |
| 就绪 / 完成谓词 | src/marvin/tasks/task.py | is_ready、is_incomplete、is_complete |
| 父子自动同步 / 上下文当前任务 | src/marvin/tasks/task.py | parent(property)、__enter__、_current_task |
| prompt 渲染 | src/marvin/tasks/task.py | get_prompt |
| prompt 模板 | src/marvin/templates/task.jinja | <result-type> 门控(is_ready) |
| 分类容器 | src/marvin/utilities/types.py | Labels、get_type、validate、get_indexed_labels |
| 分类判定 / 转换 | src/marvin/utilities/types.py | is_classifier、as_classifier |