Skip to content

feat(aws): native async for ChatBedrockConverse, ChatBedrock and BedrockEmbeddings - #1256

Open
roi hezkiyahu (roihezkiRaven) wants to merge 5 commits into
langchain-ai:mainfrom
roihezkiRaven:feat/aiobotocore-async-bedrock
Open

feat(aws): native async for ChatBedrockConverse, ChatBedrock and BedrockEmbeddings#1256
roi hezkiyahu (roihezkiRaven) wants to merge 5 commits into
langchain-ai:mainfrom
roihezkiRaven:feat/aiobotocore-async-bedrock

Conversation

@roihezkiRaven

@roihezkiRaven roi hezkiyahu (roihezkiRaven) commented Sep 2, 2026

Copy link
Copy Markdown

Closes #663.

Why

ainvoke/astream/aembed_* bridge to the synchronous boto3 client with run_in_executor. The thread is held for the whole model call, so concurrency is capped at the default executor size, min(32, os.cpu_count() + 4) — the ceiling Lucas Kim (@kimnamu) measured in #663, and the cause of the latency growth Norman Luengo (@norman-luengo) reported.

Against a local fake bedrock-runtime with 500 ms of injected latency, on a 12-core box (16-thread default executor):

concurrent ainvoke today this PR max in flight, before → after
8 0.52 s 0.61 s 8 → 8
32 1.12 s 0.55 s 16 → 32
64 2.16 s 0.56 s 16 → 64
128 4.21 s 0.64 s 16 → 128

Below the ceiling there is no gain, and slightly more overhead; above it the executor serializes into waves while the native path stays flat. A fake endpoint is used deliberately: against real Bedrock the wall clock is dominated by per-account service quotas, so it measures the account rather than the client.

Try it

import asyncio
from langchain_aws import ChatBedrockConverse

async def main():
    async with ChatBedrockConverse(
        model="us.amazon.nova-micro-v1:0", region_name="us-east-1",
        use_async_transport=True, max_tokens=8,
    ) as model:
        print(await model.ainvoke("hi"))
        async for chunk in model.astream("count to three"):
            print(chunk.text, end="", flush=True)
        await asyncio.gather(*(model.ainvoke("hi") for _ in range(32)))

asyncio.run(main())

Integration tests covering the same ground live in tests/integration_tests/test_async_client.py; they use Nova Micro and Titan v2 with one-token prompts, so a full run costs a fraction of a cent.

Why not aiobotocore

