跳到主要内容

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 是那个坚持「凭证格式不对就打回去重填」的财务。

本章覆盖的两个支柱:

支柱是什么核心符号
状态机任务的生命周期,五个状态 + 迁移方法TaskStatemark_*is_ready
类型体系声明期望类型、渲染进 prompt、校验输出result_typeLabelsvalidate_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-77class Task(Generic[T]))。

各字段一句话职责(挑与本章相关的):

字段干什么位置
instructions要模型做什么,唯一必填项task.py:84-87
result_type期望结果类型,默认 strtask.py:94-100
state当前状态,初始 PENDINGtask.py:143-147
context附加上下文,会渲染进 prompttask.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-69class TaskState):

状态含义判定方法
PENDING已创建,还没跑(初始态)is_pending()
RUNNING正在被驱动is_running()
SUCCESSFUL成功,result 是校验后的值is_successful()
FAILED失败,result 是错误串is_failed()
SKIPPED被跳过(需 allow_skipis_skipped()

Marvin 把这五态再归成两组「完成度」概念——这是调度用得最多的两个谓词:

  • is_incomplete() —— 状态是 PENDINGRUNNINGtask.py:629-631)。
  • is_complete() —— 状态是 SUCCESSFUL / FAILED / SKIPPEDtask.py:633-635)。

注意:FAILEDSKIPPED 都算「完成」。也就是说一个失败的任务不会卡住依赖它的下游——它已经有了终局。

3.2 状态怎么迁移

初始永远是 PENDING(构造函数里写死,task.py:292)。之后靠四个 mark_* 异步方法推进:

mark_running()
PENDING ───────────▶ RUNNING ──┬── mark_successful() ──▶ SUCCESSFUL
│ ├── mark_failed() ──▶ FAILED
│ └── mark_skipped() ──▶ SKIPPED
└──── mark_skipped() ───────────────────────────────▶ SKIPPED

这些方法不只是改一个枚举值,它们各自还干一件"副作用"活:

  • mark_runningtask.py:582-595):置 RUNNING,并把渲染后的 prompt 作为一条 user message 推进当前 thread(thread.add_user_message_async(self.get_prompt()))。这一步是任务"开口说话"的地方
  • mark_successfultask.py:549-567):默认先校验validate_result,见 §4.4),再把校验后的值写进 self.result,置 SUCCESSFUL
  • mark_failedtask.py:569-580):把错误串写进 self.result,置 FAILED
  • mark_skippedtask.py:597-607):置 SKIPPED

关键细节:mark_successfulresult 参数默认是 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 字段默认值是一个哨兵 NOTSETtask.py:4594-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 ValueErrortask.py:320-331)。

(同一段逻辑也以 _validate_result_type() 方法形式存在于 task.py:337-364,作为可复用的校验入口。)

4.3 Labels:分类的统一容器

Labelssrc/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-115127-130)。这是把"幻觉"约束在类型层面的一招。

围绕 Labels 有两个自由函数(本章只需知道符号级作用):

  • is_classifier(type_)types.py:192-241):判断一个类型是不是分类任务——涵盖 EnumLiteral、任意序列,以及它们的 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)—— 给校验器用的"有效类型"。分类任务返回 intlist[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 不是 Noneraw is None → 除非类型里显式含 None(如 str | None,靠 get_argsNoneType),否则报错。

这就是为什么"声明了要 int 却啥都没给"会被挡下——校验层不放行。


5. result_type 是怎么进 prompt 的(task.jinja)

前面说的"渲染进 prompt",落地在模板 src/marvin/templates/task.jinjaTask.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 自带三个字段描述任务之间的关系。本章只讲这些字段是什么数据结构,不讲调度器怎么用它们。

字段类型含义
_parentTask | None父任务(经 parent property 读写)
subtasksset[Task]子任务集合
depends_onset[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_typeNone(如 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.pyTaskGeneric[T]
五态枚举src/marvin/tasks/task.pyTaskState
默认 str / 哨兵src/marvin/tasks/task.pyNOTSETTask.__init__
分类简写转换src/marvin/tasks/task.pyTask.__init__(315-332)、_validate_result_type
有效类型 / prompt 串 / 校验src/marvin/tasks/task.pyget_result_typeget_result_type_strvalidate_result
状态迁移src/marvin/tasks/task.pymark_runningmark_successfulmark_failedmark_skipped
就绪 / 完成谓词src/marvin/tasks/task.pyis_readyis_incompleteis_complete
父子自动同步 / 上下文当前任务src/marvin/tasks/task.pyparent(property)、__enter___current_task
prompt 渲染src/marvin/tasks/task.pyget_prompt
prompt 模板src/marvin/templates/task.jinja<result-type> 门控(is_ready
分类容器src/marvin/utilities/types.pyLabelsget_typevalidateget_indexed_labels
分类判定 / 转换src/marvin/utilities/types.pyis_classifieras_classifier