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

CrewAgentExecutor:Agent 真正干活的那个 while 循环

Day 07 说 Agent 只是"档案",真正干活的是 executor_class 指向的执行器。今天就把 agents/crew_agent_executor.py 里的 CrewAgentExecutor 拆开,逐行读它的执行循环:怎么开局、怎么在"原生函数调用 / 文本 ReAct"之间分流、怎么问一次 LLM、怎么把工具结果喂回去、怎么用 max_iter 刹车、出错怎么自愈。这是整个 Agent 系统的心脏。

📍 你在 60 天里的位置(阶段2 Agent 深入 · 共 6 天)
阶段1 入门 D01-06 D07 全字段 D08 执行循环 D09 输出解析 D10 单步执行 D11 工具缓存 D12 LiteAgent 阶段3 Task
💡 先用一个类比兜住今天 执行器就像一个项目经理跟一个"不能上网的专家"来回对话。经理(执行器)把任务念给专家(LLM)听,专家要么说"我要查个资料"(要用工具),要么说"我有答案了"(Final Answer)。只要专家还没给答案,经理就不停地:问一句 → 听回答 → 该查资料就替他去查 → 把查到的念回给他 → 再问。这个"问到有答案为止"的来回,本质就是一个 while not 是最终答案 的循环。今天就是读这个循环的每一步。
L01

痛点:Agent 怎么"自己转起来"?

🤔 痛点大模型的本事就一句话:给它一段文字,它续一段文字。可我们要 Agent 能查资料、调 API、算数、然后根据结果继续想——一次对话根本做不完。那它到底是怎么"自己一步步推进、直到把活干完"的?谁来判断"该停了"?工具结果又是怎么回到对话里让它接着想的?答案全在执行器的那个循环里。
💡 一句话本质 执行器 = 一个 while not isinstance(answer, AgentFinish) 的循环。每一圈:问一次 LLM → 把回答解析成"要调工具(AgentAction)"或"给答案(AgentFinish)" → 是工具就执行、把结果 append 进消息历史 → 进入下一圈。直到 LLM 输出 Final Answer,或者转够 max_iter 圈被强制收尾。LLM 只负责"决策一步",循环负责"执行 + 把观察喂回去 + 判断该不该停"。

类的骨架(crew_agent_executor.py:98),它继承 BaseAgentExecutor

# crew_agent_executor.py:98
class CrewAgentExecutor(BaseAgentExecutor):
    ...
    def invoke(self, inputs): ...              # :208 对外入口
    def _invoke_loop(self): ...                # :309 分流:原生 or 文本
    def _invoke_loop_react(self): ...          # :330 文本 ReAct 循环
    def _invoke_loop_native_tools(self): ...   # :484 原生函数调用循环
    def _handle_agent_action(self, ...): ...   # :1456 执行工具后把结果写回
大白话看方法名就懂大结构:invoke 是大门,进去后 _invoke_loop 做一次"走哪条路"的选择,然后要么走 _react(文本解析)要么走 _native(原生调用)。两条路各是一个 while 循环,殊途同归——都要转到"拿到最终答案"。今天按这个顺序逐个读。
L02

invoke:开局做的四件事

对外入口 invokecrew_agent_executor.py:208):

# crew_agent_executor.py:208
def invoke(self, inputs: dict[str, Any]) -> dict[str, Any]:
    if self._resuming:
        self._resuming = False
    else:
        self.messages = []          # ① 清空消息历史
        self.iterations = 0         # ② 迭代计数归零
        self._setup_messages(inputs)        # ③ 组装系统提示 + 任务提示
        self._inject_multimodal_files(inputs)

    self._show_start_logs()
    self.ask_for_human_input = bool(inputs.get("ask_for_human_input", False))

    with _llm_stop_words_applied(self.llm, self):
        try:
            formatted_answer = self._invoke_loop()   # ④ 进入循环,直到拿到答案
        except AssertionError:
            ...
            raise
    if self.ask_for_human_input:
        formatted_answer = self._handle_human_feedback(formatted_answer)
    self._save_to_memory(formatted_answer)
    return {"output": formatted_answer.output}
