Day 46 / 共 60 天 · 阶段7 Flow 事件驱动

expressions:让 YAML 里的 ${...} 安全地读到运行时数据

D43 的声明式 YAML Flow 里出现了 do: {call: expression, expr: state.topic},D44 的校验器也检查过"表达式的根"。这些能力都来自 expressions.py。它用 CEL(Common Expression Language,Google 的沙盒表达式语言)让声明式 Flow 能读 state / outputs,还提供 ${...} 模板插值。今天读:${...} 模板怎么切段、单个表达式怎么保留原类型、CEL 怎么被安全求值、上下文里放了哪些数据、以及为什么坚决不用 eval() 直接跑 Python

📍 你在 60 天里的位置(阶段7 Flow 事件驱动 · 共 8 天)
阶段6 记忆 D41 Flow 总览 D42 装饰器 D43 定义契约 D44 路由跳转 D45 状态持久化 D46 表达式 D47 对话式 D48 选型 阶段8 LLM
💡 先用一个类比兜住今天 表达式就是 YAML 里的"填空占位符",像邮件模板里的 {{姓名}}。你写 "工单: ${state.ticket_id}",运行时框架把 ${...} 换成真实值。关键区别是:这个占位符只能读你给的几个盒子(state、outputs),不能执行任意代码——就像模板引擎只会取变量、不会让你在模板里 rm -rf。这份"受限"正是 CEL 相对 Python eval 的安全价值。
L01

痛点:声明式 Flow 怎么读"跑起来才知道的值"?

🤔 痛点D43 的 YAML Flow 里,某一步要用到"上一步的输出"或"state 里的字段"——但这些值写 YAML 时还不存在,运行时才有。YAML 是静态文本,怎么表达"这里填上运行时 state.topic 的值"?直接让 YAML 里能写 Python eval 吗?那用户(甚至攻击者)写的 YAML 就能执行任意代码,太危险。
💡 一句话本质CEL 表达式 + ${...} 模板解决。CEL 是一门沙盒化的、无副作用的表达式语言:能做取字段、算术、比较、字符串拼接,但不能定义函数、不能 import、不能碰文件系统。框架给它一个只含 state/outputs 的上下文,它只能在这个笼子里取值算值。既满足"读运行时数据"的需求,又杜绝"执行任意代码"的风险。
大白话需要"运行时填空",但又不能给用户一把能干任何事的 eval 枪。CEL 就是一把"只能填空、开不了别的火"的安全水枪。
L02

${...} 模板:把字符串切成"文本段 + 表达式段"

模板解析把一个字符串拆成交替的文本/表达式片段(flow/expressions.py:83):

# flow/expressions.py:83
@lru_cache(maxsize=256)                     # 同一模板只解析一次
def _parse_template_segments(value: str) -> tuple[str | _ExpressionSegment, ...]:
    segments = []
    index = 0
    while (start := value.find("${", index)) != -1:   # 找下一个 ${
        if start > index:
            segments.append(value[index:start])        # ${ 之前的纯文本
        end = _marker_end(value, start + 2)             # 找配对的 }
        source = value[start + 2 : end].strip()         # 抠出表达式源码
        if not source:
            raise ExpressionError(f"empty CEL expression in {value!r}")
        segments.append(_ExpressionSegment(source))     # 包成表达式段
        index = end + 1
    if index < len(value) or not segments:
        segments.append(value[index:])                  # 收尾剩余文本
    return tuple(segments)
lru_cache★模板字符串是固定的(来自 YAML),解析结果缓存起来,重复渲染不重复解析。
find("${")扫描 ${ 起始标记。之前的部分是纯文本段。
_marker_end 找配对 }★不是简单找下一个 },而是用 CEL 词法器数括号深度flow/expressions.py:62)——支持表达式里本身含 {}(如 map/filter),配对才准。
_ExpressionSegment表达式段包成专门类型,和文本段区分。后面渲染时按类型分别处理。
📝 例子:一个模板切成什么 "工单 ${state.id} 状态 ${state.status}"["工单 ", Expr("state.id"), " 状态 ", Expr("state.status")]。文本原样保留,两个表达式段待求值。
L03

Expression 类:校验、求值、模板渲染三件套

Expression 是对外的门面(flow/expressions.py:197):

