1. 为什么同步 Subagents 会把主代理拖死:AsyncSubAgent 要解决的真实问题
如果你用 LangChain 的 Deep Agents 搭过多代理系统,大概率踩过这个坑:主代理(Supervisor)把任务派给子代理之后,整个流程就卡住了。子代理在那边跑搜索、跑代码生成、跑数据清洗,主代理只能干等,用户发来的新消息也进不来。等子代理终于返回,主代理才继续往下走。任务一多,体验直接崩。
这就是同步子代理的硬伤——阻塞。它适合那种「必须等结果才能继续」的场景,比如先查天气再决定穿什么。但现实里大量任务是长耗时的:一份行业调研要跑十几轮搜索,一段数据分析代码要反复调试。让主代理全程阻塞,等于把整个对话系统变成单线程。
LangChain Deep Agents 里的AsyncSubAgent就是冲着这个问题来的。它让主代理启动后台任务后立即拿到一个 task_id 并返回控制权,子代理在自己的线程上并发执行。主代理可以继续和用户聊天,随时用check_async_task查进度、用update_async_task追加指令、用cancel_async_task取消任务。任务状态存在主代理图的专用状态通道async_tasks里,和消息历史分开,所以哪怕上下文窗口被压缩,任务 ID 也不会丢。
这篇文章面向三类人:正在用create_deep_agent搭多代理协作的开发者、被同步阻塞坑过的 LangChain 用户、以及想把异步调度做成可观测系统的人。我会从配置片段讲到并发验证,再到超时和失败重试的排查,全程给可复制的代码。模型调用这一层,我用 TaoToken 统一 Key 和 API 通道,省得在多个 provider 之间来回切环境变量。
需要先说明一点:异步子代理在 deepagents 0.5.0 里还是预览功能,API 可能变。所以下面的配置我会标注版本,你升级后如果报错,先回来看字段有没有改。
先看同步和异步的核心差异,这张表决定了你该选哪种模式:
| 维度 | 同步子代理 | 异步子代理 |
|---|---|---|
| 执行模型 | 主代理阻塞直到子代理完成 | 立即返回 task_id,主代理继续 |
| 并发性 | 并行但阻塞 | 并行且非阻塞 |
| 任务中更新 | 不可能 | 通过update_async_task发送后续指令 |
| 取消 | 不可能 | 通过cancel_async_task取消运行中任务 |
| 状态性 | 无状态,调用间无持久状态 | 有状态,在自己的线程上维护跨交互状态 |
| 适用场景 | 代理应等结果再继续的任务 | 聊天中交互式管理的长耗时复杂任务 |
看懂这张表,你就明白为什么异步模式更适合「可观测的调度」——因为任务有独立线程、有状态、有 ID,你才能追踪它、干预它、聚合它。同步模式下任务跑完就没了,你连中间状态都看不到。
2. 用 TaoToken 统一模型通道:AsyncSubAgent 接入前的环境准备
在写create_deep_agent之前,先把模型调用这层理顺。Deep Agents 支持多种模型 provider,model字段可以写google_genai:gemini-3.1-pro-preview、openai:gpt-4o这类格式。但如果你同时用多个 provider,Key 管理、Base URL 切换、额度监控会变成一堆散落的.env变量,调试时很难定位问题。
我的做法是用 TaoToken 做统一入口。它是一个兼容 OpenAI 接口规范的模型调用通道,把不同模型的 Key 和 API 地址收敛成一套配置。对 Deep Agents 来说,你只需要把 Base URL 指向 TaoToken 的 API 地址,Key 用 TaoToken 生成的令牌,模型 ID 按它的命名规则填就行。
具体操作路径是这样的:先到官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册账号,然后在控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 里创建 API Key。创建完去 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 复制令牌,注意它只显示一次,复制后存到安全的地方。
拿到 Key 之后,环境变量这样配。我习惯用.env文件加python-dotenv,避免把 Key 硬编码进代码:
# .env TAOTOKEN_API_KEY=sk-你的taotoken令牌 TAOTOKEN_BASE_URL=https://taotoken.net/api然后在 Python 里加载。Deep Agents 底层走的是 LangChain 的模型接口,所以你可以用ChatOpenAI指向 TaoToken 的 Base URL,再传给create_deep_agent:
import os from dotenv import load_dotenv from langchain_openai import ChatOpenAI load_dotenv() llm = ChatOpenAI( model="gemini-3.1-pro-preview", # 按 TaoToken 支持的模型 ID 填 api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], temperature=0.2, )这里有个容易踩的坑:base_url末尾不要带/v1还是不带,取决于 TaoToken 的接口约定。我实测下来,https://taotoken.net/api这个地址直接可用,SDK 会自动补全路径。如果你填成https://taotoken.net/api/v1反而可能 404。拿不准的时候,先用模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 发一条消息验证通道通不通,再去写代码。
为什么要在 AsyncSubAgent 场景下强调统一通道?因为异步子代理会并发发起多个模型调用。如果每个子代理用不同 provider 的 Key,一旦某个 Key 额度耗尽或限流,你很难判断是哪个子代理挂了。统一到 TaoToken 之后,所有调用走同一个通道,日志里的请求 ID 能直接对应到具体任务,排查效率高很多。
另外,异步任务的生命周期可能很长,跨几十分钟甚至几小时。TaoToken 的 Key 如果中途失效,正在跑的子代理会直接报错。所以建议在启动长任务前,先用一个轻量请求探活:
def health_check(llm): try: resp = llm.invoke("ping") return True except Exception as e: print(f"通道探活失败: {e}") return False if not health_check(llm): raise RuntimeError("模型通道不可用,检查 TaoToken Key 和 Base URL")这一步花两秒,能省掉后面半小时的无效调试。环境准备好之后,就可以进入create_deep_agent的配置了。
3. 可复制的 create_deep_agent 配置:AsyncSubAgent 注册与 langgraph.json 对齐
这一节是全文的核心,我给你一份能直接跑的配置。先定义异步子代理列表,每个AsyncSubAgent指向一个 Agent Protocol 服务器上的图。关键字段有三个:name是主代理派活时用的标识,description决定主代理把任务分给谁,graph_id必须和langgraph.json里注册的图名一致。
from deepagents import AsyncSubAgent, create_deep_agent async_subagents = [ AsyncSubAgent( name="researcher", description="Conducts in-depth research using web search. Use for questions requiring multiple searches and synthesis.", graph_id="researcher", # 无 url → ASGI 传输(同部署协同) ), AsyncSubAgent( name="coder", description="Generates and reviews Python code for data analysis and visualization tasks.", graph_id="coder", # url="https://coder-deployment.example.com" # 有 url → HTTP 传输(远程) ), ] agent = create_deep_agent( model=llm, # 上一节配好的 TaoToken 通道 system_prompt="""You are a supervisor agent coordinating research and coding tasks. After launching an async subagent, ALWAYS return control to the user. Never call check_async_task immediately after launch. Always show the full task_id, never truncate or abbreviate it.""", subagents=async_subagents, )字段说明我拆开讲。name必填,唯一标识符,主代理启动任务时用它。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", )graph_id必填,对应 Agent Protocol 服务器上的图 ID。如果你用 LangGraph 部署,这个值必须和langgraph.json里注册的图名匹配。url可选,省略时用 ASGI 传输(进程内调用),设置时用 HTTP 传输到远程服务器。headers可选,给远程服务器加自定义认证头。
langgraph.json的配置长这样,所有图注册在同一个文件里:
{ "graphs": { "supervisor": "./src/supervisor.py:graph", "researcher": "./src/researcher.py:graph", "coder": "./src/coder.py:graph" }, "env": ".env" }注意supervisor是主代理的图,researcher和coder是子代理的图。三个图在同一个langgraph.json里注册,ASGI 传输才能通过进程内函数调用找到它们,不需要走 HTTP 路由,也就没有网络延迟和额外认证。
传输方式的选择直接影响部署拓扑。我整理成对照表:
| 传输方式 | 触发条件 | 延迟 | 认证 | 适用场景 |
|---|---|---|---|---|
| ASGI | 省略url | 进程内,无网络延迟 | 无需额外配置 | 协同部署,推荐默认 |
| HTTP | 设置url | 走网络 | LangGraph SDK 用LANGSMITH_API_KEY | 子代理独立扩展、不同团队维护 |
混合部署也支持,一部分子代理 ASGI 协同,一部分 HTTP 远程:
async_subagents = [ AsyncSubAgent( name="researcher", description="Research agent for information gathering.", graph_id="researcher", # 无 url → ASGI ), AsyncSubAgent( name="coder", description="Coding agent for code generation.", graph_id="coder", url="https://coder-deployment.example.com", # 有 url → HTTP ), ]配置写完之后,主代理的 LLM 会通过AsyncSubAgentMiddleware拿到五个工具:start_async_task、check_async_task、update_async_task、cancel_async_task、list_async_tasks。中间件自动处理线程创建、运行管理和状态持久化,你不需要手动管线程。
这里有个部署时的关键点:本地用langgraph dev跑的时候,工作池大小要调够。每个活动运行占一个工作槽,主代理加 3 个并发子代理需要 4 个槽。默认池子不够会导致子代理启动排队,看起来像卡住了。启动命令加参数:
langgraph dev --n-jobs-per-worker 10配置到这一步,AsyncSubAgent 的骨架就搭好了。下一节我们验证它是不是真的并发执行。
4. 验证并发执行、超时与结果聚合:日志与回调的实操动作
配置写完不代表异步真的生效了。我见过不少人以为配了AsyncSubAgent就是异步,结果主代理还是在启动后立刻轮询check_async_task,把异步硬生生用成了阻塞。所以这一节专门讲怎么验证。
先看生命周期。一次典型的异步交互是这样的:主代理调用start_async_task,服务器创建新线程,以任务描述为输入启动运行,返回线程 ID 作为 task_id。主代理向用户报告这个 ID,不轮询完成状态。用户过一会儿说「查一下进度」,主代理才调check_async_task,获取当前运行状态。如果运行成功,检索线程状态提取子代理最终输出;如果还在跑,就报告进行中。
验证并发,最直接的办法是看时间戳。启动两个子代理,记录各自的created_at,如果它们的时间差在毫秒级,说明是并发启动的:
import time from datetime import datetime # 启动两个任务,记录时间 t1 = datetime.now() task_a = agent.invoke({"input": "research AI agent trends"}) t2 = datetime.now() task_b = agent.invoke({"input": "code a data visualization script"}) t3 = datetime.now() print(f"任务A启动耗时: {(t2 - t1).total_seconds():.3f}s") print(f"任务B启动耗时: {(t3 - t2).total_seconds():.3f}s") # 如果两个都在 1s 内返回,说明是非阻塞启动如果start_async_task返回很快(通常几百毫秒),而子代理实际执行要几十秒,那就对了。反过来,如果启动调用本身卡了十几秒,说明它退化成同步了。
再看状态回传。任务元数据存在主代理图的async_tasks状态通道里,每条记录包含 task_id、代理名、线程 ID、运行 ID、状态和时间戳(created_at、last_checked_at、last_updated_at)。你可以直接读这个通道来验证:
# 假设 agent 已经跑过几轮 state = agent.get_state() async_tasks = state.values.get("async_tasks", {}) for task_id, meta in async_tasks.items(): print(f"任务 {task_id}: 代理={meta['agent_name']}, 状态={meta['status']}, " f"创建于={meta['created_at']}, 最后检查={meta['last_checked_at']}")这个通道和消息历史是分开的,这点很关键。Deep Agents 在上下文窗口填满时会压缩消息历史,如果 task_id 只存在工具消息里,压缩后就丢了。专用通道保证主代理始终能通过list_async_tasks回忆任务。
超时验证要主动构造。给子代理设一个短超时,看它是否正确报错而不是无限挂起:
# 在子代理图里配置超时(示意,具体参数看你的 LangGraph 版本) config = {"configurable": {"timeout": 30}} # 30 秒超时 result = agent.invoke({"input": "research something slow"}, config=config)超时后,任务状态应该变成error,而不是一直停在running。如果它一直 running,说明超时没生效,检查子代理图的运行配置。
结果聚合是异步调度的最后一环。主代理拿到多个子代理的结果后,需要合并成一份输出。用list_async_tasks遍历所有任务,对非终态任务并行拉取实时状态,终态任务(success、error、cancelled)从缓存返回:
def aggregate_results(agent): state = agent.get_state() tasks = state.values.get("async_tasks", {}) results = {} for task_id, meta in tasks.items(): if meta["status"] == "success": results[meta["agent_name"]] = meta.get("result", "") elif meta["status"] == "error": results[meta["agent_name"]] = f"[失败] {meta.get('error', '未知错误')}" else: results[meta["agent_name"]] = f"[进行中] {meta['status']}" return results可观测性这块,LangSmith 是标配。每个异步子代理运行都是标准的 LangGraph 运行,在 LangSmith 里完全可见。主代理的追踪显示 launch、check、update、cancel、list 的工具调用,每个子代理运行作为单独追踪出现,通过线程 ID 链接。用线程 ID(就是 task_id)关联主代理编排追踪和子代理执行追踪,一眼就能看出哪个子代理慢、哪个失败了。
如果你不想依赖 LangSmith,也可以在 TaoToken 的调用日志里看请求分布。因为所有模型调用走统一通道,每个子代理的请求都会带上时间戳和模型 ID,并发时能看到请求交错出现,而不是串行排队。这算是统一通道带来的额外观测点。
验证通过的标准很简单:启动快、状态可查、超时能报错、结果能聚合。四条都满足,异步调度就算跑通了。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth 对照
异步子代理跑起来之后,报错会集中在几个地方。我把真实遇到过的错误和排查路径列出来,你对照着看。
401 Unauthorized。这个最常见,八成是 TaoToken Key 或 Base URL 的问题。先确认.env里的TAOTOKEN_API_KEY没有多余空格,TAOTOKEN_BASE_URL是https://taotoken.net/api而不是带/v1的变体。如果 Key 是对的还报 401,去 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 确认令牌没过期、没被删。还有一种情况是子代理用了 HTTP 传输到远程服务器,远程服务器的认证头没配,这时候检查AsyncSubAgent的headers字段。
local proxy failed。这个错误通常出现在网络层,不是模型层。如果你在本地跑langgraph dev,ASGI 传输是进程内调用,理论上不该有代理问题。出现这个报错,先检查是不是环境里设了HTTP_PROXY或HTTPS_PROXY变量,把它们清掉再试。另外确认langgraph.json里所有图都注册了,缺图会导致 SDK 找不到目标,报出类似代理失败的错。
reading choices 相关报错。这类错误一般是模型返回格式不符合预期。Deep Agents 期望模型返回结构化的工具调用,如果模型输出被截断或格式错乱,解析就会失败。排查方向:一是确认model字段填的模型 ID 在 TaoToken 通道里可用,去模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 试一下同一个模型;二是检查temperature是不是太高,异步任务建议 0.2 以下;三是看是不是上下文太长导致输出被截断,适当精简 system_prompt。
OAuth 相关报错。如果你用 HTTP 传输到远程 LangGraph 部署,认证走的是LANGSMITH_API_KEY或LANGGRAPH_API_KEY环境变量。报 OAuth 错误通常是这两个变量没设或设错。自托管 Agent Protocol 服务器可能用不同的认证机制,这时候要在AsyncSubAgent的headers里手动加认证头:
AsyncSubAgent( name="coder", description="Coding agent.", graph_id="coder", url="https://coder-deployment.example.com", headers={"Authorization": "Bearer 你的远程服务令牌"}, )除了这些,还有几个异步特有的坑。主代理启动后立即轮询:表现是启动任务后马上循环调check,把异步变成阻塞。中间件会注入系统提示规则来防止,如果还发生,在system_prompt里强化「After launching an async subagent, ALWAYS return control to the user. Never call check_async_task immediately after launch.」
主代理报告过时状态:它引用对话历史里早期的任务状态,而不是重新 check。解决办法是在系统提示里加「对话历史中的任务状态总是过时的」,并要求报告前必须调check或list。
任务 ID 查找失败:主代理截断或重新格式化 task_id,导致 check 或 cancel 失败。这通常是模型特定问题,在系统提示里加「始终显示完整 task_id,绝不截断或缩写」,或者换个模型试试。
子代理启动排队:启动挂起或很久才开始。这是工作池耗尽,用langgraph dev --n-jobs-per-worker 10加大池子。记住主代理加 N 个并发子代理需要 N+1 个槽。
排查的时候有个通用技巧:先看 TaoToken 的调用日志,确认请求有没有发出去。如果日志里没有对应请求,问题在客户端配置;如果有请求但报错,问题在模型或参数。这一步能快速定位是网络层还是模型层。
6. 把异步调度用起来:从单机验证到长期 Coding Plan
到这里,AsyncSubAgent 的完整链路你应该能跑通了:TaoToken 统一通道 →create_deep_agent配置 → 并发验证 → 报错排查。最后说几个实战里的经验。
异步子代理最适合的场景是「长耗时 + 需要中途干预」。比如一份深度调研,子代理跑十分钟,用户中途想加个方向,用update_async_task发新指令,之前的运行被中断,子代理带着完整对话历史加新指令重启,task_id 不变。这种交互同步模式根本做不到。
如果你要把这套东西用在长期编码或 Agent 任务上,建议走 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite,它针对持续性的编码场景做了额度优化,比按次调用划算。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有各语言的完整示例。如果你用 Claude Code 这类工具,Anthropic 兼容接入的说明在 https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode-anthropic&utm_campaign=rewrite。
一个我踩过的坑:异步任务的状态通道async_tasks会随着任务增多而膨胀。如果你跑几百个任务不清理,主代理的状态会越来越大。建议在任务进入终态(success、error、cancelled)后,定期归档或清理,只保留最近 N 条。清理逻辑可以挂在主代理的收尾节点上。
还有,description字段值得反复打磨。主代理选子代理全靠它,写得越具体,委派越准。我一般会写清楚「什么时候用」和「能做什么」,比如「Use for questions requiring multiple searches and synthesis」比「research agent」有用得多。
最后,异步不等于不管。启动任务后返回控制权是对的,但用户问进度时你得真的去 check,不能拿历史状态糊弄。中间件提示里那句「对话历史中的任务状态总是过时的」要刻进脑子里。做到这一点,你的多代理系统才算真正可观测、可干预、可聚合。