LangGraph Send:你还在循环里硬调节点?Send 焊死「动态并发路由」,从单播广播到 Map-Reduce 一篇打通

LangGraph Send:你还在循环里硬调节点?Send 焊死「动态并发路由」,从单播广播到 Map-Reduce 一篇打通

基于 LangGraph 1.0(兼容 0.2.x+)编写,API 可能迭代,请以官方文档为准


一、痛点开场:for 循环是反模式,而且藏得很深

我第一次在 LangGraph 里写生产级工作流的时候,踩过一个很蠢的坑。这个坑蠢就蠢在,代码能跑,逻辑也对,单元测试通得过,甚至上线初期都没出问题------但它从根本上背叛了 LangGraph 的设计哲学。

1.1 一个看似合理的坏例子

需求是这样的:用户输入一个主题,系统先用 LLM 生成 5 个相关的子主题("加班"、"调试"、"需求变更"、"开会"、"写周报"),然后针对每个子主题调用同一个 generate_joke 函数生成笑话,最后汇总返回。

我的第一反应很直觉:在节点 A 里写一个 for 循环,逐个调用节点 B 的函数。

python 复制代码
from typing import TypedDict, List

class AgentState(TypedDict):
    topic: str
    jokes: List[str]

def make_joke_subjects(state: AgentState):
    """
    生成笑话主题并逐个生成笑话
    """
    # 调用 LLM 生成 5 个主题
    subjects = llm.generate_subjects(state["topic"])
    
    jokes = []
    for subject in subjects:
        # 每个主题串行生成笑话
        joke = generate_joke_node({"subject": subject})
        jokes.append(joke)
    
    return {"jokes": jokes}

这段代码跑了三个月,处理了上千次请求,看起来一切正常。直到我试图在 LangGraph Studio 里追踪一次异常调用,才发现问题有多严重。

1.2 三个隐藏的致命伤

第一,完全失去并行能力。

5 个笑话串行生成,假设每次 LLM 调用 2 秒,5 次就是 10 秒。如果 LangGraph 的调度器能看到这 5 个任务是独立的,它可以并行下发,理论上还是 2 秒------但我的 for 循环把调度器蒙在鼓里。调度器只看到"一个节点执行完毕",根本不知道里面藏了 5 次子调用。

更糟的是,我用的 LLM 有 rate limit。串行调用意味着我必须等前一个返回才能发下一个请求,而如果并行调用,我可以在同一时间窗口内利用 rate limit 的并发额度。举个例子,如果 rate limit 是每分钟 20 次请求,串行模式下 5 次调用要 10 秒;并行模式下只要 2 秒,剩余时间还能处理更多请求。

第二,失去独立重试能力。

有一天晚上第 3 个主题的 LLM 调用超时了。LangGraph 的 checkpoint 机制按节点粒度做持久化------整个 make_joke_subjects 节点失败后,只能从节点入口重试。这意味着前面已经生成好的 2 个笑话也丢了,必须从头再来。

如果我用 Send 把每次调用拆成独立节点,LangGraph 只会重试失败的那个 Send,其他 4 个的结果已经安全地存在 checkpoint 里了。这就是"节点级持久化"的真正含义:持久化的粒度越细,容错能力越强。

第三,失去可观测性。

LangGraph Studio 的追踪界面里,我只看到 make_joke_subjects 这一个节点。它是绿色的(成功)还是红色的(失败)?那 5 个笑话生成各自花了多久?哪个子主题的调用卡了 8 秒?返回值里有没有格式异常?

全不知道。图给我的信息粒度,就是我给图的节点粒度。我把 5 个独立任务塞在一个节点里,图就给我 1 个数据点。调试的时候跟瞎子一样,只能在外面加 print,回到了没有 LangGraph 之前的状态。

1.3 根本问题:我在图里挖了个洞

这三个问题的共同根因是:我在节点内部用代码逻辑替代了图调度器的工作

LangGraph 的设计假设是:你把业务拆成节点,调度器负责节点的编排、并发、重试、追踪。这是一种声明式的编程范式------你"声明"节点和边的关系,调度器"执行"最优的调度策略。

而我的 for 循环是一种命令式编程------我"命令"代码按什么顺序执行,调度器无从干预。结果就是,LangGraph 的四大核心能力被我亲手干掉了:

  • 并行执行
  • 断点续跑(checkpoint 粒度粗化)
  • 可视化追踪
  • 条件路由的动态决策

后来我读到 LangGraph 很早就引入的 Send 机制,看完源码才意识到:这就是为了解决这个问题而生的。


二、Send 的核心原理:从"图内黑盒"到"声明式并发"

2.1 本质:条件边返回 Send 对象列表

在理解 Send 之前,先回顾 LangGraph 的两种基础边:

普通边(Fixed Edge):

python 复制代码
builder.add_edge("node_a", "node_b")

无条件连接,A 执行完必定走到 B。适合线性流程。

条件边(Conditional Edge):

python 复制代码
def router(state):
    if state["score"] > 0.5:
        return "high_score_node"
    else:
        return "low_score_node"

builder.add_conditional_edges("node_a", router, {
    "high_score_node": "high_score_node",
    "low_score_node": "low_score_node"
})

路由函数返回字符串,调度器根据字符串找下一个节点。这是"动态路由",但仍然是单播------一次只走一个分支。

Send 条件边(Dynamic Fan-out):

python 复制代码
from langgraph.graph import StateGraph
from langgraph.types import Send

def router(state):
    # 返回 Send 对象列表,不是字符串!
    return [
        Send("translator", {"text": state["text"], "target_lang": "en"}),
        Send("translator", {"text": state["text"], "target_lang": "jp"}),
        Send("translator", {"text": state["text"], "target_lang": "fr"})
    ]

builder.add_conditional_edges("node_a", router, {"translator": "translator"})

路由函数返回的是 Send 对象列表。每个 Send(target_node, state_override) 包含两个信息:

  1. 目标节点:图里哪个已注册的节点负责执行这次任务
  2. 状态覆盖:一个字典,会覆盖当前全局状态中对应的字段,形成这次调用的专属状态副本

调度器拿到这个列表后,会并行发起所有 Send 指定的节点执行。这是从"单播"到"广播"的关键一跃。

2.2 单播 vs 广播:两种 Send 模式

单播模式(一对一)

某些场景下,你只需要根据条件触发一次目标节点,和普通条件边类似,但带状态覆盖:

