跳到主要内容

高层门面:cast / classify / extract / generate / fn 如何都归约成一个 Task

30 秒导读: Marvin 对外那五个"一行就能用"的便捷函数——cast(转类型)、classify(分类)、extract(抽实体)、generate(造数据)、@fn(预测函数输出)——没有各自的引擎。每一个都只做同一件事:拼一个带专用 DEFAULT_PROMPTTask,设好 result_type,然后 await task.run_async() 本章讲清这条共同暗线,再逐个点出各自把"想要什么"翻译成 result_type 时的取舍。

本章只讲高层门面支撑它们的 prompt 系统。底层机制(Task 怎么跑、result_type 怎么变成类型安全的结果、编排器怎么驱动一个回合)在前几章:


1. 这是什么(零基础也能懂)

一句话定义

Marvin 的"Keep it Simple"层:五个普通函数,把最常见的五种"让 LLM 干结构化活"包成一行调用,你不用碰 Task、不用写 prompt、不用想怎么解析输出。

解决什么问题 / 给谁用

假设你手上有一段脏文本"three dollars fifty",你想要一个 float 3.5;或者你有一句用户评论,想知道它是 "positive" 还是 "negative";或者你想凭空造 10 个假用户对象来测试。这些活的共同点是:输入是自然语言/任意数据,输出必须是一个确定类型的 Python 值。裸调 LLM 会得到一段字符串,还要自己解析、校验、重试。

Marvin 把这五种活各配一个函数:

函数干什么一行例子(示意,来自各文件 docstring)
cast把数据转成一个目标类型的值cast("three point five", float)3.5
classify把数据归类到给定标签之一(或多个)classify("red car", ["red","blue"])'red'
extract从数据里抽出一串某类型实体extract("$3.50 and €2", float)[3.5, 2.0]
generate按描述造 N 个结构化对象generate(str, n=3, instructions="fruits")
@fn装饰一个函数,预测它的输出而不执行见 §3.5

一句话直觉

把 LLM 当成一个"万能类型转换器"。 你只要声明"我要什么类型",剩下的"怎么让模型吐出这个类型、怎么解析回来"都由底下那个 Task 兜住。这五个函数的全部差别,就在于它们各自怎么把"我要什么"翻译成一个 result_type


2. 顶层全景(它大概怎么转)

共同暗线:五合一

先看这张图——这是本章唯一要记住的结构。五个函数看似做五种事,却在中段汇成同一条路:

cast(...) ┐
classify(...) │ 各自:
extract(...) ├─→ ① 选定自己的 DEFAULT_PROMPT(五份不同的系统提示)
generate(...) │ ② 把入参翻译成一个 result_type(这一步是差异所在)
@fn 装饰的函数 ┘ ③ 把 data / n / 参数塞进 task 的 context


task = marvin.Task[T](
instructions = prompt,
context = {...},
result_type = <翻译出来的类型>,
)


await task.run_async(thread, handlers) ←── 机制见第 1~3 章


类型安全的结果 T (int / Enum / list[T] / …)

怎么读这张图: 从上往下是"五个入口 → 汇成一个 Task → 一次 run"。左边五个入口各不相同,汇合后完全共用下半段。所以"高层函数"其实不是引擎,而是一层薄薄的翻译器:把人类友好的签名翻译成 Task 的三件套(instructionscontextresult_type)。

逐个印证这条暗线

每个文件都短到几乎一眼看穿。核心几行几乎逐字相同,只有 result_type 那行不一样:

函数核心构造(简化自源码)result_type 是什么源码锚点
castTask(result_type=target)目标类型本身fns/cast.py:91
classifyTask(result_type=Labels(...) 或 labels)标签→整数索引fns/classify.py:150
extractTask(result_type=list[target])目标类型的 listfns/extract.py:76
generateTask(result_type=conlist(_target,min=n,max=n))定长 listfns/generate.py:84
@fnTask(result_type=model.return_annotation)反射出来的返回注解fns/fn.py:88

同步/异步的对称结构

每个文件都有一对孪生函数:xxx_async(真正干活)和 xxx(同步壳)。同步版一律只是把异步版丢进 run_sync,零逻辑:

# 真实源码 fns/cast.py:145-156,符号 cast
def cast(data, target=None, ...):
return run_sync(cast_async(data=data, target=target, ...))

run_sync 来自 utilities/asyncio,负责在有无事件循环的场景下都能同步跑一个协程。记住这个模式:下文只讲 _async 版,同步版都是这一句话包一层。


3. 逐个门面:各自的取舍

