Day 18 / 共 60 天 · 阶段3 Task 深入(收官)

条件任务:让某个任务"看情况再决定跑不跑"

阶段3 最后一站。真实流程里常有"看情况"的分支:分数低于 60 才触发补救任务、搜到结果才做深挖、上一步失败才走兜底。ConditionalTask 就是"带一个开关的任务"——执行前先看上一步产出,条件满足才跑、不满足就跳过(但留个空占位,保持链条完整)。今天读透它的实现,源码在 tasks/conditional_task.pycrews/utils.pycrew.py,收尾时回顾整个阶段3。

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

痛点:不是每个任务都该无脑执行

🤔 痛点顺序流程是"每个任务都跑一遍"。但现实里常有分支:搜索任务如果没搜到结果,后面的"深度分析"就该跳过(没东西可分析);质检不合格才需要"返工任务"。如果无脑全跑,会浪费 token、甚至基于空数据产出垃圾。
💡 一句话本质:ConditionalTask = 带一个"开关函数"的任务它比普通 Task 多一个 condition 函数。执行到它时,Crew 先把上一个任务的产出喂给这个函数,返回 True 才真跑,False 就跳过——但跳过不是"消失",而是留一个空产出占位,让后面的任务链不断裂。
🚦 生活类比:门口的保安普通任务是"来了就放行";条件任务门口站了个保安(condition),先看你手里的东西(上一步产出)合不合规矩,合规才放你进去干活,不合规就让你在门口登记一下(空占位)直接走人。
L02

继承 Task,加一个 condition 字段

ConditionalTask 直接继承 Task,只多一个 conditiontasks/conditional_task.py:14tasks/conditional_task.py:28):

# tasks/conditional_task.py:14
class ConditionalTask(Task):
    """A task that can be conditionally executed based on the output of another task.

    Notes:
        - Cannot be the only task in your crew
        - Cannot be the first task since it needs context from the previous task
    """
    condition: SerializableCallable | None = Field(
        default=None,
        description="Function that determines whether the task should be executed "
                    "based on previous task output.",
    )

    def __init__(self, condition=None, **kwargs) -> None:
        super().__init__(**kwargs)      # 先按普通 Task 造好(继承全部字段/校验)
        self.condition = condition      # 再挂上开关函数
数据结构:ConditionalTask = Task + 一个 condition 开关 Task(父类:D13 讲的全部字段) description / expected_output / agent / context ... execute_sync / 护栏 / 结构化输出 全部继承 ConditionalTask(子类:只加一样东西) + condition: Callable[[TaskOutput], bool]
图注:它就是普通 Task 加一个开关函数——能执行、能护栏、能结构化输出,只是多了"跑不跑的判断"。
💡 设计取舍①:为什么用子类继承而不是给 Task 加个 condition 字段? 完全可以在 Task 上加个 condition=None,为 None 就无脑跑。但 CrewAI 选择独立子类,好处有二:① Crew 能用 isinstance(task, ConditionalTask) 一眼区分它需要特殊处理(L05);② 能对它施加专属约束(不能当第一个、不能异步,L06)而不污染普通 Task。代价是多一个类。"用类型表达一类特殊行为,让特殊逻辑有明确的挂载点"——这比在一个大类里堆 if 更清晰。
L03

should_execute:把开关拨一下

判断跑不跑的方法是 should_executetasks/conditional_task.py:41)——它把上一步产出喂给你的 condition 函数:

# tasks/conditional_task.py:41
def should_execute(self, context: TaskOutput) -> bool:
    """Determines whether the conditional task should be executed
       based on the provided context."""
    if self.condition is None:
        raise ValueError("No condition function set for conditional task")
    return bool(self.condition(context))    # ★把上一步产出丢进 condition,取布尔结果
context: TaskOutput注意参数就是上一个任务的 TaskOutput(D14 那个三副面孔的对象),不是拼好的字符串。你的 condition 可以读 context.raw / context.pydantic 等做判断。
if self.condition is None: raise边界:条件任务却没给 condition 函数——没法判断,直接报错。
bool(self.condition(context))调用你的开关函数,结果强转布尔。True = 跑,False = 跳过。
📝 真实值 def has_results(out: TaskOutput) -> bool: return len(out.raw) > 50——上一步产出超过 50 字才认为"搜到了东西"。
ConditionalTask(description="深度分析搜索结果", condition=has_results, agent=analyst)
上一步只回了"未找到相关信息"(12 字)→ should_execute 返回 False → 分析任务跳过。
L04

跳过时:留一个空 TaskOutput 占位

关键设计:跳过不是让任务凭空消失,而是产出一个"空壳" TaskOutput(tasks/conditional_task.py:57):

