第5章 流式输出与人工审核

默认情况下,graph.invoke 要等整张图执行完才返回最终状态。但真实产品里,用户等不了 30 秒再看一个完整答案------他们要看到 token 一个个蹦出来、要看到「正在检索知识库...」的进度条、要在高风险操作前亲自点一次「确认」。

本章讲 LangGraph 的两大交互能力:stream 流式输出(五种模式)与 interrupt 人工审核(HITL 的核心原语)。


5.1 图内外数据传递:stream 的五种模式

默认的 graph.invoke 是「黑盒执行、一次性出结果」。考虑 Agent 的两个典型需求:

  1. LLM 输出时就能拿到 token,在前端逐字展示(打字机效果);
  2. Agent 流程较长时,能知道当前正在执行哪个节点/步骤。

由于 LangGraph 实现了 langchain_coreRunnable 接口,天然具备 stream / astream 流式输出方法,正好解决这两个问题。

stream 提供以下模式:

模式 描述
values 每一步执行后,流式输出完整状态
updates 每一步执行后,流式输出增量更新;同一超步内的多个增量会分别输出
custom 流式输出节点内部通过 stream_writer 发送的自定义数据
messages 在任何调用了 LLM 的节点中,流式输出二元组 (LLM Token, metadata)
debug 流式输出所有能输出的信息(任务调度、输入输出等)
混合模式 stream_mode 传列表,同时得到多种流式输出

选择建议:前端打字机效果用 messages;进度条/步骤提示用 custom;日志与审计用 updates;开发调试用 debug

完整示例

python 复制代码
import time
from typing import TypedDict, Annotated, List
import operator
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.types import StreamWriter
from langgraph.runtime import Runtime

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

# --- 1. 定义状态 ---
class State(TypedDict):
    messages: Annotated[List[BaseMessage], operator.add]
    current_step: str

# --- 2. 定义节点 ---
def node_input(state: State):
    """模拟接收用户输入"""
    return {
        "messages": [HumanMessage(content="请帮我写一段Python代码")],
        "current_step": "input_received",
    }

def node_processing(state: State, runtime: Runtime):
    """模拟中间处理,用 stream_writer 输出自定义流式数据(对应 custom 模式)"""
    steps = ["正在分析意图...", "正在检索知识库...", "正在构建Prompt..."]
    writer = runtime.stream_writer
    for i, step in enumerate(steps):
        time.sleep(0.5)  # 模拟耗时操作

        # 发送自定义数据(不影响图的状态,只能被 custom 模式接收到)
        writer({
            "step_index": i + 1,
            "description": step,
            "timestamp": time.time(),
        })

    return {"current_step": "processing_complete"}

def node_generation(state: State):
    """模拟 LLM 生成过程(messages 模式会自动捕获 LLM 的流式输出)"""
    response = llm.invoke(state["messages"])
    return {
        "messages": [response],
        "current_step": "generation_complete",
    }

# --- 3. 构建图 ---
builder = StateGraph(State)
builder.add_node("input", node_input)
builder.add_node("process", node_processing)
builder.add_node("generate", node_generation)

builder.add_edge(START, "input")
builder.add_edge("input", "process")
builder.add_edge("process", "generate")
builder.add_edge("generate", END)

graph = builder.compile()

# --- 4. 演示不同的流式输出模式 ---
def run_demo():
    initial_state = {"messages": [], "current_step": "start"}

    print(f"\n{'='*20} 1. Mode: values {'='*20}")
    for event in graph.stream(initial_state, stream_mode="values"):
        # event 就是当前的完整 State 字典
        print(f"State: keys={list(event.keys())}, step={event.get('current_step')}")

    print(f"\n{'='*20} 2. Mode: updates {'='*20}")
    for event in graph.stream(initial_state, stream_mode="updates"):
        # event 是 {节点名: 该节点的输出} 的字典
        print(f"Update: {event}")

    print(f"\n{'='*20} 3. Mode: custom {'='*20}")
    for event in graph.stream(initial_state, stream_mode="custom"):
        # event 就是 writer() 里传的对象
        print(f"Custom Data: {event}")

    print(f"\n{'='*20} 4. Mode: messages {'='*20}")
    for chunk, metadata in graph.stream(initial_state, stream_mode="messages"):
        # chunk 是 AIMessageChunk;metadata 里含节点信息
        node_name = metadata.get('langgraph_node', 'unknown')
        print(f"[{node_name}] Token: {chunk.content!r}")

    print(f"\n{'='*20} 5. Mode: debug {'='*20}")
    count = 0
    for event in graph.stream(initial_state, stream_mode="debug"):
        if count < 3:
            print(f"Debug Event: {event['type']} - {event.get('payload', {}).get('name')}")
        count += 1
    print("... (省略后续 debug 信息)")

    print(f"\n{'='*20} 6. Mixed Mode {'='*20}")
    # 返回 (mode, data) 元组
    for mode, data in graph.stream(initial_state, stream_mode=["updates", "custom"]):
        if mode == "updates":
            print(f"[Updates] 来自节点 {list(data.keys())[0]}")
        elif mode == "custom":
            print(f"[Custom] {data['description']}")