python 复制代码
def route_single(state):
    # 根据条件只发一个 Send
    if state["urgency"] == "high":
        return Send("fast_process", {"priority": 1, "timeout": 30})
    else:
        return Send("slow_process", {"priority": 3, "timeout": 120})

这本质上等价于普通条件边,但优势在于你可以精细化控制传递给目标节点的状态------只传它需要的字段,而不是整个全局状态。

广播模式(一对多)

这是 Send 最强大的用法,也是 Map-Reduce 的基础:

python 复制代码
def route_broadcast(state):
    # 为列表中的每个元素生成一个 Send
    return [
        Send("process", {"item": item, "index": i})
        for i, item in enumerate(state["items"])
    ]

如果 state["items"] 有 100 个元素,这里会返回 100 个 Send 对象,调度器会尝试并行启动 100 个 process 节点实例。每个实例拿到的是全局状态 + {"item": ..., "index": ...} 的覆盖,互不影响。

2.3 底层调度机制解析

从 LangGraph 的源码看(核心在 Pregel 类和 Task 类),Send 的调度流程是这样的:

复制代码
Step 1: 条件边路由函数执行完毕
    |
Step 2: 返回值类型检查
    - 如果是字符串    -> 普通条件边,解析为节点名
    - 如果是 Send 对象  -> 提取 (target_node, state_override)
    - 如果是 Send 列表  -> 提取 N 个 (target_node_i, state_override_i)
    |
Step 3: 创建 Task 列表
    对列表中的每个 Send:
    - 创建独立的 Task 对象
    - 将全局状态与 state_override 合并(override 优先级更高)
    - Task 标记为可并行(只要目标节点不是其他 Task 的依赖)
    |
Step 4: 提交执行池
    - 所有独立的 Task 进入线程池或协程池并行执行
    - 每个 Task 的执行上下文隔离
    |
Step 5: 等待全部完成
    - 收集每个 Task 的返回值
    - 用 reducer 合并回全局状态
    |
Step 6: 触发下游节点
    - 所有 Send 的目标节点执行完毕后,触发条件边的下游(通常是聚合节点)

几个关键实现细节:

  • 状态隔离:每个 Send 的状态覆盖只在本次 Task 的生命周期内有效,不会污染全局状态。Task 返回后,返回值经 reducer 合并。
  • 并行度 :默认通过 asyncio.to_thread(Python 3.9+)把同步节点丢到线程池执行;异步节点则用 asyncio.gather 并发。可在调用时通过 config 传 max_concurrency 限制同一时间最多执行的节点数,防止资源打满。
  • 执行顺序:并行 Task 之间没有保证的执行顺序,返回值的合并顺序取决于哪个 Task 先完成。如果你的 reducer 对顺序敏感(比如字符串拼接),要注意这一点。

2.4 add_conditional_edges 的配合方式

完整的接线方式要注意第三个参数:

python 复制代码
from langgraph.graph import StateGraph
from langgraph.types import Send

builder = StateGraph(AgentState)

# 注册所有节点
builder.add_node("generate_subjects", generate_subjects)
builder.add_node("generate_joke", generate_joke)
builder.add_node("aggregate", aggregate_jokes)

# 条件边:generate_subjects -> 动态分发到 generate_joke
builder.add_conditional_edges(
    "generate_subjects",                    # 起始节点
    lambda state: [                         # 路由函数
        Send("generate_joke", {"subject": s})
        for s in state["subjects"]
    ],
    # 第三个参数:所有可能被 Send 到的目标节点
    # 用于编译期的图拓扑校验
    {"generate_joke": "generate_joke"}     # 或者 ["generate_joke"]
)

# 所有 generate_joke 完成后统一到 aggregate
builder.add_edge("generate_joke", "aggregate")

builder.add_edge("aggregate", END)
graph = builder.compile()

第三个参数的几种写法:

python 复制代码
# 写法 1:字典映射(推荐)
{"generate_joke": "generate_joke"}

# 写法 2:列表
["generate_joke"]

# 写法 3:如果有多个可能目标
{
    "generate_joke": "generate_joke",
    "fallback_node": "fallback_node"
}

这个参数不参与运行时逻辑------无论路由函数返回多少个 Send,调度器都会执行。它的存在是为了让 LangGraph 在 compile() 时验证:所有可能被 Send 到的节点,确实已经在图里注册过了。

为什么需要这个校验?

LangGraph 在编译期做可达性分析,确保图里没有"死节点"(注册了但永远不会被走到)和"悬空引用"(路由指向不存在的节点)。这个设计让错误在开发期暴露,而不是运行时才炸。

实际开发中,第三个参数最常见的坑是:路由函数返回了动态计算出的节点名,但第三个参数没包含。比如:

python 复制代码
# 错误示例
def bad_router(state):
    lang = state["target_lang"]
    return Send(f"translate_{lang}", {...})  # 动态节点名

builder.add_conditional_edges(
    "start",
    bad_router,
    {"translate_en": "translate_en"}  # 漏了 translate_jp, translate_fr...
)

如果 target_lang 是运行时传入的 "jp",但第三个参数只写了 "translate_en",编译时不会报错(因为 "translate_en" 确实存在),运行时调度器会尝试路由到 "translate_jp" 却发现图里没有这个节点,抛出 ValueError

正确做法

  1. 如果节点名是动态生成的,确保所有可能值都在第三个参数里列出。
  2. 或者用静态节点名,把动态参数放进 Send 的 arg 里:
python 复制代码
# 正确示例:静态节点名 + 动态参数
def good_router(state):
    return Send("translator", {
        "text": state["text"],
        "target_lang": state["target_lang"]  # 动态参数放 arg 里
    })

builder.add_conditional_edges(
    "start",
    good_router,
    {"translator": "translator"}  # 只有一个静态节点
)

2.5 状态传递的完整链路

很多初学者对 Send 中的状态覆盖理解不清。完整的链路是这样的:

复制代码
全局状态(Global State)
    |-- field_a: "value_a"
    |-- field_b: "value_b"
    |-- field_c: "value_c"

Send(target="process", arg={"field_b": "override_b", "field_d": "new_d"})
    |
目标节点接收到的状态(Merged State):
    |-- field_a: "value_a"       <- 来自全局,未覆盖
    |-- field_b: "override_b"    <- 被 Send 覆盖
    |-- field_c: "value_c"       <- 来自全局,未覆盖
    |-- field_d: "new_d"         <- Send 新增的字段

