Day 13 / 共 60 天 · 阶段3 Task 深入

Task 模型全字段:一张任务"配方卡"里到底写了什么

前两周你已经把 Agent 拆到骨头缝里。今天开始进入 Task(任务) 阶段。Task 是 CrewAI 里除 Agent 外最核心的对象——它是一张声明式配方卡:你只填"要做什么、期望产出什么、谁来做",剩下的执行、重试、护栏、结构化输出全由框架接管。今天我们逐个字段读懂这张配方卡的源码,重点看 task.pyTask(BaseModel) 的字段定义、校验器和执行入口。

📍 你在 60 天里的位置(阶段3:Task 深入 · 共 6 天)
阶段2 Agent D13 Task 模型 D14 输出与护栏 D15 结构化输出 D16 context 依赖 D17 异步任务 D18 条件任务 阶段4 Crew
L01

痛点:把"做什么"和"怎么做"分开

🤔 痛点如果没有 Task 这个抽象,你想让 Agent 干活就得每次手写一大坨:拼提示词、调 LLM、解析输出、校验格式、失败重试、把结果传给下一步……这些"怎么做"的机械活儿,会淹没你真正关心的"做什么"。而且十个任务十份重复代码,改一处要改十处。
💡 一句话本质:Task = 声明式配方卡Task 把"做什么"(description)、"要什么结果"(expected_output)、"谁来做"(agent)声明成数据,把"怎么做"(拼提示、调模型、解析、校验、重试)全部交给框架。你只描述目标,不写流程——这正是声明式编程:说清"我要什么",而不是"一步步怎么走"。

Task 就是一个 Pydantic BaseModeltask.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
    )
大白话:配方卡 vs 炒菜你去餐厅点"番茄炒蛋"(description),说明"要嫩、微甜"(expected_output),指定"张师傅做"(agent)。你不需要写"先打鸡蛋、油七成热、翻炒 30 秒"——那是厨房(框架)的事。Task 就是这张点菜单,execute_sync(L07)才是后厨真正开火。
L02

必填三要素与"配置也能填"

Task 唯二强制要写的是 descriptionexpected_outputagent 可以晚点由 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)。所以报错文案特意说"直接或通过配置"。
💡 为什么 expected_output 是必填?因为 LLM 的输出全靠"你怎么描述目标"来约束。只说"分析一下销售数据",模型可能给你一段散文;写清"输出 3 条要点,每条不超过 20 字",产出才可控。CrewAI 把 expected_output 提升为必填字段,本质是在强制你写清验收标准——这是让多 Agent 协作靠谱的地基。
📝 真实值 Task(description="总结这篇财报的三大风险", expected_output="用 markdown 列出 3 条风险,每条一句话", agent=analyst) —— 三要素齐全,能直接 execute_sync()。若漏掉 expected_output,构造对象时就抛 ValueError,任务根本进不了执行。
L03

全字段地图:一张卡上有哪些格子

Task 的字段很多,但可以按"用途"分成几组。下面这张图把 task.py:141~298 里声明的字段做了归类——记住分组比记住每个字段更有用:

Task 字段地图(按用途分 5 组) ① 核心(做什么) description · expected_output agent · name · tools — 你几乎每次都要填的 — agent 可延后由 Crew 指派 ② 输出形态(要什么结果) output_json · output_pydantic response_model · output_file output(回填的 TaskOutput) — D14 / D15 专讲 ③ 护栏与重试 guardrail · guardrails guardrail_max_retries retry_count · max_retries(废弃) — D14 专讲 ④ 依赖与执行方式 context(依赖哪些任务,默认 NOT_SPECIFIED)— D16 async_execution(是否异步)— D17 callback(完成后回调) · human_input(人工复核) ⑤ 身份 / 安全 / 元数据 id(只读 UUID,frozen) · security_config config(字典批量注入) · markdown start_time / end_time · processed_by_agents 读法: ① 你天天用;② ③ 是 D14/D15 主题;④ 是 D16/D17 主题; ⑤ 是框架内部用(id 你不能自己设,见 L04)。别背字段,记"这卡上分几组格子"即可。 全部字段都是 Pydantic Field,带默认值——所以只填 description + expected_output 也能造出 Task。
图注:Task 的字段=一张配方卡上的 5 组格子。本阶段(D13-18)会把 ②③④ 逐组讲透。

