Skip to main content

14 · langchain_core 的并发与网络模型:线程、协程、和那个发请求的库

这是一篇”扒引擎盖”的深度篇:不教怎么用,教底层怎么跑。 你写 model.invoke() 时,背后是线程还是协程?batch 是真并行还是假并行?HTTP 请求到底走哪个库? 所有结论来自本机源码逐行核对(langchain_core 1.5.1、langchain_openai 1.4.1、anthropic SDK 0.120.2)。

开头的现象

小林用 LangChain 写了半年,一直有个模糊的感觉:“有时候快、有时候慢,有时候并行、有时候排队,它到底怎么调度的?” 他问老王,老王甩给他一句话:“同步走线程,异步走协程,批量偷懒走线程池。你自己去翻源码。 小林半信半疑,打开了 langchain_core/runnables/config.py——结果被一行代码震住了:他以为很高深的并发调度,核心就藏在一个叫 run_in_executor 的函数里。

第一幕:两种并发原语——线程池 vs 事件循环

小林先搞清楚了 LangChain 的并发只有两条路
LangChain 的调度层(runnables)在两条路之间反复横跳,判断标准是:你调的是同步方法还是异步方法 小林在 runnables/config.py:607 找到了第一个主角——ContextThreadPoolExecutor
为什么需要它? 因为 LangChain 的配置(RunnableConfig)、回调都是通过 contextvars 传递的。普通线程池的工作线程看不到这些上下文——所以 LangChain 造了个”会复制上下文的线程池”,确保 config 能穿透线程边界。
想验证吗?from langchain_core.runnables.config import ContextThreadPoolExecutor,看它的继承链——是 ThreadPoolExecutor 的子类,重写了 submitcopy_context().run

第二幕:run_in_executor——异步世界里”偷懒”的桥

小林翻到 config.py:678run_in_executor,这是理解一切的钥匙:
它的作用一句话让异步代码(await 上下文)能调用同步阻塞函数,而不卡死事件循环——把同步函数丢进线程池跑,事件循环继续响应其他协程。 这是全 LangChain 最关键的”桥”,因为:
  • Runnable.ainvoke(基类默认,base.py:917)= await run_in_executor(config, self.invoke, ...) ——异步调用时把同步 invoke 丢进线程池
  • BaseChatModel._agenerate 基类默认(chat_models.py:2218)= await run_in_executor(None, self._generate, ...) ——只有同步实现的模型,异步时被搬到线程池
  • BaseChatModel._astream 基类默认(chat_models.py:2247)= 先在线程池取同步生成器,然后每个 chunk 的 next() 都丢线程池
想验证吗?inspect.getsource(langchain_core.runnables.config.run_in_executor)——你会看到那两行 run_in_executor,传 None 走默认池,传 Executor 走指定池。

第三幕:batch 的真面目——同步用线程池,异步用 gather

小林最关心”批量是不是真并行”。答案让他意外——同步和异步的批量,用的并发原语完全不同 同步 Runnable.batch(base.py:919-967):
同步批量 = 每次调用新建 ThreadPoolExecutor,并发跑多个 invoke。 异步 Runnable.abatch(base.py:1054-1100):
异步批量 = 协程并发(asyncio.gather),根本不进线程池。 对比表(实测行为): 小林又发现一个反直觉的点:RunnableParallel(并行分支)同步 invoke 反而用线程池(base.py:4169):
你写 {"a": fn1, "b": fn2} 这种并行 dict,同步执行时 LangChain 用线程池让两个分支真并行——不是”看起来并行”。
想验证吗?inspect.getsource(Runnable.batch) 看那两行 executor.map;inspect.getsource(Runnable.abatch) 看 gather_with_concurrency。同步异步两条路,一目了然。

第四幕:模型层——generate 串行,agenerate 并发

小林继续看 BaseChatModel(chat_models.py),发现模型层也有自己的调度: 同步 generate(chat_models.py:1653-1672)——是串行的!
就算一次传多个输入,同步 generate 也是 for 循环逐个发——不走线程池! 异步 agenerate(chat_models.py:1780-1791)——才并发:
所以真相是model.generate([多个]) 同步是串行的(慢但省资源);await model.agenerate([多个]) 异步是并发的(快但耗配额)。 model.batch([多个]) 呢? batch 走的是 Runnable.batch(线程池),它会调 self.invoke,invoke 内部调 generate(串行)——但每个 invoke 跑在不同线程里,所以 batch 多输入是线程级并发。三层调度叠起来:
想验证吗?inspect.getsource(BaseChatModel.generate)——是 for 循环;inspect.getsource(BaseChatModel.agenerate)——是 asyncio.gather。

