1. 本章目标 #

前面八章讲的都是「怎么让智能体干对事」。这一章讲怎么让人看见它在干什么。

这件事的重要性容易被低估。一个 deep agent 跑一次可能要几分钟、调十几次工具、派三个子智能体。如果界面上只有一个转圈的加载动画,用户的体验是「卡住了」而不是「在工作」——哪怕它干得完全正确。

Deep Agents 在 LangGraph 的流式协议上加了一层子智能体投影,让「谁在做什么」这件事变得可查询。这一章把两套 API 讲清楚,并给出选择标准。

学完你应能:

前置依赖: 第 26 章(LangGraph 流式基础)、第 47 章(子智能体——本章一半内容围绕它)、第 48 章(中断,§8 会用到)、第 46 章 §7.4(摘要 token 过滤)。

参考文档:

建议阅读顺序: 如果你只想快速做个能用的界面,看 §3.2 和 §5 就够了。§2.2 那个 API 限制建议先看,能省掉一次困惑。

本章验证环境:deepagents 0.7.12、langgraph 1.x。全部实验零 token——事件流的结构用脚本化假模型看得最清楚。

2. Deep Agents 没有自己的流式 API #

2.1. 返回的就是标准 LangGraph 图 #

先破除一个可能的误解:

"""确认 create_deep_agent 返回什么。"""
agent = create_deep_agent(model=..., backend=StateBackend())
print(type(agent).__name__)
print([n for n in dir(deepagents) if "stream" in n.lower()])
CompiledStateGraph
[]

deepagents 顶层没有任何 stream 相关的导出,也没有 deepagents.stream 模块。返回的是标准的 CompiledStateGraph。

所以你在第 26 章学的 LangGraph 流式知识全部直接可用。Deep Agents 加的东西只有一个:stream.subagents 这个投影(§5)。

2.2. 同步 stream_events() 只支持 v3 #

这个限制会让人卡一下:

agent.stream_events(inp, cfg, version="v2")
NotImplementedError: stream_events(version='v2') is not supported.
Use astream_events() for v1/v2, or stream_events(version='v3') on a supported subclass.

规则是:

方法 支持的 version
stream_events()(同步) 只有 v3
astream_events()(异步) v1 / v2 / v3

而 version 的默认值是 v2,所以同步调用必须显式写 version="v3",否则直接报错。

v3 还是实验性的:

LangChainBetaWarning: The v3 streaming protocol on Pregel is experimental.

2.3. 两套 API 怎么选 #

stream() / astream() stream_events(version="v3")
返回 原始 chunk 迭代器 GraphRunStream 投影对象
抽象层次 低(节点、状态更新) 高(消息、工具调用、子智能体)
子智能体 靠 subgraphs=True + 命名空间自己区分 stream.subagents 直接给句柄
稳定性 稳定 beta
适合 调试、日志、简单界面 面向用户的执行过程面板

我的建议:

做日志和调试用 stream(stream_mode="updates");做用户界面用 stream_events(version="v3")。

后者贵在 subagents 投影——自己从原始事件里把子智能体的活动拼出来相当麻烦(§3.3 会看到)。

3. 低层 API:stream() 与 stream_mode #

stream_mode 有七个值:

StreamMode = Literal['values', 'updates', 'checkpoints', 'tasks', 'debug',
                     'messages', 'custom']

常用的是三个。

3.1. updates:谁更新了什么 #

for chunk in agent.stream(inp, cfg, stream_mode="updates"):
    for node, upd in chunk.items():
        keys = list(upd.keys()) if isinstance(upd, dict) else type(upd).__name__
        print(f"节点 {node:34s} 更新 {keys}")
节点 PatchToolCallsMiddleware.before_agent 更新 NoneType
节点 model                              更新 ['messages']
节点 tools                              更新 ['files', 'messages']
节点 model                              更新 ['messages']

这是调试首选——能直接看到执行路径和每步改了什么。注意 tools 节点同时更新了 files 和 messages,因为文件工具改的是状态里的文件字典。

3.2. messages:token 级输出 #

for msg, meta in agent.stream(inp, cfg, stream_mode="messages"):
    ns = meta.get("langgraph_checkpoint_ns", "") or ""
    src = "子智能体" if "tools:" in str(ns) else "主智能体"
    print(f"[{src}] node={meta.get('langgraph_node')} "
          f"{type(msg).__name__} {str(msg.content)[:34]!r}")