# flow/expressions.py:197
class Expression:
    def __init__(self, value, *, context=None) -> None:
        self.value = value; self.context = context

    @classmethod
    def from_flow(cls, value, flow, *, local_context=None) -> Expression:
        return cls(value, context=cls._flow_context(flow, local_context=local_context))  # 带上 Flow 上下文

    def evaluate(self, context=None) -> Any:            # 整个值当一个完整 CEL 表达式求值
        resolved = self.context if context is None else context
        return self._evaluate_cel(self._require_cel_source(self.value), resolved or {})

    def render_template(self, context=None) -> Any:     # 渲染 ${...} 模板
        resolved = self.context if context is None else context
        return self._render_template_value(self.value, resolved or {})
from_flow★便捷构造:给它一个 Flow 实例,它自动把 state/outputs 装进 context(L06)。运行时用这个入口。
evaluate把整个值当一个完整 CEL 表达式算(如 do.expr = "state.topic")。用于 action 的 expr 字段。
render_template把值当含 ${...} 的模板渲染(如 "研究: ${state.topic}")。用于 with 块里的字符串插值。
validate_* 系列还有 validate_expression/validate_template(D44 校验器调的),只检查不求值——构建期用。
两个入口对应两种用法:纯表达式(结果保留原类型,可为数字/对象/列表)vs 模板插值(结果拼成字符串,除非整串就一个 ${...})。下一节看这个"保类型 vs 转字符串"的分界。
L04

关键规则:单个 ${...} 保留类型,混了文本就变字符串

模板渲染最精妙的一条规则(flow/expressions.py:349):

# flow/expressions.py:349
@staticmethod
def _render_template_string(value: str, context) -> Any:
    segments = _parse_template_segments(value)
    expressions = [s for s in segments if isinstance(s, _ExpressionSegment)]
    if not expressions:
        return value                          # 没有 ${...} → 原样返回文本
    literals = [s for s in segments if isinstance(s, str)]
    if len(expressions) == 1 and all(not lit.strip() for lit in literals):
        return Expression._evaluate_cel(expressions[0].source, context)  # ★整串就一个表达式 → 保留原类型
    rendered = []                             # 否则逐段渲染、拼成字符串
    for segment in segments:
        if isinstance(segment, str):
            rendered.append(segment); continue
        result = Expression._evaluate_cel(segment.source, context)
        rendered.append("" if result is None else _stringify_cel_value(result))
    return "".join(rendered)
没有表达式纯文本,原样返回。
正好一个表达式 + 周围全空白"${state.count}" 这种 → 直接返回 _evaluate_cel 的结果,保留原类型(数字还是数字、列表还是列表)。这样能把整数、对象、列表原封不动传给下游。
混了文本 / 多个表达式"共 ${state.count} 条" → 每段求值,非字符串结果 json.dumps 成文本,拼接。结果一定是字符串
None → 空串表达式算出 None,在拼接时渲染成 "",不会打印出 "None" 污染文本。
控制流:模板渲染的"保类型 vs 转字符串"分叉 切段后:几个表达式段? 正好1个且无其它文本 "${state.count}" → 3 (int,保类型) 多个 / 含文本 "共 ${state.count} 条" → "共 3 条" (str) "保类型"让整数/列表/对象能原样传给下游动作,而非被迫字符串化
图注:单表达式整串 → 保留 CEL 求值的原类型;混合内容 → 逐段求值拼成字符串。
💡 设计取舍①:为什么费劲区分"保类型"和"转字符串"? 简单做法是所有 ${...} 一律渲染成字符串。但那样一个"取整数计数"的表达式,结果就永远是 "3" 而非 3——下游动作要的是数字,还得再手动转,容易出 bug。CrewAI 让"整串就一个表达式"时保留原类型,就能无损地在声明式 Flow 里传递结构化数据(数字/列表/对象);只有当你显式把表达式嵌进文本里(意图就是"拼一句话")才字符串化。用"是否夹带文本"作为"你想要值还是想要句子"的信号,符合直觉。
L05

_evaluate_cel:把求值交给沙盒引擎 celpy

真正求值不是 CrewAI 手写的,而是委托 celpy 库(flow/expressions.py:369):

