CrewAgentExecutor:Agent 真正干活的那个 while 循环
Day 07 说 Agent 只是"档案",真正干活的是 executor_class 指向的执行器。今天就把 agents/crew_agent_executor.py 里的 CrewAgentExecutor 拆开,逐行读它的执行循环:怎么开局、怎么在"原生函数调用 / 文本 ReAct"之间分流、怎么问一次 LLM、怎么把工具结果喂回去、怎么用 max_iter 刹车、出错怎么自愈。这是整个 Agent 系统的心脏。
while not 是最终答案 的循环。今天就是读这个循环的每一步。痛点:Agent 怎么"自己转起来"?
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 循环,殊途同归——都要转到"拿到最终答案"。今天按这个顺序逐个读。invoke:开局做的四件事
对外入口 invoke(crew_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": ...}。messages 存成实例属性,循环里各处 self.messages.append(...) 就都在往同一个"对话记录本"里写。状态集中在一处、循环各步共享——这是有状态执行器的标准做法。_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(兜底、向后兼容)。Thought:/Action:/Action Input: 的文本格式输出,框架用正则解析出"要调哪个工具"。缺点:模型可能格式写错,解析会失败。原生函数调用:模型直接返回结构化的 tool_call(训练时就学过这个格式),框架直接读,不用解析文本。原生更可靠,是现代首选;文本 ReAct 是老模型的兜底。两条路后面各是一个 while。文本 ReAct 主循环:那个 while 长什么样
核心循环 _invoke_loop_react(crew_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。保证刹车计数不被异常绕过。循环里的三件事:执行工具 → 写回 → 喂给下一圈
当这圈解析出 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_action(crew_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 看得到。第 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 度 → 解析成 AgentFinish,while 条件不成立,退出。👶 小白:为啥不让 LLM 一口气把答案说完,非要一圈圈来回?
👨🏫 老师:因为 LLM 脑子里没有"SF 今天 18°C"这条实时信息——你逼它一次答完,它只会瞎编一个温度。必须让它先开口要工具、框架真去查一次、把真实结果喂回去,它才有据可答。一圈圈,是为了在"想"和"真实世界数据"之间反复对齐。
原生函数调用循环:单个执行 vs 并行提速
另一条路 _invoke_loop_native_tools(crew_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_calls(crew_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]
...
result_as_answer/max_usage_count)时,才用线程池并行提速(最多 8 个)。正确性优先、性能其次——很稳健的权衡。contextvars.copy_context().run 提交任务(:749):把当前线程的上下文变量复制给子线程,保证并行工具也能读到正确的 Agent/Task 上下文,结果按 idx 归位以保持顺序。这是并发编程里"上下文隔离"的标准手法。异常自愈、上下文压缩与优雅降级
循环对各种"模型会犯的错"有细致处理(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,而非直接失败。最大化兼容性。边界 + 今日小结
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())
"
process_llm_response 把 LLM 的文本解析成 AgentAction/AgentFinish——它内部就是 agents/parser.py。明天逐行读这个解析器:Thought/Action/Final Answer 怎么用正则抠出来、破损 JSON 怎么修、解析失败怎么生成"给模型的纠错提示"。