共同暗线讲完了。真正有意思的是每个函数在"把入参翻成 result_type"这一步各自踩的坑和做的取舍。下面一节一个。

3.1 cast —— 转成"一个"目标类型,且 str 必须给指令

cast 是最纯粹的一个:result_type 就是你给的 target,不做任何包装。

# 真实源码 fns/cast.py:79-99,符号 cast_async(节选)
if target is None:
target = str
if target is str and instructions is None:
raise ValueError("Instructions are required when casting to string values.")
...
task = marvin.Task[target](
name="Cast Task",
instructions=prompt, # DEFAULT_PROMPT (+ instructions)
context=task_context, # {"Data to transform": data}
result_type=target, # ← 就是目标类型本身
)
return await task.run_async(thread=thread, handlers=handlers)

关键取舍:casting 到 str 必须给 instructions(fns/cast.py:82-83)。 为什么?因为 cast("hello", str) 语义上是空操作——你给它字符串、要它还字符串,不给方向的话模型不知道该"转"成什么。所以库在入口就抛 ValueError 逼你写清意图(比如 instructions="翻译成法语")。这是一个"前置校验代替运行期困惑"的设计:宁可在 0 成本处报错,也不发一次没意义的 LLM 请求。

DEFAULT_PROMPT(fns/cast.py:17)是"专家数据转换器"人设,还夹带了几条很具体的规则,比如"整数不要写小数""'3 dollars fifty cents' 转 float 要变 3.5""转 bool 时 truthy 值算 true"。这些规则不是通用的,是 cast 独有的——每个函数的 DEFAULT_PROMPT 都是为它那种活量身写的。

instructions 的拼接方式五个函数一致:不覆盖 DEFAULT_PROMPT,而是追加在后面(fns/cast.py:88-89):

prompt = prompt or DEFAULT_PROMPT
if instructions:
prompt += f"\n\nYou must follow these instructions for your transformation:\n{instructions}"

3.2 classify —— 标签变成"整数索引",外加 multi_label 重载

classify 是五个里 result_type 翻译最巧妙的一个。它要把"从这几个标签里选"变成一个类型。

核心分支(fns/classify.py:141-148):

if labels is bool or issubclass_safe(labels, enum.Enum):
result_type = list[labels] if multi_label else labels # bool/Enum 直接用
ReturnType = list[T] if multi_label else T
else:
result_type = Labels(labels, many=multi_label) # 裸序列 → Labels
ReturnType = list[T] if multi_label else T
  • boolEnum:本身就是合法的 Python 类型,直接当 result_type(多标签就套一层 list)。
  • 裸序列(如 ["red","blue","green"]):Python 没有"这个列表当类型"的东西,所以包成一个 Labels 对象。

Labels 的精髓:让模型输出整数索引,而不是标签文本。utilities/types.py:

  • Labels.get_type() 返回的验证类型是 int(多标签是 list[int])——utilities/types.py:92-94
  • Labels.get_indexed_labels() 生成 {0: "'red'", 1: "'blue'", ...} 的映射喂给 prompt——utilities/types.py:133-143
  • Labels.validate(value) 收到模型给的整数索引,校验范围/去重/非空,再映射回真实标签值——utilities/types.py:96-131

为什么绕这一圈?让模型选 0/1/2 比让它精确复述一长串标签字符串更稳:输出空间小、不会拼错、天然可校验(索引必须落在 [0, len))。多标签时还顺手禁掉重复索引和空列表(utilities/types.py:107-117)。

这套 Labels 逻辑并不在 classify 里执行,而是 Task 在收到 result_type=Labels(...) 后调用的——tasks/task.py:416-446is_classifier / get_result_type / validate_result 会调 get_type()get_indexed_labels()validate()。也就是说 classify 只负责把标签打包成 Labels,真正的"索引化"发生在下游的 Task。呼应本章暗线:高层函数只翻译,不执行。

multi_label@overload 做精确类型标注。 classify_async / classify 各叠了三个 @overload(fns/classify.py:24-66161-200),让类型检查器知道:

multi_label=False (Literal) → 返回 T
multi_label=True (Literal) → 返回 list[T]
multi_label: bool → 返回 T | list[T]

运行期只有最后那个真实现体,overload 纯为静态类型服务——同一个函数按参数字面量给出不同返回类型,是这五个函数里唯一用到重载的。

3.3 extract —— 抽多个 ⇒ list[T]

extractcast 几乎是双胞胎,唯一区别:result_typetarget 变成 list[target]

# 真实源码 fns/extract.py:76-82,符号 extract_async(节选)
task = marvin.Task[list[target]](
name="Extraction Task",
instructions=prompt,
context=task_context, # {"Data to extract": data}
result_type=list[target], # ← 与 cast 的唯一实质差别
)

