Day 55 / 共 60 天 · 阶段9 进阶与生态

@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 项目的骨架。

📍 你在 60 天里的位置(阶段9 进阶与生态 · D55-58)
阶段8 LLM 集成 D49-54 D55 @CrewBase+YAML D56 hooks 钩子 D57 security 安全 D58 a2a 协作 阶段10 收官 D59-60
💡 先用一个类比兜住今天 @CrewBase 就像公司的"人事+组织架构"系统。你不用在代码里一个个 new 员工——你在一张花名册(agents.yaml)里写好"研究员:目标是…、背景是…",在任务单(tasks.yaml)里写好"调研任务:交给研究员做"。系统开工时(实例化)自动读花名册、把"研究员"这个名字换成真人(Agent 对象),再按任务单把人和活配对好。配置与代码分离——改角色措辞不用碰 Python,产品经理都能改 YAML。
L01

痛点:手写组装不适合真实项目

🤔 痛点教程里 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.agentsself.tasks——你从没手动往里加过东西,它们却自动就有了所有 @agent/@task 的产物。这份"魔法"就是今天要拆的。三个源文件:annotations.py(装饰器)、crew_base.py(元类 + YAML 加载)、wrappers.py(标记类)。
L02

@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 都就位后,再由元类统一按依赖顺序实例化。把"声明"和"求值"分开——这是所有声明式框架的通用套路。
L03

@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__ 原样保留)。代价是元类魔法读起来烧脑,但换来了"用户类干净、不侵入继承体系"。框架愿意自己吃复杂度,来换用户侧的简洁。
L04

实例化时:读 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.yamltasks.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_loadsafe_load 而非 load——只解析纯数据,拒绝执行 YAML 里的任意 Python 对象(安全,见 L08 边界)。
数据结构:从"源代码 + YAML"到"可运行 Crew" 你的类 @agent researcher @task research_task @crew crew 两个 YAML agents.yaml tasks.yaml 实例化拦截 ① load YAML → dict ② map 字符串→实例 ③ 按身份牌归档 __crew_metadata__ Crew 对象 agents=[Agent(...)] tasks=[Task(...)] "声明"(左) 经"实例化拦截"(中) 变成"可运行对象"(右) —— @crew 方法产出最终 Crew
图注:@CrewBase 的核心是把"分散的声明"在实例化那一刻收拢、解析、归档,最终由 @crew 产出 Crew。
L05

字符串 "researcher" 怎么变成真的 Agent

YAML 里 task 常写 agent: researcher(一个字符串)。解析发生在 _map_task_variablescrew_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_llmtools: [...] 等字符串解析成对象(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.yamlresearch_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 字段已经是真对象,不再是字符串。
L06

@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 生命周期。
控制流:@crew wrapper 的收集顺序 ① 实例化所有 @task 从 task 收集 agent ② 补剩余 @agent(去重) ③ self.agents/tasks ④ 调 crew() 先 task 后 agent、role 去重 → 填好 self.agents/tasks → 才执行你写的 crew() 体 ⑤ 最后把 before/after_kickoff 钩子挂到 crew 的回调列表
图注:@crew 保证"先造任务、顺带收集其 agent、再补齐、去重",你写 crew() 时一切已就位。
L07

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
💡 设计取舍②:为什么 @agent/@task 必须 memoize? 想想 L06:@crew 遍历 tasks 时调 researcher() 拿到一个 Agent;某个 task 的 context 里又调 researcher()若不缓存:每次调用都 new 一个新 Agent,同一个"研究员"会有好几个不同实例、各自独立的记忆和状态——逻辑上就乱了。memoize 保证:同名工厂在一个 Crew 实例里只造一次,处处拿到的是同一个对象。代价是要维护一份缓存、并且工厂必须是"无副作用幂等"的。这是"引用同一性"和"重复实例化"之间的权衡——CrewAI 选了前者,符合直觉(一个角色就是一个人)。

👶 小白:那我在两个不同的 MyCrew() 实例里,researcher 是同一个吗?

👨‍🏫 老师:不是。memoize 的缓存 key 里含 self(第一个参数 args),不同实例的 self 不同,key 就不同。所以"同一实例内共享、不同实例间隔离"。这正是我们想要的——你跑两个 Crew 互不干扰。

L08

边界 + 今日小结

⚠️ 边界①:YAML 文件找不到会怎样? _load_configcrew_base.py:337)里 except FileNotFoundError不崩溃,而是 logging.warning 后返回 {}(空配置)。设计意图:允许"只用代码、不用 YAML"的混合写法。但坑在于——如果你路径拼错,程序不会报错,只会静默地用空配置,最后 Agent 角色全空、行为诡异。调试时先确认 warning 日志里的 YAML 路径对不对。
⚠️ 边界②:为什么必须 yaml.safe_load 而不是 yaml.load? load_yamlcrew_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
明日预告 · Day 56:今天的 @before_kickoff 是 Crew 级钩子。明天读 hooks/:更细粒度的 @before_llm_call/@after_tool_call——在每次 LLM 调用/每次工具执行前后插入你的逻辑(审计、改 prompt、拦截、人工审批),以及它们怎么用一个上下文对象暴露可变的 messages。
← Day 54 telemetry 遥测 Day 56 · hooks 钩子 →