Chapter 2 of 5 · Operational Excellence
Generated with AI

Performance

An agent loop that takes 8 seconds to answer a question feels broken even when it's working — users tab away before the response finishes. Every framework gives you the same basic tools to make agents feel fast: stream tokens as they arrive, cache repeat answers, run tool calls in parallel, batch many calls together, and pick the right concurrency model for your runtime. What differs is which of these ship out of the box and which you have to bolt on yourself. This chapter walks through each lever in plain language and compares how the ten frameworks expose them.

What's in this chapter

1Streaming

Streaming is the difference between "stare at a spinner for 6 seconds" and "watch the answer appear word by word." All ten frameworks support streaming in some form — but the API shapes vary widely, and getting tokens to actually flow over HTTP / Server-Sent Events / WebSockets to a browser is on you. None of the ten frameworks ships built-in HTTP streaming transport.

What this looks like in practice

A user types a question into your chat UI. Without streaming, the request opens, the server holds the connection for 6 seconds while the model generates a 600-token answer, then the entire answer arrives at once. The user has stared at a loading dot the whole time. With streaming, the first token reaches the browser ~200 ms after the model starts, and the user sees the answer appear progressively while later tokens are still being generated. The total time hasn't changed; the perceived time has dropped to almost nothing.

Frameworks differ on three things: (1) what the API looks like in your code (async generator, callback, IAsyncEnumerable, Go channel, Tokio stream), (2) what's available to stream — just text tokens, or tool-call deltas and reasoning steps as well, and (3) what you have to build for HTTP streaming — every framework expects you to wire up SSE / WebSockets / chunked transfer yourself.

FrameworkAPI shapeWhat's available to streamHTTP wiringSource
LangChainPython Async iterator via astream; sync via stream. Text tokens, tool-call deltas, intermediate steps. Wire SSE/chunked HTTP yourself, or use LangServe. core/runnables/base.py
LangGraphPython Iterator of StreamPart; mode flags for values / updates / messages. Node updates, message tokens, value snapshots — pick what you want. Wire it yourself; LangGraph dev server includes an SSE wrapper. pregel/_messages.py
CrewAIPython Event handlers via step_callback; async iterator on agent calls. Step events, agent thoughts, tool results. Wire it yourself. agent/core.py
Google ADKPython Async generator from run_async. Tokens, tool calls, function responses, agent transfer events. Wire it yourself; adk web includes an HTTP server with built-in streaming. agents/base_agent.py
Semantic KernelPython Async iterator via invoke_stream on agent / chat completion. Tokens, function-call deltas. Wire it yourself. agents/chat_completion/chat_completion_agent.py
MastraTypeScript Async iterator via Vercel AI SDK primitives. Tokens, tool-call deltas, custom events from workflows. Vercel AI SDK ships SSE handlers ready to drop into Next.js / edge routes. core/src/agent/
MS Agent Framework.NET RunStreamingAsync returning IAsyncEnumerable<AgentResponseUpdate>. Token deltas, tool-call updates, response status. Wire SSE/WebSockets via ASP.NET Core; AddA2AServer handles A2A streaming protocol. Microsoft.Agents.AI.Abstractions/AIAgent.cs
LangChain4jJava Handler interface (onPartialResponse, onCompleteResponse) — push-based, not pull. Partial response strings, tool-execution events. Bridge to your reactive stack (Spring WebFlux, Quarkus Mutiny) yourself. StreamingChatLanguageModel.java
GenkitGo ModelStreamCallback — a callback function passed alongside the request; no Go channel exposed by default. Token chunks (ModelResponseChunk), tool-call updates. Genkit's HTTP handler ships SSE; or wire your own with the callback. go/ai/generate.go:71
RigRust Tokio Stream via StreamingPrompt / StreamingChat traits. Text tokens, reasoning content, tool calls. Wire SSE on top with axum or actix-web. crates/rig-core/src/streaming.rs

What this means in practice

All ten frameworks support streaming, so the practical question is how much glue you have to write between the framework's stream and the user's browser. Mastra is the standout — its Vercel AI SDK foundation ships SSE handlers tuned for Next.js / edge deployment, so you can ship a streaming chat UI in a couple of lines. ADK and Genkit Go give you ready-made HTTP servers with streaming built in. Everyone else expects you to write the HTTP transport yourself, which is straightforward but adds a layer to test.

On API shape, the pull-based async iterator (Python / TypeScript / Rust) is the easiest to reason about — you write a for await / while let Some(chunk) loop and you're done. LangChain4j's push-based handler interface is the outlier — it works, but reactive-stream conversions are needed to plug into the rest of the JVM ecosystem.

