LangChain 之 Agent 异步调用

简介: Agent本质是I/O编排器,90%时间在等待模型/工具响应。同步调用导致线程空转、吞吐低下;异步(`ainvoke`)通过事件循环复用线程,实现高并发。需三层协同:请求异步、工具并行、批量限流,并确保工具真异步、超时可控、上下文隔离。

一、一个 Agent 请求,九成时间在等

先算一笔账。一个"帮我规划北京到上海行程"的 Agent,内部大概会这样跑:

阶段 干了什么 在等谁 典型耗时
第 1 次模型调用 理解意图,决定先查天气 模型服务 1.0 ~ 2.5 s
工具:查天气 HTTP 请求天气 API 外部服务 0.2 ~ 0.8 s
第 2 次模型调用 拿到天气,决定查交通 模型服务 1.0 ~ 2.5 s
工具:查交通 查询班次接口 外部服务 0.3 ~ 1.0 s
第 3 次模型调用 拿到交通,组织答案 模型服务 1.5 ~ 3.0 s

加起来 4 到 10 秒,其中真正消耗 CPU 的时间不到 5%。Agent 不是一个计算程序,它是一个 I/O 编排器:把模型调用、HTTP 请求、数据库查询按顺序串起来,中间绝大部分时间都在等对面回话。

这就是同步调用的根本问题。agent.invoke(...) 在等模型返回的那 2 秒里,当前线程被彻底占住:既不能处理下一个用户请求,也不能去查另一个城市的天气。线程在,但什么都没干。

换个角度看更直观。假设一个服务每秒来 20 个 Agent 请求,每个请求平均 6 秒,同步模型下你需要 120 个线程同时在线才能撑住。线程本身不贵,但 120 个线程的栈、上下文切换、连接池占用,都是真实成本。而它们 95% 的时间处于阻塞状态。

异步要解决的就是这件事:把"等"这件事从线程身上摘下来,交给事件循环统一调度。

二、ainvoke 到底做了什么

LangChain 的 Agent 从第一版起就同时提供同步与异步两套入口,接口形状几乎一样:

import asyncio
from langchain.agents import create_agent
from langchain.tools import tool

@tool
def get_weather(city: str) -> str:
    """查询指定城市的天气。"""
    return f"{city}:多云,18°C"

agent = create_agent(
    model=deepseek_llm,
    tools=[get_weather],
    system_prompt="你是旅行规划助手。",
)

# 同步:线程被占住直到返回
result = agent.invoke({
   "messages": [{
   "role": "user", "content": "上海天气"}]})

# 异步:挂起当前协程,把控制权交还事件循环
async def main():
    result = await agent.ainvoke(
        {
   "messages": [{
   "role": "user", "content": "上海天气"}]}
    )
    print(result["messages"][-1].content)

asyncio.run(main())

await 这个词是关键。执行到 await agent.ainvoke(...) 时,Python 不会新开一个线程去跑,而是把当前协程挂起,把控制权交还给事件循环(event loop)。事件循环手里有一张"就绪任务表",它会立刻切到下一个能跑的任务上——可能是另一个用户的 Agent 请求,可能是同一进程里的一批数据清洗任务。

等模型服务的响应回来了,操作系统通知事件循环,事件循环再把刚才挂起的协程恢复,从 await 那一行继续往下走。

整个过程只有一个线程,但同一时刻有很多个"进行到一半"的任务。并发的本质不是同时执行,而是同时未完成。

这带来一个必须记住的结论:异步不会让单个请求变快。 一个请求该等 6 秒还是 6 秒,甚至因为协程调度多出几毫秒。异步换来的是吞吐:同样的进程,能同时扛住几十上百个在等待中的请求。

如果你的场景是"用户点一下,等一个答案",异步几乎没有体感收益;如果是"一批工单要跑 Agent"、"网关后面挂着上千个会话",异步是数量级的差别。下面这张图把两种时间线摆在一起:

在这里插入图片描述

三个请求串行要 18 秒,并发只需要 6 秒——因为每个请求的内部等待被重叠了。注意单条泳道的长度没变,变的只是它们是否叠在一起。

三、三层并发,收益完全不同

很多人把"异步"当成一个开关,invoke 改成 ainvoke 就以为完事了。实际上并发有三个层次,收益和改造成本都不一样。

第一层:单请求异步。 把 invoke 换成 ainvoke,stream 换成 astream。收益是释放线程,让你能在同一个事件循环里塞进更多请求。这一层不改代码结构,只改调用方式。

