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

StepExecutor:执行"一个计划步骤"的隔离小执行器

Day 08 的 CrewAgentExecutor 是"接下整个任务、自己转到出答案"的大循环。而 CrewAI 还有一个专门执行计划里单个步骤的小执行器 agents/step_executor.py——它服务"先规划、再逐步执行"(Plan-and-Act)场景。今天看它如何拥有自己独立的消息列表、跑一个步内小循环、用 StepResult 汇报结果,以及它和前几天的零件(parser、工具执行、原生/文本分流)如何复用。

📍 你在 60 天里的位置(阶段2 Agent 深入 · 共 6 天)
阶段1 入门 D01-06 D07 全字段 D08 执行循环 D09 输出解析 D10 单步执行 D11 工具缓存 D12 LiteAgent 阶段3 Task
💡 先用一个类比兜住今天 Day 08 的执行器像一个全能承包商:给他一个大工程,他自己想、自己找人、自己干到完。而 StepExecutor 像一个只干一道工序的临时工:工头(规划器)把工程拆成"挖坑→埋管→回填"若干步,每一步单独派给一个临时工。临时工只管把这一步做完、把结果交回来,不操心整个工程、也不管"这步失败了要不要重来"(那是工头的事)。这种"单一职责的一次性执行"让规划系统的每一步都干净、可控、可替换。
L01

痛点:有了大循环,为什么还要单步执行器?

🤔 痛点Day 08 的 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."""
大白话记住三个关键词:SINGLE(只干一步)owns its own message list(自带独立记事本)recovery is PlannerObserver's(恢复不归我管)。这三点决定了它和 Day 08 大执行器的全部区别。文件头引用的 arxiv 2503.09572 就是 Plan-and-Act 这套"规划器 + 执行器分工"的思路来源。
L02

初始化:一次性把"走原生还是文本"定下来

__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_supportsetup_native_tools、后面会看到的 process_llm_responseexecute_tool_and_check_finality 全都来自 utilities/agent_utilsutilities/tool_utils——和 Day 08 的大执行器共用同一批工具函数。这就是好架构的体现:把"问 LLM、解析、执行工具"抽成公共函数,大执行器和单步执行器各自组装,而不是各写一遍。
L03

execute:计时、分流、打包 StepResult

对外入口 executestep_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、耗时、调了哪些工具)。不抛异常给上层——失败也是一种"结果",让规划器决定怎么处理。
💡 设计取舍①:为什么用返回 StepResult(success=False) 而不是抛异常? 大执行器(Day 08)出错常直接 raise。但单步执行器把失败也当成一种正常结果返回。为什么?因为它的调用方是规划器——规划器需要拿到"这步成了还是败了、败在哪、花了多久"来决定下一步(重试?跳过?重新规划?)。如果这里抛异常,规划器就得到处 try/except,逻辑会很乱。把"失败"编码进返回值,让控制流保持线性、让决策权留在规划器——这是"错误即数据"的思路,非常适合"多步骤编排"的场景。
L04

隔离的消息列表:每步一张全新记事本

_build_isolated_messagesstep_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 显式传入——受控的信息流。
数据结构:隔离执行 vs 共享状态 规划器把计划拆成多步,逐个派发 步骤A 自带 messages 步骤B 自带 messages 各返回 StepResult(成功/失败/耗时/工具) B 依赖 A 的结果 → 经 context 显式传入 对比:若共享一个大 messages 步骤A 的中间对话会污染步骤B 某步失败难以单独重试 上下文越滚越长、越乱
图注:每步一张全新记事本 + 结果经 context 显式流转 = 隔离但不失联。这就是隔离执行相对"共享大历史"的价值。
L05

步内多轮小循环:单步里也能"跑命令→看输出→再跑"

虽说是"单步",但一步内部仍允许多轮(文本模式 _execute_text_parsedstep_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_nativestep_executor.py:528)结构几乎一样:也是 for _ in range(max_step_iterations) 的小循环,只是把 self.llm.call(messages, tools=self._openai_tools, ...) 换成带工具 schema 的调用、用 is_tool_call_list 判断是否有工具调用。两条路对称,和 Day 08 的双循环设计一脉相承。
L06

观察消息与"视觉哨兵":让模型真能看见图片

_build_observation_messagestep_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-1REMOVE_ALL 这类特殊值)。
L07

原生降级、强制校验 + 今日小结

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_usagestep_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 出处
明日预告 · Day 11:这两天多次出现 tools_handler 和"工具缓存"。明天正式钻进 agents/tools_handler.pyagents/cache/cache_handler.py:工具结果怎么按"工具名+输入"缓存、读写用读写锁怎么保证线程安全、cache_function 怎么决定"这次要不要缓存"、以及 result_as_answer 这类特殊工具的处理。
← Day 09 输出解析 Day 11 · 工具处理与缓存 →