这里先看两个"依赖/执行"组的字段声明,注意它们的默认值很讲究(task.py:161task.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 为什么默认用 NOT_SPECIFIED 而不是 None? 朴素做法是 context: list | None = None。但这样"没写 context"和"明确写 context=None(我不要任何上下文)"就无法区分了。CrewAI 引入第三种状态 NOT_SPECIFIED(一个专门的哨兵对象):没写=自动把上一步的输出当上下文(默认串联);写 None=明确"这个任务不吃任何上文";写 [taskA]=只吃 taskA 的输出。用一个哨兵换来了"三态可区分",这是 D16 的核心机制,今天先埋点。
L04

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.contextfrom_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 两套标识,为什么? id身份(这次运行里"这一个"对象,随机、每次不同);key内容指纹("描述+期望输出"相同就算同一类任务,跨运行稳定)。为什么要两套?因为 replay(重放)、缓存、复制任务时,需要"内容相同就复用"——这时候比的是 key 而不是 id。注意 key 优先用 _original_description(插值前的模板原文),这样填了不同变量、但模板相同的任务,指纹一致。身份归身份、内容归内容,分开才能既做去重又不混淆实例。
💡 注意 key_original_description(模板原文)而非插值后的 description。这样 "分析 {year} 财报" 无论填 2023 还是 2024,key 都一样——D16 的 copy() 就是靠 key 在任务映射表里找对应任务的。
L05

校验器流水线:造一个 Task 时后台发生了什么

你写 Task(...) 一行,Pydantic 会依次跑一串校验器(before 先、after 后)。它们像一条装配流水线,把松散的输入组装成合法的 Task。控制流如下:

控制流:Task(...) 的校验流水线(before → 字段 → after) 你调 Task(desc=..., ...) before: process_model_config before: _deny_user_set_id 等 Pydantic 赋值+类型校验 after 校验器(顺序执行) validate_required_fields(三要素) ensure_guardrail_is_callable(护栏) set_attributes_based_on_config check_tools(没设就用 agent 的) check_output(json/pydantic 互斥) handle_max_retries_deprecation ✅ 一个合法可执行的 Task
图注:before 校验器整理原始输入 → 字段赋值 → after 校验器逐个把关,最后产出一个"随时能执行"的 Task。

其中最值得看的是 check_tools(工具兜底)和 check_output(互斥检查)(task.py:541task.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_jsonoutput_pydantic 只能二选一(都想要就报错)。因为一个任务的结构化产出只有一种形态,D15 会详谈。
⚠️ 边界:同时设 output_json 和 output_pydantic 会在构造时就炸 很多新手以为"我两个都设,框架自己挑一个"。不——check_outputTask(...) 这一步就抛 PydanticCustomError,任务根本造不出来。这是快速失败(fail-fast):把"配置矛盾"这种一定是 bug 的东西,挡在执行之前、而不是跑到一半才发现产出格式乱套。对比 L04 从 checkpoint 恢复时"网开一面"允许设 id——同一个类里对不同情况松紧有别,都是有意为之。
L06

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 真正读到的任务提示。
💡 本质:字段 → 提示词的"编译"你填的每个字段,最终都要"编译"成模型能读的自然语言。markdownexpected_output、输入文件说明(源码里 prompt() 上半段还会把附带的文件列出来)都是在这一步汇入提示。理解这点,你就明白:Task 的字段不是摆设,每一个都会以某种方式影响最终 prompt。
L07

execute_sync:配方卡开始下锅

配方卡填好后,真正"开火"的入口是 execute_synctask.py:572),它只记个开始时间,转手交给 _execute_coretask.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)、完成回调、落盘到文件。
💡 设计取舍③:为什么 Task 不自己调 LLM,而是转交 Agent? Task 大可以自己拼 prompt、调模型。但 CrewAI 让 Task 只负责"描述任务 + 收尾处理",把"怎么调模型、怎么用工具、怎么循环推理"整块交给 agent.execute_task好处是职责单一:Task 管"要什么",Agent 管"怎么想",两者能各自演化(换 Agent 实现、换执行策略都不动 Task)。这就是为什么 _execute_core 里最关键的其实只有一行 agent.execute_task——Task 是指挥,不是厨子。
注意 execute_sync 之外还有 execute_asynctask.py:596,开线程)和 aexecute_synctask.py:627,原生 async)三个入口——它们分别是 D17 的主角,今天只需知道"同步入口从这里进"。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • Task 是什么?为什么说它是"声明式配方卡"?
  • 哪两个字段必填?为什么 expected_output 被提升为必填?
  • Task 字段大致分哪几组?(核心 / 输出 / 护栏 / 依赖执行 / 元数据)
  • idkey 有什么区别?为什么要两套标识?
  • 为什么 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
明日预告 · Day 14:今天在 _execute_core 里一闪而过的 TaskOutput_guardrails,明天正式登场。我们会拆开 TaskOutput(raw / pydantic / json_dict 三副面孔)和护栏 guardrail——任务产出后怎么被校验、失败了怎么带着反馈重跑、字符串护栏怎么变成一个 LLM 裁判。
← Day 12 LiteAgent Day 14 · 输出与护栏 →