Day 07 / 共 60 天 · 阶段2 Agent 深入

Agent 数据模型:一份"角色人设 + 运行策略"的全字段清单

阶段1 你已经能跑通一个 Crew,也知道 Agent 有 role/goal/backstory。今天进入阶段2,把 Agent 这个类逐字段拆开:它到底存了哪些东西?哪些是"人设"、哪些是"运行刹车"、哪些是"接大模型"、哪些是"高级开关"?源码在 agent/core.py,它的父类 BaseAgentagents/agent_builder/base_agent.py。看懂这份清单,后面 6 天的执行循环、工具、解析才有地基。

📍 你在 60 天里的位置(阶段2 Agent 深入 · 共 6 天)
阶段1 入门 D01-06 D07 全字段 D08 执行循环 D09 输出解析 D10 单步执行 D11 工具缓存 D12 LiteAgent 阶段3 Task
💡 先用一个类比兜住今天 把一个 Agent 想成公司里的一名员工档案。档案里有三类信息:① 身份栏(他是谁、干什么、什么背景 = role/goal/backstory);② 工作纪律栏(最多加班几轮、每分钟能发几次请求、能不能把活转给同事 = max_iter/max_rpm/allow_delegation);③ 装备与技能栏(配哪个大脑、会不会用工具缓存、要不要先做计划 = llm/cache/planning)。Agent 类本身只是"档案"——它不干活;真正干活的是 Day 08 要讲的"执行器"。今天就是逐栏读这份档案。
L01

痛点:一个 Agent 为什么要几十个字段?

🤔 痛点新手写 Agent(role=..., goal=..., backstory=...) 三行就能跑,于是以为 Agent 就三个字段。可翻开源码,Agent 加上父类 BaseAgent几十个字段max_itercacherespect_context_windowguardrailexecutor_class……它们分别管什么?哪些平时用不到?不搞清楚这张"字段地图",后面读执行循环时会一头雾水——因为循环里每一步几乎都在读某个字段。
💡 一句话本质 Agent 是一个 Pydantic 数据模型(配置对象),不是一段执行逻辑。它把"这个 Agent 该怎么表现、怎么受约束、接什么大脑、有什么高级能力"全部声明成带默认值、带校验的字段。运行时,执行器逐个读这些字段来决定行为。理解 Agent = 理解"哪些旋钮可以拧、默认拧到几档"。

先看类的定义头部:Agent 继承自 BaseAgentagent/core.py:170),而 BaseAgent 是一个 Pydantic BaseModelbase_agent.py:200):

# agent/core.py:170
class Agent(BaseAgent):
    """Represents an agent in a system.
    Each agent has a role, a goal, a backstory, and an optional language model (llm).
    The agent can also have memory, can operate in verbose mode, and can delegate tasks to other agents.
    """
    model_config = ConfigDict()

# base_agent.py:200
class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
    ...
大白话字段分两层:通用字段(所有 Agent 都有的,如 role/goal/max_iter)放在父类 BaseAgent标准 Agent 特有的高级字段(如 planning、guardrail、模板)放在子类 Agent。读源码要两个文件一起看。今天按"用途"分组讲,而不是按文件顺序念。
Agent 字段地图:五个功能分组 Agent 配置对象 ① 身份 role / goal backstory / id ② 运行控制 max_iter / max_rpm cache / delegation ③ LLM llm / 模板 use_system_prompt ④ 高级能力 planning / guardrail context_window ⑤ 执行器 executor_class tools_handler ①②③ 多在父类 BaseAgent;④⑤ 多在子类 agent/core.py L02→① · L03→② · L04→③ · L05→④ · L06→⑤
图注:本讲之后的 5 讲正好一栏一讲,把这张地图逐格填满。
L02

① 身份三件套 + id(Agent 的"人设")

最核心的四个字段在父类 BaseAgentbase_agent.py:262):

# base_agent.py:262
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
role: str = Field(description="Role of the agent")
goal: str = Field(description="Objective of the agent")
backstory: str = Field(description="Backstory of the agent")
config: dict[str, Any] | None = Field(
    description="Configuration for the agent", default=None, exclude=True
)
id (frozen=True)每个 Agent 自动生成一个 UUID,且 frozen 不可改。它是 Agent 的唯一身份证——事件系统、委派、缓存都靠它区分"哪个 Agent"。
role / goal / backstory三个必填字符串(没有默认值)。它们会被拼进给 LLM 的系统提示词里,构成 Agent 的"人格"。role=职位、goal=目标、backstory=背景故事。
config (exclude=True)一个可选的字典配置入口(常来自 YAML)。exclude=True 表示序列化成 JSON 时不导出它——因为它只是"原料",会被展开成上面那些字段。
💡 为什么 role/goal/backstory 是字符串而不是结构化字段?因为它们最终要塞进自然语言 prompt 给大模型读。大模型吃的是文字,所以人设直接用文字表达最自然。CrewAI 的一个核心理念就是"用角色扮演驱动协作"——你把 Agent 描述得越像一个真人(资深调研员、严谨的编辑…),LLM 越能进入角色。
📝 真实值 Agent(role="资深天气分析师", goal="用最新数据回答天气问题", backstory="你在气象台工作了 10 年,习惯先查实测数据再下结论")。这三段话会被组装进系统提示,让同一个 GPT-4 表现得像"气象台老兵"而不是通用助手。
L03