目标节点返回:
    {"field_e": "result_e"}
    |
经 reducer 合并回全局状态:
    |-- field_a: "value_a"
    |-- field_b: "value_b"       <- 注意:目标节点没返回 field_b,全局保持原值
    |-- field_c: "value_c"
    |-- field_d: "new_d"         <- 如果 reducer 允许,可能保留
    |-- field_e: "result_e"      <- 新增

关键点:Send 的覆盖只影响目标节点的输入状态,不影响全局状态本身。目标节点返回的字段再经 reducer 决定如何合并到全局。


三、代码实战:三个完整示例

以下所有代码基于 LangGraph 1.0(兼容 0.2.x+)验证,安装命令:

bash 复制代码
pip install langgraph langchain langchain-openai

3.1 示例一:多语言并行翻译

场景:用户输入一段中文文本,系统同时翻译成英、日、韩、德四种语言,最后汇总展示。

这个例子的设计重点是展示"同构任务并行化"------同一个节点逻辑被并行调用多次,每次传入不同的参数。

状态设计

python 复制代码
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
from langchain_openai import ChatOpenAI
import operator

# LLM 初始化(API 密钥填空)
llm = ChatOpenAI(model="gpt-4o-mini", api_key="___")

# 全局状态:翻译工作流的全局数据结构
class TranslationState(TypedDict):
    text: str                          # 原始文本
    target_languages: List[str]         # 目标语言列表
    translations: Annotated[List[dict], operator.add]  # 聚合结果

# 单个翻译任务的状态(Send 传递的局部状态)
class SingleTranslationState(TypedDict):
    text: str                          # 要翻译的文本
    target_lang: str                   # 目标语言

节点实现

python 复制代码
def router_node(state: TranslationState):
    """
    路由节点:准备目标语言列表
    这里也可以直接由外部输入提供,不需要 LLM 生成
    """
    return {
        "target_languages": ["英语", "日语", "韩语", "德语"]
    }

def translate_node(state: SingleTranslationState):
    """
    翻译节点:接收单个翻译任务,调用 LLM 完成翻译
    这个节点会被并行调用多次,每次处理一种语言
    """
    prompt = f"""
    请将以下中文文本翻译成{state['target_lang']}。
    只返回翻译结果,不要解释:

    {state['text']}
    """

    result = llm.invoke(prompt)

    # 返回结构化的翻译结果,方便 reducer 聚合
    return {
        "translations": [
            {
                "language": state["target_lang"],
                "original": state["text"],
                "translated": result.content.strip()
            }
        ]
    }

def aggregate_translations(state: TranslationState):
    """
    聚合节点:所有翻译完成后,整理最终输出
    由于 translations 用了 operator.add,到这里已经是完整的列表了
    """
    # 可以在这里做后处理,比如格式化、去重、排序
    sorted_translations = sorted(
        state["translations"],
        key=lambda x: state["target_languages"].index(x["language"])
        if x["language"] in state["target_languages"] else 999
    )

    return {"translations": sorted_translations}

路由函数

python 复制代码
def route_to_translators(state: TranslationState):
    """
    条件边路由函数:为每种目标语言生成一个 Send
    这是 Send 的核心用法------从单一节点分发到多个并行节点
    """
    return [
        Send("translator", {"text": state["text"], "target_lang": lang})
        for lang in state["target_languages"]
    ]

图组装

python 复制代码
# 构建图
builder = StateGraph(TranslationState)

# 注册节点
builder.add_node("router", router_node)
builder.add_node("translator", translate_node)
builder.add_node("aggregate", aggregate_translations)

# 连线:START -> router -> [并行 translator] -> aggregate -> END
builder.add_edge(START, "router")
builder.add_conditional_edges(
    "router",
    route_to_translators,
    {"translator": "translator"}  # 编译期校验
)
builder.add_edge("translator", "aggregate")
builder.add_edge("aggregate", END)

# 编译
app = builder.compile()

# 执行
text = "人工智能正在改变我们的生活方式。"
result = app.invoke({"text": text})

# 输出结果
print(f"原文:{text}\n")
for t in result["translations"]:
    print(f"[{t['language']}] {t['translated']}")

输出示例

复制代码
原文:人工智能正在改变我们的生活方式。

[英语] Artificial intelligence is changing the way we live.
[日语] 人工知能が私たちの生活の仕方を変えています。
[韩语] 인공지능이 우리의 생활 방식을 바꾸고 있습니다.
[德语] Künstliche Intelligenz verändert unsere Lebensweise.

设计要点回顾

  • translations 字段用了 Annotated[List[dict], operator.add]------每个 translator 节点返回一个单元素列表,LangGraph 自动追加合并。
  • SingleTranslationState 是 translator 节点的局部状态类型。它不需要包含全局状态的所有字段,只需要自己用到的。Send 传递时会自动合并。
  • 路由函数里列表推导式一次性生成所有 Send,调度器在看到完整列表后才启动并行执行。

3.2 示例二:Map-Reduce 文档总结

场景:一篇长文档,先分块,每块并行提取要点(Map),最后汇总成总体摘要(Reduce)。

这是 Send 最经典的用例,也是很多 LLM 应用的性能瓶颈所在。比如 RAG 系统里,一个长文档可能需要分 20 个 chunk,串行处理要 40 秒,并行处理只要 2 秒。

状态设计

python 复制代码
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
from langchain_openai import ChatOpenAI
import operator

llm = ChatOpenAI(model="gpt-4o-mini", api_key="___")

# 全局状态
class MapReduceState(TypedDict):
    document: str                  # 原始完整文档
    chunk_size: int                # 分块大小(字符数)
    chunks: List[str]              # 分块结果
    summaries: Annotated[List[str], operator.add]  # 各块摘要(Map 输出)
    final_summary: str             # 最终汇总(Reduce 输出)
    error_count: int               # 错误计数(容错设计)

# 单个 Map 任务的状态
class MapState(TypedDict):
    chunk: str                     # 当前 chunk 内容
    index: int                     # chunk 序号(用于调试)

分块策略

