🛠️ Multi-Tool Agent#
🛠️ Multi-Tool Agent gives the same LangChain agent four tools — get_weather, add, multiply and get_population — and lets the model pick zero, one or several for each question.
How It Works#
The idea behind the demo, the request it sends and the function calls that answer it.
Concept#
Multi-Tool Agent scales the LangChain agent up from one tool to a toolkit of
four distinct capabilities: get_weather(city), add(a, b),
multiply(a, b), and get_population(city). With multiple tools on the
table, the LLM must act as a semantic router and planner — deciding not
just whether to use a tool, but which combination of tools satisfies a
compound user request.
When asked a multi-part question such as "What's the weather in Tokyo, and what is 12 times
12?", the agent dispatches both get_weather(city="Tokyo") and
multiply(a=12, b=12), collects both tool outputs, and synthesizes a single coherent
answer. Conversely, if asked "Who wrote Romeo and Juliet?", it recognizes that none of
the four tools apply and answers directly.
Theory#
1. Semantic Tool Selection Across a Toolkit
When multiple tools are registered in tools=[get_weather, add, multiply,
get_population], every LLM request includes the JSON Schema definitions for all four
functions. The model compares the user's intent against each tool's name, parameter types, and
docstring to pick the right match without any hardcoded if/elif routing logic in
Python.
2. Parallel Multi-Tool Calls in a Single Turn
Modern tool-calling models can emit multiple items in a single
tool_calls array when sub-questions are independent (for example, looking up Tokyo's
weather and multiplying two numbers at the same time). LangChain executes all requested tools,
appends a ToolMessage for each one, and feeds the combined observations back to the
model in the next step.
3. Atomic, Single-Purpose Tool Design
Notice that add(a, b) and multiply(a, b) are separate, narrowly typed
functions rather than one vague do_stuff(query: str) function. Narrow, well-named
tools with unambiguous parameter signatures dramatically reduce argument-extraction errors and
can be composed freely by the agent.
4. Guarding Against Lazy Tool Summaries in the System Prompt
Smaller or faster models sometimes see a tool output in the conversation history and reply
vaguely (e.g., "I have fetched the weather and calculation for you above"), forgetting
that the end user cannot see raw ToolMessage blocks. The system prompt explicitly
instructs: "Always state the final result clearly and completely in your answer."
Request flow#
index.html + main.js sends POST /api/multi-tool-agent with { message }.
app.py)
multi_tool_agent_service() calls run_multi_tool_agent(message).
05_langchain_multi_tools.py)
The Groq model picks from the four tools; LangChain runs them and feeds results back.
{ response, tool_called }.
Code explanation#
Core AI Module (src/python/05_langchain_multi_tools.py)
src/python/05_langchain_multi_tools.py equips a LangChain create_agent() graph with four distinct Python tools — get_weather, add, multiply, and get_population — so the LLM can act as a semantic router that selects zero, one, or multiple tools per turn to answer compound questions.
Schemas, Toolkit Functions, and Multi-Tool Agent Execution
- Provider Selection (
PROVIDER = "groq",prefix = PROVIDER.upper()): Selects the active model provider soGROQ_MODEL,GROQ_API_KEY, andGROQ_BASE_URLconfigure the OpenAI-compatibleChatOpenAIclient. class MultiToolAgentRequest(BaseModel)&class MultiToolAgentResponse(BaseModel): Pydantic schemas defining the input query (message: str = "What's the weather in Tokyo, and what is 12 times 12?") and output payload (message: str,response: str, andtool_called: bool).- Four Registered Tool Functions: LangChain introspects each function's name, type annotations, and docstring to build the four tool schemas sent to the model:
get_weather(city: str) -> str: Docstring"""Get the current weather for a given city."""; returnsf"The weather in {city} is sunny, 28 degrees C.".add(a: float, b: float) -> float: Docstring"""Add two numbers together."""; returnsa + b.multiply(a: float, b: float) -> float: Docstring"""Multiply two numbers together."""; returnsa * b.get_population(city: str) -> str: Docstring"""Get the approximate population of a major city."""; looks upcity.lower()in a dictionary coveringnew york,london,bangalore, andtokyo, or returns a fallback message if unknown.
- Agent Construction and
run_multi_tool_agent(question: str = "What's the weather in Tokyo, and what is 12 times 12?") -> tuple[str, bool]:- Step ① — Initialize the LLM Client: Instantiates
llm = ChatOpenAI(model=os.getenv(f"{prefix}_MODEL"), api_key=os.getenv(f"{prefix}_API_KEY") or "missing-key", base_url=os.getenv(f"{prefix}_BASE_URL") or None, temperature=0, timeout=20.0)withtemperature=0for deterministic multi-tool routing. - Step ② — Create the Multi-Tool LangChain Agent: Calls
agent = create_agent(model=llm, tools=[get_weather, add, multiply, get_population], system_prompt=...)with an explicit instruction to state every final result clearly and completely. - Step ③ — Invoke the Multi-Tool Agent: Runs
messages = agent.invoke({"messages": [{"role": "user", "content": question}]})["messages"], allowing the model to emit single or parallel tool calls across the four functions and synthesize all returnedToolMessageobservations. - Step ④ — Detect Tool Execution: Computes
tool_called = any(m.type == "tool" for m in messages)to record whether one or more tools were invoked during the turn. - Step ⑤ — Return Final Synthesized Answer: Returns
messages[-1].content, tool_calledfrom the finalAIMessage.
- Step ① — Initialize the LLM Client: Instantiates
How the AI Components Connect End-to-End
run_multi_tool_agent(question)passes the user's prompt intoagent.invoke({"messages": [...]}).- The LLM inspects the user's question alongside the JSON Schemas of
get_weather,add,multiply, andget_populationand decides which subset of tools (if any) to call. - For a compound prompt like "What's the weather in Tokyo, and what is 12 times 12?", the agent executes both
get_weather(city="Tokyo")andmultiply(a=12, b=12), appends aToolMessagefor each result, and sends the combined observations back to the LLM. - The model synthesizes a complete answer incorporating all tool outputs, and
run_multi_tool_agent()returns(messages[-1].content, tool_called)to fulfillMultiToolAgentResponse.
Code flow#
POST /api/multi-tool-agent"]:::entry -->|"message"| R["app.py
multi_tool_agent_service()"]:::fn R -->|"question"| A["05_langchain_multi_tools.py
agent.invoke()"]:::fn A -->|"question + 4 tool specs"| G[("Groq API")]:::ext G -->|"tool calls"| A A -->|"city"| W["get_weather()"]:::fn A -->|"a, b"| M["add() / multiply()"]:::fn A -->|"city"| P["get_population()"]:::fn W -->|"result"| A M -->|"result"| A P -->|"result"| A A -->|"tool results"| G G -->|"final answer"| A A -->|"answer + tool_called"| R R -->|"JSON"| UI classDef entry fill:#1e3a5f,stroke:#90caf9,color:#e0e0e0 classDef fn fill:#1e1e1e,stroke:#4caf50,color:#e0e0e0 classDef ext fill:#2d1f3d,stroke:#ce93d8,color:#e0e0e0 classDef ret fill:#1b3320,stroke:#81c784,color:#e0e0e0
Source Code#
Every Python file this demo runs, complete and unedited: the service implementation first, then the Flask Blueprint route handler (app.py) that exposes it.
05_langchain_multi_tools.py#
Langchain Version V1 - Agents (multiple tools + visible tool calls)
"""
Langchain Version V1 - Agents (multiple tools + visible tool calls)
Teaching idea: give the agent SEVERAL tools, then SHOW what it does internally.
For each question the agent: reads the tools -> decides which to call ->
calls it -> reads the result -> writes a final answer. The step trace below
makes that loop visible so students can see the function calls happen.
NOTE on the OpenAI 429 ('insufficient_quota'): billing state, not a bug.
PROVIDER="groq" (free) keeps you teaching without OpenAI credits.
"""
import os
from dotenv import find_dotenv, load_dotenv
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Configuration & Provider Selection
# ---------------------------------------------------------------------------
# Load API keys and model names from .env
load_dotenv(find_dotenv())
# Pick a provider: "groq" (free + fast), "gemini" (free tier) or "openai" (needs billing).
# Its settings are read from <PROVIDER>_MODEL / <PROVIDER>_API_KEY / <PROVIDER>_BASE_URL in .env
# (get keys at https://console.groq.com or https://aistudio.google.com)
PROVIDER = "groq"
prefix = PROVIDER.upper()
# ---------------------------------------------------------------------------
# Request & Response Schemas (Pydantic)
# ---------------------------------------------------------------------------
class MultiToolAgentRequest(BaseModel):
"""Input payload for multi-tool LangChain agent queries."""
message: str = "What's the weather in Tokyo, and what is 12 times 12?"
class MultiToolAgentResponse(BaseModel):
"""Output payload containing the final response and tool call indicator."""
message: str
response: str
tool_called: bool
# ---------------------------------------------------------------------------
# Tools
# 1. Type hints (city: str) -> tell the model what arguments to pass
# 2. The docstring -> tells the model WHEN to use this tool
# ---------------------------------------------------------------------------
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
return f"The weather in {city} is sunny, 28 degrees C."
def add(a: float, b: float) -> float:
"""Add two numbers together."""
return a + b
def multiply(a: float, b: float) -> float:
"""Multiply two numbers together."""
return a * b
def get_population(city: str) -> str:
"""Get the approximate population of a major city."""
data = {
"new york": "8.5 million",
"london": "9 million",
"bangalore": "13 million",
"tokyo": "14 million",
}
return data.get(city.lower(), f"Sorry, I don't have population data for {city}.")
# ---------------------------------------------------------------------------
# Build the agent
# ---------------------------------------------------------------------------
# ① Initialize the LLM client pointed at the configured provider
# (placeholder key avoids an import-time crash when the key is unset)
llm = ChatOpenAI(
model=os.getenv(f"{prefix}_MODEL"),
api_key=os.getenv(f"{prefix}_API_KEY") or "missing-key",
base_url=os.getenv(f"{prefix}_BASE_URL") or None,
temperature=0,
timeout=20.0,
)
# ② Create the multi-tool LangChain agent with all 4 tools registered
agent = create_agent(
model=llm,
tools=[get_weather, add, multiply, get_population],
# Tighter prompt: force the model to actually state the result so we don't
# get vague answers like "that's the current weather".
system_prompt=(
"You are a helpful assistant. Use the available tools when needed. "
"Always state the final result clearly and completely in your answer."
),
)
# ---------------------------------------------------------------------------
# Core Service Logic
# ---------------------------------------------------------------------------
def run_multi_tool_agent(question: str = "What's the weather in Tokyo, and what is 12 times 12?") -> tuple[str, bool]:
"""Run a user question through the multi-tool LangChain agent."""
# ③ Invoke the multi-tool agent with the user's question
messages = agent.invoke({"messages": [{"role": "user", "content": question}]})["messages"]
# ④ Detect whether one or more tools were executed during the turn
tool_called = any(m.type == "tool" for m in messages)
# ⑤ The last message is the final synthesized answer
return messages[-1].content, tool_called
app.py#
Flask Blueprint server and route handler (multi_tool_agent_service) in src/python/app.py.
"""Flask server for 02-langchain-agents."""
import importlib.util
import os
import sys
from pathlib import Path
from dotenv import find_dotenv, load_dotenv
from flask import Blueprint, Flask, jsonify, request
from flask_cors import CORS
from rate_limiter import check_rate_limit
load_dotenv(find_dotenv())
PATH_PREFIX = os.environ.get("PATH_PREFIX", "")
STATIC_DIR = Path(__file__).resolve().parents[1]
app = Flask(__name__, static_folder=str(STATIC_DIR))
CORS(app)
bp = Blueprint("main", __name__)
_CUR_DIR = Path(__file__).resolve().parent
def _load_service(filename: str, alias: str):
# ① Resolve the numbered service file path and create its import spec
file_path = _CUR_DIR / filename
spec = importlib.util.spec_from_file_location(f"services.{alias}", file_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load module from {file_path}")
# ② Register and execute the module so its functions can be called by routes
mod = importlib.util.module_from_spec(spec)
sys.modules[f"services.{alias}"] = mod
spec.loader.exec_module(mod)
return mod
mod_summarize = _load_service("01_langchain_summarizer.py", "langchain_summarizer")
mod_memory = _load_service("02_langchain_memory_chat.py", "langchain_memory_chat")
mod_agent = _load_service("03_agent_one_tool.py", "agent_one_tool")
mod_lc_tool = _load_service("04_langchain_one_tool.py", "langchain_one_tool")
mod_lc_multi = _load_service("05_langchain_multi_tools.py", "langchain_multi_tools")
@bp.before_request
def enforce_rate_limit():
# ① Only rate-limit mutating POST requests
if request.method == "POST":
# ② Check whether this client has exceeded 30 requests per hour
blocked, msg, retry_after = check_rate_limit(
request, max_requests=30, window_seconds=3600
)
if blocked:
# ③ Return HTTP 429 with Retry-After header when rate-limited
resp = jsonify({"error": msg})
resp.status_code = 429
resp.headers["Retry-After"] = str(retry_after)
return resp
@bp.route("/")
def index():
# ① Read index.html and inject the runtime PATH_PREFIX into data-api-base
with open(os.path.join(app.static_folder, "index.html"), encoding="utf-8") as f:
html = f.read()
html = html.replace('data-api-base=""', f'data-api-base="{PATH_PREFIX}"')
# ② Return the patched HTML page
return app.response_class(html, mimetype="text/html")
@bp.route("/css/<path:filename>")
def css(filename):
return app.send_static_file(os.path.join("css", filename))
@bp.route("/js/<path:filename>")
def js(filename):
return app.send_static_file(os.path.join("js", filename))
@bp.route("/explanation/<path:filename>")
@bp.route("/info/<path:filename>")
def explanation_pages(filename):
return app.send_static_file(os.path.join("explanation", filename))
@bp.route("/theory/<path:filename>")
def theory_pages(filename):
"""Serve the theory documentation pages from src/theory."""
return app.send_static_file(os.path.join("theory", filename))
@bp.route("/api/summarize", methods=["POST"])
def summarizer_service():
# ① Parse input text from JSON payload and reject empty strings
data = request.get_json(force=True, silent=True) or {}
text = (data.get("text") or data.get("message") or "").strip()
if not text:
return jsonify({"error": "Text cannot be empty"}), 400
try:
# ② Run the LangChain LCEL summarizer chain in 01_langchain_summarizer.py
summary = mod_summarize.summarize(text)
return jsonify({"result": summary, "summary": summary})
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/memory-chat", methods=["POST"])
def memory_chat_service():
# ① Parse user message and prior conversation history from JSON payload
data = request.get_json(force=True, silent=True) or {}
message = (data.get("message") or "").strip()
history = data.get("history") or []
if not message:
return jsonify({"error": "Message cannot be empty"}), 400
try:
# ② Convert history dicts to ChatMessage models and run chat_with_memory()
chat_history = [
mod_memory.ChatMessage(**m) if isinstance(m, dict) else m
for m in history
]
reply, updated_history = mod_memory.chat_with_memory(message, chat_history)
return jsonify({
"result": reply,
"response": reply,
"history": [m.model_dump() if hasattr(m, "model_dump") else m for m in updated_history],
})
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/agent", methods=["POST"])
def agent_service():
# ① Parse user query from JSON payload and reject empty messages
data = request.get_json(force=True, silent=True) or {}
message = (data.get("message") or "").strip()
if not message:
return jsonify({"error": "Message cannot be empty"}), 400
try:
# ② Run the raw OpenAI tool-calling loop in 03_agent_one_tool.py
reply = mod_agent.run_agent_with_tool(message)
return jsonify({"result": reply, "response": reply})
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/langchain-one-tool", methods=["POST"])
def langchain_one_tool_service():
# ① Parse user query from JSON payload and reject empty messages
data = request.get_json(force=True, silent=True) or {}
message = (data.get("message") or "").strip()
if not message:
return jsonify({"error": "Message cannot be empty"}), 400
try:
# ② Run the LangChain single-tool agent in 04_langchain_one_tool.py
reply, tool_called = mod_lc_tool.run_langchain_one_tool(message)
return jsonify({"result": reply, "response": reply, "tool_called": tool_called})
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/multi-tool-agent", methods=["POST"])
def multi_tool_agent_service():
# ① Parse user query from JSON payload and reject empty messages
data = request.get_json(force=True, silent=True) or {}
message = (data.get("message") or "").strip()
if not message:
return jsonify({"error": "Message cannot be empty"}), 400
try:
# ② Run the LangGraph ReAct multi-tool agent in 05_langchain_multi_tools.py
reply, tool_called = mod_lc_multi.run_multi_tool_agent(message)
return jsonify({"result": reply, "response": reply, "tool_called": tool_called})
except Exception as exc:
return jsonify({"error": str(exc)}), 500
app.register_blueprint(bp, url_prefix=PATH_PREFIX)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Quick Revision#
Key AI concepts, architectural patterns, and practical takeaways from this demo.
Quick revision#
Use these numbered points to revise the core concepts behind multi-tool LangChain agents:
- Multi-Tool Routing by the LLM: When multiple tools are bound via
model.bind_tools(TOOLS), the LLM acts as a semantic router—reading each tool's name, docstring, and parameter schema to decide which tool(s) fit each part of a user's query. - Docstrings as Routing Prompts: The model never sees the Python body of a tool; clear, specific docstrings (such as listing supported cities or book titles) directly determine how accurately the LLM selects tools and formats arguments.
- Parallel & Sequential Tool Calling: Modern OpenAI models can emit multiple entries inside
ai.tool_callsin a single turn (for independent lookups like weather + book info) or chain tools across consecutive loop iterations when a later calculation depends on an earlier lookup. TOOL_MAPDictionary Dispatch: Indexing tools withTOOL_MAP = {t.name: t for t in TOOLS}replaces brittleif/elifchains with a clean dictionary lookup that scales to any number of registered tools.ToolMessageCorrelation viatool_call_id: Every tool output must be wrapped in aToolMessage(content=..., tool_call_id=call["id"])so the LLM can match each result back to the specifictool_callsentry it requested.- Bounded Execution Loop (
range(6)): Wrapping the ReAct cycle in a fixed-ceiling loop prevents runaway token spend or infinite loops if a tool returns unexpected output. - Zero Temperature for Tool Selection: Setting
temperature=0onChatOpenAImakes tool selection and argument extraction deterministic and reliable across repeated runs. - Observability via Structured Tool Traces: Recording every
ToolCallEntry(tool, args, result)gives users and developers full transparency into how the agent arrived at its final answer rather than treating the agent as a black box.