② 运行控制字段(Agent 的"工作纪律")

这一组决定 Agent 干活时受哪些约束,全在 BaseAgentbase_agent.py:269):

# base_agent.py:269
cache: bool = Field(
    default=True, description="Whether the agent should use a cache for tool usage.")
verbose: bool = Field(
    default=False, description="Verbose mode for the Agent Execution")
max_rpm: int | None = Field(
    default=None,
    description="Maximum number of requests per minute ... to be respected.")
allow_delegation: bool = Field(
    default=False,
    description="Enable agent to delegate and ask questions among each other.")
tools: list[BaseTool] | None = Field(
    default_factory=list, description="Tools at agents' disposal")
max_iter: int = Field(
    default=25, description="Maximum iterations for an agent to execute a task")
cache=True默认开启工具结果缓存:同一个工具、同样的输入,第二次直接取上次结果,省钱省时间(D11 详解)。
verbose=False默认安静。设 True 会打印 Agent 每一步的思考/工具调用,调试时非常有用。
max_rpm=None每分钟最多几次 LLM 请求。None = 不限。设了值,执行器会 enforce_rpm_limit 卡住等待,防止触发 API 限流。
allow_delegation=False★默认不能把活转给同事。开了之后,框架会自动给它加两个"委派工具"(把任务交给别的 Agent / 向别的 Agent 提问)。
tools=[]Agent 手里的工具列表,默认空。default_factory=list 是为了避免"可变默认值"陷阱(见本讲边界)。
max_iter=25★ReAct 循环最多转 25 圈的硬刹车。到了还没给答案,就强制收尾(D08 会看到 has_reached_max_iterations)。防止 Agent 无限打转烧钱。
⚠️ 边界:为什么 tools 用 default_factory=list 而不是 default=[]? Python 有个著名坑:如果写 default=[],这个空列表会被所有 Agent 实例共享——你给 A 加个工具,B 也莫名多了个工具。default_factory=list 表示"每次新建实例时现造一个新的空列表",各实例互不干扰。Pydantic 里所有可变默认值(list/dict)都必须这么写。这是框架代码的必备常识。
💡 设计取舍①:allow_delegation 为什么默认 False? 朴素想法是"多给 Agent 一点能力总没错,默认开委派吧"。但源码默认关。原因:委派会自动注入额外工具、让 prompt 变长、让 Agent 更容易"偷懒把活推给别人"从而绕圈。能力越大越难控制,所以危险/复杂的能力一律默认关,让用户显式打开——这是"安全默认值(secure by default)"原则。同理 max_iter=25 而不是无限:先给个保守的刹车。
L04

③ LLM 与模板字段(接哪个"大脑"、怎么说话)

这一组在子类 Agentagent/core.py:210):

# agent/core.py:210
use_system_prompt: bool | None = Field(
    default=True, description="Use system prompt for the agent.")