if __name__ == "__main__":
    run_demo()

各模式输出逐个解析

Mode 1:values ------ 完整状态快照流

每个节点执行完,吐一次「当时点的完整状态」。输出形如:

text 复制代码
State: keys=['messages', 'current_step'], step=input_received
State: keys=['messages', 'current_step'], step=processing_complete
State: keys=['messages', 'current_step'], step=generation_complete

适合前端需要随时渲染全量对话历史的场景(每来一个 event 就重画整个消息列表)。数据量随状态增长,长对话下开销最大。

Mode 2:updates ------ 增量更新流

每个节点执行完,吐一次 {节点名: 增量dict}

text 复制代码
Update: {'input': {'messages': [...], 'current_step': 'input_received'}}
Update: {'process': {'current_step': 'processing_complete'}}
Update: {'generate': {'messages': [...], 'current_step': 'generation_complete'}}

只传增量、不重复传全量,比 values 轻量;事件里带节点名,天然适合审计日志(「哪个节点在什么时候写了什么」)。

Mode 3:custom ------ 自定义数据流

只输出节点里 writer(...) 发出的对象,与本例的三个进度事件一一对应:

text 复制代码
Custom Data: {'step_index': 1, 'description': '正在分析意图...', ...}
Custom Data: {'step_index': 2, 'description': '正在检索知识库...', ...}
Custom Data: {'step_index': 3, 'description': '正在构建Prompt...', ...}

注意这些数据不进状态、不落检查点,纯粹是节点向外界的广播------所以特别适合进度条、状态标签这类「给用户看的中间信息」。

Mode 4:messages ------ LLM Token 流

这是做打字机效果的核心。for chunk, metadata in ... 解包二元组:

  • chunk.content:本次产出的一小段文本(AIMessageChunk 对象);
  • metadata['langgraph_node']:token 产自哪个节点------多 Agent 图里可以用它给不同 Agent 的输出标不同颜色。

LangGraph 的实现原理:节点内部调用的 LLM 对象只要本身是 LangChain Runnable(如 ChatOpenAI),其流式回调就会被运行时捕获并转发出来------节点代码不需要为此做任何改造 ,把 llm.invoke 换成 llm.stream 都不必。

Mode 5:debug ------ 全量调试信息

输出任务调度、每个节点的输入输出等一切细节,数据量非常大,仅在排查「图到底怎么跑的」时使用。

Mode 6:混合模式

stream_mode=["updates", "custom"] 传入列表,事件变成 (mode, data) 元组,用 if mode == ... 分流处理。一个真实前端往往同时要两路:节点进度(updates)+ 打字机(custom/messages),混合模式就是为此设计的。


5.2 人工审核节点:interrupt

Agent 在完成用户设定的任务时,有时我们希望用户参与部分重要决策过程------转账前确认金额、发邮件前确认收件人、执行删除操作前确认范围。LangGraph 为此提供了一个非常方便的原语:interrupt

工作原理

复制代码
第一次 invoke:
  节点执行 → 遇到 interrupt(待审核数据)
           → 图暂停(状态存入 checkpointer,含中断位置)
           → invoke 返回,__interrupt__ 里带着待审核数据
              ↓ (人类看数据、做决策)
第二次 invoke:
  graph.invoke(Command(resume=决策结果), 同一thread_id)
           → interrupt(待审核数据) 处"恢复",返回值 = 决策结果
           → 节点从该行继续往下执行 → ...... → END

可以在节点内部任意位置 引入 interrupt。当节点执行到 interrupt 所在位置时停止执行,图外能得到需要用户审核的数据;用户审核完成后,再通过 graph.invoke 让图继续往下执行。

四个关键角色:

  • interrupt(payload) :节点内调用,payload 是要给审核人看的数据;恢复时它的返回值就是外部传入的决策结果;
  • checkpointer :interrupt 强依赖检查点------暂停的本质是把「执行到哪了」存进 checkpointer,所以 compile 时必须配 checkpointer;
  • Command(resume=value) :第二次 invoke 的输入,value 就是喂给 interrupt 返回值的决策数据;
  • thread_id:两次 invoke 必须用同一个 thread_id,才能找回暂停时的状态。