python 复制代码
def split_document(state: MapReduceState):
    """
    文档分块节点。

    分块策略选择:
    - 固定字符数:简单,但可能切断句子
    - 按段落分:保持语义完整,但块大小不均匀
    - 递归字符:LangChain 的 RecursiveCharacterTextSplitter
    - 语义分块:用 embedding 相似度判断断点

    这里演示最简单的按段落分,实际项目建议用语义分块
    """
    # 按双换行分段
    raw_chunks = state["document"].split("\n\n")

    # 过滤空段落并做长度限制
    chunks = []
    current_chunk = ""

    for paragraph in raw_chunks:
        paragraph = paragraph.strip()
        if not paragraph:
            continue

        # 如果当前段落加入后不超过限制,就合并
        if len(current_chunk) + len(paragraph) < state.get("chunk_size", 500):
            current_chunk += paragraph + "\n\n"
        else:
            # 先保存当前块
            if current_chunk:
                chunks.append(current_chunk.strip())
            # 开始新块
            current_chunk = paragraph + "\n\n"

    # 不要遗漏最后一个块
    if current_chunk:
        chunks.append(current_chunk.strip())

    return {"chunks": chunks}

Map 节点

python 复制代码
def map_summarize(state: MapState):
    """
    Map 节点:处理单个 chunk,提取核心要点。

    设计考虑:
    1. 每个 chunk 独立处理,互不影响
    2. 返回格式固定,方便 reducer 聚合
    3. 加入容错:LLM 调用失败时返回错误标记
    """
    try:
        prompt = f"""
        请用 2-3 句话总结以下段落的核心观点。
        只返回总结内容,不要添加标题或编号:

        {state['chunk']}
        """

        result = llm.invoke(prompt)
        summary = result.content.strip()

        return {
            "summaries": [f"[段落 {state['index']}] {summary}"]
        }

    except Exception as e:
        # 容错处理:单个 chunk 失败不影响整体
        # 实际项目中可以记录日志、增加重试等
        return {
            "summaries": [f"[段落 {state['index']}] [处理失败: {str(e)}]"]
        }

Reduce 节点

python 复制代码
def reduce_summarize(state: MapReduceState):
    """
    Reduce 节点:汇总所有 Map 输出,生成最终摘要。

    所有 Map 节点完成后,它们的 summaries 已经通过 operator.add
    聚合到全局状态中。这里做最终的综合和精炼。
    """
    # 过滤掉失败的条目(成功摘要不会包含"[处理失败"标记,按标记过滤最可靠)
    valid_summaries = [
        s for s in state["summaries"]
        if "[处理失败" not in s
    ]

    if not valid_summaries:
        return {"final_summary": "[所有段落处理失败,无法生成摘要]"}

    # 组合所有有效摘要
    combined = "\n".join(valid_summaries)

    prompt = f"""
    基于以下各段摘要,写一段 200 字以内的总体总结。
    要求:
    1. 涵盖所有段落的核心观点
    2. 逻辑连贯,像一篇完整的摘要
    3. 不要列出编号或段落标记

    {combined}
    """

    result = llm.invoke(prompt)

    return {"final_summary": result.content.strip()}

路由函数

python 复制代码
def route_map(state: MapReduceState):
    """
    Map 路由:为每个 chunk 生成一个 Send。

    这是 Map-Reduce 的核心------从 1 个分块节点到 N 个 Map 节点的广播。
    每个 Send 携带独立的 chunk 和序号,互不影响。
    """
    return [
        Send("mapper", {"chunk": chunk, "index": i})
        for i, chunk in enumerate(state["chunks"])
    ]

图组装与执行

python 复制代码
# 构建图
builder = StateGraph(MapReduceState)

# 注册节点
builder.add_node("splitter", split_document)
builder.add_node("mapper", map_summarize)
builder.add_node("reducer", reduce_summarize)

# 连线:START -> splitter -> [并行 mapper] -> reducer -> END
builder.add_edge(START, "splitter")
builder.add_conditional_edges(
    "splitter",
    route_map,
    {"mapper": "mapper"}
)
builder.add_edge("mapper", "reducer")
builder.add_edge("reducer", END)

app = builder.compile()

# 测试文档
document = """
LangGraph 是一个用于构建 LLM 应用的图框架。

它允许你将应用建模为有状态图,其中节点是函数,边是节点之间的连接。

Send 是 LangGraph 很早就引入的核心特性,用于实现动态并发路由。

通过 Send,你可以在条件边中返回多个目标节点调用,实现 Map-Reduce 等并行模式。

每个 Send 携带独立的状态副本,调度器会并行执行所有 Send 指定的节点。

这意味着你可以把一个大任务拆成多个独立子任务并行处理,最后再汇总结果。

对于长文档处理、批量数据分析、多语言翻译等场景,Send 能显著降低总执行时间。
"""

# 执行
result = app.invoke({
    "document": document,
    "chunk_size": 200  # 每块约 200 字符
})

# 输出
print("=== 各段摘要 ===")
for s in result["summaries"]:
    print(f"  {s}")

print(f"\n=== 最终总结 ===\n{result['final_summary']}")

关键设计决策分析

  1. 分块放在图内还是图外?

    • 图内(当前做法):分块逻辑可以被追踪和重试。如果分块策略复杂(比如需要 LLM 判断断点),放在图里更有价值。
    • 图外:如果分块是纯算法(如固定 Token 数),可以放在 invoke 之前,减少图的复杂度。
  2. operator.add vs 自定义 reducer

    • operator.add 是列表追加,适合 Map 输出是列表的场景。
    • 如果需要去重、排序、最大保留等逻辑,写自定义 reducer。
  3. Reduce 的触发时机

    • 所有 mapper 节点都完成后,调度器自动触发 reducer。不需要手动计数或等待。
    • 这是声明式编程的好处:你声明"mapper 之后是 reducer",调度器处理依赖关系。

3.3 示例三:笑话生成(修复文章开头的反模式)

这是文章开头那个 for 循环例子的正确写法,展示了从反模式到正模式的完整重构。

状态设计

python 复制代码
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
from langchain_openai import ChatOpenAI
import operator

llm = ChatOpenAI(model="gpt-4o-mini", api_key="___")

# 全局状态
class JokeState(TypedDict):
    topic: str                     # 用户输入的主题
    count: int                     # 生成数量
    subjects: List[str]            # LLM 生成的子主题列表
    jokes: Annotated[List[dict], operator.add]  # 最终笑话列表

# 子主题生成节点的输入
class SubjectState(TypedDict):
    topic: str
    count: int

# 单个笑话生成节点的输入
class SingleJokeState(TypedDict):
    subject: str                   # 子主题
    index: int                     # 序号

节点实现

