状态管理¶
ExecutionContext 是流程的"内存",承载所有运行时状态。理解它有助于写好自定义节点、调试表达式、正确使用分布式持久化。
命名空间¶
状态存在 CheckpointState(plaita.core.state,Pydantic BaseModel)里,用 $ 前缀的命名空间键组织(前缀可配置,默认 $)。CheckpointState 暴露 dict-like 视图(__getitem__/__contains__/get/keys...),表达式引擎与节点插件仍像用 dict 一样访问 execution.context,底层却是 typed model——key schema 集中在 CheckpointSchema 单一事实源。节点经 set_state("任意键", ...) 写的非 schema 字段落到 model 的 extra。
| 键 | 含义 | 写入时机 |
|---|---|---|
$INPUT |
输入参数 | setup_flow 时根据 input_type 填入 |
$NODE |
{node_id: result},结果按值入库 |
每个节点执行后由 NodeRunner.update_node_result 累加 |
$GLOBAL |
全局变量 | setup_flow 时从 flow.global_context 拷贝并注入 flow_id |
$PARENT |
父流程上下文的冻结快照(子执行创建那一刻) | setup_flow 时填入;快照在子 ExecutionContext 构造时取好(plaita#72) |
$ENV |
环境变量 | clean 时从 os.environ 拷贝并过滤敏感前缀 |
$LAST_NODE |
最近执行的节点 id | 每节点执行后写入,供 Distributed 续跑 |
$BRANCH |
最近分支命中 | 分支节点写入 |
$EXECUTION_ID |
执行 ID | 首次访问 execution_id 时惰性生成(uuid4 hex) |
$FLOW_ID |
流程 id | setup_flow 时写入 |
EXPRESS_PREFIX |
表达式前缀 | setup_flow 时写入,供 EventSubscription.matches_event 等使用 |
输入归一化¶
_get_context_value 按 input_type.dataType 决定 $INPUT 形态:
object/map:关键字参数 dict,或位置首参(dict)array:位置参数元组- 其它:位置首参单值
表达式求值¶
ExecutionContext.evaluate(value) 递归求值(list/dict 逐项),字符串走 ExpressionEvaluator(详见 表达式)。
父级回溯:当某字符串表达式在当前上下文求值为 None,且存在父上下文,会自动 parent.evaluate(value) 再试一次。这让子流程能"穿透"引用父级数据。
父子链¶
ExecutionContext.child() 创建子上下文,继承父的所有 express_* 配置、event_bus、evaluator,并把 parent 指向自己。get_global_variable(key) 也会沿父子链向上查找。
FlowExecution.get_child_execution() 创建子 facade,其 CallbackManager 通过 inherit_handlers 继承父级 handler 一份,避免事件双发。
flowchart TD
P["父 ExecutionContext"]
C1["子 ctx (InlineFlow/Loop/Parallel)"]
C2["孙 ctx (嵌套)"]
P -->|child()| C1
C1 -->|child()| C2
C2 -.->|未命中时回溯求值| C1
C1 -.->|未命中时回溯求值| P
event bus 获取¶
get_or_create_event_bus() 按优先级解析 event bus:
- 自身已设的
event_bus - 父链上的
event_bus - 惰性 import
plaita.event.get_default_event_bus(函数体内 import,不污染core的 import-time 依赖) - 都没有则返回
None
core 仍不持有 EventBus 实例;函数体内 lazy import 保住 core → event 分层(顶层包不再做 provider 注入,历史的全局 _default_event_bus_provider singleton 已删除)。
敏感环境变量过滤¶
$ENV 采用 allowlist 模型(2026-07 重构):clean() / setup_flow() 时只把 Flow 显式声明的 expose_env 中、且真实存在于 os.environ 的 key 放进 $ENV;未声明时 $ENV 为空。
历史上这里曾叠了一份「敏感前缀黑名单」作为 allowlist 之上的「第二层防御」,但它的 startswith 匹配挡不住 vendor 前缀的真实密钥名(如 OPENAI_API_KEY、STRIPE_KEY、PG_CONN),「挡不住却给人安全感」比没有更危险,已移除。现在的策略:allowlist 即用户责任,每个命中的 key 会打一条 logger.warning 做审计可见性,但不做任何启发式拦截。如果确实需要暴露敏感变量,自行把关 expose_env 内容即可。
协作取消¶
cancel_event 是一个 threading.Event:
- 进程内传播:子
ExecutionContext(经__init__传入 parent,或child())共享父的Event,父 flow 取消能传到进程内子 flow / 并行分支(thread 模式);clean()时根上下文重建新Event,子上下文重新同步到父当前Event,取消链跨 clean 周期不丢 NodeRunner在同步节点超时时 set 它,节点可 poll 提前退出- 不可 pickle:
__getstate__会剔除它,子进程得到全新未触发事件——因此跨进程取消不支持(process 模式仅做启动前is_set()检查,详见ParallelExecutor.supports_cancel_propagation)
序列化(分布式)¶
ctx.to_dict() # => CheckpointState.to_checkpoint_dict()(与旧 dict 格式逐键兼容)
ExecutionContext.from_dict(data, **kwargs)
to_dict() 会顺带跑 validate_checkpoint 漂移扫描——节点经 set_state 写的"看起来是 system key 但不在 CheckpointSchema"的字段会 warning,让新加 magic key 在 review/日志被发现。
值语义与自引用(#107)¶
存储出口(to_checkpoint_dict / from_checkpoint_dict / $PARENT 快照)与 update_node_result 都经
state._snapshot_value 按值物化 dict/list/tuple 容器:返回值与 live state 不共享嵌套容器,$NODE.<node>
是私有快照——节点结果写入后再被后续步骤 / 调用方变异都不会写穿,历史节点结果也不会继续增长
(output: "$NODE" 把整表写回自身时,$NODE['a'] is $NODE 不再成立)。
$PARENT 的冻结时点(#72):子执行拿到的是「子 ExecutionContext 构造那一刻」的父状态快照——
顶层键集与各根键的值对象在构造时钉住(一次 O(根键数) 的浅拷),每个根键的深物化仍推迟到子侧首读
(_LazyRootSnapshot,避免 while/for 每轮全量深拷贝的热路径回归)。这一点对 Parallel 的
fire-and-forget 后台分支是必需的:后台分支不阻塞父,父会在分支提交之后继续推进并写 $NODE,
若到首读才从 live 父状态取值,分支就读到父的"未来态"(非确定性)。因此 Parallel 在 submit 之前
(父线程)就创建后台分支的子执行。边界:构造时只浅拷顶层,父在首读之前对某个根键值做
原地嵌套写仍可能透出——引擎自身的写路径(update_node_result / setup_flow)都是换对象而非原地改。
物化自 2026-10 起是显式栈迭代(数据深度与 Python 递归深度解耦,2500 层嵌套值不再 RecursionError),
两条降级语义显式可见:
- 容器环(自引用值,如
$INPUT传入d["self"] = d):命中当前 DFS 路径上已出现的容器时,按<recursive: path>占位符截断——自引用快照无法 JSON 序列化,占位符把损坏点钉在快照出口而不是 宿主序列化那一跳; - 深度上限:容器深度 ≤
state._SNAPSHOT_MAX_DEPTH + 1(即 10001 层)可物化, 10002 层起抛StateSnapshotError(plaita.core.errors,FlowExecutionException子类, 消息含深度与容器路径;注意消息里的depth报的是父帧深度,比越界容器层数少 1)。 normal 模式原样透传该异常,distributed 模式按既有契约归一化为FlowErrorException,但深度/路径文案保留。 该上限只约束深度:物化无 memo,共享 DAG(同一子对象被多处引用)仍按引用次数指数展开, 不要依赖它给 CPU/内存设界(详见plaita/core/state.py的同名注释)。
因此不要把整表根($NODE、$GLOBAL…)当 assignment 的 output——文档只承认 $NODE.<node> 形态;
引擎现在不再被这种写法炸栈,但写回自身的值快照对流程语义毫无用处。
分布式模式下,把 to_dict() 的结果存入 ExecutionStorage,下次 run_distributed(saved_context=...) 时恢复。