完整示例:转账前人工审核

python 复制代码
"""
LangGraph interrupt 演示:让用户参与关键决策

场景:准备执行一笔"转账",在真正执行前让用户审核/修改转账信息。
"""
from __future__ import annotations

from typing import Any
from typing_extensions import TypedDict

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt

class TransferState(TypedDict):
    recipient: str
    amount: int
    memo: str
    approved: bool
    final_status: str

def review_transfer(state: TransferState) -> dict[str, Any]:
    print("\n[Node] review_transfer:生成待执行的转账请求")
    pending_transfer = {
        "recipient": state["recipient"],
        "amount": state["amount"],
        "memo": state["memo"],
    }

    # ★ 在这里暂停,把待审核数据交给图外的人工
    user_review = interrupt(
        {
            "title": "转账审核",
            "pending_transfer": pending_transfer,
            "instruction": "请返回 bool(是否批准) 或 dict(可改 recipient/amount/memo,并带 approved 字段)。",
        }
    )

    # ------ 恢复后从这里继续:user_review 就是外部 resume 传进来的决策 ------
    approved = False
    updated_transfer = dict(pending_transfer)
    if isinstance(user_review, bool):
        approved = user_review
    elif isinstance(user_review, dict):
        approved = bool(user_review.get("approved", True))
        for k in ("recipient", "amount", "memo"):
            if k in user_review:
                updated_transfer[k] = user_review[k]

    print(f"[Node] review_transfer:用户审核结果 approved={approved},transfer={updated_transfer}")
    return {
        "approved": approved,
        "recipient": updated_transfer["recipient"],
        "amount": updated_transfer["amount"],
        "memo": updated_transfer["memo"],
    }

def execute_transfer(state: TransferState) -> dict[str, str]:
    if not state["approved"]:
        print("\n[Node] execute_transfer:用户未批准,取消转账")
        return {"final_status": "已取消:用户未批准转账"}

    print("\n[Node] execute_transfer:模拟执行转账")
    return {"final_status": f"已转账:收款人={state['recipient']},金额={state['amount']},备注={state['memo']}"}

def build_graph():
    builder = StateGraph(TransferState)
    builder.add_node("review_transfer", review_transfer)
    builder.add_node("execute_transfer", execute_transfer)
    builder.add_edge(START, "review_transfer")
    builder.add_edge("review_transfer", "execute_transfer")
    builder.add_edge("execute_transfer", END)
    # ★ 必须配 checkpointer,interrupt 才能暂停/恢复
    return builder.compile(checkpointer=InMemorySaver())

def run_demo():
    graph = build_graph()
    config = {"configurable": {"thread_id": "interrupt-demo-1"}}

    initial_state: TransferState = {
        "recipient": "Alice",
        "amount": 100,
        "memo": "午饭AA",
        "approved": False,
        "final_status": "",
    }

    # ===== 第一次 invoke:停在 interrupt =====
    first = graph.invoke(initial_state, config=config)
    # __interrupt__ 是 invoke 返回值里的特殊键,携带暂停信息
    interrupt_payload = first["__interrupt__"][0].value
    print("\n[System] 图已暂停,等待用户审核。可给用户展示的数据如下:")
    print(interrupt_payload)

    # ===== 人工决策(真实场景来自前端表单 / 审批系统回调)=====
    user_resume_value = {"approved": True, "amount": 80, "memo": "改为80元(实际应付)"}

    # ===== 第二次 invoke:Command(resume=...) 恢复执行 =====
    final = graph.invoke(Command(resume=user_resume_value), config=config)
    print("\n[System] 已恢复执行,最终结果:")
    print(final["final_status"])

if __name__ == "__main__":
    run_demo()

逐段解析

审核节点 review_transfer

  • 先把待审核数据组装成 pending_transfer,再调 interrupt({...})interrupt 的参数是「给审核人看的全部信息」------title、待办内容、操作指引,实际项目里往往还有任务 id、发起时间、风险等级;
  • user_review = interrupt(...) 这行代码的精妙之处:第一次执行时它是一个「暂停点」 (抛出内部信号,节点到此为止);恢复执行时它是一个「返回语句」(返回值即 resume 传来的决策)。同一行代码,两种身份;
  • 恢复后对决策结果做了防御式解析:既支持 bool(纯批准/拒绝),也支持 dict(批准 + 修改金额/备注)------真实审批场景里「改一下再批」非常常见;
  • 返回增量:把审核后的最终字段写回状态,下游 execute_transfer 拿到的就是修正过的数据。