第五幕:线程池都藏在哪里——全包扫描

小林 grep 了整个 langchain_core,把所有 ThreadPoolExecutor 揪出来了: ProcessPoolExecutor:一个都没有。 LangChain 从不用多进程——因为 GIL 下多进程开销大,且模型调用是 IO 密集不是 CPU 密集,线程池足够。 三种线程池来源
  1. 临时建:每次 batch/RunnableParallel 调用新建 ContextThreadPoolExecutor,用完即关
  2. 常驻共享:callbacks 的 10 线程池、LangSmith tracer 的全局 executor
  3. 借用run_in_executor(None, ...) 用 asyncio 事件循环自带的默认线程池
想验证吗?grep ThreadPoolExecutor 在整个 langchain_core 目录——数数有几处;grep ProcessPoolExecutor——0 处。

第六幕:网络模型——core 不发请求,请求在 partner 包里

小林最后搞清楚”到底谁在发 HTTP”。结论让他意外:langchain_core 自己几乎不发网络请求! langchain_core 里的网络 import(仅 3 处,且都不是模型调用):
真正的模型 HTTP 请求在 partner 包(langchain_openai / langchain-anthropic):
而 anthropic SDK(langchain-anthropic 的底层)更直白:
全链路网络栈(实测依赖): 小林的顿悟“原来整个 LangChain 生态的网络层全是 httpx!” 它没有用老牌 requests 发模型请求(requests 不支持原生异步),而是统一走 httpx——因为 httpx 一个库同时提供 Client(同步)和 AsyncClient(异步),完美匹配 LangChain 的”同步方法 + 异步方法”双轨设计。
想验证吗?pip show anthropic 看 Requires 里 httpx<1,>=0.25.0pip show openaihttpx<1,>=0.23.0pip show chromadbhttpx>=0.27.0。全生态都是 httpx。

第七幕:同步栈 vs 异步栈——两条完整的路

小林把所有证据拼起来,画出了全链路:
关键区别
  • 同步栈:当前线程 + 阻塞 HTTP——简单,但一个 invoke 卡住,整个线程卡住
  • 异步栈:事件循环 + 非阻塞 HTTP——并发高,但代码要 async/await
  • 兼容桥run_in_executor(None, ...) 让”只有同步实现的模型”也能被 ainvoke 调用——只是背地里在事件循环的默认线程池里偷偷跑同步代码

结论(小林扒完引擎盖换来的)

LangChain 的并发模型 = 双轨制:同步方法(invoke/batch/stream)跑在当前线程 + 临时线程池;异步方法(ainvoke/abatch/astream)跑在 asyncio 事件循环 + 协程批量:同步走 ThreadPoolExecutor(每次新建),异步走 asyncio.gather(Semaphore 限流)。模型层:同步 generate 串行、异步 agenerate 并发、run_in_executor(None, ...) 是同步模型与异步调用之间的”桥”。网络层:langchain_core 几乎不发请求,真正的 HTTP 全在 partner 包,且全生态统一用 httpx(Client 同步 / AsyncClient 异步)——这就是为什么 LangChain 能一个接口同时支持同步和异步。

复现信号:什么时候你会想起这一章

  1. model.batch([多个]) 很慢——同步批量是线程池并发,但如果底层模型只实现了同步 generate,每个线程内还是串行发请求;真想要高并发,用 await model.abatch(...)
  2. ainvoke 卡住主线程?——不会。事件循环里 ainvoke 要么走真协程(httpx.AsyncClient),要么把同步代码丢进默认线程池(run_in_executor),事件循环始终不被阻塞。
  3. RunnableParallel 同步执行却并行——它内部用线程池让分支真并发(base.py:4169),不是假并行。
  4. 想看网络层——去 partner 包找 openai.OpenAI / anthropic.SyncAPIClient,底层全是 httpx。
  5. 想调 max_concurrency——config={"max_concurrency": 5},同步批量控制线程池大小,异步批量控制 Semaphore。
小林把并发和网络的引擎盖都掀开了。他最后一个疑问是:LangGraph 是怎么站在 langchain_core 肩膀上复用它这一切的? 他决定去扒 langgraph 的源码——翻到第 15 篇:LangGraph 怎么利用 langchain_core

关联阅读