[主智能体] node=model        AIMessage    ''
[子智能体] node=tools        ToolMessage  '子智能体报告:已完成。'
[主智能体] node=model        AIMessage    '主智能体:全部完成。'

这是做打字机效果的 API。每次 yield 一个 (消息块, 元数据) 元组,接真模型时消息块是 token 级的增量。

元数据里有不少有用的字段:

{
  "langgraph_node": "model",
  "langgraph_step": "2",
  "langgraph_checkpoint_ns": "model:8bcf8157-a2f8-3e73-cf0f-aff2e978d7",
  "langgraph_path": "('__pregel_pull', 'model')",
  "langgraph_triggers": "('branch:to:model',)",
  "thread_id": "s6",
  "ls_provider": "scen",
  "ls_model_type": "chat",
  "lc_agent_name": "None"
}

第 46 章 §7.4 提过的摘要过滤就是靠这里:摘要产生的 token 带 lc_source == "summarization",前端应该丢掉它们,否则用户会看到智能体突然开始自言自语地总结。

3.3. subgraphs=True:不加就看不见子智能体内部 #

这是低层 API 最容易漏的一点。 上面 messages 那个输出里,「子智能体」那条其实是 task 工具的返回值,子智能体内部的 write_file 调用完全没出现。

对比一下:

# 不带 subgraphs=True
for chunk in agent.stream(inp, cfg, stream_mode="updates"):
    ...
共 4 个 chunk
# 带 subgraphs=True,返回值变成 (命名空间, chunk) 元组
for ns, chunk in agent.stream(inp, cfg, stream_mode="updates", subgraphs=True):
    who = "子" if ns else "主"
    print(f"[{who}] ns={ns} 节点={list(chunk.keys())}")
[主] ns=() 节点=['PatchToolCallsMiddleware.before_agent']
[主] ns=() 节点=['model']
[子] ns=('tools:577381ca-87e6-b25f-8dc2-30016522c6a7',) 节点=['PatchToolCallsMiddleware.before_agent']
[子] ns=('tools:577381ca-87e6-b25f-8dc2-30016522c6a7',) 节点=['model']
[子] ns=('tools:577381ca-87e6-b25f-8dc2-30016522c6a7',) 节点=['tools']
[子] ns=('tools:577381ca-87e6-b25f-8dc2-30016522c6a7',) 节点=['model']
[主] ns=() 节点=['tools']
[主] ns=() 节点=['model']
共 8 个 chunk

4 个变 8 个,子智能体内部的四步全出来了。区分办法是:

ns 为空元组就是主智能体,非空就是子智能体。

这也解释了为什么 stream.subagents 有价值——用低层 API 你得自己维护「哪个 ns 对应哪个子智能体、它现在什么状态」,而投影 API 直接给你句柄。

3.4. 多模式同时订阅 #

for mode, chunk in agent.stream(inp, cfg, stream_mode=["updates", "messages"]):
    if mode == "messages":
        msg, meta = chunk
        print(f"[{mode:8s}] {type(msg).__name__} {str(msg.content)[:30]!r}")
    else:
        print(f"[{mode:8s}] 节点 {list(chunk.keys())}")
[updates ] 节点 ['PatchToolCallsMiddleware.before_agent']
[messages] AIMessage ''
[updates ] 节点 ['model']
[messages] ToolMessage '子智能体报告:已完成。'
[updates ] 节点 ['tools']
[messages] AIMessage '主智能体:全部完成。'
[updates ] 节点 ['model']

传列表时每个 chunk 变成 (模式名, 内容) 元组。这样一次订阅就能同时驱动打字机效果(messages)和进度指示(updates)。

4. 投影 API:GraphRunStream #

stream = agent.stream_events(inp, cfg, version="v3")
print(type(stream).__name__, type(stream).__module__)
GraphRunStream langgraph.stream.run_stream

它的公开成员:

成员 类型 说明
messages StreamChannel 主智能体的消息
tool_calls StreamChannel 主智能体的工具调用
values StreamChannel 状态快照序列
subagents StreamChannel 子智能体句柄(Deep Agents 加的)
subgraphs StreamChannel 子图(图结构层面)
lifecycle StreamChannel 生命周期事件
output dict 最终状态
interrupted bool 是否中断了
interrupts list 中断详情
interleave(*names) 方法 交错消费多个投影
abort() 方法 中止运行

