生命周期 (Lifecycle)
Slyme 的 Node 体系采用了严格的两阶段架构(构建期 Def 与执行期 Exec)。这种设计从根本上解耦了逻辑的“组装”与“运行”,在保证高度灵活性的同时,也为执行期的安全性和性能优化提供了坚实的保障。
一个 Node 从创建到最终运行,通常会经历四个完整的生命周期阶段:构建 (Build) -> 修改 (Modify) -> 准备 (Prepare) -> 执行 (Execute)。
1. 构建阶段 (Build)
当你调用一个被 @node、@expression 或 @wrapper 装饰的函数,并传入配置参数时,你并没有执行这段业务逻辑,而是实例化了一个可变的定义对象(Def)。
from slyme.node import node, Auto
from slyme.context import Context, Ref
@node
def process_data(ctx: Context, /, *, timeout: int = 30, data: Auto[list], max_len: int) -> Context:
return ctx
# 构建阶段:实例化 Def 对象
my_node_def = process_data(max_len=1024) # 此时 data 为 UNDEFINED在这个阶段,Slyme 会进行初步的参数收集,并处理 UNSET(显式触发默认值逻辑)等标记常量。此时如果某些必须提供的参数未提供,可以留到修改阶段再进行赋值。
为了更好地管理构建逻辑,我们通常推荐使用 @builder 来组装复杂的 Node 树。
2. 动态修改阶段 (Modify)
在 Def 对象调用 .prepare() 之前,它是完全可变的。Slyme 允许你使用 [] 操作符来动态访问和深层修改它的构建期参数。
这赋予了系统极大的灵活性。比如,你可以基于一个现有的 Builder 产物进行微调,而无需重新编写大量重复的代码:
# 动态修改阶段:微调 Def 参数
my_node_def["timeout"] = 60
my_node_def["data"] = [Ref("user.age"), Ref("user.name")]3. 准备阶段 (Prepare)
当 Node 树的结构和参数修改完成后,你需要调用 .prepare() 方法。这一步标志着 Node 从“构建期”正式跨越到“执行期”,它会返回一个不可变的执行对象(Exec)。
# 准备阶段:将 Def 转换为不可变的 Exec
my_node_exec = my_node_def.prepare()在这个阶段,Slyme 框架内部会进行一些检查和优化:
- 结构与参数校验:检查所有的
UNDEFINED是否都已被正确赋值。 - 参数冻结 (Parameter Freezing):这是为了执行期安全性与性能优化所做的关键操作。
- 性能优化:
- 对于 @node,Slyme 会将其挂载的 @wrapper 与它本身结合成链式函数(洋葱模型),并缓存下来。
- Slyme 会深度分析构建期参数,将其中需要自动求值的部分预分析并缓存,以提高运行时性能(自动求值的深入原理可见依赖注入)。
- 由于
.prepare()方法调用之后的产物(Exec 对象)是不可变的,Slyme 会在未来的版本考虑加入更激进的优化(比如 JIT)以进一步提高运行效率。
参数冻结机制
在 .prepare() 被调用时,Slyme 内部的 Prepare Engine 会对 Node 接收到的参数进行深度遍历和结构优化(冻结)。用户在构建阶段传入的可变结构(如 list 或 dict),在 prepare 后会被转换为不可变结构(如 tuple 或 MappingProxyType 等)。
这意味着一旦 prepare 完成,Node 的参数结构就彻底锁定了,无法再被意外修改,从而保证了执行期的高效和并发安全。
4. 执行阶段 (Execute)
最终,将 Context 传入 Exec 对象,触发真正的业务逻辑执行:
# 执行阶段:传入 Context 执行业务逻辑
new_ctx = my_node_exec(Context())在这个阶段,Slyme 最强大的特性之一——Auto 自动求值与注入——将会发挥作用。框架会深度遍历被冻结的参数结构,将其中的 Ref 和 @expression 解析为 Context 中的真实值,并注入给目标函数。
Auto 自动注入的易混淆点
由于准备阶段的参数冻结机制,结合 Auto 自动注入,有一个容易混淆的场景需要特别注意:注入可变结构的差异。
请看以下两个场景对比:
from typing import Any
from slyme.node import node, Auto
from slyme.context import Context, Ref
@node
def process(ctx: Context, /, *, data: Auto[Any]) -> Context:
print(f"Type: {type(data)}, Value: {data}")
return ctx场景 A:在构建时拼接了包含 Ref 的 list
# 1. 构建:传入一个 Python list,内部包含多个 Ref
node_def_a = process(data=[Ref("a"), Ref("b")])
# 2. Prepare:由于参数冻结机制,内部的 list 结构被转换成了 tuple!
exec_a = node_def_a.prepare()
# 此时 exec_a 内部持有的 data 参数结构实际上是: (Ref("a"), Ref("b"))
# 3. 执行:Auto 自动求值引擎解析这个 tuple,并注入最终值
ctx_a = Context().update({Ref("a"): 1, Ref("b"): 2})
exec_a(ctx_a)
# 打印输出 => Type: <class 'tuple'>, Value: (1, 2)场景 B:构建时直接传入一个指向 list 的 Ref
# 1. 构建:传入一个单独的 Ref
node_def_b = process(data=Ref("my_list"))
# 2. Prepare:Ref 本身是不可变的叶子节点,保持不变
exec_b = node_def_b.prepare()
# 此时 exec_b 内部持有的 data 参数结构仍然是: Ref("my_list")
# 3. 执行:Auto 自动求值引擎解析这个 Ref,直接取出 Context 中的值并注入
ctx_b = Context().set(Ref("my_list"), [1, 2])
exec_b(ctx_b)
# 打印输出 => Type: <class 'list'>, Value: [1, 2]总结:
- 如果你在构建期拼接了一个列表(如
[Ref(...), Ref(...)]),经过 prepare 冻结后,执行期你得到的将是一个tuple。 - 如果你仅仅是传递了一个
Ref,而这个Ref在 Context 中指向一个原始的list,那么执行期你得到的就是原汁原味的list。 - 上述两个行为差异与 Slyme 的核心思想对应:Node 结构及其配置参数在运行时不可变,而 Context 内存储的用户数据则不做限制。