llm: Annotated[
    str | BaseLLM | None,
    BeforeValidator(_validate_llm_ref),
    PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(description="Language model that will run the agent.", default=None)
function_calling_llm: Annotated[
    str | BaseLLM | None, ...
] = Field(
    ...,
    deprecated="function_calling_llm is deprecated and will be removed in a future release.")
# :228 三个模板字段
system_template: str | None = Field(default=None, description="System format for the agent.")
prompt_template: str | None = Field(default=None, description="Prompt format for the agent.")
response_template: str | None = Field(default=None, description="Response format for the agent.")
llm (Annotated + 校验器)★接哪个大模型。可以直接传 "gpt-4o" 字符串,也可传一个 BaseLLM 对象。BeforeValidator(_validate_llm_ref) 会在赋值前把字符串翻译成真正的 LLM 对象;PlainSerializer 负责序列化成 JSON 时怎么导出。
use_system_prompt=True默认用"系统提示词"这个角色来放人设。少数老模型不支持 system role,可关掉改用普通消息。
function_calling_llm (deprecated)曾用来指定"专门负责工具调用的模型"。现在标了 deprecated——保留字段但会警告,未来删除。
system/prompt/response_template三个自定义模板槽。默认 None(用框架内置模板)。高级用户想完全掌控 prompt 长相时才填。
💡 Annotated[...] 里塞校验器是什么魔法?Pydantic 的 Annotated[类型, 校验器, 序列化器] 让一个字段又能被赋值时自动转换、又能被导出时自定义格式。这里的效果就是:你写 llm="gpt-4o"(方便),框架内部帮你变成完整 LLM 对象(能用);存档时又能变回一个干净的 dict(可序列化)。"对用户友好的输入 + 对内部友好的对象 + 对存储友好的格式"三者靠一个 Annotated 打通。
💡 设计取舍②:deprecated 的字段为什么不直接删掉? function_calling_llmmultimodalreasoning 都标了 deprecated 却仍留在类里。直接删当然更干净,但会让老代码一升级就崩。源码选择"保留字段 + 加弃用标记 + 文档说明用什么替代",给用户一个平滑的迁移窗口。库的向后兼容性 > 代码整洁度——这是被广泛使用的框架必须付的税。代价是类里堆着一些"僵尸字段",读源码时要能一眼认出它们(看 deprecated=)。
L05

④ 高级能力字段(可选的"增强开关")

这一组是标准 Agent 的进阶能力(agent/core.py:242):

# agent/core.py:242
respect_context_window: bool = Field(
    default=True,
    description="Keep messages under the context window size by summarizing content.")
max_retry_limit: int = Field(
    default=2, description="Maximum number of retries ... when an error occurs.")
inject_date: bool = Field(
    default=False, description="Whether to automatically inject the current date into tasks.")
# :268 计划相关
planning: bool = Field(
    default=False,
    description="Whether the agent should reflect and create a plan before executing a task.")
# :306 输出护栏
guardrail: Annotated[GuardrailType | None, ...] = Field(
    default=None,
    description="Function or string description of a guardrail to validate agent output")
guardrail_max_retries: int = Field(
    default=3, description="Maximum number of retries when guardrail fails")
respect_context_window=True★默认开:当对话历史快撑爆模型上下文窗口时,自动摘要压缩历史而不是直接报错(D08 的 handle_context_length)。
max_retry_limit=2执行任务出错时最多重试 2 次。区别于 max_iter(那是"想几轮"),这是"崩了重来几次"。
inject_date=False要不要自动把"今天日期"注入任务。做时效性任务(今天的新闻/天气)时打开很有用。
planning=False默认关。打开后 Agent 会先想一个计划再动手(阶段4 D23 讲规划)。
guardrail (护栏)给 Agent 输出加一道验收关:可以是一个函数或一句自然语言描述(如"必须是合法 JSON")。不通过就打回重做,最多重试 guardrail_max_retries(默认 3)次。
大白话:这些开关平时用不用得上? 90% 的入门用法这些全用默认值就行。它们是为难题准备的"增强插件":任务复杂→开 planning;要求输出格式严格→配 guardrail;对话特别长→靠 respect_context_window 兜底。知道有这些旋钮,遇到对应问题时才知道去哪拧。
注意源码里 reasoningmax_reasoning_attemptsmultimodalallow_code_executioncode_execution_mode 都带 deprecatedagent/core.py:237,250,276)。它们是历史遗留,新代码别用——分别被 planning_config、原生文件传参等替代。
L06

⑤ 执行器与私有属性(档案怎么变成"会干活的人")

Agent 是配置,那"干活的"是谁?看这两个字段(agent/core.py:333):

# agent/core.py:333
agent_executor: CrewAgentExecutor | AgentExecutor | None = Field(
    default=None, description="An instance of the CrewAgentExecutor class.")
executor_class: Annotated[
    type[CrewAgentExecutor] | type[AgentExecutor],
    BeforeValidator(_validate_executor_class),
    PlainSerializer(_serialize_executor_class, return_type=str, when_used="json"),
] = Field(
    default=AgentExecutor,
    description="Class to use for the agent executor. Defaults to AgentExecutor ...")

而工具缓存/结果的容器在父类(base_agent.py:317):

