数据截至 (上游 commit 11cdf466d042)
Action 与 ActionNode:工作单元 + 结构化输出引擎
30 秒导读: 上一章讲了 Role 的"观察-思考-行动"循环。这一章讲**"行动"到底是什么**——
Action是角色能做的一件事的封装(写 PRD、写代码、跑测试……),而ActionNode是藏在 Action 里的一台结构化输出引擎:它负责把"请给我一段代码 / 一个文件列表 / 一份需求"这种模糊要求,变成一个有类型、能被 Python 直接读取的对象。核心难题只有一个:LLM 只会吐自由文本,而下游程序需要字段。
1. 这是什么(零基础也能懂)
一句话定义: Action 是"角色做的一件事"的最小工作单元;ActionNode 是把 LLM 的自由文本逼成结构化数据的引擎。
它们解决什么问题。 想象你让 LLM"写一份产品需求文档"。它会回你一大段 Markdown。可你的程序下一步要拿到其中的"项目名""需求列表""竞品分析"分别去用——你不能对着一坨文本做 output["需求列表"]。于是需要有人负责:
- 把"要产出哪些字段、每个字段什么类型、什么要求"讲清楚给 LLM;
- 收到回复后把它解析成字段;
- 校验字段齐不齐、类型对不对;
- 中间出错还要自动重试。
这四件事,就是 ActionNode 的全部工作。Action 则是它外面那层壳:给角色一个统一的 run() 入口,让"做一件事"这个动作可以被 Role 的循环调度。
一个直觉类比。 把 Action 想成一台咖啡 机上的一个按钮("美式""拿铁"各是一个 Action),按下去就出一杯东西;ActionNode 则是按钮背后那套**"按配方出料 + 质检 + 不合格重做"的管线。你按按钮(run),它保证出来的不是一摊乱七八糟的液体,而是符合规格、可以直接端走**的成品。
用起来什么样。 最简单的 Action 甚至不用写子类,给一句 instruction 就能跑:
# 示意,非源码。演示「一句话指令 → 一个可 run 的 Action」
action = Action(instruction="把这段英文翻译成中文")
result = await action.run(messages) # 内部自动走 ActionNode 那套引擎
# result 是结构化输出,而不是一坨裸文本
这一步的关键在于:你只提供了"做什么"(instruction),"怎么把文本变成结构化结果"由 Action/ActionNode 自动补齐。下面就来拆这台引擎。
本章只讲抽象基类与结构化引擎;具体业务 Action(
WritePRD、WriteCode等)属于第 4 章:经典 SOP 流水线。
2. 顶层全景(它大概怎么转)
先看两者的关系。 Action 是外壳,ActionNode 是内核;一个 Action 内部持有一个(可选的)ActionNode 作为它的"输出引擎"。
Role 的循环调用
│ run(messages)
▼
┌───────────────────────────┐
│ Action (工作单元的壳) │ action.py
│ · name / i_context │
│ · node: ActionNode ──────┼──┐ 持有一个节点作输出引擎
│ · run → _run_action_node │ │
└───────────────────────────┘ │
▼
┌────────────────────────────────────────────┐
│ ActionNode (结构化输出引擎) │ action_node.py
│ │
│ compile ──► 拼 prompt(context+示例+指令) │
│ │ │
│ ▼ │
│ fill ────► 调 LLM ─► 解析 ─► pydantic 校验 │
│ │ ▲ │
│ │ 失败重试(tenacity) │
│ ▼ │
│ instruct_content(带类型的结构化对象) │
│ │
│ review / revise ──► 自我审校 + 修订闭环 │
└────────────────────────────────────────────┘
怎么读这张图: 从上到下是一次调用的流向。Role 按下 run;Action 把历史消息整理成 context,委托给内部的 ActionNode;ActionNode 先 compile 出 prompt,再 fill(生成→解析→校验,失败就重试),最后交出一个带类型的结构化对象 instruct_content。
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
Action | 角色"做一件事"的壳,提供统一 run 入口 | actions/action.py:29 |
ActionNode | 结构化输出引擎:建模型、拼 prompt、填充、校验 | actions/action_node.py:135 |
create_model_class | 用 pydantic create_model 动态生成校验用的模型类 | action_node.py:246 |
compile | 把 instruction+example 拼进 SIMPLE_TEMPLATE 成 prompt | action_node.py:382 |
fill | 生成→解析→校验的主循环,含四种 FillMode | action_node.py:596 |
_aask_v1 | 真正调 LLM 的那步,挂着 tenacity 重试装饰器 | action_node.py:428 |
review/revise | 对已产出的结构化结果做审校与自动修订 | action_node.py:729 / 816 |
ActionOutput | 早期的输出包装(content + instruct_content) | actions/action_output.py:12 |
register_action_outcls | 让"同名同字段"的动态类复用同一个对象 | action_outcls_registry.py:11 |
主线走一遍(高层): 输入是一串历史消息 → Action.run 把它们拼成 ## History Messages 的 context → 交给 node.fill → ActionNode 用 instruction/example 编译出 prompt → 调 LLM 拿到裸文本 → 按 schema 解析 → 用动态 pydantic 模型校验 → 输出结构化的 instruct_content。
3. 核心原理(逐个机制,由浅入深)
3.1 Action:一件事的封装
它要解决的小问题: 让"角色做的每一件事"长得一样——都能被 run、都能拿到上下文、都能挂一个输出引擎。
思路。 Action 继承 SerializationMixin / ContextMixin / pydantic BaseModel,自身几乎不含业务逻辑,只提供几个通用零件:
| 字段/方法 | 作用 |
|---|---|
name | 动作名;为空时自动取类名(set_name_if_empty,action.py:69) |
i_context | 这次动作的输入上下文,类型很宽(dict / 各种 Context / str / None) |
prefix | 会被塞进 LLM 的 system message(set_prefix) |
node | 一个 ActionNode,即这个 Action 的输出引擎(默认 None) |
run | 统一入口:有 node 就走引擎,否则要求子类自己实现 |
一句指令就能建一个 Action。 最妙的一处在 _init_with_instruction:pydantic 的 "before" 校验器,发现构造参数里有 instruction,就当场替你造一个 ActionNode 挂到 node 上。
# action.py:76-83 符号:_init_with_instruction
@model_validator(mode="before")
@classmethod
def _init_with_instruction(cls, values):
if "instruction" in values:
name = values["name"]
i = values.pop("instruction")
values["node"] = ActionNode(key=name, expected_type=str, instruction=i, example="", schema="raw")
return values
这段的意思是:"给指令 = 给引擎"。所以 §1 里那个 Action(instruction=...) 才能直接跑——它其实被塞了一个 schema="raw"(自由文本)的单节点。
run 的分叉。 run 本身不做事,只做路由:
# action.py:110-114 符号:run
async def run(self, *args, **kwargs):
if self.node:
return await self._run_action_node(*args, **kwargs)
raise NotImplementedError("The run method should be implemented in a subclass.")
有 node 就委托给 _run_action_node;没有就抛 NotImplementedError——这正是业务子类(如 WriteCode)覆写 run 的地方(第 4 章)。
_run_action_node 做的事很轻: 把传入的消息列表倒序编号,拼成一段 ## History Messages,当作 req 喂给节点的 fill。
# action.py:103-108 符号:_run_action_node
msgs = args[0]
context = "## History Messages\n"
context += "\n".join([f"{idx}: {i}" for idx, i in enumerate(reversed(msgs))])
return await self.node.fill(req=context, llm=self.llm)
set_prefix 的副作用链。 设 prefix 时会一路把它同步给 LLM 的 system_prompt,并把同一个 LLM 实例交给 node,保证壳与引擎用的是同一个模型配置(action.py:85-91)。
到这里,Action 的故事讲完了——它薄得几乎透明。真正的工程含量,全在它委托出去的那个 node.fill 里。