执行节点 execute_transfer :读 approved 决定执行还是取消。它本身对 interrupt 一无所知------审核逻辑被完全封装在上游节点里,图的拓扑不用为 HITL 做任何特殊设计。

run_demo 的两次 invoke

  • 第一次 invoke(initial_state, config) 正常返回(不抛异常!),返回值里多了一个 __interrupt__ 键,first["__interrupt__"][0].value 就是传给 interrupt 的那份审核数据。程序此时可以把 payload 推给前端渲染成审批卡片;
  • 人工决策 user_resume_value = {"approved": True, "amount": 80, ...}------注意这里的改动:把 100 元改成了 80 元;
  • 第二次 invoke(Command(resume=user_resume_value), config=config)输入不是状态 dict,而是 Command 对象 ;thread_id 不变。图从暂停点恢复,interrupt(...) 返回这个 dict,节点继续执行;
  • 最终输出:已转账:收款人=Alice,金额=80,备注=改为80元(实际应付)------金额的修改被正确传递到了执行节点。

⚠️ 最重要的注意事项:恢复时的重复执行

使用 interrupt 时有一个必须刻在脑子里的行为细节:

第二次调用 graph.invoke(Command(resume=...)) 继续执行时,含 interrupt 的函数会从函数起点重新执行,执行到 interrupt 那一行时才被「短路」并拿到恢复值。

也就是说,interrupt 之前如果有更新数据、调用 API 等操作,会再执行一遍

python 复制代码
def bad_node(state):
    call_api_deduct_fee()          # ← 恢复时会再扣一次费!
    data = prepare_review(state)
    decision = interrupt(data)     # 暂停点
    ...

第一次 invoke 扣了一次费,恢复时又扣一次。因此必须做到以下两点之一:

  1. 保证操作的幂等性 :让 call_api_deduct_fee 内部带幂等键(如请求 id),重复调用不会重复生效;
  2. 把有副作用的操作隔离到单独节点 :副作用放在 interrupt 所在节点之前的节点里,审核节点只做「组装数据 → 暂停 → 应用决策」这三件事:
python 复制代码
def prepare_node(state):        # 独立节点:副作用在这里,只执行一次
    call_api_deduct_fee()
    return {"pending": prepare_data(state)}

def review_node(state):         # 审核节点:无副作用,重跑无害
    decision = interrupt(state["pending"])
    return {"decision": decision}

这是 interrupt 落地生产时最容易翻车的地方,设计图结构时就要把「副作用节点」和「审核节点」拆开。


5.3 本章小结

  • stream 五种模式:values(全量状态)、updates(增量+节点名)、custom(writer 自定义广播)、messages(LLM token 流)、debug(全量调试);混合模式传列表,收到 (mode, data) 元组;
  • 打字机效果用 messages,进度提示用 custom + runtime.stream_writer,审计用 updates
  • interrupt(payload) 是 HITL 核心原语:第一次 invoke 在此处暂停(数据在返回值 __interrupt__ 里),第二次 invoke(Command(resume=决策), 同一thread_id) 从暂停点继续;
  • interrupt 强依赖 checkpointer 和相同 thread_id;
  • 恢复时含 interrupt 的函数从头重跑:副作用操作必须幂等,或隔离到前置节点。

下一章讲图的最后一类构件:边------普通边、条件边,以及用条件边构建的循环与递归保护。

相关推荐
打工仔折腾 AI42 分钟前
数据库上 K8s 之后谁来管?拆解金仓 KES-Operator 的声明式运维方案
人工智能·后端·python·性能优化·ai agent 实战
zx_741484811 小时前
【计算机视觉入门】OpenCV + MediaPipe + dlib:从仿射变换到换脸、手势、姿态与人脸网格
python·opencv·计算机视觉
PixelBai1 小时前
在线正则表达式测试工具推荐:实时高亮、支持 flags,写完先验一遍再上代码
python
正经教主1 小时前
【FDE系列】阶段2:Day 33:进阶查询 — 窗口函数与 CTE
人工智能·python·fde
小白勇闯网安圈1 小时前
第2章 状态 State 详解
python·langchain
派大_星1 小时前
基于MediaPipe和传统机器学习的手势识别
python
ZDN_is_beauty1 小时前
綦江烟草部署(在wsl2里部署)
人工智能·python
小小龙学IT1 小时前
astAPI 异步 Web 框架深度解析
python
PiaoKe___3 小时前
云手机原理与 Python 自动化实战:ADB 批量控制、任务调度与落地建议
服务器·arm开发·python·自动化