# base_agent.py:317
cache_handler: CacheHandler | None = Field(default=None, ...)
tools_handler: ToolsHandler = Field(default_factory=ToolsHandler, ...)
tools_results: list[dict[str, Any]] = Field(default_factory=list, ...)
# core.py:199 私有属性(下划线开头,不对外、不序列化)
_times_executed: int = PrivateAttr(default=0)
_last_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
executor_class★"用哪个类来执行"。默认 AgentExecutor,可换成 CrewAgentExecutor(D08 主角)。这就是把"配置"和"执行逻辑"解耦的关键——Agent 只声明"我要用哪种执行器",不自己写循环。
agent_executor真正的执行器实例。初始为 None,用到时才按 executor_class 造出来(懒加载)。
tools_handler工具调用的记录/缓存中枢(D11 主角),每个 Agent 默认自带一个。
_times_executed (PrivateAttr)下划线开头 = 私有状态,不对外暴露、不进 JSON。记录这个 Agent 执行了几次,用于内部计数/重试判断。
💡 设计取舍③:为什么把"配置(Agent)"和"执行(Executor)"拆成两个类? 最朴素的做法是把 ReAct 循环直接写进 Agent 的方法里。但源码刻意分离Agent 只当"数据模型 + 声明用哪个执行器",真正的循环放在 CrewAgentExecutor。好处:① Agent 可以被安全地序列化/存档/传输(纯数据,没有运行时状态耦合);② 想换一套执行策略(如 LiteAgent、自定义执行器)只需换 executor_class,不动 Agent;③ 单元测试时能脱离执行器单独构造 Agent。"数据与行为分离"让配置可复用、执行可替换。代价是多一层间接,读代码要在两个类之间跳。
控制流:字段如何在运行时被读取组装 Agent 配置对象 role/llm/tools/ max_iter/executor_class 按 executor_class 造出执行器实例 agent_executor ReAct 循环 每步读取字段: max_iter/max_rpm... Agent 只"声明配置";执行器"读配置并循环"——这就是 Day 08 的主题
图注:字段是"原料",执行器是"工人"。明天 Day 08 就钻进这个执行器内部。
L07

边界抉择 + 今日小结

⚠️ 边界:cache 默认 True 可能咬你一口 cache=True 意味着"同工具 + 同输入 → 直接返回上次结果"。对纯函数(如加法、格式转换)这很棒。但对有时效性的工具(查实时股价、查当前时间、查最新新闻)就是坑——你第二次查"今天股价",可能拿到 5 分钟前缓存的旧值,还以为是实时的。解决办法:给这类工具设 cache_function 决定"什么情况才缓存"(D11 详解),或在 Agent 上把 cache=False缓存的第一原则永远是"确认这份数据可以被缓存"。

👶 小白:我只写了 role/goal/backstory,其它几十个字段是不是就没值?会不会报错?

👨‍🏫 老师:不会报错,它们全都有默认值max_iter=25cache=Trueallow_delegation=False…)。只有 role/goal/backstory 是必填(没默认值)。这就是"三行就能跑"的原因——其余字段是"合理默认 + 需要时再拧"。读源码时看 Field(default=...) 就知道不填时是什么行为。

👶 小白:注意到文件叫 agent/core.py,可大纲写的是 agent/agent.py

👨‍🏫 老师:好眼力。这个版本的源码里 Agent 类实际在 agent/core.py:170agent/ 是个包,__init__.py 会把它 re-export 成 crewai.Agent)。读源码时以 grep 到的真实文件为准,别信文档里的旧路径——这也是"源码走读"的基本功。

🧠 今天你应该能回答

  • Agent 本质是什么?(Pydantic 配置对象,不含执行逻辑)
  • 字段分哪五组?各组代表作是什么?
  • 哪三个字段是必填的?(role/goal/backstory)
  • max_itermax_retry_limit 有何区别?(想几轮 vs 崩了重来几次)
  • allow_delegationcache 的默认值?为什么这么定?
  • executor_class 存在的意义?(配置与执行解耦、可替换)
  • 怎么一眼看出一个字段是弃用的?(deprecated=

✋ 10 分钟动手

P=lib/crewai/src/crewai
# 1. 读子类 Agent 的字段(高级能力 + 执行器)
sed -n '197,343p' $P/agent/core.py
# 2. 读父类 BaseAgent 的通用字段(身份 + 运行控制)
sed -n '262,327p' $P/agents/agent_builder/base_agent.py
# 3. 亲手打印所有字段的默认值
python -c "
from crewai import Agent
a = Agent(role='测试员', goal='验证字段', backstory='我在读源码')
for name in ['max_iter','cache','allow_delegation','verbose','max_rpm','respect_context_window']:
    print(name, '=', getattr(a, name))
"
明日预告 · Day 08:今天我们知道 Agent 只是"档案",真正干活的是 executor_class 指向的执行器。明天就钻进 agents/crew_agent_executor.pyCrewAgentExecutor,逐行读它的 ReAct 执行循环:怎么问 LLM、怎么分流"原生函数调用 vs 文本解析"、怎么把工具结果喂回去、怎么用今天讲的 max_iter 刹车。
← Day 06 kickoff 旅程 Day 08 · 执行循环 →