self._resuming 判断支持"恢复执行"(人在环打断后继续)。恢复时不清空历史,接着上次跑;否则从头开始。
messages=[] / iterations=0每次全新执行都重置消息历史和圈数计数——保证两次执行互不污染。
_setup_messages把 Day 07 的 role/goal/backstory + 任务描述 + 工具说明组装成初始消息塞进 self.messages。这是 LLM 的第一份输入。
_llm_stop_words_applied上下文管理器:临时给 LLM 设置停止词(如遇到 Observation: 就停)。出了 with 自动还原。
_invoke_loop()★真正的循环在这。返回一个 AgentFinish,取它的 .output 作为结果。
_save_to_memory收尾:把这次执行的结果存进记忆(阶段6 讲)。然后返回 {"output": ...}
💡 为什么消息历史是执行器的实例状态(self.messages)?因为 ReAct 循环一圈一圈都要在同一份对话上追加:这一圈的工具结果,下一圈 LLM 得能看到。把 messages 存成实例属性,循环里各处 self.messages.append(...) 就都在往同一个"对话记录本"里写。状态集中在一处、循环各步共享——这是有状态执行器的标准做法。
L03

_invoke_loop:走原生还是走文本?

进循环第一件事是分流(crew_agent_executor.py:309):

# crew_agent_executor.py:309
def _invoke_loop(self) -> AgentFinish:
    use_native_tools = (
        hasattr(self.llm, "supports_function_calling")
        and callable(getattr(self.llm, "supports_function_calling", None))
        and self.llm.supports_function_calling()   # 模型支持"原生函数调用"吗?
        and self.original_tools                     # 且确实配了工具?
    )
    if use_native_tools:
        return self._invoke_loop_native_tools()    # 现代模型:结构化 tool_calls
    return self._invoke_loop_react()               # 老模型 / 无原生支持:文本解析
hasattr + callable 双检查防御式编程:先确认 LLM 这个方法、能调用,再真的调。不假设 LLM 一定实现了它——不同 provider 的 LLM 能力不齐。
supports_function_calling()问模型:"你会结构化的工具调用吗?"GPT-4/Claude 会,很多老模型/本地小模型不会。
and self.original_tools就算模型会,也得确实有工具才走原生。没工具就没必要,走普通文本。
两个 return四个条件全满足 → 原生模式(更可靠);否则 → 文本 ReAct(兜底、向后兼容)。
"原生函数调用" vs "文本 ReAct" 的区别 文本 ReAct:把工具说明写进 prompt,让模型按 Thought:/Action:/Action Input:文本格式输出,框架用正则解析出"要调哪个工具"。缺点:模型可能格式写错,解析会失败。原生函数调用:模型直接返回结构化的 tool_call(训练时就学过这个格式),框架直接读,不用解析文本。原生更可靠,是现代首选;文本 ReAct 是老模型的兜底。两条路后面各是一个 while。
L04

文本 ReAct 主循环:那个 while 长什么样

核心循环 _invoke_loop_reactcrew_agent_executor.py:330),骨架是"没拿到 AgentFinish 就一直转":

# crew_agent_executor.py:330
def _invoke_loop_react(self) -> AgentFinish:
    formatted_answer = None
    while not isinstance(formatted_answer, AgentFinish):   # ★核心条件
        try:
            if has_reached_max_iterations(self.iterations, self.max_iter):
                formatted_answer = handle_max_iterations_exceeded(...)   # 到上限强制收尾
                break
            enforce_rpm_limit(self.request_within_rpm_limit)   # 限流
            answer = get_llm_response(llm=..., messages=self.messages, ...)  # 问一次 LLM
            answer_str = str(answer) if not isinstance(answer, str) else answer
            formatted_answer = process_llm_response(answer_str, self.use_stop_words)  # 解析
            if isinstance(formatted_answer, AgentAction):     # 解析出"要调工具"
                tool_result = execute_tool_and_check_finality(...)   # 执行工具
                formatted_answer = self._handle_agent_action(formatted_answer, tool_result)
            self._invoke_step_callback(formatted_answer)
            self._append_message(formatted_answer.text)       # 结果进对话历史
        except OutputParserError as e:
            formatted_answer = handle_output_parser_exception(...)   # 解析失败→自愈
        except Exception as e:
            ...
        finally:
            self.iterations += 1        # ★每圈计数 +1(放 finally 保证一定执行)
    ...
    return formatted_answer
