Day 02 / 共 60 天 · 阶段1 入门与心智

装环境 + 亲手跑通第一个 crew

昨天画了地图,今天把车开起来。我们要:用 uv 装好 CrewAI、配一个模型 key、把 D01 那段 30 行的最小团队真的 kickoff 跑出结果;然后借这次运行,第一次钻进源码看看——你调 crew.kickoff(inputs=...) 后,程序在真正干活之前偷偷做了哪些准备(回调、事件、{占位符} 插值),最后返回的 CrewOutput 里到底装了什么。

📍 你在 60 天里的位置(阶段1:入门与心智 · 共 6 天)
D01 项目全景 D02 装环境跑通 D03 Agent D04 Task D05 Crew D06 kickoff 旅程 阶段2 Agent 深入
💡 先用一个类比兜住今天 kickoff() 就像按下咖啡机的"开始"键。你只按了一下,但机器在出咖啡之前先做了一串准备动作:预热、检查水位、磨豆——这些就是 prepare_kickoff。而你在配方里写的 {sector} 是个"待填占位",机器开始前会把它替换成你这次真正要的"tech"——这就是 inputs 插值。今天既动手按键,也拆开看它按键前那几秒干了啥。
L01

痛点:装 AI 库最烦的是依赖冲突

🤔 痛点AI 生态的库依赖又多又重(模型 SDK、向量库、tokenizer……),用传统 pip 装经常卡在版本冲突、编译失败、装了半小时还报错。CrewAI 官方直接把这条路铺好了:它指定用 uv 来装
💡 本质:uv 是"更快更稳的 pip + venv"README 安装章节明确写了这一点(README.md:194):
# README.md:194
Ensure you have Python >=3.10 <3.14 installed on your system.
CrewAI uses UV for dependency management and package handling,
offering a seamless setup and execution experience.
Python >=3.10 <3.14和昨天根 pyproject 里 requires-python 完全一致——先确认你的 Python 在这个区间。
UV for dependency management★uv 是 Rust 写的 Python 包管理器,比 pip 快一个数量级,还能自动建虚拟环境、锁定版本。CrewAI 官方脚手架、CI 全用它。
大白话你可以理解成:uv = pip + venv + 版本锁,合三为一还飞快。跟着官方用 uv,能躲掉一大半"装不上"的坑。
L02

三行命令:从零到能 import crewai

README 给的安装命令很短(README.md:199 / README.md:205):

# 0) 如果还没有 uv:装它(官方一键脚本)
curl -LsSf https://astral.sh/uv/install.sh | sh

# 1) 建一个项目并进入
mkdir my-first-crew && cd my-first-crew
uv venv                       # 建虚拟环境
source .venv/bin/activate     # 激活(Windows 用 .venv\Scripts\activate)

# 2) 装 CrewAI(README:199)
uv pip install crewai

# 2') 想要一堆现成工具,就装 [tools] 附加项(README:205)
uv pip install 'crewai[tools]'
uv pip install crewai装核心框架,对应昨天说的 lib/crewai 那个包。只学源码,这一条就够。
'crewai[tools]'方括号里的 tools 是"可选附加组(extras)",会顺带装上 crewai-tools(搜索/爬虫等现成工具)。依赖更重,阶段5 再装不迟。
[embeddings]README:218 还提到:如果报 No module named 'tiktoken',装 crewai[embeddings]。这就是昨天说的"按需安装"——附加组把重依赖分开了。
💡 设计取舍①:为什么把 tools / embeddings 做成"可选 extras"而不是默认装? 如果 pip install crewai 默认就把搜索工具、向量库、tokenizer 全拉下来,那么只想跑个最小 demo 的人也要等几分钟、下几百 MB。CrewAI 用 extras(crewai[tools])把重依赖拆成按需选装——这与 D01 看到的"monorepo 分包"和"Memory 惰性导入"是同一条设计哲学:默认保持最轻,重的东西谁用谁装。代价是新手偶尔会遇到"少了个模块"的报错,但 README 的 Troubleshooting 直接给出了对应的 extras。
L03