# flow/expressions.py:369
@staticmethod
def _evaluate_cel(expression: str, context: dict[str, Any]) -> Any:
    try:
        from celpy import Environment
        from celpy.adapter import CELJSONEncoder, json_to_cel
        from celpy.evaluation import Context

        environment = Environment()
        program = environment.program(
            Expression._compile_cel(expression, environment=environment),
            functions=_EXPRESSION_FUNCTIONS,          # 注入自定义函数 text()
        )
        result = program.evaluate(cast(Context, json_to_cel(context)))  # 在受限上下文里算
        return json.loads(json.dumps(result, cls=CELJSONEncoder))       # CEL 类型 → 普通 Python
    except Exception as e:
        raise ExpressionError(f"failed to evaluate CEL expression {expression!r}: {e}") from e
celpy EnvironmentCEL 的运行环境。CrewAI 不重造轮子,用成熟的 CEL 实现——安全性有 Google 规范背书。
compile 再 evaluate先编译成 AST/程序,再对给定上下文求值。编译结果也可复用。
json_to_cel(context)★把 {state:..., outputs:...} 转成 CEL 的类型系统。表达式只能访问这个上下文里的东西——沙盒的边界就在这
结果转回普通 PythonCEL 有自己的类型(StringType 等),用 CELJSONEncoder 转回普通 dict/list/str,下游好用。
包成 ExpressionError任何求值异常统一包成清晰的 ExpressionError,带上是哪条表达式。
💡 CEL 的沙盒边界表达式能访问的全部就是 program.evaluate(context) 里那个 context。context 里只有 state、outputs(和你显式加的局部变量)。它够不到 os、够不到 __import__、够不到文件——因为这些根本不在 context 里,CEL 语言本身也不提供。这就是"读得到数据,干不了坏事"。
L06

上下文:表达式能看到的世界只有 state 和 outputs

Flow 给表达式准备的上下文(flow/expressions.py:310):

# flow/expressions.py:310
@staticmethod
def _flow_context(flow, local_context=None) -> dict[str, Any]:
    from crewai.flow.runtime._outputs import outputs_by_name
    local_outputs = local_context.get("outputs") if local_context else None
    outputs = outputs_by_name(flow._method_outputs, local_outputs=local_outputs, serialize=True)
    context = {
        "state": flow._copy_and_serialize_state(),   # ★整个 state(序列化后的副本)
        "outputs": outputs,                          # ★按方法名索引的历史输出
    }
    if local_context:                                # 可选:额外的局部变量
        context.update({k: to_serializable(v, max_depth=0)
                        for k, v in local_context.items() if k not in {"outputs", "state"}})
    return context
state${state.topic} 读的就是这。用的是序列化后的副本_copy_and_serialize_state),表达式改不动真状态——只读、安全。
outputs${outputs.fetch} 读某个已完成方法的输出。outputs_by_name_method_outputs(D41 的方法输出列表)转成"方法名 → 输出"的字典。
local_context特定场景(如 each 循环里的当前项)可注入额外变量,但不能覆盖 state/outputs 这两个保留名。
serialize=True / 序列化副本全都序列化——因为 CEL 只认 JSON 式数据,且保证只读、跨线程安全。
数据结构:CEL 上下文(表达式的整个可见世界) context(沙盒) state {topic, count, id, ...} outputs {fetch: ..., clean: ...}
图注:表达式的可见世界只有 state 和 outputs 两个根——够读数据,够不到系统。
L07

校验根 + text():拼错早报错,缺值不崩

D44 的校验器调的就是 validate_expression,它检查表达式只用了允许的根(flow/expressions.py:217):

# flow/expressions.py:217
def validate_expression(self, *, allowed_roots, source="CEL expression") -> None:
    allowed = frozenset(allowed_roots)                       # 通常 {"state","outputs"}
    expression = self._require_cel_source(self.value, source=source)
    roots = self._collect_root_identifiers(self._compile_cel(expression, source=source))
    unknown = sorted(r for r in roots if r not in allowed)   # 用了不认识的根?
    if unknown:
        raise ExpressionError(
            f"unknown CEL root at {source}: ...; allowed roots: {', '.join(sorted(allowed))}. "
            "Reference flow data through one of those roots, for example state.field ...")

还有一个自定义函数 text() 专门兜"值可能缺失/为 null"(flow/expressions.py:27):