while not ...AgentFinish★整个循环的灵魂:只要还没拿到"最终答案"这个类型,就继续转。拿到了自然退出。
has_reached_max_iterations先查刹车:转够 max_iter(Day 07,默认 25)圈还没答案,就 handle_max_iterations_exceeded 逼它基于现有信息给个答案,break 跳出。
get_llm_response问一次大模型,拿到它这一圈说的话。
process_llm_response★把这段话解析AgentAction(要调工具)或 AgentFinish(给答案)——这就是 Day 09 的主题。
if AgentAction: 执行工具解析出要调工具,就真去执行,再 _handle_agent_action 把结果写回消息。
finally: iterations += 1★放在 finally 里,意味着无论这圈是正常还是抛异常,圈数都会 +1。保证刹车计数不被异常绕过。
控制流:一圈 ReAct 循环的分支 ① 到 max_iter 了吗? 是→强制收尾 break ② 问一次 LLM ③ 解析回答 AgentFinish → 退出循环 ✅ AgentAction → 执行工具 ④ 工具结果 append 进 messages → 回到②再问(下一圈 iterations+1)
图注:解析结果分两支——AgentFinish 出门,AgentAction 执行工具后把观察喂回、回到"问 LLM"。这就是 while 的一圈。
L05

循环里的三件事:执行工具 → 写回 → 喂给下一圈

当这圈解析出 AgentAction,循环做的关键动作(crew_agent_executor.py:402):

# crew_agent_executor.py:402
if isinstance(formatted_answer, AgentAction):
    fingerprint_context = {}
    if self.agent and hasattr(self.agent, "security_config") ...:
        fingerprint_context = {"agent_fingerprint": str(self.agent.security_config.fingerprint)}

    tool_result = execute_tool_and_check_finality(   # ★真正执行工具
        agent_action=formatted_answer,
        tools=self.tools,
        tools_handler=self.tools_handler,   # 缓存中枢(D11)
        task=self.task, agent=self.agent, crew=self.crew, ...)
    formatted_answer = self._handle_agent_action(formatted_answer, tool_result)  # 把结果写回

_handle_agent_actioncrew_agent_executor.py:1456)里对"贴图片工具"有特殊处理,其余交给通用核心函数:

# crew_agent_executor.py:1456
def _handle_agent_action(self, formatted_answer, tool_result):
    add_image_tool = I18N_DEFAULT.tools("add_image")
    if isinstance(add_image_tool, dict) and formatted_answer.tool.casefold().strip() == ...:
        self.messages.append({"role": "assistant", "content": tool_result.result})
        return formatted_answer     # 图片:直接作为一条消息塞进去
    return handle_agent_action_core(       # 普通工具:走通用逻辑
        formatted_answer=formatted_answer, tool_result=tool_result,
        messages=self.messages, step_callback=self.step_callback, show_logs=self._show_logs)
execute_tool_and_check_finality★干三件事:执行工具、查缓存(经 tools_handler)、并检查这个工具是不是 result_as_answer(结果就当最终答案)。
fingerprint_context把 Agent 的"指纹"传进去(安全/审计用),让工具调用能追溯到是哪个 Agent 发起的。
add_image 特判贴图片是特殊工具:结果要作为多模态消息而非普通文本观察,所以单独 append。
handle_agent_action_core通用路径:把工具结果格式化成 Observation: 追加进 messages,让下一圈 LLM 看得到。
📝 例子:一轮工具调用里 messages 怎么变 问:"SF 今天几度?"(带一个 search 工具)。
第 1 圈 LLM 输出 Thought: 我要查天气 / Action: search / Action Input: SF weather → 解析成 AgentAction;框架执行 search 得 SF 18°C,作为 Observation: SF 18°C append 进 self.messages
第 2 圈 LLM 看到这条观察 → 输出 Final Answer: SF 今天 18 度 → 解析成 AgentFinishwhile 条件不成立,退出。

👶 小白:为啥不让 LLM 一口气把答案说完,非要一圈圈来回?