配一个 LLM key(agent 得有"大脑")

Agent 靠一个大模型思考。CrewAI 默认通过 litellm 兼容各家模型,最省事的是设一个环境变量。用 OpenAI 举例:

# 方式 A:直接设环境变量
export OPENAI_API_KEY="sk-..."

# 方式 B:写进项目根的 .env 文件(CrewAI 会自动读取)
echo 'OPENAI_API_KEY=sk-...' >> .env

# 想用别家?litellm 风格的 model 名即可,比如:
#   llm="gpt-4o"            → 读 OPENAI_API_KEY
#   llm="anthropic/claude-3-5-sonnet-latest" → 读 ANTHROPIC_API_KEY
#   llm="ollama/llama3"     → 本地,无需 key
这里先建立一个印象即可:Agent 的 llm 字段可以只写一个字符串模型名,CrewAI 内部会把字符串解析成一个 LLM 对象(阶段8 会看 llm.py 的解析逻辑)。今天用默认 gpt-4o 或你手头任意一个能用的模型都行。

👶 小白:我没有任何付费 key,能跑通今天的例子吗?

👨‍🏫 老师:能。装个 Ollama 拉一个本地小模型(如 ollama pull llama3),然后给 Agent 传 llm="ollama/llama3",完全本地、零成本。结果质量会差些,但"跑通流程"的目的达到了。

L04

30 行的最小 crew:写下来、跑起来

把 D01 结尾那段(改编自 README.md:511)存成 main.py,补上打印:

# main.py
from crewai import Agent, Task, Crew, Process

analyst = Agent(
    role="Senior Market Analyst",
    goal="Conduct deep market analysis with expert insight",
    backstory="You're a veteran analyst known for identifying subtle market patterns",
    llm="gpt-4o",          # 或 "ollama/llama3"
    verbose=True)

analysis_task = Task(
    description="Analyze {sector} sector data for the past {timeframe}",
    expected_output="Detailed market analysis with a confidence score",
    agent=analyst)

crew = Crew(
    agents=[analyst],
    tasks=[analysis_task],
    process=Process.sequential,
    verbose=True)

result = crew.kickoff(inputs={"sector": "tech", "timeframe": "1W"})
print("===== 最终结果 =====")
print(result)                       # 触发 CrewOutput.__str__
print("raw 长度:", len(result.raw))
# 跑它
uv run python main.py
大白话你会看到 verbose=True 刷出一堆彩色日志:Agent 在"思考"、给出 Final Answer,最后 print(result) 打出那份分析。恭喜——你的第一个多智能体团队跑起来了。剩下几讲,我们拆开这一声 kickoff()
⚠️ 坑:{sector} 这种大括号是 CrewAI 的占位符,不是 Python f-string 注意 description="Analyze {sector} sector..." 没有 f 前缀。它是普通字符串,里面的 {sector} 由 CrewAI 在 kickoff 时用 inputs 替换(L06 看源码)。新手常犯两个错:① 误写成 f-string 导致 NameError: sector is not defined(因为定义 Task 时还没有值);② kickoff 时忘了传对应的 key,导致占位符没被替换、模型看到字面量 {sector}
L05

kickoff 真正干活前,先做一串准备

crew.kickoff() 并不是立刻开始跑任务。它先调 prepare_kickoff(...),再按 process 分流(crew.py:1021):

# crew.py:1021
inputs = prepare_kickoff(self, inputs, input_files)   # ① 先准备

if self.process == Process.sequential:
    result = self._run_sequential_process()           # ② 流水线:我们这次走这里
elif self.process == Process.hierarchical:
    result = self._run_hierarchical_process()          # 有组长派活(D05/阶段4 讲)
else:
    raise NotImplementedError(...)