# flow/expressions.py:27
def _handle_text_custom_expression(root, path, default="") -> StringType:
    fallback = StringType("" if default is None else str(default))
    current = root
    for part in str(path).split("."):        # 沿 "a.b.c" 逐层下钻
        if current is None:
            return fallback                  # 中途断了 → 返回默认
        try:
            current = current[int(part)] if isinstance(current, list) else current[StringType(part)]
        except (KeyError, IndexError, TypeError, ValueError):
            return fallback                  # 取不到 → 返回默认,不抛异常
    return StringType(...) if current is not None else fallback
allowed_roots 校验${stat.topic}(把 state 拼错成 stat)在构建期就被这条拦下,报"unknown CEL root: stat"。呼应 D43 的 fail-fast。
text(root, "path", "default")★安全取值:text(state, "user.name", "匿名"),路径中间断了或为 null,返回默认值而非崩溃。写健壮模板必备。
逐层下钻 + 异常兜底把点号路径拆开一层层取,任何一层出错都退回默认——比裸 state.user.name(中间 null 会炸)安全得多。
💡 设计取舍②:为什么 text() 要"缺值返回默认",而普通取值 state.x 缺了会报错? 两种取值语义并存是故意的:普通 state.x 缺了就报错——适合"这个字段必须有,缺了说明流程逻辑错了,该早暴露";text(state,"x","默认") 缺了给默认——适合"这个字段可能没有,我要优雅降级不中断"。框架不替你决定哪种更好,而是给两种语义让你按字段的"必需/可选"性质自己选。这比"要么全报错、要么全静默"的一刀切更贴合真实数据的不确定性。
L08

边界 + 今日小结

⚠️ 边界:表达式是给"声明式 Flow"用的,Python Flow 里别硬凑 CEL 表达式的舞台是 YAML/声明式 Flowdo: {call: expression, expr: ...} 和 with 块字符串)。如果你写的是普通 Python Flow,就别用字符串表达式——直接 self.state.topic 才是对的,又快又有类型检查、IDE 还能补全。硬在 Python 里塞 CEL 字符串纯属自找麻烦:绕一大圈、丢了类型、还可能拼错到运行时才发现。记住定位:CEL 是"为了让非代码的声明能读数据"而生的,代码里你本来就能直接读。另一个坑:${...} 里的表达式若引用了 outputs.某方法 但那个方法此刻还没执行完,取到的就是空——表达式的可见 outputs 只含已完成方法。

🧠 今天你应该能回答

  • 声明式 Flow 为什么不能直接用 Python eval 读运行时数据?CEL 解决了什么?
  • ${...} 模板怎么切段?为什么找配对 } 要用词法器数括号?
  • evaluaterender_template 两个入口分别用在什么场景?
  • "整串一个表达式保类型、混文本转字符串"——这个规则解决了什么问题?
  • CEL 的沙盒边界到底在哪一行代码体现?表达式为什么碰不到 os?
  • 表达式能看到的上下文只有哪两个根?为什么用序列化副本?
  • state.xtext(state,"x","默认") 的语义差别是什么?各适合什么字段?

✋ 10 分钟动手

P=lib/crewai/src/crewai/flow
sed -n '83,98p'    $P/expressions.py     # 模板切段
sed -n '197,266p'  $P/expressions.py     # Expression 类三入口
sed -n '349,385p'  $P/expressions.py     # 保类型规则 + _evaluate_cel
sed -n '27,48p'    $P/expressions.py     # text() 安全取值
# 直接玩一下表达式求值(需要装 celpy)
python -c "
from crewai.flow.expressions import Expression
ctx={'state':{'count':3,'topic':'AI'},'outputs':{}}
print(Expression('\${state.count}').render_template(ctx))          # 3 (int)
print(Expression('共 \${state.count} 条关于 \${state.topic}').render_template(ctx))  # 共 3 条关于 AI
print(Expression('text(state, \"missing\", \"缺省\")').evaluate(ctx))   # 缺省
"
明日预告 · Day 47:Flow 还能当聊天机器人用。明天读 conversation.py 和对话式 Mixin:ChatState 怎么存多轮消息、kickoff(user_message=...) 怎么归一化成一轮对话、意图分类怎么接回 router。
← Day 45 状态持久化 Day 47 · conversation 对话式 →