python 复制代码
def generate_subjects(state: SubjectState):
    """
    生成笑话子主题。

    这个节点只做一件事:基于主题生成 N 个不同角度的子主题。
    不在这里调用笑话生成------那是另一个节点的职责。
    """
    prompt = f"""
    基于主题'{state['topic']}',生成 {state['count']} 个不同角度的子主题。
    每个子主题应该是可以独立写成笑话的具体场景或角度。

    要求:
    1. 每行一个子主题,不要编号
    2. 子主题之间要有明显差异,覆盖不同角度
    3. 每个子主题 5-15 个字

    只返回子主题列表,不要其他内容。
    """

    result = llm.invoke(prompt)

    # 解析 LLM 输出
    subjects = [
        s.strip()
        for s in result.content.strip().split("\n")
        if s.strip() and not s.strip().startswith("#")
    ]

    # 确保数量正确(LLM 有时会多给或少给)
    subjects = subjects[:state["count"]]

    return {"subjects": subjects}

def generate_joke(state: SingleJokeState):
    """
    生成单个笑话。

    这个节点会被并行调用多次,每次处理一个子主题。
    由于状态隔离,每个调用互不干扰。
    """
    prompt = f"""
    写一个关于'{state['subject']}'的短笑话。

    要求:
    1. 50 字以内
    2. 适合程序员/互联网从业者
    3. 轻松幽默,不要冒犯

    只返回笑话内容。
    """

    result = llm.invoke(prompt)

    return {
        "jokes": [
            {
                "index": state["index"],
                "subject": state["subject"],
                "content": result.content.strip()
            }
        ]
    }

路由函数

python 复制代码
def route_subjects(state: JokeState):
    """
    核心路由:为每个子主题生成一个 Send。

    对比反模式中的 for 循环:
    - 反模式:在节点内部循环调用,调度器不可见
    - 正模式:在条件边中返回 Send 列表,调度器并行执行
    """
    return [
        Send("joke_generator", {"subject": subject, "index": i})
        for i, subject in enumerate(state["subjects"])
    ]

图组装

python 复制代码
# 构建图
builder = StateGraph(JokeState)

# 注册节点
builder.add_node("subject_generator", generate_subjects)
builder.add_node("joke_generator", generate_joke)

# 连线
builder.add_edge(START, "subject_generator")
builder.add_conditional_edges(
    "subject_generator",
    route_subjects,
    {"joke_generator": "joke_generator"}
)
# 所有 joke_generator 完成后直接结束
builder.add_edge("joke_generator", END)

app = builder.compile()

# 执行
result = app.invoke({
    "topic": "程序员",
    "count": 5
})

# 输出
print(f"主题:{result.get('topic', '程序员')}\n")
for joke in sorted(result["jokes"], key=lambda x: x["index"]):
    print(f"[{joke['index']}] {joke['subject']}")
    print(f"    {joke['content']}\n")

反模式 vs 正模式的对比分析

复制代码
反模式(串行黑盒)
+-----------------------+
|  make_joke_subjects   |
|  +-----------------+  |
|  | for joke 1      |  |  joke 1 -> 2秒
|  | for joke 2      |  |  joke 2 -> 2秒  串行等待
|  | for joke 3      |  |  joke 3 -> 2秒
|  | for joke 4      |  |  joke 4 -> 2秒  总时间:10秒
|  | for joke 5      |  |  joke 5 -> 2秒
|  +-----------------+  |
+-----------------------+

正模式(并行白盒)
+--------------------+
|  subject_generator |----Send("joke_1")---> joke_generator(1) -> 2秒
+--------------------+----Send("joke_2")---> joke_generator(2) -> 2秒
     |                    Send("joke_3")---> joke_generator(3) -> 2秒
     |                    Send("joke_4")---> joke_generator(4) -> 2秒
     |                    Send("joke_5")---> joke_generator(5) -> 2秒
     |
     总时间:约 2 秒(并行执行)

核心差异总结

维度 反模式(循环调用) 正模式(Send)
执行方式 串行 并行
失败影响范围 整个节点重试 单个 Send 重试
可观测性 LangGraph Studio 看不到内部 每个 Send 独立节点可见
状态隔离 共享 mutable list 每个 Send 独立状态副本
代码耦合 节点 A 硬编码节点 B 的调用 纯声明式,节点互不知情
调试难度 高(黑盒) 低(白盒)
性能天花板 串行时间总和 并行时间约等于单个时间
扩展性 改循环逻辑等于改节点代码 改 Send 列表等于改路由函数

3.4 错误做法 vs 正确做法的完整对比

为了更清楚地展示区别,我把反模式和正模式并排写在一起:

反模式:节点内嵌循环

python 复制代码
from typing import TypedDict, List

# 这个状态设计本身没问题
class BadState(TypedDict):
    items: List[str]
    results: List[str]

def bad_fanout_node(state: BadState):
    """
    反模式:在节点内部用循环处理所有 items。

    问题:
    1. process_func 的调用隐藏在节点内部,调度器不可见
    2. 串行执行,无法并行化
    3. 失败时整个节点重试,前面的工作丢失
    4. 无法单独追踪每个 item 的处理状态
    """
    results = []

    for item in state["items"]:
        # 这里直接调用另一个节点的逻辑
        # 调度器不知道这里发生了多次调用
        result = process_func(item)  # 假设这是某个处理函数
        results.append(result)

    return {"results": results}

def process_func(item: str) -> str:
    """假设的处理逻辑"""
    # 调用 LLM 或做复杂计算
    return f"Processed: {item}"

正模式:Send + 独立节点

python 复制代码
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
import operator

class GoodState(TypedDict):
    items: List[str]
    results: Annotated[List[str], operator.add]  # 关键:用 operator.add

def good_router(state: GoodState):
    """
    正模式:在条件边路由函数中返回 Send 列表。

    每个 Send 携带独立的 item,调度器并行处理。
    """
    return [
        Send("processor", {"item": item})
        for item in state["items"]
    ]

def processor_node(state):
    """
    独立节点:只处理一个 item。

    优势:
    1. 调度器知道这个节点的存在,可以并行执行
    2. 失败时只重试这个节点,不影响其他 item
    3. 可以在 LangGraph Studio 追踪这个节点的输入输出
    4. 节点逻辑单一,可测试、可复用
    """
    result = process_func(state["item"])

    # 返回单元素列表,operator.add 会自动追加
    return {"results": [result]}

# 组装图
builder = StateGraph(GoodState)
builder.add_node("fanout", lambda state: state)  # 透传节点
builder.add_node("processor", processor_node)

