langchain-dynamic-tools-middleware
LangChain agent middleware that hands the model only the tools it needs for the current step. Local hybrid vector search picks the top-k relevant tools in about 100 ms, with zero extra LLM calls and zero selection tokens.
| Full tool set | LLM selector | This middleware | |
|---|---|---|---|
| Prompt tokens, 10-turn run (100 tools) | 196,153 | 120,367 | 38,207 (−80.5%) |
| Selection latency at 100 tools | 0 ms | 13,160 ms | 108 ms (122x faster) |
| BFCL overall accuracy (218 questions) | 69.3% | 47.7% | 69.7% |
| BFCL prompt tokens per question | 20,493 | 5,135 | 1,435 (14.3x fewer) |
| Selection API cost | $0 | billed every step | $0, runs offline |
Every number above is measured, not claimed. Methodology, environment, raw
JSON, and reproduction commands live in
benchmarks/RESULTS.md.
Contents
- The problem
- Install
- Quickstart
- How it works
- Why zvec
- Benchmarks
- Comparison
- Configuration
- Custom embedders
- Multi-turn conversations
- Requirements
- API reference
- Troubleshooting
- FAQ
- Development
- License
The problem it solves
Agents with dozens or hundreds of tools pay for all of them on every single
model call. Tool schemas eat context, cost money, and make the model worse at
choosing: lookalike options blur together. LangChain's built-in
LLMToolSelectorMiddleware fixes the symptom with another LLM call per step,
which adds its own latency, tokens, and bill (our measurements: +26 model
calls and only −38.6% tokens over 10 turns, plus malformed-selection errors
on smaller models).
This middleware takes a different path. It indexes every tool once into a local zvec collection (an in-process vector engine built in Rust) using both a dense embedding and a sparse embedding. Before each model call it embeds the search query, runs one hybrid search, fuses the two rankings with reciprocal rank fusion, and hands the model only the top-k winners. The full tool set stays registered with the agent, so any selected tool still executes normally.
Install
pip install "langchain-dynamic-tools-middleware[local]"
The [local] extra installs sentence-transformers, which powers the default
embedding models (about a 500 MB download on first use, then cached and fully
offline). Skip the extra if you bring your own embedders (OpenAI, Jina, Qwen,
Ollama, or any LangChain Embeddings object).
Requires Python 3.10 or newer and langchain>=1.0.
Quickstart
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langchain_dynamic_tools import DynamicToolSelectorMiddleware
# 1. Fifty or hundreds of available tools
all_tools = [get_weather, query_sql, send_email, git_push, ...]
# 2. Add the dynamic middleware. Every tool is indexed once, locally.
tool_router = DynamicToolSelectorMiddleware(
tools=all_tools,
top_k=4,
)
# 3. Create the agent. The full set stays available for execution, but each
# model call only carries the tools relevant to the user's message.
agent = create_agent(
model=ChatOpenAI(model="gpt-4o"),
tools=all_tools,
middleware=[tool_router],
)
That is the whole integration. Tools are standard LangChain tools built with
the @tool decorator or StructuredTool. A runnable six-tool demo lives in
examples/quickstart.py, and
examples/custom_embedders.py shows OpenAI
embeddings.
How it works
index time (once) every model call
──────────────── ─────────────────
tool name + description user message
+ parameters │
│ embed dense + sparse
embed dense (MiniLM) │
embed sparse (SPLADE) zvec hybrid query
│ RRF fusion
zvec collection ──────────────► top-k tool names
on local disk model sees only those
- Indexing, once. Each tool is rendered as one compact text string:
name, description, parameters with types and descriptions. The text is
embedded into a dense vector (all-MiniLM-L6-v2, 384 dims) and a sparse
lexical vector (SPLADE), and stored in a zvec collection on local disk at
.dynamicToolsMiddleware/. - Per model call. The middleware builds a search query from the conversation (latest user message, plus the previous one when the latest is a very short follow-up like "and in Berlin?"), embeds it both ways, and queries the collection once. zvec fuses the dense and sparse rankings with RRF and returns the best tool names in milliseconds.
- Override. The model request is rewritten so the model sees only the selected tools. Provider tool dicts pass through untouched, tools registered after startup are indexed on the fly, and if a search ever fails the agent falls back to all tools rather than breaking.
Because the collection persists, restarting your application with the same tools re-embeds nothing. Change a tool's description and only that tool is re-embedded on the next sync.
Why zvec
The search engine is zvec: Alibaba's open-source (Apache-2.0, 15.9k stars), in-process vector database, battle-tested inside Alibaba Group. What that buys this middleware:
- No server to run. The engine lives inside your process and the collection is a local directory. Nothing to deploy, monitor, or pay for.
- Hybrid in one query. Dense vectors, sparse vectors, full-text search, and filters fuse in a single call, which is exactly the dense plus SPLADE plus RRF pattern used here.
- Fast enough to disappear. HNSW and IVF index types with WAL durability; in our measurements the zvec search itself is single-digit milliseconds, the rest of the ~100 ms step is local embedding on CPU.
- Batteries included. Local MiniLM and SPLADE models, OpenAI/Jina/Qwen
API wrappers, and Ollama-style HTTP endpoints all ship in
zvec.extension, so every embedder option in this README comes from one dependency.
Benchmarks
Measured with the harness in benchmarks/: real
zvec engine, real local embedding models, live model calls through the full
create_agent stack, Berkeley Function Calling Leaderboard data, token
counts from provider usage metadata. Full tables in
benchmarks/RESULTS.md.
Selection latency stays flat while the tool count grows 20x (the cost is almost entirely the local SPLADE embedding, zvec search is single-digit ms):
| Tools | Build (one time) | Query mean | Query p95 |
|---|---|---|---|
| 50 | 9.8 s | 106.5 ms | 215.2 ms |
| 100 | 18.0 s | 103.8 ms | 180.7 ms |
| 250 | 42.5 s | 95.6 ms | 124.8 ms |
| 997 | 197.2 s | 101.9 ms | 118.3 ms |
10-turn conversation, 100 tools: dynamic bills 38,207 prompt tokens vs 196,153 for the full set (−80.5%) and 120,367 for the LLM selector (−68.3% relative). The LLM selector needs 37 model calls for the same 10 turns (dynamic: 17, baseline: 11).
BFCL function-calling accuracy, aggregated over 218 questions.
BFCL (Berkeley Function
Calling Leaderboard) is the standard open benchmark for tool use: each
question ships real function schemas and checks whether the model calls the
right function with the right arguments. We ran all three configurations on
identical questions with every tool set padded to 100 distractors, scoring
the first model response with BFCL possible-answer semantics. The aggregate
covers every tested behavior: straightforward calls, knowing when no tool
fits (irrelevance), and still acting when one does (relevance). Full
per-category tables in benchmarks/RESULTS.md.
| Config | Accuracy (218 questions) | Prompt tokens/question | Errors |
|---|---|---|---|
| Baseline (all 100 tools) | 69.3% | 20,493 | 4 |
| LLM selector (top 4) | 47.7% | 5,135 | 62 |
| Dynamic (top 4) | 69.7% | 1,435 | 4 |
The takeaway is the breakthrough this package exists for: the LLM selector sacrifices 21 points of accuracy for a partial token saving and 62 failed questions (extra model calls double rate-limit exposure, and smaller models return malformed selections). The vector route matches the full tool set on accuracy, bills one-fourteenth of its tokens, answers 122x faster, and fails only on provider outages. Filtering tools no longer means dumber agents.
Comparison with the built-in LLM tool selector
LLMToolSelectorMiddleware |
DynamicToolSelectorMiddleware |
|
|---|---|---|
| Selection method | Extra LLM call per step | Local hybrid vector search |
| Selection latency (100 tools) | ~13.2 s mean | ~108 ms mean |
| Selection token cost | Full schemas billed every step | Zero, runs offline |
| Lexical matching (exact names, rare terms) | Depends on the model | Native, via sparse SPLADE vectors |
| Semantic matching (paraphrases) | Yes | Yes, via dense embeddings |
| Robustness on small models | Malformed selections observed | No generation involved |
| Privacy | Sends conversation to selector model | Nothing leaves your machine |
The two compose: cut 200 tools to 20 with vector search, then 20 to 5 with an LLM selector. For most applications the vector step alone is enough.
Configuration
DynamicToolSelectorMiddleware(
tools=all_tools, # required: the agent's tools
top_k=4, # tools handed to the model per step
path=".dynamicToolsMiddleware", # local zvec collection location
dense_embedder=None, # optional: .dimension/.embed(text), or any LangChain Embeddings
sparse_embedder=None, # optional: any object with .embed_document and .embed_query
dense_dim=None, # optional: override; skips dimension probing for LangChain embedders
always_include=None, # tool names added to every selection, beyond top_k
reranker=None, # optional: zvec reranker, defaults to RrfReRanker(60)
on_error="fallback_all", # or "raise" on search failure
)
| Parameter | Default | Notes |
|---|---|---|
tools |
required | BaseTool instances get indexed, provider tool dicts pass through |
top_k |
4 |
Must be at least 1 |
path |
.dynamicToolsMiddleware |
Collection persists between runs, one path per agent |
always_include |
None |
Never filtered out, does not count against top_k |
on_error |
fallback_all |
fallback_all keeps every tool if the search fails |
dense_embedder |
local MiniLM | LangChain Embeddings, OpenAI, Jina, Qwen, or your own model |
sparse_embedder |
local SPLADE | Any {index: weight} embedder works |
dense_dim |
embedder's own | Override when the dimension is known up front |
Other useful methods: middleware.sync() re-indexes after you edit tool
descriptions, middleware.close() releases the collection's file lock.
Custom embedders
Standard LangChain embeddings drop straight in (adapted automatically,
embed() delegates to embed_query()):
from langchain_openai import OpenAIEmbeddings
from langchain_dynamic_tools import DefaultSparseEmbedder, DynamicToolSelectorMiddleware
tool_router = DynamicToolSelectorMiddleware(
tools=all_tools,
top_k=4,
dense_embedder=OpenAIEmbeddings(model="text-embedding-3-small"),
dense_dim=1536, # skips the one-time dimension probe call
sparse_embedder=DefaultSparseEmbedder(),
)
zvec's own dense wrappers work too (needs pip install openai), as do Jina,
Qwen, Ollama, or LM Studio endpoints:
from zvec.extension import HTTPDenseEmbedding, OpenAIDenseEmbedding
dense_embedder=OpenAIDenseEmbedding(model="text-embedding-3-small"),
# or fully local servers, stdlib HTTP only, dimension auto-detected:
dense_embedder=HTTPDenseEmbedding(base_url="http://localhost:11434", model="nomic-embed-text"),
Cloud sparse embeddings need a tiny two-sided wrapper, because zvec fixes the query/document encoding per instance:
from zvec.extension import QwenSparseEmbedding
class DualSparse:
def __init__(self, doc_model, query_model):
self._doc, self._query = doc_model, query_model
def embed_document(self, text): return self._doc.embed(text)
def embed_query(self, text): return self._query.embed(text)
sparse_embedder=DualSparse(
QwenSparseEmbedding(dimension=1024, encoding_type="document"),
QwenSparseEmbedding(dimension=1024, encoding_type="query"),
),
The index adapts its vector dimension to whatever your dense embedder
produces, and rebuilds the collection automatically if you switch embedders
later. See examples/custom_embedders.py.
Multi-turn conversations
The search query is the latest user message. When it is very short (under 40 characters, e.g. "and in Berlin?"), the previous user message is prepended so follow-ups still match the right tools. Queries are capped at 2000 characters to keep embedding cost flat. Tool outputs are never part of the query: they are large and lexically noisy.
Requirements
- Python 3.10+
langchain>=1.0,zvec>=0.5- Default embedders:
pip install "langchain-dynamic-tools-middleware[local]"(sentence-transformers, ~500 MB first download, then offline) - zvec wheels: Linux x86_64/aarch64, macOS ARM64, Windows x86_64. Intel Macs use the Linux dev container (see CONTRIBUTING.md).
API reference
| Name | Kind | One line |
|---|---|---|
DynamicToolSelectorMiddleware |
class | The middleware, drop into create_agent(middleware=[...]) |
ToolVectorIndex |
class | The zvec-backed index: sync(), search(), close() |
DefaultDenseEmbedder |
class | Local all-MiniLM-L6-v2 wrapper, 384 dims, lazy load |
DefaultSparseEmbedder |
class | Local SPLADE wrapper, query/document encodings |
LangChainDenseEmbedder |
class | Adapter for any LangChain Embeddings object |
DenseEmbedder |
protocol | .dimension + .embed(text) |
SparseEmbedder |
protocol | .embed_document(text) + .embed_query(text) |
render_tool_text |
function | The compact tool string that gets embedded |
Troubleshooting
Can't lock read-write collection ... LOCK
Two writers share one collection path. Give each middleware its own path
and call close() (or use a context manager) before reopening. Parallel
benchmark workers each get their own directory for the same reason.
First run is slow. Model downloads (~500 MB) plus one-time indexing, linear in tool count (about 200 s for 1000 tools on CPU). Later runs skip unchanged tools entirely. Switch to API embedders to skip downloads.
Selection looks wrong for a tool.
Print render_tool_text(tool): vague descriptions embed vaguely. A
one-sentence description naming the entity and action ("Send an email
message to a recipient") beats a clever one.
AttributeError on a custom embedder.
Dense needs .dimension and .embed(text); standard LangChain embeddings
are adapted automatically. Sparse needs .embed_document and
.embed_query on one object; wrap two single-encoding zvec models as shown
above.
Dimension mismatch after switching embedders. The collection rebuilds automatically when the stored dimension differs. A stale lock or foreign path raises a clear error naming the path.
FAQ
Does the agent still execute all tools? Yes. Selection only changes what the model sees per step. Tool execution is untouched, and a selected tool always resolves to the real implementation.
What happens on the first run? The default embedders download their models once, index your tools, and store everything under the collection path. Later runs are instant until a tool changes.
Can I use one collection for several agents?
Give each middleware its own path. Collections hold an exclusive write
lock, so sharing one path between concurrently writing middlewares is not
supported.
Does my conversation leave my machine? Selection never calls out with the default or Ollama-style embedders: search is local. Only your actual model provider sees your conversation, same as without this middleware. API embedders (OpenAI, Jina, Qwen) send short tool and query texts to that provider.
Which top_k should I use?
4 is the measured default: 79% BFCL simple accuracy at ~5% of baseline
tokens. Raise it when tools overlap heavily, lower it for cost. The
benchmark harness reruns any top_k with --top-k.
Windows support? Yes, zvec ships Windows x86_64 wheels. macOS runs on Apple Silicon; Intel Macs can use the Linux dev container, see CONTRIBUTING.md.
Development
uv sync --all-extras
uv run pytest
See CONTRIBUTING.md for the full guide, including the container workflow and the benchmark harness.
License
Author
Built by Rauhan Ahmed Siddiqui.
Portfolio
- Portfolio: rauhanahmed.in
- GitHub: RauhanAhmed
- LinkedIn: Rauhan Ahmed
- X: @ahmed_rauh46040
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file langchain_dynamic_tools_middleware-0.2.0.tar.gz.
File metadata
- Download URL: langchain_dynamic_tools_middleware-0.2.0.tar.gz
- Upload date:
- Size: 454.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
458e42c458844b4a78d556cab96ed511f6cda5cbfbad015cf83d18494cbbc0c8
|
|
| MD5 |
39b41714663eb005e89dc0a595d39f73
|
|
| BLAKE2b-256 |
f94e9f9238c4c032834a5b68c3072a4ab5841230e81b0daa1ede7df57d7008af
|
Provenance
The following attestation bundles were made for langchain_dynamic_tools_middleware-0.2.0.tar.gz:
Publisher:
publish.yml on RauhanAhmed/langchain-dynamic-tools-middleware
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langchain_dynamic_tools_middleware-0.2.0.tar.gz -
Subject digest:
458e42c458844b4a78d556cab96ed511f6cda5cbfbad015cf83d18494cbbc0c8 - Sigstore transparency entry: 2811884257
- Sigstore integration time:
-
Permalink:
RauhanAhmed/langchain-dynamic-tools-middleware@5055df93587070d0336e58bb12e00ac8d1388e9a -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/RauhanAhmed
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@5055df93587070d0336e58bb12e00ac8d1388e9a -
Trigger Event:
push
-
Statement type:
File details
Details for the file langchain_dynamic_tools_middleware-0.2.0-py3-none-any.whl.
File metadata
- Download URL: langchain_dynamic_tools_middleware-0.2.0-py3-none-any.whl
- Upload date:
- Size: 22.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eef42d345b2469f9df3ddb97dd5b221d08256e207fdd2a65a82e31b1b8f1895b
|
|
| MD5 |
2a1d8aabcbe9fc593e5afb651f6b2efe
|
|
| BLAKE2b-256 |
51b72a35ad765159a25bd3fba083895f3c1906378959ac7af1300c57d371472f
|
Provenance
The following attestation bundles were made for langchain_dynamic_tools_middleware-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on RauhanAhmed/langchain-dynamic-tools-middleware
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langchain_dynamic_tools_middleware-0.2.0-py3-none-any.whl -
Subject digest:
eef42d345b2469f9df3ddb97dd5b221d08256e207fdd2a65a82e31b1b8f1895b - Sigstore transparency entry: 2811884353
- Sigstore integration time:
-
Permalink:
RauhanAhmed/langchain-dynamic-tools-middleware@5055df93587070d0336e58bb12e00ac8d1388e9a -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/RauhanAhmed
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@5055df93587070d0336e58bb12e00ac8d1388e9a -
Trigger Event:
push
-
Statement type: