Day 48 / 共 60 天 · 阶段 8 函数式 API 与子图

func 与 pregel 的接缝:一次 @task 调用如何变成一个真节点

昨天留了个悬念:_call_with_options 里那句 impl = config[CONF][CONFIG_KEY_CALL] ——这个"提交任务"的回调到底是谁塞进 config 的?今天我们顺着这条线,从"你写的 inc(n)"一路追到"引擎里一个 PregelExecutableTask"。看完你会明白:函数式的 task 不是编译期的静态节点,而是运行时动态长出来的 PUSH 任务——和 Day 16 的 Send 扇出用的是同一套机制。

📍 阶段 8 · 函数式 API 与子图(6 天)你在这里
D47 @entrypoint/@task D48 func→pregel D49 子图基础 D50 子图隔离/stream D51 CachePolicy D52 重试容错
🤔 痛点:entrypoint 编译成"单节点图",那 task 到底跑在哪个节点里? D47 我们看到 entrypoint 只组装了一个 PregelNode。可你在函数体里 inc(1); inc(2); inc(3) 明明并行跑了三个活儿。它们既不在静态 nodes 字典里,也没写 add_node——这三个任务是从哪冒出来的?谁调度的?结果又怎么传回给 Future 的?
💡 本质:task 调用 = 运行时向引擎"追加"一个 PUSH 任务 Pregel 有两种任务:PULL(编译期就在图里、被通道触发的静态节点)和 PUSH(运行时被"推"进来的动态任务,Day 16 的 Send 就是 PUSH)。@task 调用走的正是 PUSH 通路:entrypoint 节点在跑的时候,每调一次 task,就通过 config 里的回调向引擎"推"一个新任务,引擎当场调度、跑完把结果塞回你手里的 Future。类比:主厨(entrypoint 节点)做菜时随时喊"传菜口再来一份小料"(PUSH task),厨房立刻加派一个人做,做好递回主厨手里。
L01

悬念揭晓: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 启动每个任务时。它把 _callpartial 绑好一堆上下文,塞进 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 持有回调"形成循环引用导致内存泄漏。
💡 本质:依赖注入task 的代码在用户模块里,引擎在框架内部。二者不能硬编码互相 import。于是引擎"运行时把能力(提交任务的回调)注入到 config",task 运行时"从 config 取能力"。config 就是它们之间的传话筒——这也是为什么 task 必须在图内部调用(脱离图 config 里没这个回调)。
L02

_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)。
L03

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 省内存、访问更快。这是"高频小对象"的常见优化。
数据结构:Call → 包装 → PregelExecutableTask Call (申请单) func input=(args,kwargs) retry_policy cache_policy timeout RunnableSeq ① 跑 func ② 写 RETURN PregelExecutableTask name / input / proc checkpoint_ns=父|name retry_policy / cache_key task_id(确定性) triggers=PUSH
图注:一次 task 调用的申请单 Call,经 get_runnable_for_task 包装,具现成 PUSH 型可执行任务。

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)。
L04

RETURN 通道:task 的结果怎么回到 Future

L03 看到 task 跑完会把返回值写进 RETURN 通道。L02 看到 _callnext_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 内部的报错。
💡 设计取舍①:为什么 task 结果走 RETURN 通道,而不是函数直接 return 给调用者?朴素想法是"task 跑完把返回值直接塞进 Future 完事"。但 LangGraph 让结果先写进 RETURN 通道再由 _call 取出。多这一步是为了让 task 结果纳入统一的 writes 体系:writes 会被 checkpoint 持久化(Day 34)、会参与幂等去重(Day 46)、会被缓存复用(Day 51)。如果结果绕开通道直接返回,这些能力就全都用不上了——恢复时无法知道"这个 task 上次返回了啥"。用"结果也是一种通道写"这个统一抽象,换来了持久化/幂等/缓存三大能力的免费复用。代价是多一次通道读写的间接,但对可靠性来说完全值得。
控制流:一次 inc(n) 从调用到拿结果 你写 inc(n) _TaskFunction.__call__ _call_with_options 取 CONFIG_KEY_CALL _call (runner) 造 Call + schedule_task PUSH 任务被调度 RunnableSeq: 跑函数→写RETURN Future.set_result 从 writes 挑 RETURN f.result() 拿到 n+1
图注:调用 → 取回调 → 造 Call 调度 PUSH → 跑完写 RETURN → 回填 Future → result()。
L05

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 的机制处理。
📝 一次 compose(topic) 调用具现出的真实坐标@entrypoint 名为 wf 的图里调 compose("cats"),引擎会算出类似:name="compose"checkpoint_ns="wf:<父task_id>|compose"、再拼上自己的 task_id 得到 task_checkpoint_ns="…|compose:<task_id>"。同一次调用只要输入和调用序号(call_counter)不变,task_id 就恒定——这正是恢复时"认得上上次那个任务"的锚点。
💡 设计取舍①:为什么 task 走"运行时 PUSH",而不是编译期变成静态节点?朴素想法是"扫描 entrypoint 函数体,把每个 task 调用抽成一个静态节点连边"。但函数体是命令式代码——task 可能在循环里、if 分支里、甚至递归里调用,数量和结构直到运行时才确定(比如 for n in numbers 有几个 task 取决于 numbers 多长)。静态分析根本画不出这张图。用 PUSH 动态派生,就把"图的形状"推迟到运行时按实际调用决定,天然支持数据驱动的并行/扇出。代价是无法在编译期画出完整图形(get_graph 只看到单节点),换来的是命令式代码的完整表达力。
L06

幂等复用:断点恢复时 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
💡 本质:task 的结果被当作 checkpoint 写持久化了task 跑完,RETURN 写进 checkpoint(Day 34 的 put_writes)。当 entrypoint 因 interrupt 中断、之后恢复时,引擎重放到同一个 task 调用,发现它的 writes 已在档里——于是 next_task.writes 非空,直接取旧结果填 Future,跳过真正执行。这就是为什么"人工审阅期间等了半天,恢复后不会把文章重新生成一遍"。task_id 的确定性(L05)保证了"同一次调用"能对上"同一份存档写"。
⚠️ 边界:task 的输入/输出必须可序列化(开了 checkpointer 时)因为要把 RETURN 结果存进 checkpoint 再读回,task 的参数和返回值必须能被序列化(Day 39 的 serde)。返回一个数据库连接、文件句柄、lambda 之类不可序列化的东西,恢复时会炸。docstring func/__init__.py:154 明确写了这条约束。规避:task 返回可序列化的"数据",副作用(写库、发请求)在 task 内部完成即可。

👶 那 @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
明天预告 · Day 49:函数式讲完,进入"子图"。一张图能不能当另一张图的节点?add_node("sub", 编译好的子图) 是怎么工作的?父子图如何共享状态?明天读 graph/state.py 的 add_node、pregel/_read.py 的子图自动探测,看"图套图"。
← Day 47 函数式 API Day 49 · 子图基础 →