for after_callback in self.after_kickoff_callbacks:    # ③ 跑完后的回调
    result = after_callback(result)
result = self._post_kickoff(result)
self.usage_metrics = self.calculate_usage_metrics()    # ④ 统计 token 用量
return result

准备阶段 prepare_kickoff 干的事在 crews/utils.py:249。挑三处关键动作:

# crews/utils.py:289 —— 先跑 before_kickoff 回调(可改写 inputs)
for before_callback in crew.before_kickoff_callbacks:
    if normalized is None:
        normalized = {}
    normalized = before_callback(normalized)

# crews/utils.py:309 —— 发出"kickoff 开始"事件,记录事件 id
started_event = CrewKickoffStartedEvent(crew_name=crew.name, inputs=normalized)
crew._kickoff_event_id = started_event.event_id
future = crewai_event_bus.emit(crew, started_event)

# crews/utils.py:331 —— ★把 inputs 插进任务/agent 的文本里
crew._inputs = normalized
crew._interpolate_inputs(normalized)
before_kickoff_callbacks你注册的"开跑前钩子",可以在这里最后修改一遍 inputs(比如补默认值)。
emit(CrewKickoffStartedEvent)★发一条"我要开跑了"的事件到事件总线。日志、遥测、监听器都靠它。CrewAI 全程事件驱动,阶段4 专讲。
_interpolate_inputs★核心:把 {sector} 这类占位符用真实值替换。下一讲 L06 打开它。
_task_output_handler.reset()(:318)清空上次运行留下的任务输出缓存,保证这次是干净的开始。
控制流:kickoff() 的准备与分流 kickoff(inputs) prepare_kickoff 按 process 分流 CrewOutput prepare_kickoff 内部四步 ① before 回调改 inputs ② emit CrewKickoffStartedEvent ③ _interpolate_inputs 插值 ④ 重置任务输出缓存
图注:真正跑任务前,prepare_kickoff 先完成"回调→发事件→插值→重置"四步准备。
💡 设计取舍②:为什么把"准备"从 kickoff 主流程里抽成独立的 prepare_kickoff? kickoff 本身还要处理流式(stream)、检查点恢复(from_checkpoint)、异常兜底、token 统计等一堆事。如果把"回调+事件+插值+重置"也堆在同一个函数里,kickoff 会变成几百行的巨兽。抽出 prepare_kickoff 的好处:① kickoff 主干只剩"准备→按 process 分流→收尾"三步,一眼看懂;② 准备逻辑能被 kickoff / kickoff_async / kickoff_for_each 复用(阶段4 会看到这些变体)。代价是读代码要多跳一个文件——但换来了主流程的可读性和复用。
L06

inputs 插值:{sector} 是怎么变成 "tech" 的

L04 那个坑的答案就在这里。_interpolate_inputs 遍历所有 task 和 agent,把你传的 inputs 灌进它们的文本字段(crew.py:2098):

# crew.py:2098
def _interpolate_inputs(self, inputs: dict[str, Any]) -> None:
    """Interpolates the inputs in the tasks and agents."""
    [
        task.interpolate_inputs_and_add_conversation_history(inputs)  # 每个任务
        for task in self.tasks
    ]
    for agent in self.agents:
        agent.interpolate_inputs(inputs)                              # 每个 agent
对每个 task 插值把 task 的 descriptionexpected_output 里的 {sector}/{timeframe} 替换成 "tech"/"1W"。所以模型最终看到的是"Analyze tech sector data for the past 1W"。
对每个 agent 插值agent 的 role/goal/backstory 里也能写占位符,同样会被替换。让你能用同一套 agent 模板跑不同输入。
列表推导 [ ... for task in ]小细节:这里用列表推导只是为了"对每个 task 都执行一次",返回值没用到(注释也说 interpolate 返回 None)。等价于普通 for 循环。
add_conversation_history方法名后半段暗示:插值的同时还会拼接对话历史(多轮/对话式场景用),今天用不到,记个名。
📝 真实值走一遍 你传 inputs={"sector":"tech","timeframe":"1W"};task.description 原本是 "Analyze {sector} sector data for the past {timeframe}"。插值后变成 "Analyze tech sector data for the past 1W"——这句才是真正送进模型的 prompt 的一部分。没传 sector 这个 key 会怎样?占位符 {sector} 原样保留,模型就会看到奇怪的字面量 {sector},输出跑偏。这正是 L04 坑②的机理。
L07