builder.add_edge(START, "fanout")
builder.add_conditional_edges(
    "fanout",
    good_router,
    {"processor": "processor"}
)
builder.add_edge("processor", END)

app = builder.compile()

一句话结论:正模式(Send + 独立节点)在并行度、失败隔离、可观测性、解耦性上都优于节点内循环,详细对比见 3.3 的"核心差异总结"表。


四、关键概念对比表

特性 Send(动态并发路由) 普通条件边 Batch(批处理)
返回值 Send 对象列表 字符串(节点名) 不是路由,是输入形态
目标节点 运行时动态决定 运行时动态决定 固定节点
执行方式 并行(多个节点同时执行) 串行(只选一个分支) 串行(一个节点处理批次)
状态副本 每个 Send 独立覆盖 全局状态传递 全局状态,批次循环处理
并发控制 调度器自动并行 无并发 用户手动分批
典型场景 Map-Reduce、广播、Fan-out 条件分支、If-else 批 API 调用、批量数据库操作
可视化 每个 Send 独立节点可见 单一路径 单个节点
失败隔离 单个 Send 可独立重试 整条路径重试 整个 batch 重试
状态合并 通过 reducer(operator.add) 直接返回覆盖 节点内手动合并
API 引入 LangGraph 1.0(0.2.x 起稳定) LangGraph 基础功能 无专用 API
适用数据量 中小规模(建议小于 100) 任意 大规模数据
内存开销 每个 Send 一份状态副本 一份全局状态 一份全局状态

一句话区分三种模式

  • 普通条件边:走哪条路------多个出口中选一个,串行执行。
  • Send:一次派多少人、每个人带什么装备------一次派 N 个任务,并行执行,每人带专属参数。
  • Batch:一个人一次扛多少货------单节点内部循环处理一批数据,不触发图调度。

五、局限与踩坑:Send 不是银弹

5.1 Send 只发状态、不产生新信息

这是最容易误解的一点。

Send 对象只有两个字段:node(目标节点名,字符串)和 arg(状态覆盖字典)。它在路由函数中一次性生成,调度器拿到列表后一次性派发。

这意味着:你不能在第一个 Send 执行完后,根据它的返回值动态决定第二个 Send 去哪。

python 复制代码
# 这无法实现:
def bad_dynamic_router(state):
    sends = []
    for i, item in enumerate(state["items"]):
        result = process(item)  # 试图在路由函数里执行节点逻辑
        if result["score"] > 0.5:
            sends.append(Send("high_priority", {"item": item}))
        else:
            sends.append(Send("low_priority", {"item": item}))
    return sends

路由函数在图的调度循环中执行,它不能调用 LLM 或执行耗时操作。如果你需要前序节点的输出影响后续 Send,有两种方案:

方案 A:子图嵌套

把"动态决策"封装成子图:外层图先执行一轮得到决策结果,然后根据结果触发第二轮 Send。

方案 B:分阶段路由

python 复制代码
# 阶段 1:评估节点
def evaluate_items(state):
    # 对每个 item 做简单评估(可以用 LLM 批量处理)
    evaluations = []
    for item in state["items"]:
        score = quick_evaluate(item)  # 轻量级评估
        evaluations.append({"item": item, "score": score})
    return {"evaluations": evaluations}

# 阶段 2:根据评估结果路由
def route_by_evaluation(state):
    return [
        Send("high" if e["score"] > 0.5 else "low", {"item": e["item"]})
        for e in state["evaluations"]
    ]

5.2 目标节点必须是图内已注册节点

编译期校验会卡住你:

python 复制代码
builder.add_conditional_edges(
    "fanout",
    lambda s: [Send("not_registered", {"item": "x"})],
    {"not_registered": "not_registered"}  # 编译报错!
)

错误信息类似:

复制代码
ValueError: Node 'not_registered' not found in graph.

解决办法

  1. 确保所有 Send 的目标节点都通过 builder.add_node(name, func) 注册过。
  2. 如果目标节点是动态生成的,考虑用子图或者把逻辑合并到已有节点中。

5.3 Reducer 设计是命脉

Send 的并行节点返回结果怎么合并到全局状态,完全取决于你的 reducer。设计不当会导致数据丢失。

踩坑 1:默认覆盖行为

python 复制代码
from typing import TypedDict, List

class BadState(TypedDict):
    results: List[str]  # 没有 Annotated + reducer

def node1(state):
    return {"results": ["from_node1"]}

def node2(state):
    return {"results": ["from_node2"]}  # 覆盖 node1 的结果!

如果两个并行节点同时写 results 字段,且没有用 Annotated[..., operator.add],默认行为是覆盖。因为并行执行没有确定顺序,你可能只看到 node1node2 的结果之一,另一个丢了。

正确做法

python 复制代码
from typing import Annotated
import operator

class GoodState(TypedDict):
    results: Annotated[List[str], operator.add]  # 追加而非覆盖

踩坑 2:自定义 reducer 没处理 None

python 复制代码
def my_reducer(existing, new):
    # 如果 existing 是 None,要处理
    if existing is None:
        existing = []
    return existing + new

class MyState(TypedDict):
    results: Annotated[List[str], my_reducer]

LangGraph 第一次调用 reducer 时,existing 可能为 None(字段还没有值)。你的 reducer 必须处理这种情况。

踩坑 3:reducer 的幂等性

如果节点执行了多次重试,reducer 会被调用多次。确保你的 reducer 逻辑是幂等的,或者 LangGraph 的状态管理机制能正确处理重试时的重复写入。

5.4 大量 Send 的内存与线程开销

生产环境中,这个坑我踩过。

场景:一次 Send 500 个任务,每个任务携带一个 1MB 的文档 chunk。

问题

  1. 内存爆炸:500 个 Send 乘以 1MB 等于 500MB 状态副本。实际上 LangGraph 会保留更多中间状态,内存占用可能翻倍。
  2. 线程池耗尽:LangGraph 默认线程池大小有限,500 个任务不会真正并行 500 个线程,大部分会排队。但排队本身也有内存开销(每个等待的任务对象)。
  3. LLM rate limit:500 个并发 LLM 调用,大概率触发 rate limit,导致大量重试和超时。

实践建议

问题 解决方案
状态太大 Send 里只传轻量标识符(如 chunk_id),实际数据放外部存储(Redis/文件)
并发太多 控制 Send 数量(建议小于 100),超量考虑分批或改用批处理 API
rate limit 在目标节点内部做限速,或者减少并发数
内存监控 开启 LangSmith 追踪,观察内存曲线