每个投影都是独立的通道,你只订阅用到的那些。文档说这个投影是「轻量的」——发现子智能体任务是先做的,消息和工具调用流只在你访问句柄上对应属性时才打开。

4.1. values 与 output #

for v in stream.values:
    print(f"keys={list(v.keys())} 消息数={len(v.get('messages', []))}")
第 1 次 values: keys=['messages', 'files'] 消息数=1
第 2 次 values: keys=['messages', 'files'] 消息数=2
...
第 6 次 values: keys=['messages', 'files'] 消息数=6

values 是每步之后的完整状态快照(消息数递增),output 是最终那一份:

最终 output 的 key: ['messages', 'files']
output['files']: ['/main_note.md', '/sub_out.md']

注意 output['files'] 里同时有主和子写的文件——这就是第 47 章 §4 那条「文件系统完全共享」的另一个证据。

4.2. lifecycle #

for ev in stream.lifecycle:
    print(ev)
{'event': 'started', 'namespace': ['tools:646b5b83-...'], 'graph_name': 'worker',
 'trigger_call_id': '646b5b83-...'}
{'event': 'completed', 'namespace': ['tools:646b5b83-...']}

只有子智能体的启停事件。graph_name 是子智能体名字,trigger_call_id 指向触发它的那次 task 调用——做界面时可以用它把子任务卡片挂到对应的工具调用下面。

4.3. subagents 和 subgraphs 的区别 #

subagents:
    name='worker' graph_name='worker' trigger_call_id='104dba8a-...'
subgraphs:
    1. name='worker' path=('tools:2b761cf2-...',)

这个例子里数量相同,但语义不同:

subgraphs 是图执行结构,subagents 是产品级的任务委派。

官方明确建议:面向用户的界面用 subagents,因为它隐藏了内部图节点,直接暴露「子智能体」这个概念。subgraphs 留给调试。

5. stream.subagents:每个子任务一个句柄 #

这是 Deep Agents 在 LangGraph 之上唯一的增量,也是本章最有价值的部分。

"""每个 task 调用得到一个独立句柄。"""
stream = agent.stream_events(inp, cfg, version="v3")

for sub in stream.subagents:
    print(f"句柄: name={sub.name!r} path={sub.path!r} status={sub.status!r}")
    for m in sub.messages:
        print(f"  [{sub.name}] {m.text!r}")
    print(f"完成后 status={sub.status!r}")
句柄: name='worker' path=('tools:33c7e42a-4190-3b89-3d51-892f3dddd48e',) status='started'
  [worker] ''
  [worker] '子智能体报告:已完成。'
完成后 status='completed'

三个要点:

  1. name 就是 subagent_type——主智能体调 task 时选的那个名字。你在 SubAgent(name=...) 里定义的标签,就是流里用来过滤和路由的标签
  2. status 是动态的:刚拿到句柄时是 'started',消费完 messages 后变成 'completed'
  3. 句柄本身就是一个完整的流对象

5.1. 句柄的完整字段 #

name  path  status  graph_name  trigger_call_id
messages  tool_calls  values  subagents  output  lifecycle  subgraphs
error  cause  interrupted  interrupts
abort()  interleave()  extensions

比顶层多了四个:graph_name、trigger_call_id、error、cause。

status 的取值是 started / completed / failed / interrupted。

5.2. 只跟踪生命周期 #

如果界面只需要显示「哪些子任务开始了、结束了」,不用订阅消息流:

"""轻量的子任务进度跟踪。"""
stream = agent.stream_events(inp, version="v3")

running, completed, failed = 0, 0, 0
for sub in stream.subagents:
    running += 1
    print(f"{sub.name}: 开始")
    try:
        _ = sub.output          # 访问 output 会等它跑完
        running -= 1
        completed += 1
        print(f"{sub.name}: 完成")
    except Exception:
        running -= 1
        failed += 1
        print(f"{sub.name}: 失败")

这就够驱动一个「3 个子任务,2 完成 1 进行中」的进度条了。

5.3. 主与子的消息分开渲染 #

"""主智能体的话进主聊天区,子智能体的话进折叠卡片。"""
stream = agent.stream_events(inp, version="v3")

for m in stream.messages:
    print("[主]", m.text)

for sub in stream.subagents:
    for m in sub.messages:
        print(f"[{sub.name}]", m.text)

顶层 stream.messages 只有主智能体的消息,不含子智能体的——这跟低层 API 不加 subgraphs=True 的效果一致,但这里是设计好的分离而不是需要绕过的限制。

