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" 污染文本。图注:单表达式整串 → 保留 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 式数据,且保证只读、跨线程安全。图注:表达式的可见世界只有 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/声明式 Flow(
do: {call: expression, expr: ...} 和 with 块字符串)。如果你写的是普通 Python Flow,就别用字符串表达式——直接 self.state.topic 才是对的,又快又有类型检查、IDE 还能补全。硬在 Python 里塞 CEL 字符串纯属自找麻烦:绕一大圈、丢了类型、还可能拼错到运行时才发现。记住定位:CEL 是"为了让非代码的声明能读数据"而生的,代码里你本来就能直接读。另一个坑:${...} 里的表达式若引用了 outputs.某方法 但那个方法此刻还没执行完,取到的就是空——表达式的可见 outputs 只含已完成方法。🧠 今天你应该能回答
- 声明式 Flow 为什么不能直接用 Python
eval读运行时数据?CEL 解决了什么? ${...}模板怎么切段?为什么找配对}要用词法器数括号?evaluate和render_template两个入口分别用在什么场景?- "整串一个表达式保类型、混文本转字符串"——这个规则解决了什么问题?
- CEL 的沙盒边界到底在哪一行代码体现?表达式为什么碰不到 os?
- 表达式能看到的上下文只有哪两个根?为什么用序列化副本?
state.x和text(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。