👨‍🏫 老师:因为 LLM 脑子里没有"SF 今天 18°C"这条实时信息——你逼它一次答完,它只会瞎编一个温度。必须让它先开口要工具、框架真去查一次、把真实结果喂回去,它才有据可答。一圈圈,是为了在"想"和"真实世界数据"之间反复对齐。

L06

原生函数调用循环:单个执行 vs 并行提速

另一条路 _invoke_loop_native_toolscrew_agent_executor.py:484),先把工具转成 OpenAI schema,再进 while True

# crew_agent_executor.py:484
def _invoke_loop_native_tools(self) -> AgentFinish:
    if not self.original_tools:
        return self._invoke_loop_native_no_tools()   # 没工具→只问一次就结束
    openai_tools, available_functions, self._tool_name_mapping = (
        convert_tools_to_openai_schema(self.original_tools))
    while True:
        ...
        answer = get_llm_response(llm=..., tools=openai_tools, ...)  # LLM 直接返回结构化
        if isinstance(answer, list) and answer and self._is_tool_call_list(answer):
            tool_finish = self._handle_native_tool_calls(answer, available_functions)
            if tool_finish is not None:
                return tool_finish
            continue                       # 没结束→喂回结果,继续 while
        if isinstance(answer, str):        # 返回纯文本→就是最终答案
            formatted_answer = AgentFinish(thought="", output=answer, text=answer)
            ...
            return formatted_answer

关键在 _handle_native_tool_callscrew_agent_executor.py:667)的策略——默认只执行第一个

# crew_agent_executor.py:667(docstring + 精简)
"""Executes only the FIRST tool call and appends the result to message history.
This enables sequential tool execution with reflection after each tool ..."""
if len(parsed_calls) > 1:
    has_result_as_answer_in_batch = any(... "result_as_answer" ...)
    has_max_usage_count_in_batch  = any(... "max_usage_count" ...)
    if has_result_as_answer_in_batch or has_max_usage_count_in_batch:
        logger.debug("Skipping parallel native execution ...")   # 有特殊工具→不并行
    else:
        # :746 用线程池并行执行多个独立工具
        max_workers = min(8, len(execution_plan))
        with ThreadPoolExecutor(max_workers=max_workers) as pool:
            futures = {pool.submit(contextvars.copy_context().run,
                       self._execute_single_native_tool_call, ...): idx for ...}
            for future in as_completed(futures):
                ordered_results[futures[future]] = future.result()
        ...
        return None
# :786 常规情况:只取第一个执行
call_id, func_name, func_args = parsed_calls[0]
...
💡 设计取舍①:为什么默认只执行第一个工具? Agent 干活常有依赖——"先查 A,根据 A 的结果再决定查什么"。如果模型一次说要调 3 个工具就全执行,第 2、3 个的参数可能本该依赖第 1 个的结果,一起并行就错了。所以源码默认只执行第一个 → 把结果喂回 → 让模型重新推理再决定下一步(顺序 + 反思,更稳)。只有当一批多个工具明显互不依赖(都不带 result_as_answer/max_usage_count)时,才用线程池并行提速(最多 8 个)。正确性优先、性能其次——很稳健的权衡。
并行时用 contextvars.copy_context().run 提交任务(:749):把当前线程的上下文变量复制给子线程,保证并行工具也能读到正确的 Agent/Task 上下文,结果按 idx 归位以保持顺序。这是并发编程里"上下文隔离"的标准手法。
L07

异常自愈、上下文压缩与优雅降级

循环对各种"模型会犯的错"有细致处理(crew_agent_executor.py:434):

# crew_agent_executor.py:434
except OutputParserError as e:
    formatted_answer = handle_output_parser_exception(   # ① 解析失败→把纠错提示喂回,重来
        e=e, messages=self.messages, iterations=self.iterations, ...)
except Exception as e:
    if e.__class__.__module__.startswith("litellm"):
        raise e                          # ② litellm 的错(如鉴权/网络)→原样抛出,不吞
    if is_context_length_exceeded(e):
        handle_context_length(           # ③ 上下文超长→自动摘要压缩历史
            respect_context_window=self.respect_context_window, messages=self.messages, ...)
        continue                         # 压缩后继续 while
    handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
    raise e
finally:
    self.iterations += 1

原生模式里还有一处降级(crew_agent_executor.py:577):