界面上通常这样处理:主智能体的输出直接显示,子智能体的输出收进一个可展开的卡片。因为子智能体的中间过程对用户来说是「实现细节」,想看的时候能展开就行。

6. tool_calls 投影的懒求值 #

工具调用投影有个容易误判的语义:

for c in stream.tool_calls:
    print(f"{c.tool_name}")
    print(f"  刚拿到:    completed={c.completed}")
    deltas = list(c.output_deltas)
    print(f"  读 output 后: completed={c.completed} error={c.error}")
    print(f"  output={str(c.output)[:80]!r}")
write_file
  刚拿到:    completed=False
  output_deltas: []
  读 output 后: completed=True error=None
  output="content='Updated file /main_note.md' name='write_file' tool_call_id='m1'"
  tool_call_id='m1'
task
  刚拿到:    completed=False
  读 output 后: completed=True error=None
  output="Command(update={'files': {'/main_note.md': {'content': '主的笔记', ...

刚迭代到时 completed 是 False。 这不是 bug——迭代器在工具开始执行时就把句柄交给你了,所以你能立刻在界面上显示「正在调用 write_file...」。completed 变 True 要等你访问 output 或消费完 output_deltas。

所以正确的用法是:

"""工具调用的正确渲染顺序。"""
for c in stream.tool_calls:
    render_pending(c.tool_name, c.input)        # 立刻显示「正在执行」

    for delta in c.output_deltas:               # 有流式输出就逐块渲染
        render_delta(delta)

    if c.error is not None:                     # 访问 output 后状态才确定
        render_error(c.error)
    else:
        render_done(c.output)

别在拿到句柄时就判断 c.completed——那时候它必然是 False。

字段清单:tool_name、input、output、output_deltas、completed、error、tool_call_id。

顺便看一眼 task 的 output:它是个 Command(update={'files': ...}),而不是普通的 ToolMessage。子智能体的文件改动是以状态更新的形式回传的——这是第 47 章「文件系统共享」在流层面的实现细节。

7. interleave:保证顺序正确 #

stream.messages 和 stream.subagents 是两个独立通道。分别遍历会得到「先主全部、再子全部」的顺序,跟实际发生的顺序不符。

同步代码用 interleave:

"""按实际发生顺序交错消费多个投影。"""
stream = agent.stream_events(inp, cfg, version="v3")

for name, item in stream.interleave("messages", "subagents", "tool_calls"):
    if name == "messages":
        print(f"[messages]   {item.text!r}")
    elif name == "tool_calls":
        print(f"[tool_calls] {item.tool_name}")
    else:
        print(f"[subagents]  {item.name} status={item.status}")
        for m in item.messages:
            print(f"     └ {m.text!r}")
[messages]   ''
[tool_calls] write_file
[messages]   ''
[tool_calls] task
[subagents]  worker status=started
     └ ''
     └ '子智能体报告:已完成。'
[messages]   '主智能体:全部完成。'

这个顺序就是界面上该显示的顺序:主智能体说话 → 调 write_file → 派子智能体 → 子智能体干活 → 主智能体收尾。

异步代码用 asyncio.gather 并发消费:

"""异步并发消费主与子的消息流。"""
import asyncio

stream = await agent.astream_events(inp, version="v3")


async def consume_coordinator():
    async for message in stream.messages:
        print("[主]", await message.text)


async def consume_subagents():
    async for subagent in stream.subagents:
        async for message in subagent.messages:
            print(f"[{subagent.name}]", await message.text)


await asyncio.gather(consume_coordinator(), consume_subagents())

注意异步版里 message.text 是需要 await 的。

7.1. 需要精确到达顺序时 #

interleave 的粒度是「投影项」。要精确到每个 token 的到达顺序,就得读原始协议事件:

"""按 namespace 区分来源,逐 token 处理。"""
stream = agent.stream_events(inp, version="v3")

for event in stream:
    if event.get("method") != "messages":
        continue
    payload = event["params"]["data"][0]
    if not isinstance(payload, dict) or payload.get("event") != "content-block-delta":
        continue
    block = payload.get("delta") or {}
    if block.get("type") == "text-delta":
        source = "subagent" if event["params"]["namespace"] else "coordinator"
        print(f"[{source}] {block['text']}")

判断来源的办法和低层 API 一样:namespace 非空就是子智能体。

8. 流里的中断 #

第 48 章的审批在流式场景下也能工作:

stream = agent.stream_events(inp, cfg, version="v3")
for m in stream.messages:
    print(f"[message] {m.text!r}")

print(f"interrupted={stream.interrupted}")
for i in stream.interrupts:
    print(i.value)
[message] ''
interrupted=True
{"action_requests": [{"name": "write_file",
                      "args": {"file_path": "/main_note.md", "content": "主的笔记"},
                      "description": "Tool execution requires approval\n\n..."}],
 ...}

流会在中断点停下(messages 只吐了一条),interrupted 变 True,interrupts 里是第 48 章 §3.2 那个结构。

前端的处理流程是:

  1. 消费流,发现 stream.interrupted 为真
  2. 从 stream.interrupts 取出 action_requests 和 review_configs,渲染审批界面
  3. 用户点了之后调 Command(resume={"decisions": [...]}),开一个新的流继续

关键是第 3 步——恢复是一次新的 stream_events 调用,不是在原来的流上继续。

9. 一个完整的执行面板 #

把前面的东西拼起来:

"""能看清「谁在做什么」的执行过程面板(控制台版)。"""
import warnings

warnings.filterwarnings("ignore")      # v3 是 beta,会打警告


def render(agent, inp, cfg):
    stream = agent.stream_events(inp, cfg, version="v3")

    for name, item in stream.interleave("messages", "tool_calls", "subagents"):
        if name == "messages":
            if item.text:                          # 过滤纯工具调用的空消息
                print(f"\n💬 {item.text}")

        elif name == "tool_calls":
            print(f"\n🔧 {item.tool_name}({_brief(item.input)})", end="", flush=True)
            for delta in item.output_deltas:
                print(delta, end="", flush=True)
            if item.error is not None:
                print(f"  ✗ {item.error}")
            else:
                print(f"  ✓ {_brief(item.output)}")

        else:                                       # subagents
            print(f"\n┌─ 子任务 [{item.name}] 开始")
            for sname, sitem in item.interleave("messages", "tool_calls"):
                if sname == "messages":
                    if sitem.text:
                        print(f"│  💬 {sitem.text}")
                else:
                    print(f"│  🔧 {sitem.tool_name}({_brief(sitem.input)})")
                    _ = sitem.output               # 等它完成
            print(f"└─ 子任务 [{item.name}] {item.status}")

    if stream.interrupted:
        print("\n⏸  等待审批:")
        for i in stream.interrupts:
            for req in i.value["action_requests"]:
                print(f"    {req['name']}: {req['args']}")
        return None

    print(f"\n✅ 完成。产出文件: {list(stream.output.get('files', {}) or {})}")
    return stream.output


def _brief(x, n=60):
    s = str(x)
    return s if len(s) <= n else s[:n] + "…"

这个结构直接对应界面上的三种元素:主聊天气泡、工具调用行、可折叠的子任务卡片。

10. 官方前端组件 #

官方提供了一套 React 组件,覆盖三个场景:

组件 文档 解决什么
任务清单 todo-list 渲染 write_todos 的三状态清单(第 47 章 §2)
子智能体流式 subagent-streaming 把主消息和子任务卡片分开的 UI 模式
沙箱视图 sandbox 展示沙箱里的文件系统和命令执行(第 43 章 §6)

这些是 TypeScript / React 的,Python 后端通过 SSE 或 WebSocket 把事件推给它们。三个组件对应的正是本章的三类投影:values 里的 todos、subagents 句柄、output 里的 files。

如果你自己写前端,这三个组件的形态值得参考——它们把「智能体在长时间工作」这件事的可视化做了标准化。