第二层:请求内工具并行。 模型在一轮里可能返回多个 tool_calls,比如同时要查北京天气、上海天气、两地交通。LangGraph 的 ToolNode 会把同一轮的多个工具调用并发执行,前提是这些工具是 async def 定义的。这一层收益直接体现在单个请求的延迟上:三个 0.8 秒的工具,串行 2.4 秒,并行 0.8 秒。

第三层:多请求并发。 一批任务同时发起,用 asyncio.gather 把它们叠在一起:

import asyncio

async def run_one(agent, city: str, idx: int) -> str:
    # 并发下每个请求必须有自己的 thread_id,否则 checkpoint 会串台
    config = {
   "configurable": {
   "thread_id": f"trip-{idx}"}}
    resp = await agent.ainvoke(
        {
   "messages": [{
   "role": "user", "content": f"规划{city}一日游"}]},
        config=config,
    )
    return resp["messages"][-1].content

async def main():
    cities = ["北京", "上海", "广州", "深圳", "成都"]
    results = await asyncio.gather(*(run_one(agent, c, i) for i, c in enumerate(cities)))
    for c, r in zip(cities, results):
        print(c, r[:60])

这三层叠起来,一个批次的实际耗时从"求和"变成"求最大值"。但要注意:第三层不是免费的,gather 会把所有任务同时点亮,1000 个任务就是 1000 个并发的模型请求——这是下一节要解决的坑。

改造到哪一层 单请求延迟 单进程吞吐 主要代价
全同步 invoke 6.0 s 受线程数限制 线程贵,扩容靠加机器
仅请求异步 ainvoke 6.0 s 提升 5 ~ 20 倍 一处阻塞调用就前功尽弃
工具也异步并行 3.5 s 同上 工具需改造为 async def
批量 gather + 闸门 3.5 s 由下游配额决定 需处理 429、超时、状态隔离

这张表想说明的是:前两层改的是"同时能接多少",第三层改的是"一次能推多少"。单请求延迟只被第二层影响,剩下两层的收益都在吞吐上。评估收益时把这两个指标分开看,才不会被"改完还是 6 秒"的错觉劝退。

在这里插入图片描述

四、异步工具怎么写才真的异步

这是最容易翻车的地方。把 @tool 挂在 async def 上只完成了一半,函数体里只要有一行阻塞调用,整个事件循环就被卡住了。

# 错误示范:async 外壳 + 同步 I/O
@tool
async def query_order(order_id: str) -> str:
    resp = requests.get(f"https://api.internal/orders/{order_id}")  # 阻塞!
    return resp.text

requests.get 是同步的,它阻塞的不是"这个协程",而是整个线程——也就是整个事件循环。在这 300 毫秒里,其他 50 个等待中的请求一个都动不了。异步退化成了串行,而且比纯同步更糟:你付出了协程的复杂度,却没拿到任何并发收益。

对照表:

阻塞写法(毁掉事件循环) 异步替代
requests.get(...) httpx.AsyncClient / aiohttp
time.sleep(1) await asyncio.sleep(1)
pymysql / psycopg2 同步驱动 asyncpg / aiomysql
open().read() 大文件 asyncio.to_thread(...) 或 aiofiles
同步 SDK(如部分云厂商 SDK) await asyncio.to_thread(sdk.call, ...)

最后一行是实用的兜底:如果某个 SDK 只有同步版本,别硬改写,用 asyncio.to_thread 把它丢到线程池,让事件循环继续转。

一个真正异步的工具长这样:

import httpx
from langchain.tools import tool

# 全局复用一个 client:连接池、DNS 缓存、keep-alive 都在里面
_client = httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0))

@tool
async def get_weather(city: str) -> str:
    """查询指定城市的实时天气。"""
    try:
        async with asyncio.timeout(3):          # 工具级硬超时
            resp = await _client.get("/weather", params={
   "city": city})
            resp.raise_for_status()
            return resp.json()["summary"]
    except (asyncio.TimeoutError, httpx.HTTPError) as e:
        # 工具不该把异常原样抛给框架,返回可读的失败原因
        return f"天气服务暂时不可用:{type(e).__name__}"

两个细节值得强调。一是 client 要复用。 每次调用 httpx.AsyncClient() 都会新建连接池,高并发下等于每秒几十次 TCP 握手,延迟和端口占用都会爆。二是超时要给。 模型侧的超时管不到工具内部,一个卡死的下游接口能把整个请求拖到天荒地老。外层再配一层 asyncio.timeout 是必要的双保险。

五、并发控制:别让 gather 把下游打穿