2Caching

Two users ask "what's our return policy?" within a minute. With response caching, the second call hits an in-memory or Redis cache and returns in 5 ms; without it, the model runs again and the user waits 1.5 seconds. Only three of the ten frameworks ship a response-caching primitive in the box. Everyone else expects you to put a cache in front of the model client yourself.

What this looks like in practice

At scale, a meaningful percentage of agent traffic is repeat or near-repeat requests — common questions, repeat document lookups, identical summarisation prompts. A well-placed response cache can drop the answer for those down to milliseconds and shave a real percentage off the model bill at the same time. The trade-off is staleness: a cached answer might be wrong if the underlying data has changed.

Two layers worth distinguishing. Exact-match cache — keyed by a hash of the prompt, returns the previous response if the prompt is byte-identical. Cheap and safe; only catches true repeats. Semantic cache — keyed by an embedding, returns the previous response if the new prompt is similar enough. Catches paraphrases too, but you have to tune the similarity threshold to avoid wrong answers.

FrameworkBuilt-in cache?Backends shippedFor other backendsSource
LangChainPython Yes — BaseCache abstraction with set_llm_cache() activator. In-memory, SQLite, Redis, Cassandra, MongoDB, Upstash, Astra DB, semantic (embedding-based). Implement BaseCache for any custom store. core/langchain_core/caches.py
LangGraphPython Yes — CachePolicy on individual nodes; reuses LangChain backends. Same as LangChain, applied per node. Implement BaseCache. pregel/_runner.py (cache_policy: CachePolicy)
CrewAIPython Tool-level only — no agent-level response cache. — Put a cache layer in front of your LLM client. —
Google ADKPython No agent-level cache — auth tokens have their own cache. — Put a cache layer in front of your model client. —
Semantic KernelPython No built-in cache. — Use Azure-side prompt caching, or wrap the chat-completion service. —
MastraTypeScript Yes — abstract MastraServerCache with concrete in-memory + Redis + Upstash backends. InMemoryServerCache (uses @isaacs/ttlcache) in core; Redis + Upstash backends ship as separate stores/ packages. Extend MastraServerCache for any other store. packages/core/src/cache/base.ts · stores/redis/src/cache.ts
MS Agent Framework.NET No agent-level cache (MCP client maintains its own dict). — Wire IDistributedCache / IMemoryCache in front of IChatClient. —
LangChain4jJava No response cache (thread pools used elsewhere). — Wrap the ChatLanguageModel, or use a Spring/Quarkus cache layer in front. —
GenkitGo No core cache; provider plugins may cache. — Implement a flow wrapper that consults a cache before delegating. —
RigRust No built-in cache. — Wrap the model trait yourself, or use a crate like moka. —

What this means in practice

LangChain ships the most complete cache story — BaseCache with backends for nearly every store you'd consider, plus a separate semantic-cache implementation. LangGraph layers a per-node cache policy on top of the same backends. Mastra ships a TTL primitive aimed at TS / edge deployment. Everyone else delegates: you put your favourite cache (Redis with a hash-of-prompt key, Azure prompt caching, your edge runtime's KV store) in front of the model client and the agent framework never knows the difference.

The "delegate to your cache" pattern is fine in production but it costs you the ability to express cache rules at the agent level — e.g. "cache this tool response for 5 minutes but not this other one." The frameworks that ship a cache primitive let you express that without leaving the framework.

3Parallel tool execution

Modern models can return multiple tool calls in a single response — "search for X, look up Y, and check Z." Each call is independent. If your framework runs them serially, the total time is the sum; if it runs them in parallel, the total time is the slowest one. For multi-tool agents this is often the biggest single performance lever — and most frameworks default to serial.

What this looks like in practice

A research agent gets asked to compare three vendors. The model responds with three tool calls at once: search("Vendor A pricing"), search("Vendor B pricing"), search("Vendor C pricing"). Each search takes ~1.5 seconds. With serial execution, the next model call doesn't fire for 4.5 seconds; with parallel execution, all three searches run together and the next model call fires after 1.5 seconds. Across the rest of the agent loop the user sees a 3-second improvement on every turn.

Parallel execution requires both concurrency (the runtime can issue overlapping I/O) and orchestration (the framework knows that multiple tool calls in one response are independent and can be fanned out). All ten frameworks have the concurrency; only some default to fanning out. The rest leave it to you to write the parallel orchestration with whatever async / threading primitive your runtime exposes.