读懂返回值 CrewOutput + 今日小结

kickoff 最终返回一个 CrewOutput。它不是一个普通字符串,而是一个装了多种视图的对象(crews/crew_output.py:13):

# crews/crew_output.py:13
class CrewOutput(BaseModel):
    raw: str = Field(description="Raw output of crew", default="")           # 纯文本结果
    pydantic: BaseModel | None = ...        # 若任务设了 output_pydantic,这里是结构化对象
    json_dict: dict[str, Any] | None = ...  # 若设了 output_json,这里是 dict
    tasks_output: list[TaskOutput] = ...     # ★每个任务各自的输出(多任务时按顺序)
    token_usage: UsageMetrics = ...          # 本次跑掉多少 token

# crews/crew_output.py:55 —— 所以 print(result) 有优先级
def __str__(self) -> str:
    if self.pydantic:  return str(self.pydantic)   # 有结构化就打结构化
    if self.json_dict: return str(self.json_dict)
    return self.raw                                 # 否则打纯文本
数据结构:CrewOutput 装了什么 CrewOutput raw: str 纯文本 pydantic 结构化对象 json_dict dict tasks_output[] 每个任务的输出 token_usage(用量统计)
图注:一份结果,多种视图。print 走 __str__ 的优先级:pydantic > json_dict > raw。
📝 常用取值 result.raw(纯文本)、result.tasks_output[0].raw(第一个任务的输出)、result.token_usage(花了多少 token)、result["confidence"](若有结构化输出,__getitem__ 会去 pydantic/json_dict 里取键)。D04 讲 Task 时会看到怎么让输出变成结构化的 pydantic/json。

🧠 今天你应该能回答

  • 为什么官方用 uv 装?crewai vs crewai[tools] 区别?(快/稳;后者带现成工具,依赖更重)
  • Agent 的大脑怎么配?(llm="gpt-4o" 字符串 + 环境变量 key,或本地 ollama)
  • {sector} 是 f-string 吗?(不是!是 CrewAI 占位符,kickoff 时按 inputs 替换)
  • kickoff 在跑任务前先做了什么?(prepare_kickoff:before 回调 → 发事件 → 插值 → 重置)
  • 为什么把准备逻辑抽成 prepare_kickoff?(主流程可读 + 被多种 kickoff 变体复用)
  • CrewOutput 有哪些视图?print 打哪个?(raw/pydantic/json_dict/tasks_output/token_usage;优先 pydantic)

✋ 10 分钟动手

# 1. 装好并跑通最小 crew
uv venv && source .venv/bin/activate
uv pip install crewai
export OPENAI_API_KEY=sk-...   # 或用 ollama
uv run python main.py

# 2. 亲手验证"占位符必须匹配 inputs"这个坑
#    删掉 kickoff 里的 "sector" 这个 key,再跑,观察模型是否看到字面量 {sector}

# 3. 读 kickoff 主干与准备逻辑
sed -n '1021,1038p' lib/crewai/src/crewai/crew.py
sed -n '289,332p' lib/crewai/src/crewai/crews/utils.py
明日预告 · Day 03:例子里 Agent(role=..., goal=..., backstory=..., llm=...) 这几行,背后是一个有几十个字段的大模型类。明天打开 agent/core.py 和它的父类 BaseAgent,搞清楚"一个 Agent 到底由什么构成",以及 role/goal/backstory 为什么是三件套。
← Day 01 项目全景 Day 03 · Agent 是什么 →