取舍:语义从"转换"变成"枚举"。 cast 要一个值,extract 要一串。所以它的 DEFAULT_PROMPT(fns/extract.py:12)是"实体抽取专家……生成一个匹配格式的实体列表",连坑都写进 prompt:"3 dollars fifty cents" 是单值 [3.5] 不是 [3, 50](fns/extract.py:20-22)。

cast 一样,str 也必须给 instructions(fns/extract.py:67-68)——理由相同:不给方向,"从文本里抽字符串"没有意义。

3.4 generate —— 按描述造 N 个,用 conlist 锁定数量

generate 是唯一一个**没有输入 data**的:它凭空造数据。它的 result_type 要表达"正好 N 个 target",用了 Pydantic 的 conlist:

# 真实源码 fns/generate.py:84-90,符号 generate_async(节选)
task = marvin.Task[list[target]](
name="Generation Task",
instructions=prompt,
context=task_context, # {"Number to generate": n}
result_type=conlist(_target, min_length=n, max_length=n), # ← 定长 list
)

取舍:数量约束交给类型系统,而不是 prompt。 与其在 prompt 里说"请给我正好 3 个"(模型可能给 2 或 4),不如用 conlist(min_length=n, max_length=n)结构校验保证数量——模型给错数量会在校验层被挡。n 同时也塞进 context["Number to generate"](fns/generate.py:78)让模型知情,双保险。

它的 DEFAULT_PROMPT(fns/generate.py:18-33)强调"随机、多样、真实,但偏好常见值",专为造测试数据/示例设计。同样地,造 strinstructions 时报错(fns/generate.py:74-75)。

同文件还有一对 generate_schema / generate_schema_async(fns/generate.py:142-206):造的是一个 JSON Schema 本身。值得注意它的 prompt 是内联 jinja 模板,用 jinja_env.from_string(prompt).render(...) 渲染 {{instructions}} 和可选的 {{base_schema}}(fns/generate.py:153-177)——这是 prompt 系统(§4)在便捷函数里露头的一处。

3.5 @fn 装饰器 —— 反射签名,"预测函数输出"而不执行

@fn 是最有想象力的一个。它装饰一个只有签名和 docstring、没有函数体的函数,然后让 LLM"预测这个函数会返回什么"。

用法直觉(示意,基于 fns/fn.py:105-134 的 docstring):

# 示意,非源码
@fn
def sentiment(text: str) -> float:
"""Return a sentiment score from -1 (negative) to 1 (positive)."""

sentiment("I love this!") # → 调 LLM,返回一个 float,比如 0.9

核心:用 PythonFunction 反射出函数的一切,再当 prompt 素材。 关键在 _build_task(fns/fn.py:34-94):

# 真实源码 fns/fn.py:57-92,符号 _build_task(节选)
model = PythonFunction[P, T].from_function_call(func, *fn_args, **fn_kwargs)

original_return_annotation = model.return_annotation
if original_return_annotation is inspect.Signature.empty:
model.return_annotation = str
context["JSON result"] = "If possible, your answer will be parsed by json.loads()"

model_context = {
"signature": str(model.signature),
"name": model.name,
"parameters": [asdict(p) for p in model.parameters],
"docstring": model.docstring,
"return_annotation": model.return_annotation,
}
context.update({
"Function definition": model_context,
"Function arguments": model.bound_parameters,
"Additional context": model.return_value,
})
return marvin.Task[T](
...,
result_type=model.return_annotation, # ← 反射出来的返回注解
)

PythonFunction.from_function_call(utilities/types.py:373-412)做的事:

  • inspect.signature(func) + sig.bind(*args, **kwargs):拿到签名并把实参绑定到形参。
  • 它真的调用了 func(utilities/types.py:396)拿 return_value——但空体函数返回 None,这只是给 prompt 当 "Additional context"。协程会用 run_sync 解开(utilities/types.py:397-398)。
  • docstring 当 jinja 模板渲染(utilities/types.py:401-403):func.__doc__ 里可以写 {{ 参数名 }},用绑定后的实参渲染。这让 docstring 能动态引用入参。
  • 参数被建成 ParameterModel(name/annotation/default,utilities/types.py:273-277),asdict 后进 prompt。