5.5 错误传播与容错策略

默认行为:任一 Send 失败,整个图中断。

LangGraph 追求确定性执行,默认策略是"全有或全无"。这意味着如果你有 100 个 Send,99 个成功了,1 个失败了,那 99 个的结果也丢了(除非你开启了 checkpoint 且失败后从 checkpoint 恢复,但恢复时仍然需要重试失败的那个)。

容错 workaround

在目标节点内部捕获异常,返回占位值:

python 复制代码
def resilient_processor(state):
    try:
        result = risky_call(state["item"])
        return {"results": [{"status": "ok", "data": result}]}
    except Exception as e:
        # 不抛异常,返回错误标记
        return {
            "results": [
                {
                    "status": "failed",
                    "item": state["item"],
                    "error": str(e)
                }
            ]
        }

然后在下游节点过滤:

python 复制代码
def filter_results(state):
    successes = [r for r in state["results"] if r["status"] == "ok"]
    failures = [r for r in state["results"] if r["status"] == "failed"]

    # 记录日志或告警
    if failures:
        print(f"警告:{len(failures)} 个任务失败")

    return {"successful_results": successes, "failed_items": failures}

未来展望 :LangGraph 团队可能会引入更原生的容错策略,比如 Send 的错误回调或者"ignore_failed"参数。关注官方 release notes。

5.6 Send 与循环的边界

不是所有循环都应该换成 Send。有些场景保持循环更合理:

适合 Send 的场景

  • 每次迭代是独立的 LLM/API 调用
  • 迭代次数不确定(运行时决定)
  • 需要独立重试和追踪
  • 结果需要 reducer 聚合

适合循环的场景

  • 纯计算逻辑,不涉及 IO
  • 迭代之间有依赖(后一次依赖前一次的结果)
  • 迭代次数固定且很少(小于 5)
  • 性能不是瓶颈

混合策略

对于"很多小任务"的场景,考虑批处理:

python 复制代码
# 不是 100 个 Send,而是 10 个 Send,每个处理 10 个 items
def batched_router(state):
    batch_size = 10
    items = state["items"]
    return [
        Send("batch_processor", {
            "items": items[i:i + batch_size]
        })
        for i in range(0, len(items), batch_size)
    ]

def batch_processor(state):
    # 内部循环处理 10 个 items
    results = []
    for item in state["items"]:
        results.append(process(item))
    return {"results": results}  # 返回 10 个结果

这样 100 个任务变成 10 个 Send,每个 Send 内部处理 10 个,平衡了并行度和开销。


六、面试题速查

以下是我面试别人和被别人面试时,真实遇到过的 Send 相关问题,附答案要点。

Q1:LangGraph 的 Send 和普通条件边有什么区别?

普通条件边返回字符串(节点名),调度器根据字符串找到下一个节点,串行执行。Send 返回 Send 对象列表,每个 Send 包含目标节点名和状态覆盖字典,调度器并行启动所有 Send 指定的节点。

本质区别是"串行单播" vs "并行广播"。普通条件边是"走哪条路",Send 是"一次派多少人、每个人带什么装备"。

Q2:Send 的并行度由什么决定?

由 LangGraph 内部调度器决定,默认使用 Python 的 ThreadPoolExecutor。实际并行度受限于:

  1. 线程池大小(受 Python ThreadPoolExecutor 上限与 configmax_concurrency 约束)
  2. 目标节点的性质(同步函数 vs async 函数)
  3. 外部依赖的并发能力(如 LLM API 的 rate limit)

用户不直接控制并发数,但可以通过控制 Send 列表长度间接控制。对于 async 节点,LangGraph 用 asyncio.gather 并发,并行度更高。

Q3:多个 Send 同时写同一个状态字段,如何避免覆盖?

必须用 Annotated[T, reducer] 声明 reducer。对于列表字段,常用 operator.add 做追加。默认无 reducer 时是覆盖行为,并行写入时后完成的覆盖先完成的,数据会丢失。

示例:results: Annotated[List[str], operator.add]

Q4:Send 能不能根据前序节点的输出动态决定?

不能。Send 列表在路由函数中一次性生成,是一次性派发的。路由函数本身不能执行耗时操作(如调用 LLM)。

如果需要前序输出影响后续 Send,有两种方案:

  1. 分阶段路由:先执行一轮评估节点,把评估结果写回状态,再触发条件边生成 Send。
  2. 子图嵌套:把动态决策封装成子图。

Q5:Map-Reduce 在 LangGraph 中的典型实现方式?

三个阶段:

  1. Split:分块节点把输入拆成 N 份(如文档分块)。
  2. Map :条件边返回 N 个 Send 到同一个 map 节点,每份带独立的 chunk。所有 map 节点并行执行,结果通过 operator.add 聚合。
  3. Reduce:所有 map 完成后,调度器自动触发 reduce 节点汇总结果。

核心设计:用 Annotated[List[T], operator.add] 聚合 map 输出,reduce 节点自动等待所有 map 完成。

Q6:Send 的目标节点不在图里会怎样?

编译时报错。add_conditional_edges 的第三个参数(可能的节点映射)会触发 LangGraph 的编译期校验。如果 Send 的目标节点没有在 add_node 里注册过,compile() 会抛出 ValueError: Node 'xxx' not found in graph

Q7:Send 的失败处理策略是什么?

默认策略:"全有或全无"。任一 Send 失败,整个图中断,所有并行的 Send 结果丢弃(除非从 checkpoint 恢复)。

容错 workaround:在目标节点内部 try-except 捕获异常,返回错误标记而不是抛异常。下游节点根据标记过滤结果。LangGraph 目前没有原生的 per-Send 错误回调或容错策略。

Q8:为什么不能在节点内部 for 循环调用另一个节点?

三点原因:

  1. 性能:串行执行,失去并行能力。LangGraph 调度器看不到内部循环,无法优化。
  2. 可靠性:失去节点级重试。整个节点作为一个整体 checkpoint,内部循环的任何失败导致整个节点重试。
  3. 可观测性:LangGraph Studio、LangSmith 等追踪工具只能看到外层节点,看不到内部循环的每次调用。调试困难。

Q9:Send 的状态副本是怎么合并的?

Send 的 arg 参数(字典)会覆盖全局状态中对应的字段,形成目标节点的输入状态。目标节点看到的是:全局状态 + Send 局部覆盖(覆盖优先级更高)。