# tasks/conditional_task.py:57
def get_skipped_task_output(self) -> TaskOutput:
    """Generate a TaskOutput for when the conditional task is skipped."""
    return TaskOutput(
        description=self.description,
        raw="",                                       # ★空文本:表示"没产出"
        agent=self.agent.role if self.agent else "",
        output_format=OutputFormat.RAW,
    )
💡 为什么跳过也要造个空 TaskOutput?因为整个 crew 的执行是靠 task_outputs 这个列表串起来的:后面任务的上下文(D16 _get_context)、最终产出的挑选(_create_crew_output)都按这个列表来。如果跳过的任务什么都不留,列表就会"缺一格",下游按索引/位置的逻辑可能错乱。留一个 raw="" 的空壳,等于说"这一格我来过、但没干活"——链条完整,语义清晰。D16 讲过 aggregate_raw_outputs 拼接时空 raw 拼进去也无害(就是段空串)。
大白话:请假条 vs 旷工员工跳过某项工作,不是直接"人间蒸发"(旷工,账对不上),而是交一张"请假条"(空 TaskOutput):记录在案、位置还在、只是这次没output。这样考勤表(task_outputs)不会因为少一行而错位。
L05

Crew 里的跳过决策

Crew 顺序循环里,碰到 ConditionalTask 会先走 _handle_conditional_taskcrew.py:1590),它调 check_conditional_skipcrews/utils.py:186):

# crew.py:1590
def _handle_conditional_task(self, task, task_outputs, futures, task_index, was_replayed):
    if futures:                                        # ★先把在跑的异步任务收割掉(D17)
        task_outputs.extend(self._process_async_tasks(futures, was_replayed))
        futures.clear()
    return check_conditional_skip(self, task, task_outputs, task_index, was_replayed)

# crews/utils.py:186
def check_conditional_skip(crew, task, task_outputs, task_index, was_replayed):
    previous_output = task_outputs[-1] if task_outputs else None    # ★只看"上一个"产出
    if previous_output is not None and not task.should_execute(previous_output):
        crew._logger.log("debug", f"Skipping conditional task: {task.description}", ...)
        skipped_task_output = task.get_skipped_task_output()         # 造空占位
        if not was_replayed:
            crew._store_execution_log(task, skipped_task_output, task_index)
        return skipped_task_output      # 返回空占位 = "跳过了"
    return None                         # 返回 None = "该执行,照常跑"
控制流:碰到 ConditionalTask 的决策 轮到一个 ConditionalTask 先收割在跑的异步任务(D17) should_execute(上一个产出) ? True 返回 None → 照常 execute_sync False 造空占位 TaskOutput 跳过 占位加进 task_outputs → continue
图注:先收割异步(拿到"上一个产出"),再判断——False 就放空占位并跳过,True 就返回 None 让主循环照常执行它。
先收割 futures★呼应 D17:判断条件要看"上一个产出",若前面有异步任务还没收割,就拿不到真正的上一步结果。所以先把异步全 result() 回来。
task_outputs[-1]条件只依据紧邻的上一个产出判断(不是全部历史)。
返回空占位 vs 返回 None巧妙的信号:返回空占位对象=已决定跳过(主循环把它加进列表、continue);返回 None=不跳过(主循环照常 execute_sync)。用"返回值是不是 None"传递决策。
L06

三条硬约束

Crew 对 ConditionalTask 有三条 after 校验(构造 crew 时就检查):

# crew.py:790 —— ① 不能当第一个任务
@model_validator(mode="after")
def validate_first_task(self) -> Crew:
    if self.tasks and isinstance(self.tasks[0], ConditionalTask):
        raise PydanticCustomError("invalid_first_task",
            "The first task cannot be a ConditionalTask.", {})
    return self

# crew.py:774 —— ② 不能是唯一/全部(至少要有一个非条件任务)
@model_validator(mode="after")
def validate_must_have_non_conditional_task(self) -> Crew:
    if not self.tasks: return self
    non_conditional_count = sum(
        1 for task in self.tasks if not isinstance(task, ConditionalTask))
    if non_conditional_count == 0:
        raise PydanticCustomError("only_conditional_tasks",
            "Crew must include at least one non-conditional task", {})
    return self

# crew.py:800 —— ③ 不能是异步
@model_validator(mode="after")
def validate_async_tasks_not_async(self) -> Crew:
    for task in self.tasks:
        if task.async_execution and isinstance(task, ConditionalTask):
            raise PydanticCustomError("invalid_async_conditional_task",
                "Conditional Task: {description}, cannot be executed asynchronously.",
                {"description": task.description})
    return self