The issue asks for aiobotocore/aioboto3 support, but neither can be a dependency of this package:

  • aiobotocore 3.9.0 (latest) pins botocore>=1.43.3,<1.43.57; this package requires boto3>=1.43.64. pip install langchain-aws aiobotocore has no solution today.
  • That is structural, not transient. aiobotocore's ceiling trails botocore by design, while this package's floor moves forward for new AWS APIs (1.42.42 → 1.43.32 → 1.43.64, the last in feat(aws): add DynamoDBVectorStore backed by DynamoDB vector indexes #1200 for DynamoDB vector indexes). The ranges cross and stay crossed.
  • aioboto3 is further behind: its latest release pins aiobotocore==2.25.1, i.e. botocore<1.40.62.

So BedrockAsyncClient signs with botocore and sends with httpx — the approach Anthropic's SDK takes, and the one ChatAnthropicBedrock already benefits from for Anthropic models only. This covers the rest of the surface (Nova, Titan, Cohere, Llama, embeddings). Event stream framing is parsed by botocore's own EventStreamBuffer rather than reimplemented.

No new dependencies are declaredhttpx is already in the tree via langchain-core. async_client is duck-typed on the boto3 method names, so anyone who prefers aiobotocore can pass their own entered client and manage its botocore pin themselves.

Reading it

Five commits, in dependency order:

  1. feat: add a native async Bedrock runtime client — the new async_client.py and _async_transport.py, plus a factory in utils.py. Purely additive (+919/−3); nothing else in the package changes behavior.
  2. feat: ... ChatBedrockConverse — the bulk of the diff is an extraction: _generate and _stream duplicated ~50 lines of request building verbatim, now shared. _agenerate/_astream reuse it.
  3. feat: ... BedrockEmbeddings — same shape: _embedding_func split into request building and response parsing so both paths share the per-provider logic.
  4. feat: ... ChatBedrock and BedrockBase — same again for the Invoke API.
  5. test: cover the native async Bedrock paths — 52 unit tests and 9 integration tests.

Commits 2–4 are refactor-then-reuse. The refactors are meant to be behavior-preserving, and the check for that is mechanical: all 1307 pre-existing unit tests pass untouched, and each model has a test asserting the sync and async paths send byte-identical requests, so the two cannot drift apart.

Opt-in and additive throughout: with neither use_async_transport nor async_client set, every async method keeps its current executor behavior.

Decisions worth a second opinion

  • Retries. The client retries transient errors with jittered backoff honoring retries["max_attempts"]. This is not botocore's adaptive mode and shares no retry quota; retries["mode"] is ignored and documented as such. I included retries because boto3 retries by default, so shipping none would silently regress the async path against the sync one. Happy to drop them if you would rather retries stayed with the caller.
  • Cross-loop reuse raises. An httpx pool binds to the loop that first drives it. Sharing one client across loops previously hung forever with nothing raised (reproduced: 25 threads each running asyncio.run on one shared model — 14 of 75 calls completed, the rest deadlocked in selectors.select). It now raises immediately with an actionable message. Louder than before, and deliberate, but it is a behavior choice.
  • Bearer tokens are refused, not ignored. The built client signs SigV4 only, so use_async_transport=True together with a bedrock_api_key raises rather than silently authenticating as a different identity. Same approach as fix(aws): Raise error on unsupported guardrail header usage with Mantle #1225.

Known deviations from boto3

  • Bearer-token authentication is unsupported on the async path (see above).
  • Responses are the raw JSON, so members botocore would drop as unmodeled may be present — usage is a superset of the sync path's.
  • ReasoningContentBlock.redactedContent stays a base64 str rather than bytes. It is passed through opaquely so it round-trips, but it differs from the sync path.
  • Header-bound Invoke members are transcribed from the service model into _INVOKE_HEADER_MEMBERS. If that mapping drifts upstream, guardrails would quietly stop being applied — worth a check by someone who knows the model well.

Testing done

  • 1324 unit tests (52 new), --disable-socket; make lint clean, and mypy clean under each of 3.10 / 3.11 / 3.12 / 3.13.
  • 9 integration tests against real Bedrock, all passing.
  • Verified against botocore's own service model that Converse/ConverseStream have no header-bound members while the two Invoke operations have eight, and that InvokeModelWithResponseStream moves accept to X-Amzn-Bedrock-Accept.

Disclaimer: this contribution was developed with the assistance of AI agents (Claude), including adversarial review passes that found several defects in my own first draft (silently dropped guardrail headers, swallowed event-stream error frames, and a cross-loop deadlock). The analysis, benchmarks and code were produced under my direction, and I have reviewed the result.

`boto3` is synchronous, so every async method in this package bridges to it
with `run_in_executor`. That holds a thread for the whole duration of a model
call, capping concurrency at the default executor size, `min(32, cpu + 4)`.

`aiobotocore` is the obvious fix but cannot be depended on here: it pins a
narrow, trailing `botocore` window (3.9.0 allows `<1.43.57`) while this package
requires `boto3>=1.43.64`, and that floor moves forward with each new AWS API.
The ranges cross permanently, so neither `aioboto3` nor `aiobotocore` resolves
against langchain-aws today.

`BedrockAsyncClient` instead signs with `botocore` and sends with `httpx` — the
approach the Anthropic SDK uses for Bedrock, and the one `ChatAnthropicBedrock`
already benefits from for Anthropic models only. It has no version conflict,
and `httpx` is already in the dependency tree via `langchain-core`. Event
stream framing is parsed by `botocore`'s own `EventStreamBuffer` rather than
reimplemented, and header-bound request members are mapped explicitly so that
guardrails cannot be silently dropped. Transient errors are retried with
jittered backoff, since boto3 retries by default and losing that would be a
regression against the sync path.

Method names and payload shapes match the `boto3` client, so an `aiobotocore`
client remains usable wherever a caller prefers one. `AsyncTransportMixin`
holds the setup, access and teardown shared by the models that use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_generate` and `_stream` duplicated their entire request-building block, so
that is extracted first; `_agenerate` and `_astream` then reuse it, keeping the
sync and async paths from drifting apart.

Both fall back to the previous executor behavior when no async client is
configured, so existing callers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`aembed_query` and `aembed_documents` ran the sync methods in an executor.
Splitting `_embedding_func` into request building and response parsing lets the
async path share the per-provider logic, and the Cohere multi-input batches now
fan out concurrently rather than serially.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_aprepare_input_and_invoke_stream` already existed but obtained its response
through `run_in_executor`, so the Invoke API had the same ceiling as Converse.
It now uses the async client when one is configured, and
`_aprepare_input_and_invoke` is added for the non-streaming path.

`aprepare_output_stream` iterated the response body synchronously, which only
works for botocore's `EventStream`; it now accepts either kind of stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unit tests drive the transport through a fake HTTP client with real event
stream frames, and the models through async client doubles; no network, so they
run under `--disable-socket` like the rest of the suite.

Each model has a test asserting the sync and async paths send byte-identical
requests, which is what stops the two from drifting apart. Integration tests
cover the same ground against real Bedrock using the cheapest available models
with one-token prompts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roihezkiRaven
roi hezkiyahu (roihezkiRaven) marked this pull request as ready for review September 2, 2026 20:01
@roihezkiRaven

Copy link
Copy Markdown
Author

Hi Michael Chin (@michaelnchin), is this something that we can push somehow? Would love to get this out with your help :)
True async is extremely important at scale; we see this as a blocker for multiple high-throughput agents we run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async client aioboto3 support on the ChatBedrockConverse and BedrockEmbeddings

1 participant