目标节点返回的字段再经 reducer 合并回全局状态。如果字段没有 reducer,默认行为是覆盖。

Q10:Send 和子图(Subgraph)怎么选?

Send 适合"同一节点逻辑的 N 次并行调用",所有调用是同质化的。

子图适合"不同阶段的嵌套工作流",子图内部有自己的节点和边。

如果 map 阶段本身是一个复杂子流程(多节点、有条件分支),把 map 逻辑包成子图,然后在 Send 里调用子图的起始节点。LangGraph 支持在 Send 中指定子图的入口节点。

Q11:Send 机制下,如何确保 Reduce 节点等到所有 Map 节点完成才执行?

LangGraph 调度器会自动处理。条件边返回 Send 列表后,调度器会追踪所有 Send 对应的 Task。所有 Task 完成后,调度器才会继续执行条件边的下游节点(即 Reduce 节点)。

不需要手动计数或等待。这是声明式依赖管理的好处------你声明了"这些任务完成后才能到下一步",调度器负责实现。

Q12:Send 能跨图使用吗?比如在父图里 Send 到子图的节点?

不能直接跨图 Send。Send 的目标节点必须是当前编译后的图内已注册的节点。

如果需要父图向子图发送任务,有两种方式:

  1. 把子图编译后作为节点注册到父图(builder.add_node("subgraph", compiled_subgraph)),然后 Send 到 "subgraph" 这个节点名。
  2. 在父图节点中手动调用子图(类似文章开头的反模式,不推荐)。

Q13:使用 Send 时,reducer 的返回值类型必须和字段声明一致吗?

是的。如果你的状态字段声明为 Annotated[List[str], operator.add],那么所有向这个字段写入的节点都必须返回 List[str] 类型。

常见错误:节点返回字符串而不是列表,导致 operator.add 抛出 TypeError。确保每个并行节点返回的数据结构一致。

Q14:Send 列表可以为空吗?会发生什么?

可以返回空列表 []。这意味着本次不派发任何任务,图会正常结束当前这条路径(效果等同直接走到 END),不会报错。

但要注意:如果路由函数在特定输入下总是返回空列表,下游节点将永远不会被触发,容易让人误以为"任务卡住了"。建议在路由函数里对空列表情况显式处理:返回一个兜底的 Send(如 Send("fallback", {...})),或给条件边配置 fallback 路径,保证业务流程有明确出口。


七、最佳实践速查表

我把日常使用 Send 的经验整理成一张速查表,写代码前扫一眼:

检查项 做法 反例
状态字段 reducer 所有会被并行写入的字段必须用 Annotated[T, reducer] 用裸 List[str],并行覆盖丢数据
路由函数耗时 路由函数里只做轻量计算,不调用 LLM 或 IO 在路由函数里调用 LLM 做动态决策
Send 目标校验 所有目标节点必须在 add_node 里注册 Send 到未注册节点,编译报错
并发数量控制 一次 Send 控制在 100 个以内 一次 Send 1000 个,内存爆炸
状态大小控制 Send 的 arg 只传必要字段,大数据用外部存储 Send 里传 10MB 文档 chunk
错误处理 目标节点内 try-except,返回错误标记 直接抛异常导致整个图中断
输出格式统一 所有并行节点返回相同结构,方便 reducer 有的返回 dict 有的返回 list
节点职责单一 一个节点只做一件事,循环逻辑拆成 Send 一个节点里塞 5 层嵌套逻辑
聚合节点等待 不用手动计数,调度器自动等所有 Send 完成 在聚合节点里轮询检查完成状态
类型标注完整 每个节点的 state 参数都有 TypedDict 类型 用裸 dict,IDE 无法提示

我的 workflow

  1. 先画 ASCII 图:哪些节点可以并行?哪些必须串行?
  2. 确定 reducer:每个会被并行写入的字段用什么合并策略?
  3. 写状态类型:全局状态 + 每个节点的局部状态(用于 Send 覆盖)
  4. 写路由函数:列表推导式生成 Send 列表
  5. 组装图:add_node -> add_edge -> add_conditional_edges -> compile
  6. 跑单测:先用 mock LLM 跑通流程,再接入真实 API
  7. 加容错:目标节点里 try-except,返回错误标记
  8. 开追踪:接入 LangSmith,观察并行度和耗时分布

八、参考资源


写在最后

我第一次用 Send 重构项目,是把一个长文档处理流程从 40 秒降到 8 秒。数字上的提升是次要的,更重要的是思维方式的变化。

在那之前,我对 LangGraph 的理解停留在"用图画工作流"。Send 让我意识到,LangGraph 真正的威力不在于可视化,而在于声明式并发调度------你把任务描述成"这些节点可以并行",调度器负责最优执行。

这和现代前端框架(React 的声明式 UI)或者基础设施即代码(Terraform 的声明式编排)是同一个范式:你描述"想要什么样的系统",框架负责"怎么实现"。

如果你还在节点里写 for 循环,现在就是替换的时机。Send 不只是一段代码,它代表一种思维方式:让图调度器做它该做的事,你的节点只负责业务逻辑

把并发交给框架,把心思放回问题本身。


版本记录

  • v1.0:2026-08-27,基于 LangGraph 1.0(兼容 0.2.x+)编写
相关推荐
鲨鱼辣钊2 天前
Github Copilot 研发效能提升实战指南
log4j·github·copilot
深维AI随笔6 天前
《构建之法》| 第二章个人技术和流程:单元测试不是“可选“,而是“必需“
单元测试·log4j·apache
七牛开发者10 天前
为什么 Go 很适合 AI 辅助开发?
数据库·人工智能·python·elasticsearch·log4j
IT机器猫11 天前
RabbitMQ基础二
java·spring boot·分布式·spring cloud·log4j·rabbitmq·springamqp
看浪的路人14 天前
第6讲:单元测试自动生成
python·ai·log4j
jufeng130718 天前
【系列:MiniKV 原理剖析 · 第 8 篇(完结篇)】
linux·c++·log4j·软件工程·makefile
冷咖啡离 我的笨笨22 天前
HttpContext.Current并非无处不在
log4j
5系暗夜孤魂z1 个月前
优雅的.net REST API之FastEndpoints
log4j·.net
名字还没想好☜1 个月前
Go 表驱动测试实战:用 t.Run 子测试组织可维护的单元测试
golang·单元测试·log4j·go·testing