FrameworkDefault behaviourHow to overrideSource
LangChainPython Parallel on the async path — classic AgentExecutor uses asyncio.gather over tool.arun() calls. Already on by default for async agents; sync agents iterate serially. langchain_classic/agents/agent.py:1510
LangGraphPython Parallel — the prebuilt ToolNode calls await asyncio.gather(*coros) over the model's tool-call list. Already on by default; control via graph topology. libs/prebuilt/langgraph/prebuilt/tool_node.py:858
CrewAIPython Serial — tool calls execute one after the other. Wrap with asyncio.gather in a custom agent step. —
Google ADKPython Concurrency-enabled but explicit — you fan out via asyncio.gather in your flow. Spawn parallel run_async tasks yourself. flows/llm_flows/
Semantic KernelPython Serial by default in auto-function-invocation. Issue asyncio.gather manually around independent calls. —
MastraTypeScript Concurrency-enabled — tool calls run in parallel when wrapped in Promise.all within a workflow step. Workflow steps make parallel execution explicit. packages/core/src/workflows/
MS Agent Framework.NET Serial by default. Use Task.WhenAll in your tool wrapper, or chain via the workflow framework. —
LangChain4jJava Parallel via ExecutorService when configured — the core ships VirtualThreadUtils + DefaultExecutorProvider aimed at Java 21+ virtual threads. Configure the executor; on JDK 21+ virtual threads make this near-free. internal/VirtualThreadUtils.java · DefaultExecutorProvider.java
GenkitGo Concurrency-enabled — fan out with goroutines in your flow. Spawn goroutines around independent calls; use sync.WaitGroup. —
RigRust Concurrency-enabled — fan out with Tokio tasks. Use tokio::join! or FuturesUnordered. —

What this means in practice

Three frameworks fan out tool calls automatically: LangChain (classic AgentExecutor on the async path), LangGraph (prebuilt ToolNode), and LangChain4j (configurable ExecutorService; effectively free on Java 21+ with virtual threads). Everywhere else, the language runtime can do it but the framework leaves the orchestration to you. For multi-tool agents that's a meaningful piece of glue you have to write — and easy to forget. If parallel-tool dispatch is critical to your latency budget, the LangChain family and LangChain4j get you there with the least effort.

4Concurrency model

Whether you call an agent with await, with a blocking .run(), with a callback, or with a goroutine determines how easily you can scale it up — and how well it fits your existing application. Most frameworks pick one model and stick to it; a few support both sync and async surfaces.

What this looks like in practice

You're building a chat backend that handles 200 concurrent users. With an async-first framework on Python's asyncio, Node.js's event loop, .NET's Task, Go's goroutines, or Rust's Tokio, those 200 users share a small pool of OS threads — each agent call yields control while waiting for the model, and the runtime multiplexes. With a sync-first framework, each user occupies a thread for the duration of their request; you scale by adding threads, which costs memory.

The async-vs-sync distinction matters less than it used to thanks to runtime advances — Java 21+ virtual threads make sync code on the JVM behave like async without code changes. But the API shape still matters for ergonomics: an async API integrates cleanly with FastAPI, Next.js, ASP.NET Core, Tokio-based servers; a sync API requires a thread pool to bridge.

FrameworkHow you call an agentBest fit forSource
LangChainPython Both — sync invoke() and async ainvoke() on every Runnable. Either FastAPI / async stacks or scripts / batch jobs. core/runnables/base.py
LangGraphPython Both — same Runnable surface as LangChain. Async servers; long-running stateful runs. core/runnables/base.py
CrewAIPython Both — kickoff() is the sync entry point at crew.py:899; kickoff_async() at :1028. Long-running multi-agent flows; less ideal for high-concurrency request servers. —
Google ADKPython Async-first — run_async is the primary entry point. FastAPI / Cloud Run agents; high-concurrency request servers. agents/base_agent.py
Semantic KernelPython Async-first — agents expose async def get_response. FastAPI / async stacks. —
MastraTypeScript Async-first — Promise-based, single-threaded JS event loop. Next.js, Vercel, Cloudflare Workers, Deno Deploy. —
MS Agent Framework.NET Async-first — Task-based async/await. ASP.NET Core, Azure Container Apps, Azure Functions. —
LangChain4jJava Sync-first — blocking calls; reactive bridges via Spring WebFlux / Quarkus Mutiny. Spring Boot apps; Quarkus + virtual threads on Java 21+. —
GenkitGo Goroutines — flows take context.Context and run inside a goroutine. Cloud Run, Cloud Functions, native binary servers. —
RigRust Async — Tokio async/await. Axum / actix-web, Lambda custom runtime, native binary servers. —

What this means in practice