# crew_agent_executor.py:577
if is_native_tool_calling_unsupported_error(e):
    self._append_text_tool_calling_fallback_message()
    return self._invoke_loop_react()     # ④ 模型其实不支持原生→当场切回文本 ReAct
① OutputParserErrorLLM 格式跑偏(既没 Action 也没 Final Answer)→ 把"你哪里写错了"的提示喂回去,让它自己改,而不是崩。
② litellm 的错原样抛★边界:鉴权失败、网络错这类底层错不该被"自愈"吞掉——吞了会让 Agent 假装没事继续瞎转。直接 raise 让上层看到真问题。
③ 上下文超长历史太长撑爆窗口 → 按 Day 07 的 respect_context_window 自动摘要压缩,然后 continue 接着跑。
④ 原生不支持→降级模型声称支持原生但实际报错 → 当场切回文本 ReAct,而非直接失败。最大化兼容性。
💡 设计取舍②:哪些错该"自愈"、哪些该"抛出"? 源码画了一条清晰的线:"模型犯的错"(格式错、上下文爆、原生不支持)→ 自愈(转成反馈让它改、压缩、降级);"系统/底层的错"(litellm 鉴权、网络、未知异常)→ 抛出。区分标准是"这个错重试/换策略有没有意义"。格式错重试有意义(模型下次可能写对),鉴权错重试无意义(key 错了转一万圈也没用)。盲目 try/except 吞所有错是反模式——会把真 bug 藏起来,让 Agent 在错误状态里空转烧钱。
L08

边界 + 今日小结

⚠️ 边界:循环结束却没拿到 AgentFinish 怎么办? 循环退出后源码还有一道校验(crew_agent_executor.py:462):if not isinstance(formatted_answer, AgentFinish): raise RuntimeError(...)。为什么要多此一举?因为 while 是靠"是不是 AgentFinish"来退出的——正常退出时它一定是 AgentFinish。但如果因为某个 bug(比如 break 时状态没设对)退出而它不是,说明代码逻辑坏了。与其返回一个类型不对的东西让调用方在很远处崩,不如在这里立刻 raise 一个清晰的错误。这是"防御性断言"——把不该发生的情况显式拦下。

👶 小白:max_iter=25 转满了,会报错吗?

👨‍🏫 老师:不会报错。到上限时 handle_max_iterations_exceeded逼 LLM 基于目前已有信息给一个最终答案,然后 break 正常退出。所以答案可能质量不高(因为它没查够),但流程不会崩。这比"无限循环烧钱"或"直接抛错扔掉半成品"都好——有下限地优雅收尾。

🧠 今天你应该能回答

  • 执行器的本质是什么?(一个 while not AgentFinish 循环)
  • invoke 开局做哪四件事?为什么 messages 是实例状态?
  • 原生 vs 文本模式怎么分流?各自更适合什么模型?
  • 一圈循环里"执行工具→写回→喂下一圈"是怎么串起来的?
  • 原生模式为什么默认只执行第一个工具?何时才并行?
  • 哪些错自愈、哪些错抛出?判断标准是什么?
  • iterations += 1 为什么放在 finally

✋ 10 分钟动手

P=lib/crewai/src/crewai
sed -n '309,328p'  $P/agents/crew_agent_executor.py   # 分流
sed -n '330,468p'  $P/agents/crew_agent_executor.py   # 文本 ReAct 主循环 + 异常
sed -n '667,807p'  $P/agents/crew_agent_executor.py   # 原生工具调用:单个/并行策略
# 打开 verbose 亲眼看循环每一圈
python -c "
from crewai import Agent, Task, Crew
a=Agent(role='算术员', goal='算数', backstory='你会用工具', verbose=True)
t=Task(description='算 23*19', expected_output='一个数字', agent=a)
print(Crew(agents=[a], tasks=[t]).kickoff())
"
明日预告 · Day 09:今天循环里那句 process_llm_response 把 LLM 的文本解析成 AgentAction/AgentFinish——它内部就是 agents/parser.py。明天逐行读这个解析器:Thought/Action/Final Answer 怎么用正则抠出来、破损 JSON 怎么修、解析失败怎么生成"给模型的纠错提示"。
← Day 07 全字段 Day 09 · 输出解析 →