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

TaskOutput 与护栏:产出的"三副面孔"和它的质检员

昨天 _execute_core 结尾把 Agent 的返回包成了一个 TaskOutput,还偷偷跑了 _guardrails。今天正式拆这两块:TaskOutput——同一份产出为什么要保留 raw / pydantic / json_dict 三副面孔;guardrail(护栏)——任务产出后如何被"质检",不合格怎么带着反馈让 Agent 重做,以及一句话描述的护栏怎么摇身变成一个 LLM 裁判。源码在 tasks/task_output.pytask.pytasks/llm_guardrail.pyutilities/guardrail.py

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

痛点:产出到底是字符串、对象还是字典?

🤔 痛点Agent 干完活,返回的可能是一段纯文本、也可能是一段 JSON、还可能被你要求成一个 Pydantic 对象。下游想拿"给人看的文本"、想拿"能 .model_dump() 的对象"、想直接 json.dumps——如果只给一种形态,另外两种就得各自现场转换,到处是 if isinstance(...)
💡 一句话本质:TaskOutput = 一份产出的三副面孔CrewAI 把同一份结果同时存成三种形态:raw(原始文本,永远有)、pydantic(模型对象,配了才有)、json_dict(字典,配了才有),再加一个 output_format 标签告诉你"这次主推哪副面孔"。下游按需取用,不用自己转。
🎭 生活类比:一个人的三种证件照同一个人,有生活照(raw,谁都能看)、标准证件照(pydantic,正式场合用)、电子档 JSON(json_dict,系统对接用)。TaskOutput 就是把这三张照片一次拍好放同一个信封里,用 output_format 贴张便签写"这次主要用哪张"。
L02

TaskOutput 全字段

TaskOutput 的字段声明(tasks/task_output.py:14):

# tasks/task_output.py:14
class TaskOutput(BaseModel):
    """Class that represents the result of a task."""
    description: str = Field(description="Description of the task")
    name: str | None = Field(default=None)
    expected_output: str | None = Field(default=None)
    summary: str | None = Field(default=None)          # 自动从 description 截取
    raw: str = Field(default="")                        # ★永远有:原始文本
    pydantic: BaseModel | None = Field(default=None)    # 配了 output_pydantic 才有
    json_dict: dict[str, Any] | None = Field(default=None)  # 配了 output_json 才有
    agent: str = Field(description="Agent that executed the task")
    output_format: OutputFormat = Field(default=OutputFormat.RAW)  # 主推哪副面孔
    messages: list[LLMMessage] = Field(default_factory=list)
数据结构:一个 TaskOutput 的三副面孔 + 一张标签 TaskOutput 实例 raw(永远有) "三大风险:1.汇率 2..." 给人看 / 兜底 pydantic(可选) Report(risks=[...]) 强类型对象 json_dict(可选) {"risks":[...]} 系统对接 output_format 标签 RAW / JSON / PYDANTIC —— 告诉下游主推哪副面孔
图注:raw 是地基(总在),pydantic / json_dict 按配置生成,output_format 是"用哪张"的指路牌。
💡 设计取舍①:为什么三种都存,不只存一种再转? 朴素做法是只存一份(比如只存 pydantic),下游要 json 就 .model_dump()、要文本就 str()。但问题是:并非每个任务都配了 pydantic 模型,很多任务只有裸文本;而且转换可能有损(模型 → 文本回不去)。CrewAI 选择在产出时就把能得到的形态都存好raw 作为永远存在的地基,pydantic/json_dict 有就填、没有就是 None。代价是稍微多占点内存,换来的是下游"取哪副面孔都不用现算、也不会失败"。
L03

summary / json / __str__:三个便利出口

TaskOutput 还有三个贴心方法。先看 summary——它在对象造好后自动从描述截前 10 个词(tasks/task_output.py:50):

# tasks/task_output.py:50
@model_validator(mode="after")
def set_summary(self) -> TaskOutput:
    excerpt = " ".join(self.description.split(" ")[:10])
    self.summary = f"{excerpt}..."
    return self

再看 json 属性和 __str__tasks/task_output.py:61tasks/task_output.py:99):

# tasks/task_output.py:61
@property
def json(self) -> str | None:
    if self.output_format != OutputFormat.JSON:
        raise ValueError("Invalid output format requested. ...set the output_json...")
    return json.dumps(self.json_dict)

# tasks/task_output.py:99
def __str__(self) -> str:
    if self.pydantic:   return str(self.pydantic)     # 优先对象
    if self.json_dict:  return str(self.json_dict)    # 再 json
    return self.raw                                   # 最后兜底文本
set_summary自动生成一句"摘要"(描述前 10 词 + "..."),用于日志/事件里简短标识这个产出,不用你手写。
json 属性边界:只有 output_format == JSON 才允许取,否则主动 raise ValueError 并提示你"去设置 output_json"。宁可报错也不返回可能误导的空值。
__str__ 优先级把 TaskOutput 当字符串用时(print(output)),按 pydantic → json_dict → raw 的优先级挑"最结构化的那副面孔"来展示。
💡 小抄:拿产出的正确姿势output.raw 取文本(永远安全);output.pydantic 取对象(配了才非 None);output.to_dict() 取字典(优先 json_dict、退而 pydantic.model_dump);str(output) 打印最结构化的一副。别用 output.json 除非你确定设了 output_json——它会抛异常。
L04

护栏是什么:产出后的质检员

🤔 痛点LLM 会"胡说"或不守规矩:你要 3 条要点它给 5 条、你要纯 JSON 它前面加一句"好的,这是您要的"、你要中文它蹦英文。任务产出后如果不检查就往下传,错误会污染整条流水线。
💡 本质:guardrail = 产出后的质检员 + 退货重做护栏是一个"检查产出合不合格"的函数:合格就放行(可顺手修正内容);不合格就带着"哪里不对"的反馈,让 Agent 重做,最多重试 N 次。它把"人肉复查+打回重写"这套流程自动化了。

护栏在 Task 上有两个字段:单个 guardrail 和一串 guardrailstask.py:246task.py:257),还有重试次数 guardrail_max_retriestask.py:273,默认 3)。它可以是一个函数,也可以是一句话描述

# 用法示例(两种写法)
# ① 写个函数:返回 (是否通过, 结果或错误说明)
def no_english(output: TaskOutput) -> tuple[bool, Any]:
    if any(c.isalpha() and c.isascii() for c in output.raw):
        return (False, "输出里不能出现英文字母")
    return (True, output.raw)
Task(..., guardrail=no_english)

# ② 写一句话,让 LLM 当裁判(见 L05)
Task(..., guardrail="输出必须是纯中文,且不超过 100 字", agent=writer)
大白话护栏就像工厂流水线末端的质检员:合格盖章放行,不合格贴张"哪不合格"的纸条打回车间重做。重做还不合格?最多打回三次(guardrail_max_retries),再不行就报警停线。
L05

一句话护栏怎么变成 LLM 裁判

当你把 guardrail 写成字符串时,Task 的 after 校验器会把它包装成一个 LLMGuardrailtask.py:377):

# task.py:377
@model_validator(mode="after")
def ensure_guardrail_is_callable(self) -> Task:
    if callable(self.guardrail):
        self._guardrail = self.guardrail            # 本来就是函数:直接用
    elif isinstance(self.guardrail, str):           # 是字符串:包成 LLM 裁判
        from crewai.tasks.llm_guardrail import LLMGuardrail
        if self.agent is None:
            raise ValueError("Agent is required to use LLMGuardrail")
        if not isinstance(self.agent.llm, BaseLLM):
            raise ValueError("Agent must have a BaseLLM instance to use LLMGuardrail")
        self._guardrail = cast(
            GuardrailCallable,
            LLMGuardrail(description=self.guardrail, llm=self.agent.llm),
        )
    return self

LLMGuardrail 本身,就是"拿另一个 Agent 当裁判":把你的产出和规则一起丢给 LLM,让它判合不合格(tasks/llm_guardrail.py:69tasks/llm_guardrail.py:98):

# tasks/llm_guardrail.py:69
def _validate_output(self, task_output: TaskOutput) -> LiteAgentOutput:
    agent = Agent(role="Guardrail Agent", goal="Validate the output of the task",
                  backstory="You are a expert at validating the output of a task...",
                  llm=self.llm)
    query = f"""
    Ensure the following task result complies with the given guardrail.
    Task result: {task_output.raw}
    Guardrail: {self.description}
    - Confirm if the Task result complies with the guardrail.
    - If not, provide clear feedback explaining what is wrong ...
    """
    kickoff_result = agent.kickoff(query, response_format=LLMGuardrailResult)  # ★结构化返回
    ...