⚠️ 边界:三条约束背后的同一个道理 ① 不能当第一个:条件要看"上一个产出",第一个任务没有上一个,条件无从判断(task_outputs 为空)。② 不能是唯一/全部条件任务:若全是条件任务,可能全被跳过,crew 最后一个产出都没有——_create_crew_output 会因"没有有效产出"报错。所以至少留一个"铁定会跑"的普通任务兜底。③ 不能异步:D17 说过异步任务的收割由后面同步任务触发、时机微妙;而条件任务的"跳过决策"必须在确定的时刻同步做出(要先拿到上一步结果)。异步 + 条件跳过混在一起,收割/判断顺序会纠缠不清。三条约束都是为了保证"条件判断时,它依赖的上一步产出一定已经确定"。
L07

和 Flow 的 router 有什么不同

👶 小白:这个"看条件决定跑不跑",和别的框架的路由/分支有啥区别?

👨‍🏫 老师:ConditionalTask 是 Crew(顺序流程)里的轻量分支——它只能表达"这一步跑 or 跳过",不能改变整体走向(不能"跳到第 5 个任务"或"回到第 2 个")。它本质是线性链条上的一个可选节点。如果你要真正的路由/循环/多路分叉(根据结果去 A 或 B、绕回去重来),那是 CrewAI Flow@router 领域(S7 阶段,D44 会讲)。一句话:ConditionalTask 管"跳过某一步",Flow router 管"改变整个流向"。简单分支用前者,复杂编排用后者。

💡 设计取舍②:为什么 Crew 只给"跳过"这种弱分支,不给完整路由? Crew 的心智模型是"一队人按顺序把活干完"——它的价值在简单直观。如果往 Crew 里塞完整的路由/循环,它就变成了一个复杂的工作流引擎,和 Flow 职责重叠、还失去了简单性。CrewAI 的取舍是:Crew 保持线性 + 一点点条件跳过的灵活;真需要复杂控制流,请上 Flow。两个抽象各司其职,用户按复杂度选工具(D48 会专门对比 Flow vs Crew 选型)。这是"抵制功能膨胀、让每个抽象保持锋利"的克制。
L08

阶段3 收官 + 动手

🎓 阶段3(D13-18)知识地图:你已经把 Task 拆透了

  • D13 模型:Task 是声明式配方卡,字段分 5 组,id(身份)/key(内容指纹),NOT_SPECIFIED 三态埋点。
  • D14 输出与护栏:TaskOutput 三副面孔(raw/pydantic/json_dict);护栏是质检员,(bool,Any) 契约,带反馈重试。
  • D15 结构化输出:output_pydantic/json/response_model 三种;convert_to_model 的降级链;解析失败优雅降级。
  • D16 context 依赖:NOT_SPECIFIED 三态语义;_get_context 分流;raw 用分隔线拼接;不许依赖未来任务。
  • D17 异步任务:execute_async 开线程 + copy_context;Future 装结果/异常;攒批-同步屏障-收割;最多一个异步收尾。
  • D18 条件任务:继承 Task 加 condition;跳过留空占位保持链条;三条约束都为"条件判断时上一步已确定"。

贯穿阶段3 的两条主线:① fail-fast vs 优雅降级——配置矛盾(互斥、未来依赖、异步收尾)在构造时就报错;LLM 不配合(解析失败、护栏不过)则重试/降级。② 声明式——你只声明"要什么、依赖谁、跑不跑、并不并行",搬运/调度/解析全由框架接管。

🧠 今天你应该能回答

  • ConditionalTask 比普通 Task 多了什么?为什么用子类而非加字段?
  • should_execute 的参数是什么?拿它做什么判断?
  • 跳过时为什么要造一个空 TaskOutput,而不是什么都不留?
  • Crew 判断条件前为什么要先收割异步任务?
  • 三条硬约束(不能第一个/不能全是/不能异步)背后的共同道理是什么?
  • ConditionalTask 和 Flow 的 router 有何本质区别?

✋ 10 分钟动手

P=lib/crewai/src/crewai
# 1. ConditionalTask 全文(很短,通读)
sed -n '1,69p' $P/tasks/conditional_task.py

# 2. Crew 里的跳过决策
sed -n '1590,1605p' $P/crew.py
sed -n '186,217p' $P/crews/utils.py

# 3. 三条硬约束
sed -n '773,812p' $P/crew.py
明日预告 · Day 19(进入阶段4 Crew 与流程):Task 讲完,任务的"指挥部"登场。明天开始拆 Crew 模型全字段(crew.py)——agents/tasks/process/memory/manager 等一大堆字段怎么组织,kickoff 前 Crew 做了哪些校验和准备,为后面 sequential / hierarchical 两种流程铺路。
← Day 17 异步任务 Day 19 · Crew 模型 →