@CrewBase:把 Crew 写成"一个类 + 两个 YAML"
前 54 天我们都是用 Agent(...)、Task(...)、Crew(...) 手写着组装。但 crewai create crew 生成的项目却长得完全不一样——一个 @CrewBase 装饰的类,方法上贴着 @agent/@task/@crew,角色/目标全写在 config/agents.yaml 里。今天就把 project/ 这套"声明式脚手架"拆开:装饰器怎么给方法打标记、@CrewBase 怎么偷偷换掉元类、实例化时怎么读 YAML、YAML 里的字符串"researcher"怎么变成真的 Agent 对象、@crew 又怎么把它们收集成一个 Crew。这是所有生产级 CrewAI 项目的骨架。
@CrewBase 就像公司的"人事+组织架构"系统。你不用在代码里一个个 new 员工——你在一张花名册(agents.yaml)里写好"研究员:目标是…、背景是…",在任务单(tasks.yaml)里写好"调研任务:交给研究员做"。系统开工时(实例化)自动读花名册、把"研究员"这个名字换成真人(Agent 对象),再按任务单把人和活配对好。配置与代码分离——改角色措辞不用碰 Python,产品经理都能改 YAML。痛点:手写组装不适合真实项目
Agent(role="研究员", goal="...", backstory="一大段...") 写得很爽,可真实项目里:角色描述动辄几百字、要频繁调措辞、非技术同事也想改;一个 Crew 有五六个 Agent、七八个 Task,全塞进 __init__ 里读都读不下去;同一批 Agent 还想在不同 Crew 间复用。把"配置数据"和"Python 逻辑"混在一起,是维护噩梦。怎么优雅地拆开?@CrewBase 提供一套声明式脚手架:① 用装饰器(@agent/@task/@crew)给类里的方法打"身份标记";② 用两个 YAML 文件存放角色/任务的纯数据;③ 实例化时自动读 YAML、把 YAML 里的字符串引用(如 agent: researcher)解析成真实对象;④ @crew 方法负责把所有 @agent/@task 收集起来装进一个 Crew。你只写"是什么",框架负责"怎么连起来"。一个标准项目的样子(crewai create crew 生成):
@CrewBase
class MyCrew:
agents_config = "config/agents.yaml" # 花名册
tasks_config = "config/tasks.yaml" # 任务单
@agent
def researcher(self) -> Agent:
return Agent(config=self.agents_config["researcher"]) # 只引用 YAML
@task
def research_task(self) -> Task:
return Task(config=self.tasks_config["research_task"])
@crew
def crew(self) -> Crew:
return Crew(agents=self.agents, tasks=self.tasks, process=Process.sequential)
@crew 里写的是 self.agents、self.tasks——你从没手动往里加过东西,它们却自动就有了所有 @agent/@task 的产物。这份"魔法"就是今天要拆的。三个源文件:annotations.py(装饰器)、crew_base.py(元类 + YAML 加载)、wrappers.py(标记类)。@agent / @task:装饰器只干一件事——打标记
装饰器本体极简(annotations.py:66):
# project/annotations.py:66
def task(meth: Callable[P, TaskResultT]) -> TaskMethod[P, TaskResultT]:
"""Marks a method as a crew task."""
return TaskMethod(memoize(meth)) # 包一层"任务标记类" + memoize
# :78
def agent(meth: Callable[P, R]) -> AgentMethod[P, R]:
"""Marks a method as a crew agent."""
return AgentMethod(memoize(meth)) # 包一层"Agent 标记类" + memoize
# :90 def llm(...) :126 def tool(...) :138 def callback(...) 同理
标记类是啥?看 wrappers.py 里同类结构(此处以 hook 方法为例,agent/task 标记类形态一致)——核心就是一个带 is_xxx=True 属性、能当方法用的包装对象:
# 每个标记类都携带一个布尔属性做"身份牌",例如:
# AgentMethod.is_agent = True
# TaskMethod.is_task = True
# 后面元类就是靠 hasattr(method, "is_agent") 把它们筛出来
task = TaskMethod(memoize(meth))把你写的 research_task 方法,先用 memoize 包一层(缓存,见 L07),再塞进 TaskMethod 包装对象。TaskMethod 携带 is_task=True这就是"身份牌"。方法本身逻辑没变,但多了一个可被识别的标记属性。装饰器只标记、不执行关键:此刻没有真的去创建 Agent/Task。只是"贴了个贴纸",说"这个方法将来是造 Agent/Task 的工厂"。research_task 里可能引用 self.researcher,而 Python 从上到下读类体时对象都还没造好。装饰器阶段只登记"谁是谁",等实例化、YAML 都就位后,再由元类统一按依赖顺序实例化。把"声明"和"求值"分开——这是所有声明式框架的通用套路。@CrewBase:一个"偷换元类"的类装饰器
@CrewBase 不是普通装饰器,它自己的元类重写了 __call__(crew_base.py:762):
# project/crew_base.py:762
class _CrewBaseType(type):
"""Metaclass for CrewBase that makes it callable as a decorator."""
def __call__(cls, decorated_cls: type) -> type[CrewClass]:
__name = str(decorated_cls.__name__)
__bases = tuple(decorated_cls.__bases__)
__dict = {k: v for k, v in decorated_cls.__dict__.items()
if k not in ("__dict__", "__weakref__")}
__dict["__metaclass__"] = CrewBaseMeta
return cast(type[CrewClass], CrewBaseMeta(__name, __bases, __dict)) # 用新元类重造类
# :787
class CrewBase(metaclass=_CrewBaseType):
"""Class decorator that applies CrewBaseMeta metaclass."""
被换上的 CrewBaseMeta.__new__ 会在建类时注入配置与方法(crew_base.py:196):
# project/crew_base.py:196
def __new__(mcs, name, bases, namespace, **kwargs):
cls = cast(type[CrewClass], super().__new__(mcs, name, bases, namespace))
cls.is_crew_class = True
cls._crew_name = name
for setup_fn in _CLASS_SETUP_FUNCTIONS: # :221 设 base_directory / 配置路径 / MCP 参数
setup_fn(cls)
for method in _METHODS_TO_INJECT: # :224 把 load_yaml/map_all_*等函数装进类
setattr(cls, method.__name__, method)
return cls
@CrewBase 写在 class 上本质是"把你的类当参数,返回一个新类"。所以 @CrewBase 的元类实现 __call__(cls, decorated_cls)——被装饰的类就是那个参数。CrewBaseMeta(name, bases, dict)用你原类的名字/父类/内容重新造一个类,但这次带上 CrewBaseMeta 元类。相当于"换了个更聪明的模具"。_CLASS_SETUP_FUNCTIONS(:743) 三个函数:_set_base_directory(YAML 相对哪个目录找)、_set_config_paths(默认 config/agents.yaml)、_set_mcp_params。_METHODS_TO_INJECT(:749) 把 load_yaml/load_configurations/map_all_agent_variables 等一堆函数塞进你的类——所以你的类"凭空"就有了这些方法。class MyCrew(CrewBase): 继承。问题:继承会暴露一堆内部方法、和用户自己的父类冲突、MRO(方法解析顺序)复杂。源码做法:用 @CrewBase 装饰器 + 元类,在建类那一刻动态注入方法、设置属性,用户的类保持自己原本的父类不变(__bases__ 原样保留)。代价是元类魔法读起来烧脑,但换来了"用户类干净、不侵入继承体系"。框架愿意自己吃复杂度,来换用户侧的简洁。实例化时:读 YAML + 建立元数据
元类还重写了 __call__(实例化拦截),每次 MyCrew() 都会跑一遍初始化(crew_base.py:229):
# project/crew_base.py:229
def __call__(cls, *args, **kwargs) -> CrewInstance:
instance: CrewInstance = super().__call__(*args, **kwargs)
CrewBaseMeta._initialize_crew_instance(instance, cls) # 拦截:多做一步初始化
return instance
# :243
@staticmethod
def _initialize_crew_instance(instance, cls):
instance._mcp_server_adapter = None
instance.load_configurations() # ① 读两个 YAML → self.agents_config/tasks_config
instance._all_methods = _get_all_methods(instance)
instance.map_all_agent_variables() # ② 把 YAML 里的字符串引用解析成实例(L05)
instance.map_all_task_variables()
...
original_methods = {name: m for name, m in cls.__dict__.items()
if any(hasattr(m, attr) for attr in
["is_task","is_agent","is_before_kickoff","is_after_kickoff","is_kickoff"])}
instance.__crew_metadata__ = CrewMetadata( # ③ 按身份牌分类归档
original_tasks =_filter_methods(original_methods, "is_task"),
original_agents =_filter_methods(original_methods, "is_agent"),
before_kickoff =_filter_methods(original_methods, "is_before_kickoff"),
after_kickoff =after_kickoff_callbacks,
kickoff =_filter_methods(original_methods, "is_kickoff"))
而 load_configurations 就是读 YAML 文件(crew_base.py:368):
# project/crew_base.py:368
def load_configurations(self):
self.agents_config = self._load_config(self.original_agents_config_path, "agent")
self.tasks_config = self._load_config(self.original_tasks_config_path, "task")
# :378
def load_yaml(config_path: Path):
with open(config_path, encoding="utf-8") as file:
content = yaml.safe_load(file)
return content if isinstance(content, dict) else {}
super().__call__()先正常造出实例(跑你自己的 __init__),再拦截着多做初始化。你无感。load_configurations()把 config/agents.yaml 和 tasks.yaml 读进 self.agents_config/self.tasks_config(就是两个 dict)。_get_all_methods(:399) 扫描实例上所有非 dunder 的可调用方法——为下一步"找出哪些是 @llm/@tool/@agent"做准备。hasattr(m,"is_task")...★靠 L02 打的身份牌,把方法分门别类归到 __crew_metadata__:哪些是任务工厂、哪些是 Agent 工厂、哪些是 kickoff 前后钩子。yaml.safe_load用 safe_load 而非 load——只解析纯数据,拒绝执行 YAML 里的任意 Python 对象(安全,见 L08 边界)。字符串 "researcher" 怎么变成真的 Agent
YAML 里 task 常写 agent: researcher(一个字符串)。解析发生在 _map_task_variables(crew_base.py:688):
# project/crew_base.py:712
def _map_task_variables(self, task_name, task_info, agents, tasks, ...):
if context_list := task_info.get("context"): # context: [task_a, task_b]
self.tasks_config[task_name]["context"] = [
tasks[context_task_name]() for context_task_name in context_list] # 名字→调工厂
if tools := task_info.get("tools"):
if _is_string_list(tools): # tools 全是字符串?
self.tasks_config[task_name]["tools"] = [tool_functions[t]() for t in tools]
if agent_name := task_info.get("agent"): # agent: researcher
self.tasks_config[task_name]["agent"] = agents[agent_name]() # ★调 @agent 工厂拿实例
if output_pydantic := task_info.get("output_pydantic"):
self.tasks_config[task_name]["output_pydantic"] = output_pydantic_functions[output_pydantic]
Agent 侧同理,把 llm: my_llm、tools: [...] 等字符串解析成对象(crew_base.py:636):
# project/crew_base.py:636
def _map_agent_variables(self, agent_name, agent_info, llms, tool_functions, ...):
if llm := agent_info.get("llm"):
factory = llms.get(llm)
self.agents_config[agent_name]["llm"] = factory() if factory else llm # 有@llm工厂就调,否则当模型名
if tools := agent_info.get("tools"):
if _is_string_list(tools):
self.agents_config[agent_name]["tools"] = [tool_functions[tool]() for tool in tools]
agents[agent_name]()★灵魂一行:agents 是 {方法名: @agent工厂} 的字典。用 YAML 里的字符串当 key 取出工厂,调用它(末尾的 ())得到真 Agent 实例。_is_string_list(tools)(:169) 类型守卫:只有当 tools 全是字符串时才去查工厂;如果用户已经传了真 BaseTool 对象,就不动它。兼容两种写法。factory() if factory else llm降级逻辑:llm: gpt-4o 这种是模型名(没对应 @llm 工厂),就原样保留字符串交给底层 LLM 层处理。写回 self.tasks_config解析结果直接原地改回配置 dict。之后 Task(config=...) 拿到的就是已经含真实 Agent 对象的配置。tasks.yaml 写 research_task: {description: "调研 {topic}", agent: researcher}。实例化时
_map_task_variables 看到 agent="researcher" → 从 agents 字典取出你的 @agent def researcher 工厂 → 调用它 → 得到 Agent(role='研究员', ...) 实例 → 写回 tasks_config["research_task"]["agent"]。于是
Task(config=self.tasks_config["research_task"]) 里的 agent 字段已经是真对象,不再是字符串。@crew:自动收集所有 agent/task 装进 Crew
@crew 是最"忙"的装饰器,它 wrapper 里替你把 self.agents/self.tasks 填好(annotations.py:200):
# project/annotations.py:200
@wraps(meth)
def wrapper(self, *args, **kwargs) -> Crew:
instantiated_tasks: list[Task] = []
instantiated_agents: list[Agent] = []
agent_roles: set[str] = set()
tasks = self.__crew_metadata__["original_tasks"].items()
agents = self.__crew_metadata__["original_agents"].items()
for _, task_method in tasks: # ① 先实例化所有 @task
task_instance = _call_method(task_method, self)
instantiated_tasks.append(task_instance)
agent_instance = getattr(task_instance, "agent", None)
if agent_instance and agent_instance.role not in agent_roles: # 顺带收集任务用到的 agent
instantiated_agents.append(agent_instance)
agent_roles.add(agent_instance.role)
for _, agent_method in agents: # ② 再补上剩余 @agent(去重)
agent_instance = _call_method(agent_method, self)
if agent_instance.role not in agent_roles:
instantiated_agents.append(agent_instance)
agent_roles.add(agent_instance.role)
self.agents = instantiated_agents # ③ 填好实例属性
self.tasks = instantiated_tasks
crew_instance: Crew = _call_method(meth, self, *args, **kwargs) # ④ 才真正调你写的 crew() 体
...
for hook_callback in self.__crew_metadata__["before_kickoff"].values(): # ⑤ 挂 kickoff 钩子
crew_instance.before_kickoff_callbacks.append(callback_wrapper(hook_callback, self))
return crew_instance
遍历 original_tasks把每个 @task 工厂都调用一遍,得到真 Task 列表。顺便从每个 Task 的 .agent 收集 Agent。agent_roles 去重用一个 set 记录已收集的 role。多个任务共用一个研究员时,Agent 只进列表一次——避免重复。self.agents = ...★这就是为什么你在 crew() 体里能直接写 self.agents——wrapper 在调用你之前已经替你填好了。_call_method(meth, self)最后才执行你手写的 crew() 方法体(返回 Crew(...))。此时它拿到的 self.agents/tasks 都是齐的。before_kickoff_callbacks把 @before_kickoff/@after_kickoff 标记的方法,绑定到 crew 实例的回调列表。串起了 D22 的 kickoff 生命周期。memoize:为什么每个工厂都要缓存
注意 L02 里每个装饰器都 memoize(meth)。看它的实现(project/utils.py):
# project/utils.py(memoize 精简)
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = str(args) + str(kwargs)
if key not in cache:
cache[key] = func(*args, **kwargs) # 只在第一次真正执行
return cache[key] # 之后都返回同一个对象
return wrapper
@crew 遍历 tasks 时调 researcher() 拿到一个 Agent;某个 task 的 context 里又调 researcher()。若不缓存:每次调用都 new 一个新 Agent,同一个"研究员"会有好几个不同实例、各自独立的记忆和状态——逻辑上就乱了。memoize 保证:同名工厂在一个 Crew 实例里只造一次,处处拿到的是同一个对象。代价是要维护一份缓存、并且工厂必须是"无副作用幂等"的。这是"引用同一性"和"重复实例化"之间的权衡——CrewAI 选了前者,符合直觉(一个角色就是一个人)。👶 小白:那我在两个不同的 MyCrew() 实例里,researcher 是同一个吗?
👨🏫 老师:不是。memoize 的缓存 key 里含 self(第一个参数 args),不同实例的 self 不同,key 就不同。所以"同一实例内共享、不同实例间隔离"。这正是我们想要的——你跑两个 Crew 互不干扰。
边界 + 今日小结
_load_config(crew_base.py:337)里 except FileNotFoundError 时不崩溃,而是 logging.warning 后返回 {}(空配置)。设计意图:允许"只用代码、不用 YAML"的混合写法。但坑在于——如果你路径拼错,程序不会报错,只会静默地用空配置,最后 Agent 角色全空、行为诡异。调试时先确认 warning 日志里的 YAML 路径对不对。load_yaml(crew_base.py:378)坚持用 yaml.safe_load。因为 yaml.load 能反序列化任意 Python 对象——恶意 YAML 可借此执行任意代码(经典 RCE 漏洞)。配置文件常来自他人/仓库,只解析纯数据、绝不执行是底线。这与 Day 57 的安全主题一脉相承。🧠 今天你应该能回答
- @agent/@task 装饰器到底做了什么?(只打身份牌 + memoize,不立即执行)
- @CrewBase 为什么要"偷换元类"而不用继承?
- 实例化时的三步(读 YAML → 解析引用 → 归档元数据)分别在哪些函数?
- YAML 里
agent: researcher这个字符串是怎么变成真 Agent 的? - 你从没填过 self.agents,为什么 crew() 里它有值?
- 为什么每个工厂都要 memoize?跨实例会共享吗?
✋ 10 分钟动手
P=lib/crewai/src/crewai/project
sed -n '66,135p' $P/annotations.py # @task/@agent/@llm/@tool 装饰器
sed -n '200,278p' $P/annotations.py # @crew wrapper:收集 agents/tasks
sed -n '196,285p' $P/crew_base.py # 元类 __new__ / __call__ / _initialize_crew_instance
sed -n '594,740p' $P/crew_base.py # map_all_* / _map_task_variables 名字→对象
sed -n '743,784p' $P/crew_base.py # 注入清单 + _CrewBaseType 装饰器机制
# 亲手体验
crewai create crew demo && cat demo/src/demo/config/agents.yaml
@before_kickoff 是 Crew 级钩子。明天读 hooks/:更细粒度的 @before_llm_call/@after_tool_call——在每次 LLM 调用/每次工具执行前后插入你的逻辑(审计、改 prompt、拦截、人工审批),以及它们怎么用一个上下文对象暴露可变的 messages。