# tasks/llm_guardrail.py:98
def __call__(self, task_output: TaskOutput) -> tuple[bool, Any]:
    result = self._validate_output(task_output)
    if result.pydantic.valid:
        return True, task_output.raw           # 合格:放行原文
    return False, result.pydantic.feedback     # 不合格:返回 LLM 给的反馈
Agent(role="Guardrail Agent")临时造一个"质检员 Agent",用你任务 agent 同一个 LLM。
response_format=LLMGuardrailResult★让裁判返回结构化结果 {valid: bool, feedback: str}——不是让它自由发挥,而是逼它给出"过/不过 + 理由"。
return (True, raw) / (False, feedback)把裁判结论翻译成统一的 (bool, Any) 契约(L07 详谈):过就放行原文,不过就把理由传回去当重试反馈。
💡 设计取舍②:为什么允许"一句话护栏",而不是逼你写函数? 很多校验规则("要礼貌"、"不能有推测性表述"、"必须引用来源")根本无法用简单代码判断——它们需要语义理解。CrewAI 的选择是:能用代码判的(长度、格式、关键词)你写函数(快、免费、确定);判不了的语义规则,写一句话交给 LLM 当裁判。代价是字符串护栏每次校验要多花一次 LLM 调用(钱+延迟),而且裁判自己也可能出错;收益是能表达任意复杂的语义约束。所以源码强制字符串护栏必须有 agent 且 agent 有 LLM——没有裁判就没法评。
L06

带反馈的重试循环:不合格怎么打回重做

护栏真正的执行在 _invoke_guardrail_functiontask.py:1246)。这是今天的心脏——一个"校验→不过就带反馈重跑→再校验"的循环:

# task.py:1246(骨架)
def _invoke_guardrail_function(self, task_output, agent, tools, guardrail, guardrail_index=None):
    current_retry_count = ...                       # 该护栏已重试几次
    max_attempts = self.guardrail_max_retries + 1   # 允许尝试的总次数

    for attempt in range(max_attempts):
        guardrail_result = process_guardrail(       # ★跑一次质检(L07)
            output=task_output, guardrail=guardrail, retry_count=current_retry_count,
            event_source=self, from_task=self, from_agent=agent)

        if guardrail_result.success:                # 通过!
            if guardrail_result.result is None:
                raise Exception("Task guardrail returned None as result. ...")
            if isinstance(guardrail_result.result, str):
                task_output.raw = guardrail_result.result           # 护栏可顺手改文本
                task_output.pydantic, task_output.json_dict = self._export_output(...)
            elif isinstance(guardrail_result.result, TaskOutput):
                task_output = guardrail_result.result               # 或直接换整个产出
            return task_output

        if attempt >= self.guardrail_max_retries:   # 重试用尽 → 放弃报错
            raise Exception(f"Task failed guardrail validation after "
                            f"{self.guardrail_max_retries} retries. Last error: ...")

        self.retry_count += 1
        context = I18N_DEFAULT.errors("validation_error").format(  # ★把错误塞进上下文
            guardrail_result_error=guardrail_result.error, task_output=task_output.raw)
        result = agent.execute_task(task=self, context=context, tools=tools)  # 带反馈重做
        task_output = TaskOutput(...raw=result...)  # 重新包装,进下一轮 for 再校验
控制流:护栏的"质检 → 打回重做"循环 拿到 task_output process_guardrail 质检 success ? 通过 放行(可改写 raw/换产出)→ return 不过 重试用尽? → raise 报错停止 还有次数:把错误塞进 context agent.execute_task 带反馈重做 ↑ 重做的新产出回到质检,进下一轮 for
图注:通过就(可改写后)放行;不过且有次数,就把"错在哪"塞进上下文让 Agent 重做,新产出再回炉质检。
💡 关键:重试不是"再赌一次",而是"带着错误再来"普通重试是同样的输入再跑一遍(结果大概率还错)。护栏重试把 guardrail_result.error(哪里不合格)拼进 context 再让 Agent 做——Agent 这次知道"上次错在没用中文",命中率大增。这就是"反馈式重试",和 D09 输出解析失败后回喂错误、人给 AI 打回意见是同一套思路。
📝 真实值走一遍(护栏="必须纯中文",max_retries=3) 第 1 次:产出含英文 → 不过,error="含英文字母" → 塞进 context → 重做。
第 2 次:还夹了个"OK" → 不过 → 再塞反馈 → 重做。
第 3 次:纯中文 → 通过 → return task_output
若三次都不过:raise Exception("Task failed guardrail validation after 3 retries...")
L07

(bool, Any) 契约与边界

