Task 模型全字段:一张任务"配方卡"里到底写了什么
前两周你已经把 Agent 拆到骨头缝里。今天开始进入 Task(任务) 阶段。Task 是 CrewAI 里除 Agent 外最核心的对象——它是一张声明式配方卡:你只填"要做什么、期望产出什么、谁来做",剩下的执行、重试、护栏、结构化输出全由框架接管。今天我们逐个字段读懂这张配方卡的源码,重点看 task.py 里 Task(BaseModel) 的字段定义、校验器和执行入口。
痛点:把"做什么"和"怎么做"分开
Task 就是一个 Pydantic BaseModel(task.py:114),所有字段都是声明:
# task.py:114
class Task(BaseModel):
"""Class that represents a task to be executed.
Each task must have a description, an expected output and an
agent responsible for execution.
"""
__hash__ = object.__hash__
...
description: str = Field(description="Description of the actual task.")
expected_output: str = Field(
description="Clear definition of expected output for the task."
)
agent: Annotated[BaseAgent | None, BeforeValidator(_resolve_agent)] = Field(
description="Agent responsible for execution the task.", default=None
)
execute_sync(L07)才是后厨真正开火。必填三要素与"配置也能填"
Task 唯二强制要写的是 description 和 expected_output;agent 可以晚点由 Crew 指派(比如 hierarchical 流程里交给 manager)。这条底线由一个 after 校验器兜住(task.py:365):
# task.py:365
@model_validator(mode="after")
def validate_required_fields(self) -> Self:
if self.description is None:
raise ValueError(
"description must be provided either directly or through config"
)
if self.expected_output is None:
raise ValueError(
"expected_output must be provided either directly or through config"
)
return self
mode="after"这是 Pydantic 的"对象都造好了再检查"钩子——字段已赋值,此时校验整体是否合法。"either directly or through config"关键提示:这两个字段既能直接传,也能通过 config 字典注入(见 L05 的 process_model_config / set_attributes_based_on_config)。所以报错文案特意说"直接或通过配置"。Task(description="总结这篇财报的三大风险", expected_output="用 markdown 列出 3 条风险,每条一句话", agent=analyst) —— 三要素齐全,能直接 execute_sync()。若漏掉 expected_output,构造对象时就抛 ValueError,任务根本进不了执行。全字段地图:一张卡上有哪些格子
Task 的字段很多,但可以按"用途"分成几组。下面这张图把 task.py:141~298 里声明的字段做了归类——记住分组比记住每个字段更有用:
这里先看两个"依赖/执行"组的字段声明,注意它们的默认值很讲究(task.py:161、task.py:165):
# task.py:161 —— context 默认不是 None,而是一个特殊哨兵 NOT_SPECIFIED
context: list[Task] | None | _NotSpecified = Field(
description="Other tasks that will have their output used as context for this task.",
default=NOT_SPECIFIED,
)
# task.py:165
async_execution: bool | None = Field(
description="Whether the task should be executed asynchronously or not.",
default=False,
)
context: list | None = None。但这样"没写 context"和"明确写 context=None(我不要任何上下文)"就无法区分了。CrewAI 引入第三种状态 NOT_SPECIFIED(一个专门的哨兵对象):没写=自动把上一步的输出当上下文(默认串联);写 None=明确"这个任务不吃任何上文";写 [taskA]=只吃 taskA 的输出。用一个哨兵换来了"三态可区分",这是 D16 的核心机制,今天先埋点。id 是只读的,key 是内容指纹
每个 Task 有唯一 id,但它禁止用户设置——只能框架自动生成(task.py:222),并且有个 before 校验器专门拦截用户赋值(task.py:460):
# task.py:222
id: uuid.UUID = Field(
default_factory=uuid.uuid4,
frozen=True, # ★冻结:造好后不能改
description="Unique identifier for the object, not set by user.",
)
# task.py:460 —— 拦截"用户手动传 id"
@field_validator("id", mode="before")
@classmethod
def _deny_user_set_id(cls, v, info):
if v and not (info.context or {}).get("from_checkpoint"):
raise PydanticCustomError(
"may_not_set_field", "This field is not to be set by the user.", {}
)
return v
default_factory=uuid.uuid4不写 id 时自动生成一个随机 UUID。用 factory 而非固定默认值,保证每个实例都不同。frozen=Trueid 造好后不可变,防止执行中途身份漂移。if v and not ...from_checkpoint边界:用户主动传 id → 直接报错;唯一例外是从 checkpoint 恢复时(info.context 带 from_checkpoint 标记),此时允许把存档里的 id 还原回来。那"两个任务是不是同一个任务"靠什么判断?靠 key ——它是内容指纹,用描述+期望输出算 md5(task.py:582):
# task.py:582
@property
def key(self) -> str:
description = self._original_description or self.description
expected_output = self._original_expected_output or self.expected_output
source = [description, expected_output]
return md5("|".join(source).encode(), usedforsecurity=False).hexdigest()
id 是身份(这次运行里"这一个"对象,随机、每次不同);key 是内容指纹("描述+期望输出"相同就算同一类任务,跨运行稳定)。为什么要两套?因为 replay(重放)、缓存、复制任务时,需要"内容相同就复用"——这时候比的是 key 而不是 id。注意 key 优先用 _original_description(插值前的模板原文),这样填了不同变量、但模板相同的任务,指纹一致。身份归身份、内容归内容,分开才能既做去重又不混淆实例。key 用 _original_description(模板原文)而非插值后的 description。这样 "分析 {year} 财报" 无论填 2023 还是 2024,key 都一样——D16 的 copy() 就是靠 key 在任务映射表里找对应任务的。校验器流水线:造一个 Task 时后台发生了什么
你写 Task(...) 一行,Pydantic 会依次跑一串校验器(before 先、after 后)。它们像一条装配流水线,把松散的输入组装成合法的 Task。控制流如下:
其中最值得看的是 check_tools(工具兜底)和 check_output(互斥检查)(task.py:541、task.py:548):
# task.py:541
@model_validator(mode="after")
def check_tools(self) -> Self:
"""Check if the tools are set."""
if not self.tools and self.agent and self.agent.tools:
self.tools = self.agent.tools # 任务没配工具 → 继承 agent 的工具
return self
# task.py:548
@model_validator(mode="after")
def check_output(self) -> Self:
"""Check if an output type is set."""
output_types = [self.output_json, self.output_pydantic]
if len([type for type in output_types if type]) > 1:
raise PydanticCustomError(
"output_type",
"Only one output type can be set, either output_pydantic or output_json.",
{},
)
return self
check_tools贴心兜底:任务级没写 tools,就自动用它 agent 身上的 tools。这样你不必重复声明。check_output边界:output_json 和 output_pydantic 只能二选一(都想要就报错)。因为一个任务的结构化产出只有一种形态,D15 会详谈。check_output 在 Task(...) 这一步就抛 PydanticCustomError,任务根本造不出来。这是快速失败(fail-fast):把"配置矛盾"这种一定是 bug 的东西,挡在执行之前、而不是跑到一半才发现产出格式乱套。对比 L04 从 checkpoint 恢复时"网开一面"允许设 id——同一个类里对不同情况松紧有别,都是有意为之。prompt():把字段拼成给 LLM 的提示
Task 最终要变成一段文字丢给模型。这个"拼装"发生在 prompt()(task.py:890)——它把 description、expected_output、markdown 指令等拼成一条提示:
# task.py:963(节选核心拼装)
tasks_slices = [description]
output = I18N_DEFAULT.slice("expected_output").format(
expected_output=self.expected_output
)
tasks_slices = [description, output]
if self.markdown:
markdown_instruction = """Your final answer MUST be formatted in Markdown syntax.
Follow these guidelines:
- Use # for headers
- Use ** for bold text
..."""
tasks_slices.append(markdown_instruction)
return "\n".join(tasks_slices)
tasks_slices = [description, output]核心就两块:任务描述 + "期望输出"提示(后者用 i18n 模板包一层,支持多语言)。if self.markdown字段 markdown=True 时,追加一段"请用 Markdown 语法"的硬性指令。字段变成了提示词的一部分。"\n".join(...)最后用换行拼成一整段。这就是 Agent 真正读到的任务提示。markdown、expected_output、输入文件说明(源码里 prompt() 上半段还会把附带的文件列出来)都是在这一步汇入提示。理解这点,你就明白:Task 的字段不是摆设,每一个都会以某种方式影响最终 prompt。execute_sync:配方卡开始下锅
配方卡填好后,真正"开火"的入口是 execute_sync(task.py:572),它只记个开始时间,转手交给 _execute_core(task.py:762)这个大厨:
# task.py:572
def execute_sync(self, agent=None, context=None, tools=None) -> TaskOutput:
"""Execute the task synchronously."""
self.start_time = datetime.datetime.now()
return self._execute_core(agent, context, tools)
# task.py:762(_execute_core 骨架,删繁就简)
def _execute_core(self, agent, context, tools) -> TaskOutput:
agent = agent or self.agent
if not agent:
raise Exception(f"The task '{self.description}' has no agent assigned...")
self.prompt_context = context
tools = tools or self.tools or []
result = agent.execute_task(task=self, context=context, tools=tools) # ★交给 Agent 干
...
task_output = TaskOutput(name=..., raw=raw, pydantic=..., json_dict=..., ...) # 包装结果
if self._guardrails: ... # 跑护栏(D14)
self.output = task_output
if self.callback: ... # 完成回调
if self.output_file: self._save_file(...)
return task_output
agent = agent or self.agent优先用传入的 agent,否则用任务自带的。二者都没有就报错——任务不能没人执行。agent.execute_task(...)★真正干活的一行:把自己(task)、上下文、工具交给 Agent。Agent 内部才是 D08 讲过的执行循环。TaskOutput(...)把 Agent 的原始返回包装成结构化的 TaskOutput(D14 主角)。护栏 / callback / output_file产出后的三件收尾:护栏校验(D14)、完成回调、落盘到文件。agent.execute_task。好处是职责单一:Task 管"要什么",Agent 管"怎么想",两者能各自演化(换 Agent 实现、换执行策略都不动 Task)。这就是为什么 _execute_core 里最关键的其实只有一行 agent.execute_task——Task 是指挥,不是厨子。execute_sync 之外还有 execute_async(task.py:596,开线程)和 aexecute_sync(task.py:627,原生 async)三个入口——它们分别是 D17 的主角,今天只需知道"同步入口从这里进"。今日小结 + 动手
🧠 今天你应该能回答
- Task 是什么?为什么说它是"声明式配方卡"?
- 哪两个字段必填?为什么 expected_output 被提升为必填?
- Task 字段大致分哪几组?(核心 / 输出 / 护栏 / 依赖执行 / 元数据)
id和key有什么区别?为什么要两套标识?- 为什么
context默认是NOT_SPECIFIED而不是None? - 同时设 output_json 和 output_pydantic 会怎样?(构造时报错)
execute_sync里最关键的一行是什么?(agent.execute_task)
✋ 10 分钟动手
P=lib/crewai/src/crewai
# 1. 通读 Task 全部字段声明
sed -n '141,300p' $P/task.py
# 2. 看校验器流水线(三要素/工具兜底/输出互斥)
sed -n '365,375p;541,558p' $P/task.py
# 3. 看 id 只读 + key 指纹
sed -n '222,226p;460,467p;582,588p' $P/task.py
# 4. 看执行入口
sed -n '572,580p' $P/task.py
_execute_core 里一闪而过的 TaskOutput 和 _guardrails,明天正式登场。我们会拆开 TaskOutput(raw / pydantic / json_dict 三副面孔)和护栏 guardrail——任务产出后怎么被校验、失败了怎么带着反馈重跑、字符串护栏怎么变成一个 LLM 裁判。