TaskOutput 与护栏:产出的"三副面孔"和它的质检员
昨天 _execute_core 结尾把 Agent 的返回包成了一个 TaskOutput,还偷偷跑了 _guardrails。今天正式拆这两块:TaskOutput——同一份产出为什么要保留 raw / pydantic / json_dict 三副面孔;guardrail(护栏)——任务产出后如何被"质检",不合格怎么带着反馈让 Agent 重做,以及一句话描述的护栏怎么摇身变成一个 LLM 裁判。源码在 tasks/task_output.py、task.py、tasks/llm_guardrail.py、utilities/guardrail.py。
痛点:产出到底是字符串、对象还是字典?
.model_dump() 的对象"、想直接 json.dumps——如果只给一种形态,另外两种就得各自现场转换,到处是 if isinstance(...)。raw(原始文本,永远有)、pydantic(模型对象,配了才有)、json_dict(字典,配了才有),再加一个 output_format 标签告诉你"这次主推哪副面孔"。下游按需取用,不用自己转。output_format 贴张便签写"这次主要用哪张"。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)
.model_dump()、要文本就 str()。但问题是:并非每个任务都配了 pydantic 模型,很多任务只有裸文本;而且转换可能有损(模型 → 文本回不去)。CrewAI 选择在产出时就把能得到的形态都存好,raw 作为永远存在的地基,pydantic/json_dict 有就填、没有就是 None。代价是稍微多占点内存,换来的是下游"取哪副面孔都不用现算、也不会失败"。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:61、tasks/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——它会抛异常。护栏是什么:产出后的质检员
护栏在 Task 上有两个字段:单个 guardrail 和一串 guardrails(task.py:246、task.py:257),还有重试次数 guardrail_max_retries(task.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),再不行就报警停线。一句话护栏怎么变成 LLM 裁判
当你把 guardrail 写成字符串时,Task 的 after 校验器会把它包装成一个 LLMGuardrail(task.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:69、tasks/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 详谈):过就放行原文,不过就把理由传回去当重试反馈。带反馈的重试循环:不合格怎么打回重做
护栏真正的执行在 _invoke_guardrail_function(task.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 再校验
guardrail_result.error(哪里不合格)拼进 context 再让 Agent 做——Agent 这次知道"上次错在没用中文",命中率大增。这就是"反馈式重试",和 D09 输出解析失败后回喂错误、人给 AI 打回意见是同一套思路。第 2 次:还夹了个"OK" → 不过 → 再塞反馈 → 重做。
第 3 次:纯中文 → 通过 →
return task_output。若三次都不过:
raise Exception("Task failed guardrail validation after 3 retries...")。(bool, Any) 契约与边界
所有护栏——无论函数还是 LLM——都必须返回 (bool, Any) 元组。process_guardrail 把这个元组转成结构化的 GuardrailResult(utilities/guardrail.py:123、utilities/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 那一格过了装修正后的结果、没过装反馈——一个契约覆盖三种意图(放行/放行并改写/打回带反馈)。这比异常灵活得多,也让重试循环能统一处理。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_callables(task.py:398)末尾一旦发现有复数护栏,就把单数 guardrail 和 _guardrail 清空。多个护栏时按顺序逐个跑(都要过)。今日小结 + 动手
🧠 今天你应该能回答
- 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
_export_output 一闪而过。明天专讲结构化输出——output_pydantic / output_json 到底怎么把 LLM 吐的文本"翻译"成 Pydantic 对象或字典:convert_to_model 的 JSON 解析、部分 JSON 兜底、以及 response_model 走原生 provider 结构化输出的那条更快的路。