Eight of ten frameworks are async-first or async-only. LangChain4j is the lone sync-first holdout — and on Java 21+ that's largely a non-issue thanks to virtual threads, which let blocking code scale like async without rewrites. LangChain (and by extension LangGraph) is the only Python framework that exposes both sync and async surfaces equally — useful when an existing sync codebase is being incrementally modernised.

5Batching

Sometimes you want to issue 100 model calls at once — to summarise 100 documents, score 100 candidate answers, or embed 100 queries. A batch API lets you express this as a single call instead of writing your own concurrency loop. Only LangChain and LangGraph ship a first-class batch surface; everywhere else you write the loop yourself.

What this looks like in practice

You have 1,000 customer reviews to classify. With a batch API, you call batch(reviews) and the framework dispatches them with controlled concurrency (configurable max in-flight) and returns the results in order. With no batch API, you write a loop that spawns 1,000 tasks, manages a semaphore to bound concurrency, handles partial failures, and reassembles results — which works but is repeat infrastructure work every team writes from scratch.

FrameworkBatch API?What it gives youSource
LangChainPython Yes — batch() / abatch() on every Runnable, plus batch_as_completed() for streaming results. Configurable max concurrency, returns results in input order. core/runnables/base.py (def batch at L872)
LangGraphPython Yes — same Runnable surface. Same as LangChain. core/runnables/base.py
CrewAIPython No agent-level batch. Write the loop with asyncio.gather + a Semaphore. —
Google ADKPython No agent-level batch. Spawn parallel run_async tasks yourself. —
Semantic KernelPython No agent-level batch. Use asyncio.gather around independent kernel invocations. —
MastraTypeScript No first-class batch — workflows handle parallel maps. Use a parallel workflow step or Promise.all. —
MS Agent Framework.NET No first-class batch. Task.WhenAll + SemaphoreSlim for bounded concurrency. —
LangChain4jJava No first-class batch. Use ExecutorService with virtual threads on JDK 21+. —
GenkitGo No first-class batch. Spawn goroutines + errgroup.Group with SetLimit. —
RigRust No first-class batch. Use FuturesUnordered or tokio::join!. —

What this means in practice

Batching lives in only one part of the catalogue: LangChain's Runnable interface and LangGraph's reuse of it. If your workload is "process N items independently and gather results," LangChain saves you a clean piece of concurrency-management work. Every other framework expects you to write the bounded-parallel loop in your host language's idiom — well-trodden territory in each language, but not framework-managed.

At a glance

Source-grounded observations on performance surfaces across the ten frameworks.

STREAMING

1 · Streaming is universal; HTTP transport is not

All ten frameworks expose a streaming API. None ships HTTP/SSE/WebSocket transport out of the box. Mastra (via Vercel AI SDK), Genkit Go, and ADK come closest with built-in HTTP servers that include streaming; everywhere else the wire format is your responsibility.

CACHING

2 · Only three ship a response-cache primitive

LangChain (BaseCache + multiple backends including semantic), LangGraph (CachePolicy per node, reuses LangChain backends), and Mastra (TTL + Redis/Upstash). The other seven expect you to put a cache in front of the model client yourself.

PARALLEL

3 · Auto-parallel tool dispatch is rare

Three frameworks fan out multi-tool responses automatically: LangChain (classic AgentExecutor async path uses asyncio.gather), LangGraph (prebuilt ToolNode at tool_node.py:858), and LangChain4j (via configurable ExecutorService; effectively free on Java 21+ virtual threads). Everyone else needs explicit asyncio.gather / Promise.all / Task.WhenAll.

ASYNC

4 · Non-blocking concurrency is the default

Five frameworks are async-first: ADK, Semantic Kernel, Mastra, MS Agent Framework, and Rig (Tokio). Two expose both sync and async equally (LangChain, LangGraph). One uses goroutines (Genkit Go). CrewAI ships both kickoff() and kickoff_async(). LangChain4j is the lone sync-first holdout — and on Java 21+ that gap closes via virtual threads.

BATCH

5 · Batch APIs ship in only LangChain and LangGraph

batch() / abatch() at core/runnables/base.py:872 give you bounded-concurrency dispatch with ordered results. Every other framework expects you to write the loop in your host language's idiom (asyncio.gather, Promise.all, Task.WhenAll, errgroup, FuturesUnordered).

RUNTIME

6 · Language runtime shapes the perf story

Tokio for Rust (Rig), goroutines for Go (Genkit), virtual threads for Java 21+ (LangChain4j), single-threaded event loop for TS (Mastra), Task-based async for .NET (MS Agent Framework), asyncio for Python. Concurrency primitives in the host language largely determine your scaling profile; the framework's job is to play nicely with them.