11. 实战约定与坑 #

  1. Deep Agents 没有自己的流式 API,返回的是标准 CompiledStateGraph。第 26 章的知识全部可用(§2.1)
  2. 同步 stream_events() 只支持 version="v3",而默认值是 v2——必须显式写,否则报 NotImplementedError(§2.2)
  3. v3 协议是 beta,会打 LangChainBetaWarning(§2.2)
  4. 选择标准:日志调试用 stream(stream_mode="updates"),用户界面用 stream_events(version="v3")(§2.3)
  5. 低层 API 不加 subgraphs=True 就看不到子智能体内部。实测 4 个 chunk 变 8 个(§3.3)
  6. ns 为空是主智能体、非空是子智能体,低层 API 靠这个区分来源(§3.3)
  7. stream_mode 传列表时,chunk 变成 (模式名, 内容) 元组(§3.4)
  8. 过滤摘要 token:metadata 里 lc_source == "summarization" 的要丢掉,否则用户会看到智能体自言自语(§3.2,第 46 章 §7.4)
  9. stream.subagents 的 name 就是 subagent_type——SubAgent(name=...) 里定义的标签就是流里过滤用的标签(§5)
  10. status 是动态的:拿到句柄时 started,消费完变 completed。取值还有 failed / interrupted(§5)
  11. tool_calls 的 completed 刚拿到必然是 False。 别在这时候判断,先渲染「正在执行」,访问 output 后状态才确定(§6)
  12. 顶层 stream.messages 不含子智能体消息,这是设计好的分离(§5.3)
  13. 分别遍历多个投影会丢掉顺序,同步用 interleave、异步用 asyncio.gather(§7)
  14. 异步版的 message.text 需要 await(§7)
  15. 要精确的 token 到达顺序就读原始事件,靠 namespace 判断来源(§7.1)
  16. 中断后恢复是一次新的 stream_events 调用,不是在原流上继续(§8)
  17. 界面用 subagents 不用 subgraphs:前者是产品概念,后者是图结构(§4.3)
  18. task 的 output 是 Command(update={...}) 而不是 ToolMessage,解析时注意(§6)
  19. 子智能体的输出收进可折叠卡片——中间过程对用户是实现细节,能展开就够(§5.3)
  20. 只要生命周期就别订阅消息流,投影是按需打开的,不访问就不产生开销(§5.2)

12. 练习 #

  1. 对比两套 API。 同一个带子智能体的任务,分别用 stream(stream_mode="updates") 和 stream_events(version="v3") 跑一遍,数一数各自要写多少代码才能区分主与子。

  2. 踩 version 的坑。 调 stream_events(inp) 不传 version,确认报 NotImplementedError。再用 astream_events(inp, version="v2"),确认异步版可以。

  3. 验证 subgraphs=True。 数一下带与不带的 chunk 数量差,并把每个 chunk 的 ns 打出来。

  4. 验证懒求值。 在 for c in stream.tool_calls 循环里,先打印 c.completed,再访问 c.output,再打印一次 c.completed。确认它从 False 变 True。

  5. 顺序问题。 先分别遍历 stream.messages 和 stream.subagents,记下输出顺序;再用 interleave 跑一遍,对比差异。

  6. 做执行面板。 把 §9 那个函数跑起来,接一个真模型和两个子智能体(比如「调研」和「写作」),看输出是否清晰。

  7. 流式审批。 给 write_file 配 interrupt_on,在流里检测 stream.interrupted,渲染审批提示,然后用 Command(resume=...) 开新流恢复。

  8. (选做)SSE 服务。 用 FastAPI 把 stream_events 的投影包成 SSE 端点,每种投影发一类事件(event: message / event: tool / event: subagent),前端用 EventSource 消费。

13. 本章小结 #

  1. Deep Agents 没有独立的流式 API,create_deep_agent 返回标准 CompiledStateGraph。它加的唯一东西是 stream.subagents 投影。
  2. 同步 stream_events() 只支持 version="v3",默认值 v2 会直接报错;v1/v2 只能用 astream_events()。
  3. 两套 API 的分工:stream() 做日志调试,stream_events(version="v3") 做用户界面。
  4. 低层 API 必须加 subgraphs=True 才能看到子智能体内部(4 chunk → 8 chunk),靠 ns 是否为空区分来源。
  5. GraphRunStream 有六个投影通道(messages / tool_calls / values / subagents / subgraphs / lifecycle)加上 output / interrupted / interrupts,按需订阅。
  6. stream.subagents 给每个 task 调用一个独立句柄,name 就是 subagent_type,status 会从 started 变到 completed。
  7. tool_calls 是懒求值的:刚拿到 completed=False,访问 output 后才确定。正确做法是先渲染「正在执行」再取结果。
  8. 顶层 messages 不含子智能体消息,这是设计好的分离——界面上主消息直接显示、子消息收进折叠卡片。
  9. 分别遍历投影会丢顺序:同步用 interleave(...),异步用 asyncio.gather,要精确 token 顺序就读原始事件按 namespace 判断。
  10. subagents 是产品概念、subgraphs 是图结构,面向用户的界面用前者。
  11. 中断在流里表现为 interrupted=True + interrupts 列表,恢复是一次新的流调用。
  12. 官方三个前端组件(任务清单、子智能体流式、沙箱视图)正好对应本章的三类投影。