两个取舍点:

  1. DEFAULT_PROMPT 明说"我们没有源码"(fns/fn.py:19-29): "We do not have the function's source code. Therefore you must generate its output"——尽管 PythonFunction 其实抓到了 source_code,_build_task故意把它从 prompt 里排除(fns/fn.py:67 注释:"exclude ... source_code")。给模型签名+docstring+类型注解,不给实现,才是"预测输出"而非"跑代码"。

  2. 无返回注解 → 回退 str + JSON 解析: 若函数没写 -> T,return_annotationSignature.empty,就退化成 str,并在 context 埋一句"会用 json.loads() 解析"(fns/fn.py:61-65)。执行完 _fn 再据此尝试 json.loads,失败就返回原始字符串(fns/fn.py:213-218):

# 真实源码 fns/fn.py:213-219,符号 _fn(节选)
if "JSON result" in task.context:
try:
result = json.loads(result)
except Exception:
logger.debug("Failed to parse result as JSON, returning raw result")
return result

as_task() —— 拿到 Task 但不执行。 fnwrapper 额外挂了一个 as_task 方法(fns/fn.py:160-176),它直接返回 _build_task(...) 的结果而不 run。这让你能拿到底层 Task 去手动编排(丢进多任务依赖图、见 04)。这是本章暗线最直白的证据:高层函数的产物本来就是一个 Task,as_task() 只是把那个 Task 交出来而不替你跑。

wrapper 还处理同步/异步函数的区分(fns/fn.py:137,156-158):被装饰的是协程就直接返回协程,否则 run_sync。并支持调用时用 _agent / _thread / _instructions 覆盖(fns/fn.py:140-155)。


4. Prompt 系统:模板如何支撑这些门面

上面五个函数把 prompt 当纯字符串处理(取 DEFAULT_PROMPT,追加 instructions)。但 Marvin 还有一套更结构化的 prompt 设施,在 prompts.py,支撑"从模板/函数生成带角色的多消息 prompt"。generate_schema 已经用到它的 jinja 底座;Task 内部渲染系统提示也依赖它。

4.1 Template —— 极简 jinja 渲染

Template(prompts.py:26-52)就是一个薄壳:source 是字符串或模板文件 Path,render(**kwargs) 把 dataclass 上的字段和传入 kwargs 合并后交给 jinja_env 渲染。字符串走 from_string,Pathget_template(prompts.py:47-50)。

jinja_env(utilities/jinja.py)用 StrictUndefined(未定义变量直接报错,不静默留空),并注入了 now / zip / pretty_print 等全局函数——所以模板里能写 {{ now() }}

4.2 Prompt.to_messages —— 从一段文本解析出带角色的消息

Prompt(prompts.py:55-220)比 Template 高一层:它能把一段带角色标记的文本切成 SystemMessage / UserMessage / AgentMessage 列表。

核心是 _parse_messages(prompts.py:95-152):用正则找 SYSTEM: / USER: / ASSISTANT:(大小写皆可)标记,把文本切段,每段配上角色;没有标记的文本默认当 user 消息(prompts.py:121,138-141)。to_messages 则是"先 _render_template 渲染 jinja,再 _parse_messages 切角色"(prompts.py:154-172)。

于是你能把整段对话写成一个模板:

SYSTEM: You are a helpful assistant.
USER: Hi {{ name }}!
ASSISTANT: Hello {{ name }}!

渲染+解析后得到三条带正确角色的消息。

4.3 Prompt.from_fn —— 把函数变成 Prompt 类

from_fn(prompts.py:174-220)是 §3.5 @fn 反射思路在 prompt 侧的对应物:传一个函数进去,用它的 docstring 当模板、用它的签名参数当模板变量,动态生成一个 Prompt 子类。

# 真实源码 prompts.py:198-220,符号 from_fn(节选)
sig = inspect.signature(fn)
hints = get_type_hints(fn)
_template = inspect.getdoc(fn)
if not _template:
raise ValueError(f"Function {fn.__name__} must have a docstring")

@dataclass
class DynamicPrompt(cls):
source: str | Path = field(default=_template) # docstring 当模板
for name, param in sig.parameters.items(): # 每个参数变成 dataclass 字段
...
if name in hints:
__annotations__[name] = hints[name]
return DynamicPrompt

于是 Prompt.from_fn(greet) 给你一个 GreetPrompt 类,GreetPrompt(name="Alice", age=30).to_messages() 就渲染并切成角色消息。这是"用 Python 函数的形状定义一个类型化 prompt"的模式——docstring 是内容、签名是接口、类型注解上静态检查。

注意:prompts.py 这套 Prompt/Template 是 prompt 层的通用设施,五个便捷函数没有直接用 Prompt.from_fn;它们走的是更简单的"字符串拼接 + Task 内部渲染"。这套设施主要服务 Task/引擎的系统提示组装,以及像 generate_schema 那样需要 jinja 内联模板的场景。放在本章,是因为它和 @fn 的反射哲学同源,一起看更清楚 Marvin"用函数签名当接口"的一贯口味。