所有护栏——无论函数还是 LLM——都必须返回 (bool, Any) 元组。process_guardrail 把这个元组转成结构化的 GuardrailResultutilities/guardrail.py:123utilities/guardrail.py:105):

# utilities/guardrail.py:123
def process_guardrail(output, guardrail, retry_count, ...) -> GuardrailResult:
    if not isinstance(output, (TaskOutput, LiteAgentOutput)):
        raise TypeError("Output must be a TaskOutput or LiteAgentOutput")
    ...
    crewai_event_bus.emit(event_source, started_event)   # 发"护栏开始"事件
    result = guardrail(output)                            # ★调护栏,拿 (bool, Any)
    guardrail_result = GuardrailResult.from_tuple(result) # 转结构化
    crewai_event_bus.emit(event_source, LLMGuardrailCompletedEvent(...))  # 发"护栏完成"事件
    return guardrail_result

# utilities/guardrail.py:105
@classmethod
def from_tuple(cls, result: tuple[bool, Any | str]) -> Self:
    success, data = result
    return cls(success=success,
               result=data if success else None,     # 成功:data 是结果
               error=data if not success else None)   # 失败:data 是错误说明
(bool, Any) 契约第一位是"过没过",第二位一物两用:过了它是"结果",没过它是"错误说明"。from_tuple 按 success 把第二位分流到 result 或 error。
两个 emit护栏前后各发一个事件(开始/完成),这样监控/遥测能看到每次质检的过程(D26 事件系统)。护栏不是黑盒。
💡 设计取舍③:为什么护栏用元组契约而不是抛异常表示不通过? "不通过"可以用抛异常表达,但那样护栏就无法携带反馈信息(异常里塞字符串很别扭),也无法表达"通过并顺手修正了内容"这种情况。用 (bool, Any):布尔说过没过,Any 那一格过了装修正后的结果、没过装反馈——一个契约覆盖三种意图(放行/放行并改写/打回带反馈)。这比异常灵活得多,也让重试循环能统一处理。
⚠️ 边界:护栏通过时返回 None 会被主动判为非法 回看 L06:if guardrail_result.result is None: raise Exception("Task guardrail returned None as result. This is not allowed.")。为什么?因为护栏声明"通过"(success=True)却没给出结果,是自相矛盾——下游拿什么当产出?框架宁可当场报错,也不让一个"空的通过"悄悄污染后续。这和 D13 的 fail-fast 一脉相承:矛盾状态越早暴露越好。另一处边界:字符串护栏必须配 agent 且 agent 有 BaseLLM,否则 ensure_guardrail_is_callable 在构造时就报错——没裁判就没法评。
💡 guardrails(复数)会覆盖 guardrail(单数):ensure_guardrails_is_list_of_callablestask.py:398)末尾一旦发现有复数护栏,就把单数 guardrail_guardrail 清空。多个护栏时按顺序逐个跑(都要过)。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • TaskOutput 为什么要 raw / pydantic / json_dict 三副面孔?哪副永远有?
  • str(output)output.json 分别怎么工作?后者什么时候会抛异常?
  • 护栏解决什么问题?合格/不合格分别怎么处理?
  • 一句话字符串护栏是怎么变成 LLM 裁判的?它为什么要求 agent 有 LLM?
  • 护栏重试为什么比普通重试聪明?(把错误反馈塞进 context)
  • 护栏的 (bool, Any) 契约里,第二格分别代表什么?
  • 护栏"通过却返回 None"会怎样?(主动报错)

✋ 10 分钟动手

P=lib/crewai/src/crewai
# 1. TaskOutput 三副面孔 + 便利方法
sed -n '14,105p' $P/tasks/task_output.py

# 2. 字符串护栏 → LLMGuardrail
sed -n '377,396p' $P/task.py
sed -n '49,120p' $P/tasks/llm_guardrail.py

# 3. 带反馈重试循环
sed -n '1246,1353p' $P/task.py

# 4. (bool,Any) 契约 → GuardrailResult
sed -n '105,187p' $P/utilities/guardrail.py
明日预告 · Day 15:今天 _export_output 一闪而过。明天专讲结构化输出——output_pydantic / output_json 到底怎么把 LLM 吐的文本"翻译"成 Pydantic 对象或字典:convert_to_model 的 JSON 解析、部分 JSON 兜底、以及 response_model 走原生 provider 结构化输出的那条更快的路。
← Day 13 Task 模型 Day 15 · 结构化输出 →