func 与 pregel 的接缝:一次 @task 调用如何变成一个真节点
昨天留了个悬念:_call_with_options 里那句 impl = config[CONF][CONFIG_KEY_CALL] ——这个"提交任务"的回调到底是谁塞进 config 的?今天我们顺着这条线,从"你写的 inc(n)"一路追到"引擎里一个 PregelExecutableTask"。看完你会明白:函数式的 task 不是编译期的静态节点,而是运行时动态长出来的 PUSH 任务——和 Day 16 的 Send 扇出用的是同一套机制。
inc(1); inc(2); inc(3) 明明并行跑了三个活儿。它们既不在静态 nodes 字典里,也没写 add_node——这三个任务是从哪冒出来的?谁调度的?结果又怎么传回给 Future 的?@task 调用走的正是 PUSH 通路:entrypoint 节点在跑的时候,每调一次 task,就通过 config 里的回调向引擎"推"一个新任务,引擎当场调度、跑完把结果塞回你手里的 Future。类比:主厨(entrypoint 节点)做菜时随时喊"传菜口再来一份小料"(PUSH task),厨房立刻加派一个人做,做好递回主厨手里。悬念揭晓:CONFIG_KEY_CALL 的回调是 runner 塞的
昨天 _call_with_options 从 config 里取回调 pregel/_call.py:288:
# pregel/_call.py:288
config = get_config()
impl = config[CONF][CONFIG_KEY_CALL] # ← 这个 impl 是谁放的?
fut = impl(func, (args, kwargs), retry_policy=retry_policy,
cache_policy=cache_policy, callbacks=config["callbacks"], timeout=timeout)
return fut
答案在执行器 runner 启动每个任务时。它把 _call 用 partial 绑好一堆上下文,塞进 CONFIG_KEY_CALL pregel/_runner.py:211:
# pregel/_runner.py:211
run_with_retry(
t,
retry_policy,
configurable={
CONFIG_KEY_CALL: partial(
_call, # ← 就是它!
weakref.ref(t), # 当前正在跑的任务(弱引用)
retry_policy=retry_policy,
futures=weakref.ref(futures), # 收集子任务 Future 的容器
schedule_task=schedule_task, # "向引擎追加任务"的钩子
submit=self.submit, # 线程池提交器
),
},
)
partial(_call, ...)runner 把"当前任务、futures 容器、schedule_task、submit"这些只有引擎知道的东西预先绑到 _call 上,做成一个"傻瓜按钮"塞进 config。CONFIG_KEY_CALL: 这个 partial于是你在函数体里 inc(1) → _call_with_options → 从 config 取到这个按钮 → 一按,引擎就帮你排任务。task 和引擎的"暗号"就此接上。weakref.ref(t)用弱引用避免"回调持有任务、任务持有 config、config 持有回调"形成循环引用导致内存泄漏。_call:把一次调用变成一个 PUSH 任务
_call 是接缝的核心 pregel/_runner.py:700。它做三件事:造 Call、请引擎 schedule_task、拿到(或提交)任务的 Future:
# pregel/_runner.py:720
scratchpad = task().config[CONF][CONFIG_KEY_SCRATCHPAD]
if next_task := schedule_task( # ① 请引擎追加一个任务
task(),
scratchpad.call_counter(), # 这是本任务内第几次 task 调用(计数器)
Call( # ② 把这次调用打包成 Call
func, input,
retry_policy=retry_policy, cache_policy=cache_policy,
callbacks=callbacks, timeout=timeout,
),
):
...
elif next_task.writes: # ③a 若任务已经跑过(有写)→ 直接取缓存结果
fut = concurrent.futures.Future()
ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
if ret is not MISSING:
fut.set_result(ret)
...
else: # ③b 否则真正提交去线程池跑
fut = submit()(run_with_retry, next_task, retry_policy,
configurable={CONFIG_KEY_CALL: partial(_call, ...)},
__next_tick__=True)
futures()[fut] = next_task
scratchpad.call_counter()同一个父任务里第 1、2、3 次调 task,counter 递增。它参与生成子任务的确定性 id——这样恢复重跑时,同一次调用能对上同一个任务(幂等基础)。schedule_task(...)把 Call 交给引擎,引擎按 PUSH 规则派生一个 PregelExecutableTask(下一超步执行)并返回它。if next_task.writes: 取 RETURN幂等/缓存命中:如果这个任务此前已经跑完(writes 里有结果),不重跑,直接从 RETURN 写里取值填进 Future。L06 细讲。else: submit(run_with_retry, ...)没跑过就丢进线程池,并给它也塞一个 CONFIG_KEY_CALL——这样 task 里还能再调 task(嵌套),递归地长出任务树。__next_tick__=True新任务放到"下一个超步"跑,保证本超步的写先提交/流式输出,顺序不乱(BSP 模型,Day 19-20)。Call:一次调用的"任务申请单"
Call 是个极简的数据容器 pregel/_algo.py:120,装着"跑这个 task 需要的一切":
# pregel/_algo.py:120
class Call:
__slots__ = ("func", "input", "retry_policy", "cache_policy",
"callbacks", "timeout")
func: Callable # 你的原函数
input: tuple[tuple[Any, ...], dict[str, Any]] # (args, kwargs)
retry_policy: Sequence[RetryPolicy] | None # 来自 @task 的重试策略
cache_policy: CachePolicy | None # 来自 @task 的缓存策略
callbacks: Callbacks
timeout: TimeoutPolicy | None
__slots__ 而非普通 dict:Call 会被大量创建(每次 task 调用一个),slots 省内存、访问更快。这是"高频小对象"的常见优化。func 本身不能直接当节点跑——引擎节点吃的是 Runnable。所以 get_runnable_for_task 把它裹一层 pregel/_call.py:200:
# pregel/_call.py:200
def get_runnable_for_task(func) -> Runnable:
...
run = RunnableCallable(func, ..., explode_args=True, trace=False, recurse=False)
seq = RunnableSeq(
run, # ① 跑你的函数
ChannelWrite([ChannelWriteEntry(RETURN)]), # ② 把返回值写进 RETURN 通道
name=name,
trace_inputs=functools.partial(_explode_args_trace_inputs, inspect.signature(func)),
)
...
return CACHE.setdefault(key, seq) # 缓存,同一 func 只包一次
RunnableCallable(func, explode_args=True)把普通函数变成 Runnable。explode_args=True 让它接收 (args, kwargs) 形式的输入并展开成实参调用。RunnableSeq(run, ChannelWrite(RETURN))关键:任务 = "跑函数" + "把结果写进 RETURN 通道"两步串起来。RETURN 是 task 传结果给父任务的专用通道。CACHE.setdefault同一个函数只包装一次并缓存 Runnable,避免每次调用都重复构造,也让 identifier(func) 稳定(缓存 key 用得上,D51)。RETURN 通道:task 的结果怎么回到 Future
L03 看到 task 跑完会把返回值写进 RETURN 通道。L02 看到 _call 从 next_task.writes 里挑出 c == RETURN 的写,填进 Future pregel/_runner.py:748:
# pregel/_runner.py:748
ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
if ret is not MISSING:
fut.set_result(ret) # ← 结果回到你手里的 Future
elif exc := next((v for c, v in next_task.writes if c == ERROR), None):
fut.set_exception(exc if isinstance(exc, BaseException) else Exception(exc))
else:
fut.set_result(None)
挑 RETURN 写任务的 writes 是一串 (通道名, 值)。task 只关心通道名叫 RETURN 的那条,它就是函数返回值。fut.set_result(ret)把值放进 Future。这时你在 entrypoint 里写的 f.result() 才不再阻塞、拿到结果。ERROR 写 → set_exceptiontask 里抛的异常也通过 writes 传回,Future 会重新抛出——所以 f.result() 能拿到 task 内部的报错。PUSH 任务如何"具现":prepare_push_task_functional
schedule_task 最终会调到 prepare_push_task_functional pregel/_algo.py:801,把 Call 变成一个真正的可执行任务。核心是命名和坐标:
# pregel/_algo.py:826
call = task_path[-1] # 取出附在路径末尾的 Call
proc_ = get_runnable_for_task(call.func) # L03 那个包装
name = proc_.name
...
checkpoint_ns = f"{parent_ns}{NS_SEP}{name}" if parent_ns else name # 命名空间嵌套
task_id = task_id_func(checkpoint_id_bytes, checkpoint_ns, str(step),
name, PUSH, task_path_str(task_path[1]), str(task_path[2]))
task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
然后组装成 PregelExecutableTask pregel/_algo.py:893:
# pregel/_algo.py:893
return PregelExecutableTask(
name,
call.input, # (args, kwargs)
proc_, # 要跑的 Runnable
writes,
patch_config(..., configurable={
CONFIG_KEY_TASK_ID: task_id,
CONFIG_KEY_SEND: writes.extend, # task 写结果的出口
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns, # 独立命名空间
CONFIG_KEY_RUNTIME: runtime,
...
}),
triggers, # PUSH_TRIGGER
call.retry_policy or retry_policy, # @task 的重试策略在此生效(D52)
cache_key, # @task 的缓存 key 在此生成(D51)
task_id,
in_progress_task_path,
timeout=call.timeout,
)
checkpoint_ns = parent|name子任务的命名空间挂在父任务下面(用 | 分隔)。这让每个 task 在存档里有独立坐标,恢复时能精确定位——和子图的命名空间嵌套是同一套(D50)。task_id 确定性生成id 由 checkpoint_ns、step、name、PUSH、路径共同哈希得到。同样的输入→同样的 id→恢复重跑能对上上次那个任务。call.retry_policy / cache_key你在 @task(retry_policy=..., cache_policy=...) 设的策略,此刻被搬进可执行任务,交给 D51/D52 的机制处理。@entrypoint 名为 wf 的图里调 compose("cats"),引擎会算出类似:name="compose"、checkpoint_ns="wf:<父task_id>|compose"、再拼上自己的 task_id 得到 task_checkpoint_ns="…|compose:<task_id>"。同一次调用只要输入和调用序号(call_counter)不变,task_id 就恒定——这正是恢复时"认得上上次那个任务"的锚点。
for n in numbers 有几个 task 取决于 numbers 多长)。静态分析根本画不出这张图。用 PUSH 动态派生,就把"图的形状"推迟到运行时按实际调用决定,天然支持数据驱动的并行/扇出。代价是无法在编译期画出完整图形(get_graph 只看到单节点),换来的是命令式代码的完整表达力。幂等复用:断点恢复时 task 不重跑 + 小结
回看 L02 那个分支——这正是 D47 docstring 说的"恢复后 compose_essay 不会重跑" pregel/_runner.py:745:
# pregel/_runner.py:745
elif next_task.writes:
# if it already ran, return the result
fut = concurrent.futures.Future()
ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
if ret is not MISSING:
fut.set_result(ret) # 直接返回上次的结果,不重新执行 func
next_task.writes 非空,直接取旧结果填 Future,跳过真正执行。这就是为什么"人工审阅期间等了半天,恢复后不会把文章重新生成一遍"。task_id 的确定性(L05)保证了"同一次调用"能对上"同一份存档写"。👶 那 @task 和 Day 16 的 Send 有什么区别?都是 PUSH。
👨🏫 底层都是 PUSH 任务,但入口不同:Send 是在 StateGraph 里 return [Send("node", arg)] 显式扇出到已定义的节点;@task 是在函数式代码里直接调用一个普通函数,引擎帮你把这个函数临时包装成任务。可以说 @task 是"给命令式代码用的 Send 糖衣"——你不用先定义节点,写函数、调函数即可。
🧠 今天你应该能回答
- CONFIG_KEY_CALL 的回调是谁塞的?(runner 用
partial(_call, ...)在启动任务时注入 config) - task 调用走的是 PULL 还是 PUSH?(PUSH,运行时动态派生,和 Send 同机制)
- Call 对象装了什么?(func、input、retry/cache 策略、callbacks、timeout)
- task 的结果怎么传回 Future?(RunnableSeq 把返回值写进 RETURN 通道,_call 从 writes 挑出来 set_result)
- 为什么不能编译期把 task 变静态节点?(task 数量/结构运行时才确定,如循环里调用)
- 恢复时 task 为什么不重跑?(RETURN 写已持久化,next_task.writes 非空则直接取旧结果)
✋ 10 分钟动手
# 1. 顺着接缝读三段
sed -n '700,786p' libs/langgraph/langgraph/pregel/_runner.py # _call
sed -n '200,242p' libs/langgraph/langgraph/pregel/_call.py # get_runnable_for_task
sed -n '801,933p' libs/langgraph/langgraph/pregel/_algo.py # prepare_push_task_functional
# 2. 观察 task 不重跑:中断再恢复
python - <<'PY'
import time
from langgraph.func import entrypoint, task
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
calls = {"n": 0}
@task
def compose(topic: str) -> str:
calls["n"] += 1
return f"essay about {topic}"
@entrypoint(checkpointer=InMemorySaver())
def wf(topic: str):
essay = compose(topic).result()
review = interrupt({"essay": essay})
return {"essay": essay, "review": review}
cfg = {"configurable": {"thread_id": "t"}}
list(wf.stream("cats", cfg)) # 触发 interrupt,compose 跑 1 次
list(wf.stream(Command(resume="good"), cfg)) # 恢复
print("compose 执行次数:", calls["n"]) # 仍是 1,没重跑
PY
add_node("sub", 编译好的子图) 是怎么工作的?父子图如何共享状态?明天读 graph/state.py 的 add_node、pregel/_read.py 的子图自动探测,看"图套图"。