TECH NOTE
5801 字 29 分钟 系列 20 / 2203 Async subagents:后台跑的子智能体(启动、轮询、中途干预)
ON THIS PAGE / 目录34 节
一句话讲透:同步子智能体像打电话——对方说完你才能走开;异步子智能体像派个同事去办事,先给你一张工单号,你该干嘛干嘛,随时能问一句”到哪了”,也能改需求,实在不行还能叫他先停下。
这篇在系列里的位置:04 Subagents 模式原理 的「决策一:同步还是异步」 讲的是”同步 vs 异步”这个概念,以及通用的后台作业三工具模式(start / status / get result);14 底层机制与抓包 把手写 Tool 包装和内置 subagents 的差别拆开了;本目录 01 静态子智能体 讲静态(同步)子智能体的配置面,02 动态子智能体 讲运行时动态生成子智能体。
本篇负责的那一层:如果 04 篇回答的是”为什么要异步”,这篇回答”Deep Agents 里到底怎么异步”——后台作业、长任务、并行工作流、中途干预(steering)与取消、传输方式(transport)与部署拓扑。概念在 04,API 在这儿。
它解决什么问题
原文的定位很直接:异步子智能体让主管(supervisor)启动一个立刻返回的后台任务,于是主管可以在子智能体并发干活的同时继续和用户对话。主管随时能查进度、补指令、取消任务。
判断标准就一句:主管需要结果才能往下走 → 用同步(01 篇 的静态子智能体、02 篇 的动态子智能体都是同步的);用户不该干等、活儿又长 → 用异步。
| 情形 | 结论 | 原因 |
|---|---|---|
| 长任务(几分钟到几十分钟的调研、批量生成、长数据拉取) | 用异步 | 同步会把对话冻住那么久 |
| 多个互不依赖的活儿 | 用异步 | 一起甩出去,真正并行 |
| 要中途改方向 / 要能取消 | 用异步 | 只有异步有 update / cancel |
| 状态要跨多轮对话活着 | 用异步 | task ID 存在主管 graph 的专用通道里 |
| 一分钟内能跑完的小任务 | 别用 | 启动 + 轮询的额外往返比直接等还慢 |
| 下一步动作依赖这个结果 | 别用 | 主管拿不到结果只能空转,异步变成忙等 |
本地只想 python xx.py 跑通 | 别用 | 调用要打到 Agent Protocol 服务器上,本机不一定有 |
📌 注意:这张”用 / 别用”表是按原文的对比表反推的判断口径,原文没有单独写”什么时候不要用”。
同步 vs 异步:原文那张对照表
| 维度 | 同步子智能体 Sync | 异步子智能体 Async |
|---|---|---|
| 执行模型 / 并发性 | 主管阻塞,直到子智能体做完;并行但阻塞 | 立刻返回作业 ID 并继续;并行且非阻塞 |
| 任务中更新 | 做不到 | 用 update_async_task 追加指令 |
| 取消 | 做不到 | 用 cancel_async_task 取消运行中的任务 |
| 有状态性 | 无状态,两次调用之间不留东西 | 有状态,自己一条 thread 跨交互保持 |
| 最适合 | 主管必须先等到结果才能继续的任务 | 长跑、复杂、要在聊天里交互式管理的任务 |
整体机制
graph TB U(["用户 User"]) --> S["主管 Supervisor"] S -->|"start_async_task 启动"| J["作业 Job<br/>task_id 就是 thread_id"] S -->|"立刻回话,不阻塞"| U J -->|"Transport 传输方式"| T{"有没有 url?"} T -->|"没有 url → ASGI 传输"| A["异步子智能体 A: researcher<br/>同进程里的独立 thread"] T -->|"有 url → HTTP 传输"| B["异步子智能体 B: coder<br/>远端 Agent Protocol 服务器"] S -->|"check / update / cancel / list"| T A -.->|"状态与结果"| S B -.->|"状态与结果"| S这张图在说什么:
① 主管(Supervisor)是唯一和用户对话的人;异步子智能体(Async Subagent)不直接对用户说话,只对主管负责。
② 主管启动一个作业(Job)时拿到的是一个 task_id 而不是结果——task_id 就是服务器上的 thread_id,“作业”本质是一条独立会话线程。
③ 主管后续所有动作(查状态、补指令、取消、列表)都经过传输方式(Transport)这一层:url 空 → ASGI 传输走进程内调用,url 有值 → HTTP 传输走网络。换传输方式不改业务代码,只改配置。
时间轴:一次真实交互长什么样
时间往下走(主管视角)T0 用户 → 主管: "把这份合同审一下,顺便查下对手公司背景"T1 主管 → start_async_task("审合同", "legal_reviewer") └─ 立刻返回 task_id: 7f3a9c(= 服务器上的 thread_id)T2 主管 → 用户: "已开始审查(任务 ID 7f3a9c),你先忙别的" ← 交回控制权T3 用户 → 主管: "那先聊点别的" ← 同步子智能体永远到不了这一步T4 用户 → 主管: "审得怎么样了?" → check_async_task("7f3a9c") → runningT5 用户 → 主管: "方向改一下,重点看违约金条款" 主管 → update_async_task("7f3a9c", "重点看违约金") ← 中途干预 (旧 run 被打断,带完整历史 + 新指令重启,task_id 不变)T6 用户 → 主管: "算了别审了" → cancel_async_task("7f3a9c") → cancelledT7 用户 → 主管: "刚发的背景调研呢?" 主管 → check_async_task("b21e04") → success + result ← 拿结果T8 主管 → 用户: 把结果整理成人话汇报和 04 篇的”同步子智能体”对照着看
| 04 篇的同步子智能体 | 本篇的异步子智能体 | |
|---|---|---|
| 一次调用的形态 | 主管调 task,等返回值 | 调 start_async_task,拿 task_id 立刻走 |
| 用户在等待期间 | 什么都看不到,对话被冻住 | 可以继续聊,也能再派新任务 |
| 进度怎么来 | 没有进度,只有最终结果 | check_async_task 拉当前状态 |
| 改需求 / 收手 | 只能撤了重开 | update_async_task / cancel_async_task |
| 层次 | 通用取舍(概念层) | 概念在 Deep Agents 的落地 API(实现层) |
04 篇那个”三工具模式”(start / status / get result)就是这套东西的极简版;Deep Agents 把它做成了 5 个工具 + 一个中间件 + 一个专用状态通道。
怎么配(Configure async subagents)
异步子智能体就是一份 AsyncSubAgent 规格列表,每条指向一个 Agent Protocol 服务器:
from deepagents import AsyncSubAgent, create_deep_agent
async_subagents = [ AsyncSubAgent( # 不写 url → ASGI 传输(和主管同一个部署里) name="researcher", description="Research agent for information gathering and synthesis", graph_id="researcher", ), AsyncSubAgent( # 写了 url → HTTP 传输(远端部署) name="coder", description="Coding agent for code generation and review", graph_id="coder", # url="https://coder-deployment.langsmith.dev" ),]
agent = create_deep_agent( model="google_genai:gemini-3.6-flash", subagents=async_subagents)关键点:同一个 subagents= 参数,靠有没有 url 决定这条规格走哪种传输方式,靠对象类型区分同步 / 异步。
官方给这个例子准备了公开 trace:https://smith.langchain.com/public/0727618d-02b5-4e99-b7c7-79d6f37f234d/r
AsyncSubAgent 的字段
| 字段 | 类型 | 说明 |
|---|---|---|
name | str | 必填。唯一标识,主管启动任务时用它指名道姓。 |
description | str | 必填。这个子智能体干什么,主管靠它决定派谁去。 |
graph_id | str | 必填。Agent Protocol 服务器上的 graph ID(或 assistant ID)。LangGraph 部署里必须和 langgraph.json 里注册的名字对上。 |
url | str | 可选。不写 → ASGI 传输(进程内);写了 → HTTP 传输打远端。 |
headers | dict[str, str] | 可选。给远端服务器带的自定义请求头,自建 Agent Protocol 服务器做认证时用。 |
同部署时的 langgraph.json
LangGraph 部署里,被同部署(co-deployed)的图都要注册在同一个 langgraph.json:
{ "graphs": { "supervisor": "./src/supervisor.py:graph", "researcher": "./src/researcher.py:graph", "coder": "./src/coder.py:graph" }}这不是可选项:ASGI 传输要求两边在同一份注册表里,否则主管按 graph_id 找不到图。
和静态 SubAgent 的差异(01 篇 对照)
| 维度 | 静态 SubAgent | 异步 AsyncSubAgent |
|---|---|---|
| 跑在哪 | 主管进程内(内联子图) | 独立部署上的 Agent Protocol 服务器(或同进程 ASGI) |
| 主管怎么调 | task 工具,同步阻塞 | 5 个 *_async_task 工具,非阻塞 |
| 必填字段 | name、description、提示词/工具 | name、description、graph_id |
| 提示词在哪配 | 规格里直接写 system_prompt | 规格里没有这个字段,提示词在远端那张图上 |
| 状态 | 无状态,一次调用一次结果 | 有状态,自己一条 thread 跨交互保持 |
| 中途改 / 取消 | 都不支持 | update_async_task / cancel_async_task |
| 前置条件 | 无需额外环境 | 需要一个能跑的 agent server / 部署环境(这一篇最容易踩的坑) |
主管手里多了哪些工具
配置了异步子智能体后,Deep Agents stack 会自动包含 AsyncSubAgentMiddleware,主管因此多出 5 个工具:
| 工具 | 作用 | 返回 |
|---|---|---|
start_async_task | 启动一个新的后台任务 | 任务 ID(立刻返回) |
check_async_task | 取任务的当前状态与结果 | 状态 + 结果(若已完成) |
update_async_task | 给运行中的任务发新指令 | 确认 + 更新后的状态 |
cancel_async_task | 停掉运行中的任务 | 确认 |
list_async_tasks | 列出所有被跟踪的任务及其实时状态 | 所有任务的摘要 |
主管的 LLM 像调其它工具一样调它们;建 thread、管 run、持久化状态都由 middleware 自动处理(原文)——你不需要自己写”任务注册表”。
入参长什么样(原文只给了工具名和用途,这里按本机 0.7.5 源码的 schema 补充):
| 工具 | 入参 |
|---|---|
start_async_task | description(任务描述)、subagent_type(必须是已注册的异步子智能体名) |
check_async_task | task_id(原样传 start_async_task 返回的那个字符串) |
update_async_task | task_id、message(追加的指令或上下文) |
cancel_async_task | task_id |
list_async_tasks | status_filter:running / success / error / cancelled / all(默认 all) |
生命周期状态机
stateDiagram-v2 [*] --> running: start_async_task 新建 thread + run,返回 task_id running --> running: update_async_task 打断旧 run,带历史 + 新指令重启 running --> success: run 成功,check 时从 thread state 取最终输出 running --> error: run 失败,返回错误信息 running --> cancelled: cancel_async_task 调服务器 runs.cancel() success --> [*] error --> [*] cancelled --> [*]终态(terminal)就是不会再变的状态。原文明确列了 success、error、cancelled;本机 0.7.5 源码里这个集合是 {cancelled, success, error, timeout, interrupted}——多出来的 timeout / interrupted 属于 SDK 侧状态,原文未提及。
五个动作在服务器上各自做了什么(原文逐个说明)
- Launch(启动):在服务器上新建一条 thread,用任务描述作为输入起一个 run,把 thread ID 当 task ID 返回。主管把 ID 报给用户,然后不去轮询等完成。
- Check(查询):拉当前 run 状态。run 成功了就再取一次 thread state 抽出子智能体的最终输出;还在跑就如实告诉用户还在跑。
- Update(更新):在同一条 thread 上用 interrupt 多任务策略起一个新 run。旧 run 被打断,子智能体带完整对话历史 + 新指令重启,task ID 不变。
- Cancel(取消):在服务器上调
runs.cancel(),并把任务标记为"cancelled"。 - List(列表):遍历所有被跟踪任务;非终态任务并发去服务器拉实时状态,终态任务直接返回缓存值(省一次网络往返)。
看第 3 条:update 不是”追加一条消息就完了”,而是打断重启。这解释了为什么干预后子智能体还记得前面聊过什么(历史在),也解释了为什么干预本身有成本(一次新 run)。
状态管理(Understand state management)
任务元数据存在主管 graph 上一个专用状态通道 async_tasks 里,和消息历史分开。
为什么必须分开?因为 deep agents 在上下文窗口塞满时会压缩消息历史(compaction / summarization)。如果 task ID 只存在于某条工具消息里,压缩之后就被摘要掉了,主管从此失忆——用户再问”上次那个调研呢”,主管答不上来,任务也取消不掉。专用通道保证无论压缩多少轮,主管都能通过 list_async_tasks 想起自己派过哪些活。
每个被跟踪的任务记录这些字段(原文):
| 字段 | 含义 |
|---|---|
| task ID / thread ID | 任务标识;本机源码里 task ID 就等于 thread ID |
| agent name / run ID | 哪个异步子智能体在跑;该 thread 上当前这次执行的 run ID |
| status | 当前状态(running / success / error / cancelled 等) |
created_at | 任务创建时间 |
last_checked_at | 最后一次通过 SDK 查状态的时间 |
last_updated_at | 状态发生变化、或通过 update 发了跟消息的时间 |
后两个时间戳分工挺讲究:last_checked_at 记录”你查过”,last_updated_at 记录”它变过”。想判断”这个状态是不是老古董”,看这两个字段就够了。
和主管的关系可以概括成三句话:
- 状态挂在主管的 graph 上,不在子智能体那边——子智能体只管自己的 thread 和 run。
- 主管是唯一的读写方:启动、查询、更新、取消都改这条通道,子智能体压根不知道有这张表。
- 状态随主管会话持久化(配了 checkpointer 就落库),所以主管重启或换轮次后依然能接管之前派的活。
选传输方式(Transport)
ASGI 传输(同进程部署)
规格里不写 url 就用 ASGI 传输:LangGraph SDK 的调用被路由成进程内函数调用,不走 HTTP。LangGraph 部署下要求两张图注册在同一个 langgraph.json。
原文给的结论:省掉网络延迟;不需要额外认证配置;子智能体仍是独立的一条 thread,有自己的状态(不是”变成一次函数调用就没状态了”);这是推荐的默认选择。
HTTP 传输(远端)
加个 url 字段就切到 HTTP 传输,SDK 调用走网络打到远端 Agent Protocol 服务器:
from deepagents import AsyncSubAgent
AsyncSubAgent( name="researcher", description="Research agent", graph_id="researcher", url="https://my-research-deployment.langsmith.dev",)认证方面(原文):LangGraph 部署由 SDK 用环境变量 LANGSMITH_API_KEY(或 LANGGRAPH_API_KEY)自动处理;自建的 Agent Protocol 服务器可能用别的认证机制。
什么时候值得付这份网络和运维成本:子智能体要独立扩缩容、资源画像不同(比如要 GPU)、或者干脆是别的团队维护的。
两种传输方式对照
| ASGI 传输 | HTTP 传输 | |
|---|---|---|
| 触发条件 / 调用路径 | 不写 url;进程内函数调用 | 写了 url;网络请求 |
| 延迟 | 无网络开销 | 有一次网络往返 |
| 认证 | 不需要额外配 | SDK 读 LANGSMITH_API_KEY / LANGGRAPH_API_KEY,或自建认证 |
| 前置要求 | 同部署 + 同一份 langgraph.json | 远端有可用的 Agent Protocol 服务器 |
| 适用 | 默认起步、本地和单部署 | 独立扩缩容、异构资源、跨团队 |
⚠️ 警告(本机 0.7.5 源码观察,原文未说明):ASGI 传输(
url=None)只在异步调用路径上可用。本机源码里,同步客户端拿到没有url的规格会直接报错:ASGI transport (url=None) requires async invocation。所以纯异步子智能体 + ASGI 的组合,要用ainvoke/astream这类异步入口跑主管,别用同步invoke。
💡 提示(本机 0.7.5 源码观察,原文未说明):HTTP 传输的请求头默认会补上
x-auth-scheme: langsmith(除非你在headers里自己给了)。自建服务器一般忽略它,真要覆盖就用headers。
选部署拓扑(Deployment topology)
单部署(Single deployment)
所有 agent 同部署、同服务器,用 ASGI 传输;LangGraph 部署下所有图注册进一份 langgraph.json。
单部署(推荐起步) 一个服务器 / 一份 langgraph.json 主管 supervisor ├─ ASGI 传输 ─▶ researcher (同进程,独立 thread + 独立状态) └─ ASGI 传输 ─▶ coder (同进程,独立 thread + 独立状态)一个服务器管到底,零网络延迟;缺点是大家抢同一份资源,谁把 worker 占满大家一起排队。
拆分部署(Split deployment)
主管一台服务器,子智能体另一台,用 HTTP 传输。
拆分部署 服务器 A:主管 ──HTTP + 认证──▶ 服务器 B:researcher / coder适合:子智能体需要不同的计算画像(CPU 密集 / GPU / 内存大户),或者要独立扩缩容。
混合(Hybrid)
一部分子智能体同部署走 ASGI,另一部分远端走 HTTP:
from deepagents import AsyncSubAgent
async_subagents = [ AsyncSubAgent( # 不写 url → ASGI(同部署) name="researcher", description="Research agent", graph_id="researcher", ), AsyncSubAgent( # 写了 url → HTTP(远端) name="coder", description="Coding agent", graph_id="coder", url="https://coder-deployment.langsmith.dev", ),]混合部署 用户 ─▶ 主管(服务器 A) ├─ ASGI ─▶ researcher 同进程:轻量、频繁调用 └─ HTTP ─▶ coder 远端:重资源、独立扩缩容官方混合部署示例的公开 trace:https://smith.langchain.com/public/dddcb1d1-92ac-468b-aa34-5d9070661aa1/r
原文的推荐路线很清楚:从单部署开始(一个服务器管到底、零网络延迟),等真出现资源或组织边界了再拆:拆分部署适合子智能体资源画像差异大、要独立扩缩容的场景(代价是多一套部署 + 认证 + 网络延迟);混合适合大部分子智能体轻量、个别重的场景(代价是配置更碎)。
本机可用性实测(deepagents 0.7.5)
结论先给:这一篇的能力在本机可用,API 已经导出——但能不能真跑起来取决于有没有能跑的 agent server / 部署环境。
已核实的事实(本机 deepagents 0.7.5):
from deepagents import AsyncSubAgent, AsyncSubAgentMiddleware可用,两个符号都已导出。AsyncSubAgent的字段是name/description/graph_id/url/headers,和原文表格一致。create_deep_agent(model=..., subagents=[AsyncSubAgent(...)])能构造出 agent;工具里确实有start_async_task、check_async_task、update_async_task、cancel_async_task、list_async_tasks五个;状态通道名async_tasks与中间件AsyncSubAgentMiddleware都在本机包里。
from deepagents import AsyncSubAgent
spec = AsyncSubAgent(name="researcher", description="Research agent", graph_id="researcher") # 不写 url → ASGI 传输print(spec)但请注意边界:构造 agent 不等于跑得通任务。任务真正执行要靠 Agent Protocol 服务器——同进程 ASGI 传输需要同一个部署(同一份 langgraph.json,且走异步调用入口),远端 HTTP 传输需要一台真实的远端服务。本地单纯 python xx.py 不一定具备这个环境;原文整篇也是围绕部署环境展开的(langgraph.json、langgraph dev、LangSmith Deployments)。
📌 注意(原文未说明):本机实测里只传一个
AsyncSubAgent时,构造出的 agent 里同时还有同步的task工具——默认的 general-purpose 同步子智能体仍会被挂上。两者并存不冲突,但排查”主管为什么走了同步路径”时要记得这一点。
最佳实践
本地开发时给工作进程池(worker pool)定容量
用 langgraph dev 在本地跑时,要把工作进程池调大以容纳并发的子智能体 run。每个活跃 run 占一个 worker 槽位,算账很简单:1 个主管 + 3 个并发子智能体任务 = 需要 4 个槽位;槽位给少了不会报错,而是排队(launch 一直等着),这是最容易被误判成”卡死”的现象。
langgraph dev --n-jobs-per-worker 10子智能体的 description 怎么写
主管就是靠 description 决定派谁去的,所以要具体、以动作开头。原文给了一好一坏两个例子:
AsyncSubAgent( # 好例子:说清手段 + 什么场景该找我 name="researcher", description="Conducts in-depth research using web search. Use for questions requiring multiple searches and synthesis.", graph_id="researcher",)反面例子:AsyncSubAgent(name="helper", description="helps with stuff", graph_id="helper")——等于没说,主管只能瞎猜,然后派错人。
用会话线程 ID(thread ID)做追踪
LangGraph 部署下,每个异步子智能体 run 都是标准的 LangGraph run,在 LangSmith 里完整可见。主管的 trace 里能看到 launch / check / update / cancel / list 这些工具调用,每个子智能体 run 是另一条独立 trace,两边靠 thread ID 串起来。
所以排障姿势是:用 thread ID(= task ID)把”主管的编排 trace”和”子智能体的执行 trace”对上——先看主管什么时候派的活,再看子智能体那边跑到哪一步。
排障清单
原文的 Troubleshooting 给了四个问题,每个都是”模型行为”层面的坑。
主管启动后立刻轮询
- 症状:主管刚
launch完就在循环里调check,把异步硬生生用成了阻塞。 - 原因:模型的本能是”我派了活就该盯着”,于是自己把非阻塞变成了忙等。
- 处理:middleware 会注入系统提示规则来阻止这种行为;如果还在轮询,就在主管的
system_prompt里再加一条硬约束:
from deepagents import create_deep_agent
agent = create_deep_agent( model="google_genai:gemini-3.6-flash", system_prompt="""...your instructions...
After launching an async subagent, ALWAYS return control to the user. Never call check_async_task immediately after launch.""", subagents=async_subagents,)官方这个问题的公开 trace:https://smith.langchain.com/public/181f8fc2-b3aa-4c4b-b23d-d4204db98849/r
主管报的是旧状态
- 症状:主管直接引用对话历史里早先那次的状态,而不去重新
check。 - 原因:对话历史里的状态天然过期,模型却当成了当前值。
- 处理:middleware 提示里已写明”对话历史里的任务状态永远是旧的”;如果还犯,就在系统提示里明确要求”报状态前必须先调
check或list”。
任务 ID 查不到
- 症状:
check或cancel失败,因为主管把 task ID 截断或改写格式了。 - 原因:模型倾向于”省略中间那段”或”美化一下”,而 task ID 必须逐字精确。
- 处理:middleware 提示要求始终使用完整 task ID;如果还截断,原文说这通常是模型相关的毛病——换个模型,或在系统提示里加”永远显示完整 task_id,绝不截断或缩写”。
子智能体只排队、不执行
- 症状:启动一个子智能体后长时间挂着,或者很久才开始跑。
- 原因:工作进程池被占满了,新 run 没有槽位。
- 处理:用
--n-jobs-per-worker把池子调大,参考工作进程池容量算法。
四个问题排一下优先级:先怀疑模型行为(前三个),再怀疑资源(第四个)。前三个的修法都在提示词层,第四个在启动参数层。
参考实现
- async-deep-agents 仓库:https://github.com/langchain-ai/async-deep-agents Python 和 TypeScript 两种语言的可运行示例,都能部署到 LangSmith Deployments;里面演示了一个主管带 researcher 和 coder 两个子智能体、以后台任务方式并跑。想抄作业就从这里开始,它把”主管 + 两个后台子智能体”的最小闭环跑通了。
小结
- 异步子智能体 = 后台作业 + 主管不阻塞。启动立刻拿 task ID,用户该聊天聊天,随时能查进度、改需求、取消——这正是 04 篇 讲的”异步”概念在 Deep Agents 里的落地 API。
- 配置只差一个
url:AsyncSubAgent必填name/description/graph_id;不写url走 ASGI 传输(同部署、推荐默认),写了走 HTTP 传输(远端、要认证)。 - 主管多 5 个工具:
start_async_task/check_async_task/update_async_task/cancel_async_task/list_async_tasks。update 是”打断重启、task ID 不变”,cancel 走服务器的runs.cancel()。 - 状态存在主管 graph 的
async_tasks通道里,刻意和消息历史分开——因为消息会被压缩,task ID 一旦进了被摘要掉的工具消息就找不回来了。 - 部署拓扑从单部署起步:不够用了再拆分或混合,别一上来就上多套部署 + 认证 + 网络延迟。
- 本机能导入、能构造,但要有 server 才跑得通:本机
deepagents 0.7.5已导出AsyncSubAgent与AsyncSubAgentMiddleware;端到端跑通还需要能跑的 agent server / 部署环境。
一句话选型
用户不该干等、活儿又长又可能要改方向 → 用异步子智能体(先单部署 + ASGI 起步);主管必须拿到结果才能往下走 → 老老实实用 01 篇 / 02 篇 的同步子智能体,别为了”显得高级”把对话冻住。