StepExecutor:执行"一个计划步骤"的隔离小执行器
Day 08 的 CrewAgentExecutor 是"接下整个任务、自己转到出答案"的大循环。而 CrewAI 还有一个专门执行计划里单个步骤的小执行器 agents/step_executor.py——它服务"先规划、再逐步执行"(Plan-and-Act)场景。今天看它如何拥有自己独立的消息列表、跑一个步内小循环、用 StepResult 汇报结果,以及它和前几天的零件(parser、工具执行、原生/文本分流)如何复用。
StepExecutor 像一个只干一道工序的临时工:工头(规划器)把工程拆成"挖坑→埋管→回填"若干步,每一步单独派给一个临时工。临时工只管把这一步做完、把结果交回来,不操心整个工程、也不管"这步失败了要不要重来"(那是工头的事)。这种"单一职责的一次性执行"让规划系统的每一步都干净、可控、可替换。痛点:有了大循环,为什么还要单步执行器?
CrewAgentExecutor 已经能"接任务→自己循环→出答案"了,看起来够用。可当任务复杂到需要先规划再执行(比如"做一份竞品报告"要拆成搜集→整理→撰写→校对),你希望:每一步独立执行、每步的上下文互不干扰、某步失败能单独重试或重新规划。如果全塞进一个大循环里,步骤之间的上下文会互相污染、失败恢复也难做。怎么办?StepExecutor 是一个"执行单个计划步骤"的一次性、隔离执行器。它拥有自己的消息列表(不读不写外部 AgentExecutor 的状态),跑一个"LLM→工具→观察"的步内小循环,把结果打包成 StepResult 返回。它刻意不带"失败恢复"逻辑——重试、重新规划是上层规划器(PlannerObserver)的职责。单一职责,所以简单、快、可测。类的 docstring 直接引用了论文并说明了这个定位(step_executor.py:63):
# step_executor.py:63(类 docstring 节选)
class StepExecutor:
"""Executes a SINGLE todo item using direct-action execution.
The StepExecutor owns its own message list per invocation. It never reads
or writes the AgentExecutor's state. Results flow back via StepResult.
Execution pattern (per Plan-and-Act, arxiv 2503.09572): ...
No inner loop — recovery is PlannerObserver's responsibility."""
初始化:一次性把"走原生还是文本"定下来
__init__(step_executor.py:90)在构造时就把工具、原生支持检测好,避免每步重复判断:
# step_executor.py:90
def __init__(self, llm, tools, agent, original_tools=None, tools_handler=None,
task=None, crew=None, function_calling_llm=None, ...):
self.llm = llm
self.tools = tools # 结构化工具(供文本模式描述用)
self.original_tools = original_tools or []
self.tools_handler = tools_handler # 复用 Day 11 的缓存中枢
...
self._use_native_tools = check_native_tool_support( # ★构造时就定:走原生还是文本
self.llm, self.original_tools)
self._openai_tools: list[dict[str, Any]] = []
self._available_functions: dict[str, Callable[..., Any]] = {}
if self._use_native_tools and self.original_tools:
(self._openai_tools, self._available_functions, _) = setup_native_tools(
self.original_tools) # 提前把工具转成 OpenAI schema
check_native_tool_support★和 Day 08 _invoke_loop 同样的判断(模型支持原生 + 有工具),但这里在构造时算一次存成 _use_native_tools,后面每步直接读,不重复算。setup_native_tools如果走原生,提前把工具转成 OpenAI schema(_openai_tools)和可调函数表(_available_functions)。也是"构造时准备好、执行时直接用"。tools_handler 传入把 Day 11 要讲的缓存中枢传进来——单步执行也享受工具缓存,零件复用。tools vs original_tools两份工具:original_tools 是原始对象(造原生 schema 用);tools 是结构化封装(文本模式里生成工具说明文字用)。check_native_tool_support、setup_native_tools、后面会看到的 process_llm_response、execute_tool_and_check_finality 全都来自 utilities/agent_utils 和 utilities/tool_utils——和 Day 08 的大执行器共用同一批工具函数。这就是好架构的体现:把"问 LLM、解析、执行工具"抽成公共函数,大执行器和单步执行器各自组装,而不是各写一遍。execute:计时、分流、打包 StepResult
对外入口 execute(step_executor.py:126):
# step_executor.py:126
def execute(self, todo: TodoItem, context: StepExecutionContext,
max_step_iterations: int = 15, step_timeout: int | None = None) -> StepResult:
start_time = time.monotonic()
tool_calls_made: list[str] = []
try:
enforce_rpm_limit(self.request_within_rpm_limit)
messages = self._build_isolated_messages(todo, context) # ① 造独立消息
if self._use_native_tools:
result_text = self._execute_native(messages, todo, tool_calls_made, ...) # ② 分流
else:
result_text = self._execute_text_parsed(messages, todo, tool_calls_made, ...)
self._validate_expected_tool_usage(todo, tool_calls_made) # ③ 校验该用的工具用了没
elapsed = time.monotonic() - start_time
return StepResult(success=True, result=result_text,
tool_calls_made=tool_calls_made, execution_time=elapsed) # ④ 打包
except Exception as e:
... # 见 L07 的降级/失败处理
return StepResult(success=False, result="", error=str(e), ...)
time.monotonic()用单调时钟计时(不受系统时间调整影响),算这一步真正花了多久,回填进 StepResult。tool_calls_made一个列表,记录这步实际调了哪些工具。既用于 ③ 的校验,也放进结果供上层审计。① _build_isolated_messages★每次执行都现造一份全新消息(L04)。这是"隔离"的关键——步与步之间不共享上下文。② 分流和 Day 08 一样分原生/文本,但这里读的是构造时算好的 _use_native_tools。④ StepResult成功/失败都返回一个统一的 StepResult(含 success、result、error、耗时、调了哪些工具)。不抛异常给上层——失败也是一种"结果",让规划器决定怎么处理。raise。但单步执行器把失败也当成一种正常结果返回。为什么?因为它的调用方是规划器——规划器需要拿到"这步成了还是败了、败在哪、花了多久"来决定下一步(重试?跳过?重新规划?)。如果这里抛异常,规划器就得到处 try/except,逻辑会很乱。把"失败"编码进返回值,让控制流保持线性、让决策权留在规划器——这是"错误即数据"的思路,非常适合"多步骤编排"的场景。隔离的消息列表:每步一张全新记事本
_build_isolated_messages(step_executor.py:233)为这一步现造消息:
# step_executor.py:233
def _build_isolated_messages(self, todo, context) -> list[LLMMessage]:
system_prompt = self._build_system_prompt() # 系统提示:你是执行者,只干这一步
user_prompt = self._build_user_prompt(todo, context) # 用户提示:步骤描述 + 依赖结果 + 工具
return [
format_message_for_llm(system_prompt, role="system"),
format_message_for_llm(user_prompt, role="user"),
]
# step_executor.py:249 系统提示的组装
def _build_system_prompt(self) -> str:
role = self.agent.role if self.agent else "Assistant"
goal = self.agent.goal if self.agent else "Complete tasks efficiently"
backstory = getattr(self.agent, "backstory", "") or ""
...
return I18N_DEFAULT.retrieve("planning", "step_executor_system_prompt").format(
role=role, backstory=backstory, goal=goal, tools_section=tools_section)
每次返回新 list★两条消息(system + user)从零组装。这一步的对话只有这两条起手,没有别的步骤的历史。这就是"隔离"。system 用 Day 07 的 role/goal/backstory人设照旧从 Agent 拿,但提示词模板是专门的 step_executor_system_prompt——告诉模型"你现在只负责执行这一个步骤"。user_prompt 含依赖结果把这一步依赖的前置步骤结果(来自 context)拼进去。步骤间不共享消息历史,但需要的前置结果通过 context 显式传入——受控的信息流。步内多轮小循环:单步里也能"跑命令→看输出→再跑"
虽说是"单步",但一步内部仍允许多轮(文本模式 _execute_text_parsed,step_executor.py:317):
# step_executor.py:317
def _execute_text_parsed(self, messages, todo, tool_calls_made,
max_step_iterations=15, step_timeout=None, start_time=None) -> str:
use_stop_words = self.llm.supports_stop_words() if self.llm else False
last_tool_result = ""
for _ in range(max_step_iterations): # ★步内有上限的小循环
if step_timeout and start_time:
if time.monotonic() - start_time >= step_timeout:
return last_tool_result or f"Step timed out after ..." # 超时兜底
answer = self.llm.call(messages, callbacks=self.callbacks, ...)
if not answer:
raise ValueError("Empty response from LLM")
formatted = process_llm_response(str(answer), use_stop_words) # 复用 Day 09 解析
if isinstance(formatted, AgentFinish):
return str(formatted.output) # 给答案 → 这步完成
if isinstance(formatted, AgentAction):
tool_calls_made.append(formatted.tool)
tool_result = self._execute_text_tool_with_events(formatted, todo) # 执行工具+发事件
last_tool_result = tool_result
messages.append({"role": "assistant", "content": str(answer)})
messages.append(self._build_observation_message(tool_result)) # 观察喂回
continue
return answer_str
return last_tool_result
for _ in range(max_step_iterations)★步内小循环,默认最多 15 轮。让"这一步"也能:跑个命令→看输出→根据输出调整→再跑一次。比"一步只准调一次工具"灵活。step_timeout 双保险除了轮数上限,还有墙钟超时。哪个先到都会收尾——防止单步卡死拖垮整个计划。process_llm_response★又见 Day 09 的解析!单步执行也靠它把文本翻译成 AgentAction/AgentFinish。零件复用。AgentFinish → return模型说"这步的结果是……"就返回,退出小循环。没有外层大循环——返回即这步结束。观察喂回和 Day 08 一样:把工具结果作为 observation append 回 messages,让下一轮看得到。区别是这些消息是这一步私有的。_execute_native(step_executor.py:528)结构几乎一样:也是 for _ in range(max_step_iterations) 的小循环,只是把 self.llm.call(messages, tools=self._openai_tools, ...) 换成带工具 schema 的调用、用 is_tool_call_list 判断是否有工具调用。两条路对称,和 Day 08 的双循环设计一脉相承。观察消息与"视觉哨兵":让模型真能看见图片
_build_observation_message(step_executor.py:474)有个巧妙设计——工具若返回图片,转成多模态消息:
# step_executor.py:474
@staticmethod
def _build_observation_message(tool_result: str) -> LLMMessage:
parsed = StepExecutor._parse_vision_sentinel(tool_result) # 解析 "VISION_IMAGE:..." 前缀
if parsed:
media_type, b64_data = parsed
return {"role": "user", "content": [
{"type": "text", "text": "Observation: Here is the image:"},
{"type": "image_url",
"image_url": {"url": f"data:{media_type};base64,{b64_data}"}}, # 转成 data URI
]}
return {"role": "user", "content": f"Observation: {tool_result}"} # 普通文本观察
# step_executor.py:462
@staticmethod
def _parse_vision_sentinel(raw: str) -> tuple[str, str] | None:
prefix = "VISION_IMAGE:"
if not raw.startswith(prefix):
return None
rest = raw[len(prefix):]
sep = rest.find(":")
if sep <= 0:
return None
return rest[:sep], rest[sep + 1:] # 返回 (media_type, base64_data)
VISION_IMAGE: 哨兵约定:工具想返回图片,就返回 VISION_IMAGE:<类型>:<base64> 这种带前缀的字符串。这叫"哨兵值"——用特殊标记暗示"我不是普通文本"。转成 image_url / data URI识别到哨兵,就构造多模态消息(文本块 + 图片块)。这样模型能真正"看"到图片,而不是收到一大坨 base64 乱码。否则普通 Observation没哨兵 → 就是普通文本观察 Observation: xxx。绝大多数工具走这条。sep <= 0 返回 None边界:前缀后找不到第二个冒号(格式不完整)就当普通文本,不硬拆——防御坏数据。VISION_IMAGE: 开头的正常文本就会误判),但换来了整条链路无需为图片改类型签名。哨兵值是"在既有通道里夹带额外语义"的经典手法,很多框架都用(如 None、-1、REMOVE_ALL 这类特殊值)。原生降级、强制校验 + 今日小结
execute 的 except 分支里有一处精心设计的降级(step_executor.py:183):
# step_executor.py:183
except Exception as e:
if self._use_native_tools and is_native_tool_calling_unsupported_error(e):
try:
self._use_native_tools = False # 关掉原生
self._openai_tools = []; self._available_functions = {}
# 保留已有对话(含已完成的原生工具往返),只追加文本工具说明,
# 不重启这一步——避免已执行的工具被重复执行
messages.append(format_message_for_llm(
build_text_tool_calling_fallback_message(...), role="user"))
result_text = self._execute_text_parsed(messages, todo, tool_calls_made, ...)
...
return StepResult(success=True, result=result_text, ...)
except Exception as fallback_error:
e = fallback_error
return StepResult(success=False, result="", error=str(e), ...)
成功路径最后还有一道强制校验 _validate_expected_tool_usage(step_executor.py:504):
# step_executor.py:504
def _validate_expected_tool_usage(self, todo, tool_calls_made) -> None:
expected_tool = getattr(todo, "tool_to_use", None)
if not expected_tool:
return # 没指定必用工具 → 不校验
expected_tool_name = sanitize_tool_name(expected_tool)
available_tool_names = {...} | set(self._available_functions.keys())
if expected_tool_name not in available_tool_names:
return # 指定的工具压根不可用 → 跳过
called_names = {sanitize_tool_name(n) for n in tool_calls_made}
if expected_tool_name not in called_names:
raise ValueError(f"Expected tool '{expected_tool_name}' was not called ...")
tool_to_use("这步必须用某工具,比如必须真的执行 shell 命令,而不是嘴上说说")。_validate_expected_tool_usage 就是在这步结束时检查:说好要用的工具,到底调了没?没调就 raise 让这步判失败。为什么需要?因为 LLM 有时会"偷懒"——不真调工具,直接凭想象编一个结果。对"必须落地执行"的步骤,这种偷懒是致命的。用一道硬校验逼模型真的动手,而不是纸上谈兵。当然,如果指定的工具压根不可用(第二个 return),就跳过校验——不为不存在的东西报错,宽严有度。👶 小白:StepExecutor 和 Day 08 的 CrewAgentExecutor,我平时会直接用到哪个?
👨🏫 老师:绝大多数情况你用的是 Day 08 那个大执行器(普通 Agent 执行任务的默认路径)。StepExecutor 是在你给 Agent 开了规划(planning)、任务被拆成多步之后,框架内部用来跑每一步的。你一般不直接 new 它。理解它的价值在于:看懂 CrewAI 如何用"规划器 + 单步执行器"的分工,把复杂任务拆成可控的小步——这是阶段4 讲 planning 时的重要铺垫。
🧠 今天你应该能回答
- StepExecutor 和大执行器的三点核心区别?(单步 / 独立 messages / 不管恢复)
- 为什么 execute 用
StepResult(success=False)而不是抛异常? - "隔离的消息列表"解决了什么问题?步骤间怎么传结果?
- "单步"内部为什么还能多轮?靠什么防卡死?
- 视觉哨兵
VISION_IMAGE:是什么手法?代价是什么? - 原生降级时为什么"不重启这一步"?(避免有副作用的工具重复执行)
_validate_expected_tool_usage为什么要逼模型真调工具?
✋ 10 分钟动手
P=lib/crewai/src/crewai
sed -n '63,124p' $P/agents/step_executor.py # 定位 + 初始化
sed -n '126,231p' $P/agents/step_executor.py # execute + 降级 + StepResult
sed -n '317,367p' $P/agents/step_executor.py # 文本步内小循环
sed -n '462,526p' $P/agents/step_executor.py # 视觉哨兵 + 强制校验
grep -n "arxiv 2503.09572" $P/agents/step_executor.py # Plan-and-Act 出处
tools_handler 和"工具缓存"。明天正式钻进 agents/tools_handler.py 和 agents/cache/cache_handler.py:工具结果怎么按"工具名+输入"缓存、读写用读写锁怎么保证线程安全、cache_function 怎么决定"这次要不要缓存"、以及 result_as_answer 这类特殊工具的处理。