asyncio.gather(*[run_one(i) for i in range(1000)]) 会在几毫秒内发起 1000 个请求。结果通常是三种:模型服务返回 429、连接池排队到超时、或者进程内存被未完成的响应撑爆。

正确的做法是加一道闸门:

import asyncio, random

SEM = asyncio.Semaphore(8)   # 全局并发上限,按下游配额定

async def run_guarded(agent, task: dict, idx: int):
    async with SEM:                       # 拿到令牌才允许发请求
        for attempt in range(3):
            try:
                async with asyncio.timeout(30):
                    resp = await agent.ainvoke(
                        {
   "messages": [{
   "role": "user", "content": task["q"]}]},
                        config={
   "configurable": {
   "thread_id": f"batch-{idx}"}},
                    )
                return {
   "id": task["id"], "ok": True, "data": resp["messages"][-1].content}
            except (asyncio.TimeoutError, RateLimitError) as e:
                if attempt == 2:
                    return {
   "id": task["id"], "ok": False, "err": type(e).__name__}
                await asyncio.sleep(2 ** attempt + random.random())   # 指数退避 + 抖动
            except Exception as e:
                return {
   "id": task["id"], "ok": False, "err": type(e).__name__}

async def main():
    results = await asyncio.gather(*(run_guarded(agent, t, i) for i, t in enumerate(tasks)))
    ok = [r for r in results if r["ok"]]
    print(f"成功 {len(ok)} / {len(results)}")

这段代码里有四个决定成败的细节:

  1. Semaphore 必须建在函数外。 建在函数内部,每次调用都是一个新的信号量,限流等于没做。
  2. asyncio.timeout 要包住整个 ainvoke,而不只是工具。 Agent 是循环的,单次工具超时控不住"反复重试十轮"这种病态情况。
  3. 捕获要分层。 可重试的(429、5xx、超时)走退避重试;不可重试的(400 参数错、鉴权失败)立即失败,重试只会放大账单。
  4. gather 默认一个异常就整体抛出。 用 return_exceptions=True,或者像上面这样在协程内部消化掉,保证单条失败不拖垮整批。

在这里插入图片描述

并发数设多少?别拍脑袋。先看下游给多少配额(TPM/RPM),再按 平均单请求耗时 × 目标 QPS 估算在飞请求数,最后留 20% 余量。8 到 16 是多数中小场景的合理起点,压测后再调。

这里有个反直觉的现象值得提前知道:并发调上去,平均延迟可能下降,P99 却会变差。 原因是排队。把下游看成一个服务台,利用率到 70% 时排队还短,到 90% 时队列长度会非线性地飙起来——这是排队论里的经典曲线,不是代码 bug。所以压测时别只看 QPS,要同时看两个数:在飞请求数和排队等待时间。如果前者已经顶到闸门值、后者还在涨,说明瓶颈在下游而不在你的并发度,继续加闸门只会把 429 变成超时。

顺带说一句异步流式。astream 返回的是异步生成器,消费端必须 async for;如果只想读前几个 chunk 就提前退出(比如命中缓存、用户点了停止),记得显式 await gen.aclose(),否则底层连接可能悬着不释放。这是异步生成器比普通函数多出来的一条规矩。

六、生产环境才暴露的四个问题

一、在 FastAPI 里用 asyncio.run。 这是最常见的启动错误:RuntimeError: asyncio.run() cannot be called from a running event loop。Uvicorn 已经跑着一个事件循环了,路由函数里直接 await agent.ainvoke(...) 就行:

@app.post("/plan")
async def plan(req: PlanRequest):
    resp = await agent.ainvoke(
        {
   "messages": [{
   "role": "user", "content": req.query}]},
        config={
   "configurable": {
   "thread_id": req.session_id}},
    )
    return {
   "answer": resp["messages"][-1].content}

顺带一提:别把 async def 路由写成 def。 同步路由会被 FastAPI 丢到线程池,看着也能跑,但一个阻塞调用就会吃掉线程池里的一个坑。

二、并发下 thread_id 复用。 上面代码里 thread_id 来自 session_id。如果大批量任务图省事都用同一个默认值,LangGraph 的 checkpoint 会互相覆盖,出现"用户 A 看到了用户 B 的对话历史"这种事故。并发批处理必须给每个任务独立 ID。

三、取消不等于退款。 客户端断开、asyncio.timeout 触发、task.cancel(),都会让 ainvoke 抛 CancelledError。但已经发出的模型请求不会因此撤销——token 照样计费,只是你不知道结果了。所以超时值要按业务能接受的最坏成本来设,而不是"随便给个 60 秒"。