5. 巧妙之处(可带走的技术)

  • "五合一"的归约: 便捷函数不是五个引擎,而是五个"翻译器 + 同一个 Task"。想加第六个高层函数,你只需写一份 DEFAULT_PROMPT 再决定 result_type 怎么拼——引擎白拿。fns/*.py 每个文件都短得能一眼读完,正是这个设计的回报。
  • 分类用整数索引而非标签文本(utilities/types.py:92-131): 把开放式的"复述标签"压成封闭式的"选索引",输出稳、可校验、天然防拼错。
  • 数量约束交给 conlist 而非 prompt(fns/generate.py:88): 结构校验保证 N 个,比祈求模型数对更可靠。
  • 前置校验代替无意义请求: cast/extract/generate 到 strinstructions 时在入口就 raise(fns/cast.py:82fns/extract.py:67fns/generate.py:74),0 成本处报错胜过发一次废请求。
  • @fn 故意藏源码(fns/fn.py:67): 抓到了 source_code 却不给模型,才是"预测输出"而非"执行"。
  • as_task()(fns/fn.py:160)把暗线摊开: 高层函数的产物本就是 Task,交出来即可。
  • @overload 精确刻画 multi_label(fns/classify.py:24-66): 同一函数按参数字面量返回 Tlist[T],静态类型不撒谎。
  • 反射一以贯之: @fn 反射函数签名当 prompt 素材(utilities/types.py:373),Prompt.from_fn 反射签名当模板接口(prompts.py:174)——同一种"函数即接口"的品味。

6. 边界与局限

  • 裸调 LLM 的活它做不了: 这五个函数是"输入→结构化输出"的一次性转换,不管多轮对话流程(那是 Task/编排器的地盘,见 02)。要多轮就直接用 Task
  • caststrextract/generatestrinstructions 会直接抛错,不是宽容的默认行为。
  • @fn 会真的调用被装饰函数(utilities/types.py:396)拿 return_value——空体函数无副作用,但若你给的函数体有副作用,装饰后每次"预测"都会触发它。docstring 里能用 jinja 也意味着 docstring 会被当模板渲染,含 {{ }} 的普通 docstring 可能出意外。
  • Prompt.from_fn 要求函数有 docstring,否则 raise ValueError(prompts.py:201-202)。

7. 全库代码地图(导航索引)

覆盖本章高层门面 + prompt 系统,并把前几章的关键落点一并列出,方便 grep 跳转(优先用符号名,抗行号漂移)。

本章:高层门面与 prompt 系统

主题文件路径符号名
cast:转成一个目标类型src/marvin/fns/cast.pycast_async / cast / DEFAULT_PROMPT
classify:标签→索引 + 重载src/marvin/fns/classify.pyclassify_async / classify(含 @overload)
extract:抽成 list[T]src/marvin/fns/extract.pyextract_async / extract
generate:造 N 个(conlist)src/marvin/fns/generate.pygenerate_async / generate
generate:造 JSON Schemasrc/marvin/fns/generate.pygenerate_schema_async / generate_schema
fn:预测函数输出src/marvin/fns/fn.pyfn / _fn / _build_task / wrapper.as_task
分类标签容器(索引化)src/marvin/utilities/types.pyLabels(get_type / validate / get_indexed_labels)
反射一个函数src/marvin/utilities/types.pyPythonFunction(from_function / from_function_call)
目标类型别名src/marvin/utilities/types.pyTargetType / is_classifier / as_classifier
jinja 模板壳src/marvin/prompts.pyTemplate.render
带角色消息的 promptsrc/marvin/prompts.pyPrompt.to_messages / _parse_messages / _render_template
从函数生成 Prompt 类src/marvin/prompts.pyPrompt.from_fn
jinja 环境与全局函数src/marvin/utilities/jinja.pyjinja_env / global_fns
同步壳src/marvin/utilities/asyncio.pyrun_sync

前几章:底层机制(引用即可,不在本章展开)

主题文件路径符号名
Task 与 result_typesrc/marvin/tasks/task.pyTask / run_async / _validate_result_type / validate_result01
Labels 在 Task 中被消费src/marvin/tasks/task.pyis_classifier / get_result_type01
编排器与回合循环src/marvin/engine/orchestrator / turn loop02
结束回合的类型化工具src/marvin/engine/end-turn tool / agentlet03
演员 / 线程 / 记忆src/marvin/agents/,src/marvin/thread.pyAgent / Thread / handlers04