四、异步栈里没有免费的上下文。 同步代码里常用 threading.local() 存 trace_id、租户 ID。异步下一个线程跑几十个协程,threading.local 立刻串数据。改用 contextvars.ContextVar,它随协程走而不是随线程走;更稳的做法是通过 config 或 ToolRuntime.context 显式传递。

七、换个语言看:Java 与 Flink 的同一件事

对 Java 工程师,ainvoke 对应的是 CompletableFuture:

ExecutorService pool = new ThreadPoolExecutor(
    16, 16, 0L, TimeUnit.MILLISECONDS,          // I/O 密集,别用 commonPool
    new LinkedBlockingQueue<>(1000),
    new ThreadPoolExecutor.CallerRunsPolicy());

List<CompletableFuture<String>> futures = cities.stream()
    .map(c -> CompletableFuture
        .supplyAsync(() -> agent.invoke(buildRequest(c), configFor(c)), pool)
        .orTimeout(30, TimeUnit.SECONDS)
        .exceptionally(e -> "FAILED: " + e.getClass().getSimpleName()))
    .toList();

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

三个对照点值得记:asyncio.Semaphore 对应有界线程池 + 有界队列;asyncio.timeout 对应 orTimeout;gather 对应 allOf。区别在于线程模型的成本——Java 一个线程约 1 MB 栈,协程只有几 KB,所以 Python 能靠单线程撑住上千并发,Java 要么控线程数,要么上 JDK 21 的虚拟线程。

对做实时计算的读者,这套东西其实早见过。Flink 的 AsyncDataStream.orderedWait(stream, asyncFunc, timeout, unit, capacity) 就是同一个模型:capacity 是闸门(对应 Semaphore),timeout 是超时(对应 asyncio.timeout),AsyncFunction 里回调 ResultFuture.complete() 对应协程恢复。Flink 当年引入 Async I/O 就是为了解决"每条记录同步查一次维表,吞吐被网络往返锁死"的问题——Agent 的处境一模一样,只不过维表换成了模型服务。

八、小结:先量清楚,再谈并发

异步调用不是一个 API 替换,而是一次执行模型的切换。落地前先回答三个问题:

  • 瓶颈在哪一层? 是单请求内部的工具串行(做工具并行),还是整批任务排队(做请求并发)。改错层次,收益接近零。
  • 下游能承受多少并发? 闸门值由配额决定,不由代码决定。
  • 失败了怎么办? 分类、退避、降级,三件事缺一不可,否则一次 429 就会在重试风暴里变成雪崩。

一份可以直接抄的清单:

  • 入口统一 ainvoke / astream,不在 async 代码里调同步版本
  • 所有工具 async def,I/O 走 httpx.AsyncClient,client 全局复用
  • 每个工具内部有超时,每个请求外层有总超时
  • 批量任务用 Semaphore 限流,值来自下游配额
  • 每条任务独立 thread_id,避免状态串台
  • 只重试可重试错误,退避带抖动
  • 上下文用 contextvars,别用 threading.local

Agent 慢,很少是因为模型慢,更多是因为我们把本该重叠的等待排成了队。先把等待叠起来,再谈优化模型。

目录
相关文章
|
14天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
6038 44
|
15天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
5060 60
|
9天前
|
Linux 开发工具 iOS开发
2026最新codex下载安装保姆级使用教程(下载+安装+登录+使用,含所有历史版本)
本文详解OpenAI Codex国内快速上手指南:提供Win/macOS/Linux全平台免安装包、npm/Homebrew等多方式安装、账号/API Key双登录、简体中文切换,覆盖下载、安装、汉化、使用及高频避坑(如微软商店失败、npm超时等),10分钟即可跑通。
2406 6
|
7天前
|
人工智能 安全 API
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(dsh)是DeepSeek AI于2026年开源的轻量级Agent运行框架,基于Cordis内核,践行“一切皆插件”理念。支持Web/桌面双端,开箱即用;内置多模型接入、丰富插件生态(超3.5万),适配编程、办公、创作等场景。(239字)
1510 0
|
9天前
|
云安全 人工智能 安全
|
2天前
|
云安全 人工智能 安全
|
8天前
|
人工智能 并行计算 调度
本地 AI 一键部署工具 LocModel:Windows 端集成画图、视频生成、Agent 的开源模型运行方案
LocModel 是专为 Windows 设计的一站式本地 AI 工具,支持零配置启动、自动硬件检测、GGUF 模型一键加载、文生图/视频生成及 Agent 自动化任务。适配 8G 显存,内置显存分片调度,轻松实现 AI 漫剧批量创作与离线大模型调用。(239 字)

热门文章

最新文章