Module 3: Function Calling Patterns

3. Forced Tool Calls and Tool Routing

Overview

In the previous capsule you saw parallel function calling: the model calls N tools simultaneously when the tasks are independent. That works well with 2-3 tools. But in production, an agent can have 15 or more tools. And that's where a real problem shows up: the more tools available, the worse the model decides which one to use. It gets confused, picks the wrong tools, or invents arguments for tools that don't apply.

The solution has two layers. You already know the first from Module 2: tool_choice to force the model to use a specific tool. But here we go much further: dynamic tool_choice that changes per request. The second layer is tool routing: instead of handing the model every tool, you decide which subset is available based on the context.

Tool routing is the difference between an agent that works in demos (3 tools, always picks right) and one that works with real users (20 tools, varied contexts). This capsule covers: a deep dive into tool_choice, static routing by rules, dynamic routing with LLM classification, and a complete router implementation.


The problem: too many tools, bad decisions

Imagine an agent with these 10 tools:

from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"Weather in {city}: 22°C, sunny"

@tool
def get_forecast(city: str, days: int = 3) -> str:
    """Get the weather forecast for the coming days."""
    return f"Forecast {city}: next {days} days, mild"

@tool
def web_search(query: str) -> str:
    """Search the web for information."""
    return f"Results for: {query}"

@tool
def news_search(query: str, max_results: int = 5) -> str:
    """Search for recent news about a topic."""
    return f"News about: {query}"

@tool
def calculator(expression: str) -> str:
    """Evaluate a mathematical expression."""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Error: {e}"

@tool
def unit_converter(value: float, from_unit: str, to_unit: str) -> str:
    """Convert between units of measurement."""
    return f"{value} {from_unit} = {value * 2.54} {to_unit}"

@tool
def create_ticket(title: str, description: str, priority: str = "medium") -> str:
    """Create a support ticket."""
    return f"Ticket created: {title} (priority: {priority})"

@tool
def search_tickets(query: str) -> str:
    """Search existing tickets."""
    return f"Tickets found for: {query}"

@tool
def update_ticket(ticket_id: str, status: str) -> str:
    """Update the status of a ticket."""
    return f"Ticket {ticket_id} updated to: {status}"

@tool
def get_user_info(user_id: str) -> str:
    """Get information about the user."""
    return f"User {user_id}: Pro plan, active since 2024"

ALL_TOOLS = [
    get_weather, get_forecast, web_search, news_search,
    calculator, unit_converter, create_ticket, search_tickets,
    update_ticket, get_user_info
]

With an ambiguous input like "I need information about yesterday's problem", the model may pick search_tickets (correct for support), web_search (wrong), or news_search (wrong). It has to guess the context.

The hidden costs of too many tools

ProblemImpact
Extra tokens10 tools ≈ 2000-3000 extra tokens per request
LatencyMore schemas → more inference time
ConfusionTools with similar descriptions compete with each other
CostInput tokens are billed. At volume, that's real money

The solution: instead of giving it all the tools every time, give it only the relevant ones:

Without routing:  User → [10 tools] → Model → (confusion)
With routing:     User → Router → [2-3 tools] → Model → (clear decision)

Forced tool calls with tool_choice

You already know the three modes from Module 2. Here we go further: dynamic tool_choice based on context.

When to force a specific tool

1. Classification as the first step

When the first step is always classifying the intent:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field

class ClassifyIntent(BaseModel):
    """Classify the intent of the user's message."""
    intent: str = Field(
        description="The user's intent",
        enum=["weather", "search", "math", "support", "general"]
    )
    confidence: float = Field(description="Confidence of the classification (0.0 to 1.0)")

@tool(args_schema=ClassifyIntent)
def classify_intent(intent: str, confidence: float) -> str:
    """Classify the intent of the user's message."""
    return f"Intent: {intent} (confidence: {confidence})"

model = init_chat_model("openai:gpt-4.1-mini")
classifier = model.bind_tools([classify_intent], tool_choice="classify_intent")

response = classifier.invoke([HumanMessage(content="How much is 15 * 23?")])
print(response.tool_calls[0]["args"])
# {'intent': 'math', 'confidence': 0.95}

With tool_choice="classify_intent", the model always classifies. There's no chance it answers directly or calls another tool.

2. Mandatory extraction

When you need to extract structured data from every input:

class TicketInfo(BaseModel):
    """Information extracted to create a support ticket."""
    title: str = Field(description="Short title of the problem")
    category: str = Field(enum=["billing", "technical", "account", "other"])
    urgency: str = Field(enum=["low", "medium", "high", "critical"])

@tool(args_schema=TicketInfo)
def extract_ticket_info(title: str, category: str, urgency: str) -> str:
    """Extract structured information from a problem report."""
    return f"Extracted: {title} ({category}, {urgency})"

extractor = model.bind_tools([extract_ticket_info], tool_choice="extract_ticket_info")
response = extractor.invoke(
    [HumanMessage(content="My app crashes when I upload large files, I already lost work")]
)
print(response.tool_calls[0]["args"])
# {'title': 'App crash when uploading large files', 'category': 'technical', 'urgency': 'high'}

Dynamic tool_choice: changing it per request

The key: tool_choice doesn't have to be static. You can change it per request:

def invoke_with_dynamic_choice(user_input: str, model, tools: list) -> dict:
    """Invoke the model with a dynamic tool_choice."""
    ambiguous_patterns = ["help", "problem", "i need", "i want"]

    if any(p in user_input.lower() for p in ambiguous_patterns):
        choice = "classify_intent"
    else:
        choice = "auto"

    configured = model.bind_tools(tools, tool_choice=choice)
    response = configured.invoke([HumanMessage(content=user_input)])
    return {"tool_choice": choice, "tool_calls": response.tool_calls, "content": response.content}

Every request can have a different tool_choice. This is routing at the tool_choice level.


Static tool routing

Tool routing goes one step further: instead of controlling how the model uses the tools, you control which tools it sees.

tool_choice:  "Use this specific tool"
tool routing: "You only have these 3 tools available (out of the 10 that exist)"

Router by keywords

TOOL_ROUTES = {
    "weather": [get_weather, get_forecast],
    "search": [web_search, news_search],
    "math": [calculator, unit_converter],
    "support": [create_ticket, search_tickets, update_ticket, get_user_info],
}

KEYWORD_ROUTES = {
    "weather": ["weather", "climate", "temperature", "forecast"],
    "search": ["search", "research", "news", "what is"],
    "math": ["calculate", "how much", "how many", "convert"],
    "support": ["ticket", "problem", "support", "error", "bug"],
}

def route_by_keywords(user_input: str) -> list:
    """Select tools based on keywords in the input."""
    input_lower = user_input.lower()
    for category, keywords in KEYWORD_ROUTES.items():
        if any(kw in input_lower for kw in keywords):
            return TOOL_ROUTES[category]
    return ALL_TOOLS

tools = route_by_keywords("How much is 15 * 23?")
print([t.name for t in tools])  # ['calculator', 'unit_converter']

The model now sees 2 tools instead of 10. Less confusion, fewer tokens, a better decision.

Router by user role

USER_ROLE_TOOLS = {
    "admin": ALL_TOOLS,
    "support_agent": [create_ticket, search_tickets, update_ticket, get_user_info],
    "end_user": [get_weather, get_forecast, web_search, calculator],
}

def route_by_user_role(user_role: str) -> list:
    return USER_ROLE_TOOLS.get(user_role, [web_search])

Router by flow stage

In a multi-step flow, the tools change with the stage:

FLOW_STAGE_TOOLS = {
    "intake": [classify_intent, extract_ticket_info],
    "research": [web_search, news_search, search_tickets],
    "resolution": [create_ticket, update_ticket],
}

def route_by_stage(stage: str) -> list:
    return FLOW_STAGE_TOOLS.get(stage, ALL_TOOLS)

Advantages and limitations

AdvantageLimitation
Simple to implementDoesn't handle ambiguous inputs
Deterministic and predictableKeywords can fail: "do I have tickets about the weather?"
No extra cost (no LLM needed)Manual maintenance of keywords

Dynamic tool routing

Dynamic routing uses an LLM to classify the intent before selecting the tools. A two-step pattern: classify → execute.

User Input → [LLM Classify] → intent → [Tool Map] → tools subset → [LLM Execute with subset]

Basic implementation

@tool(args_schema=ClassifyIntent)
def classify_user_intent(intent: str, confidence: float) -> str:
    """Classify the intent of the user's message for routing."""
    return f"{intent}: {confidence}"

def dynamic_route(user_input: str, model, tool_map: dict, all_tools: list) -> list:
    """Classify the intent with an LLM and return the appropriate tool subset."""
    classifier = model.bind_tools([classify_user_intent], tool_choice="classify_user_intent")
    response = classifier.invoke([HumanMessage(content=f"Classify the intent: {user_input}")])

    intent = response.tool_calls[0]["args"]["intent"]
    confidence = response.tool_calls[0]["args"]["confidence"]

    if confidence < 0.7:
        return all_tools

    return tool_map.get(intent, all_tools)

# "I need info about yesterday's problem" → intent: support → [create_ticket, search_tickets, ...]
# "What's the temperature in Madrid?"     → intent: weather → [get_weather, get_forecast]

The LLM classifier resolves the ambiguity that keywords couldn't.

Optimization: a cheap model to classify

You don't need GPT-4.1 to classify intents:

cheap_model = init_chat_model("openai:gpt-4.1-nano")
powerful_model = init_chat_model("openai:gpt-4.1-mini")

selected_tools = dynamic_route(user_input, cheap_model, tool_map, ALL_TOOLS)

executor = powerful_model.bind_tools(selected_tools)
final_response = executor.invoke([HumanMessage(content=user_input)])

A nano model to classify (~$0.0001 per request), a capable model to execute. At volume, significant savings.


Implementing a router

Let's put it all together into a reusable implementation:

from langchain_core.messages import ToolMessage

class ToolRouter:
    """Router that selects tools dynamically based on the intent."""

    def __init__(self, classifier_model, executor_model, tool_map, all_tools,
                 confidence_threshold=0.7):
        self.classifier = classifier_model.bind_tools(
            [classify_user_intent], tool_choice="classify_user_intent"
        )
        self.executor_model = executor_model
        self.tool_map = tool_map
        self.all_tools = all_tools
        self.tools_by_name = {t.name: t for t in all_tools}
        self.confidence_threshold = confidence_threshold

    def classify(self, user_input: str) -> dict:
        response = self.classifier.invoke([
            HumanMessage(content=f"Classify the intent: {user_input}")
        ])
        return response.tool_calls[0]["args"]

    def select_tools(self, classification: dict) -> list:
        intent = classification["intent"]
        confidence = classification["confidence"]
        if confidence < self.confidence_threshold:
            return self.all_tools
        return self.tool_map.get(intent, self.all_tools)

    def execute(self, user_input: str, tools: list) -> str:
        model_with_tools = self.executor_model.bind_tools(tools)
        messages = [HumanMessage(content=user_input)]
        response = model_with_tools.invoke(messages)
        messages.append(response)

        if not response.tool_calls:
            return response.content

        for tc in response.tool_calls:
            if tc["name"] in self.tools_by_name:
                result = self.tools_by_name[tc["name"]].invoke(tc["args"])
                messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
            else:
                messages.append(ToolMessage(
                    content=f"Error: tool '{tc['name']}' not available",
                    tool_call_id=tc["id"]
                ))

        return model_with_tools.invoke(messages).content

    def route_and_execute(self, user_input: str) -> dict:
        classification = self.classify(user_input)
        selected_tools = self.select_tools(classification)
        result = self.execute(user_input, selected_tools)

        return {
            "input": user_input,
            "intent": classification["intent"],
            "confidence": classification["confidence"],
            "tools_available": [t.name for t in selected_tools],
            "response": result
        }

Usage

router = ToolRouter(
    classifier_model=init_chat_model("openai:gpt-4.1-nano"),
    executor_model=init_chat_model("openai:gpt-4.1-mini"),
    tool_map=TOOL_ROUTES,
    all_tools=ALL_TOOLS
)

result = router.route_and_execute("How much is 15 * 23?")
# Intent: math (0.98), Tools: ['calculator', 'unit_converter'], Response: "345"

result = router.route_and_execute("Create a ticket: login isn't working")
# Intent: support (0.95), Tools: ['create_ticket', ...], Response: "I've created a ticket..."

Hybrid routing: keywords + LLM

The most robust pattern for production:

class HybridRouter(ToolRouter):
    def __init__(self, keyword_routes, **kwargs):
        super().__init__(**kwargs)
        self.keyword_routes = keyword_routes

    def route_and_execute(self, user_input: str) -> dict:
        input_lower = user_input.lower()
        for category, keywords in self.keyword_routes.items():
            if any(kw in input_lower for kw in keywords):
                tools = self.tool_map.get(category, self.all_tools)
                return {
                    "routing": "keyword", "intent": category,
                    "tools_available": [t.name for t in tools],
                    "response": self.execute(user_input, tools)
                }

        base_result = super().route_and_execute(user_input)
        base_result["routing"] = "llm"
        return base_result

Keywords first (free, fast). The LLM only when keywords aren't enough.


When to use it and when NOT to

TechniqueUse it when...Do NOT use it when...
tool_choice="auto"Conversational agent, few toolsYou need a deterministic result
tool_choice="any"Extraction pipelines, always structured outputThe input may not require tools
tool_choice="specific"Classification, first step of a pipeline, testingVaried input, the forced tool doesn't always apply
Static routingClear categories, predictable inputsAmbiguous inputs, many categories
Dynamic routing10+ tools, varied inputsFew tools, latency is the priority
Hybrid routingReal production, a mix of clear and ambiguous inputsQuick prototype

Rule of thumb

5 tools or fewer: tool_choice="auto" is enough. More than 5: implement routing. More than 15: dynamic routing is practically mandatory.


Connection with the project

In this module's project (capsule 08)

The project is an extraction + routing system with function calling:

  • Dynamic routing: classifies the document type (invoice, contract, email) and routes it to specialized extractors
  • Forced tool calls: each extractor uses tool_choice to force the correct tool
  • Conditional tools: depending on the document type, the model only sees the relevant tools

In the evolving project (Modules 4-10)

ModuleHow routing is used
4 (State Machines)Conditional edges select nodes with different tools
5 (Planning)The planner decides which tools each sub-task needs
7 (MCP)Dynamic tool loading — routing decides which servers to connect
8 (Multi-Agent)Each sub-agent has its own tool subset, the supervisor routes

Troubleshooting

Problem 1: The classifier returns wrong intents

Cause: Ambiguous category descriptions.

Solution: Add detailed per-category descriptions in the Field:

class ClassifyIntent(BaseModel):
    intent: str = Field(
        description=(
            "weather = weather, temperature, forecast. "
            "search = general search, news. "
            "math = calculations, numeric conversions. "
            "support = technical problems, tickets, account."
        ),
        enum=["weather", "search", "math", "support", "general"]
    )

Problem 2: Routing loses the right tool

Cause: "How many degrees is it in Madrid?" → math (because of "how many") instead of weather.

Solution: Allow multiple categories:

class IntentClassification(BaseModel):
    primary_intent: str = Field(enum=["weather", "search", "math", "support", "general"])
    secondary_intent: str | None = Field(default=None, enum=["weather", "search", "math", "support", "general", None])

def select_tools(classification):
    tools = set()
    for key in ["primary_intent", "secondary_intent"]:
        intent = classification.get(key)
        if intent and intent in TOOL_ROUTES:
            tools.update(TOOL_ROUTES[intent])
    return list(tools) if tools else ALL_TOOLS

Problem 3: Dynamic routing is too slow

Cause: +200-500ms from the classification call.

Solution: (1) A cheaper model to classify (gpt-4.1-nano), (2) Cache the classifications, (3) Hybrid routing: keywords first, LLM only when needed.

Problem 4: The executor model ignores the selected tools

Cause: With tool_choice="auto", the model answers directly if it thinks it "knows" the answer.

Solution: Combine routing with tool_choice="any" for categories that always require tools:

if classification["intent"] in ["weather", "math"]:
    executor = model.bind_tools(selected_tools, tool_choice="any")
else:
    executor = model.bind_tools(selected_tools, tool_choice="auto")

Problem 5: bind_tools with an empty list

Cause: tool_map.get(intent) returns None or [].

Solution: Always validate and use a fallback:

tools = tool_map.get(intent, [])
if not tools:
    tools = all_tools

Exercises

Exercise 1: Keyword router with a union of categories (Easy)

Implement keyword_router(user_input) that returns the names of the relevant tools. If the input matches multiple categories, return the union of the tools from all matched categories.

View solution
ROUTES = {
    "weather": {"keywords": ["weather", "temperature", "forecast"], "tools": ["get_weather", "get_forecast"]},
    "math": {"keywords": ["calculate", "how much", "sum", "convert"], "tools": ["calculator", "unit_converter"]},
    "support": {"keywords": ["ticket", "problem", "error", "help"], "tools": ["create_ticket", "search_tickets", "update_ticket"]},
}

ALL_NAMES = ["get_weather", "get_forecast", "web_search", "news_search",
             "calculator", "unit_converter", "create_ticket", "search_tickets",
             "update_ticket", "get_user_info"]

def keyword_router(user_input: str) -> list[str]:
    input_lower = user_input.lower()
    matched = set()
    for config in ROUTES.values():
        if any(kw in input_lower for kw in config["keywords"]):
            matched.update(config["tools"])
    return sorted(matched) if matched else ALL_NAMES

print(keyword_router("How much is 15 * 23?"))
# ['calculator', 'unit_converter']
print(keyword_router("I have a problem, calculate how much they owe me"))
# ['calculator', 'create_ticket', 'search_tickets', 'unit_converter', 'update_ticket']
print(keyword_router("Hello"))
# [...all of them...] ← fallback

Explanation: A set accumulates tools when there's a match in several categories. The fallback for inputs with no match is key — better to give all the tools than none.

Exercise 2: Intent classifier with Pydantic (Medium)

Create a classify_request tool with a Pydantic schema that classifies into intent (enum: "info", "action", "question") and urgency (enum: "low", "medium", "high"). Use tool_choice to force the classification. Invoke it with 3 different inputs.

View solution
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field

class RequestClassification(BaseModel):
    intent: str = Field(
        description="info = asks for data. action = wants something done. question = general question.",
        enum=["info", "action", "question"]
    )
    urgency: str = Field(
        description="low = casual. medium = clear need. high = active problem.",
        enum=["low", "medium", "high"]
    )

@tool(args_schema=RequestClassification)
def classify_request(intent: str, urgency: str) -> str:
    """Classify the type and urgency of the request."""
    return f"intent={intent}, urgency={urgency}"

model = init_chat_model("openai:gpt-4.1-mini")
classifier = model.bind_tools([classify_request], tool_choice="classify_request")

for inp in [
    "How many active users do we have?",
    "Create an urgent ticket: the database is down",
    "What is machine learning?"
]:
    response = classifier.invoke([HumanMessage(content=f"Classify: {inp}")])
    args = response.tool_calls[0]["args"]
    print(f"'{inp[:50]}' → intent={args['intent']}, urgency={args['urgency']}")

# 'How many active users do we have?' → intent=info, urgency=low
# 'Create an urgent ticket: the database is down' → intent=action, urgency=high
# 'What is machine learning?' → intent=question, urgency=low

Explanation: tool_choice="classify_request" guarantees the model always classifies. The detailed descriptions guide the model. In a real pipeline, you'd use this result to route to different tool subsets.

Exercise 3: Dynamic routing with a confidence fallback (Medium)

Implement dynamic_selector(user_input, model, tool_map, all_tools) that uses an LLM to classify, selects tools based on the intent, and returns all_tools as a fallback if confidence < 0.6.

View solution
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field

class IntentResult(BaseModel):
    intent: str = Field(enum=["weather", "search", "math", "support", "general"])
    confidence: float = Field(ge=0.0, le=1.0)

@tool(args_schema=IntentResult)
def detect_intent(intent: str, confidence: float) -> str:
    """Detect the intent of the message."""
    return f"{intent} ({confidence})"

def dynamic_selector(user_input, model, tool_map, all_tools, threshold=0.6):
    classifier = model.bind_tools([detect_intent], tool_choice="detect_intent")
    response = classifier.invoke([HumanMessage(content=f"Classify: {user_input}")])
    args = response.tool_calls[0]["args"]

    if args["confidence"] < threshold:
        return {"intent": args["intent"], "confidence": args["confidence"],
                "routing": "fallback", "tools": all_tools}

    tools = tool_map.get(args["intent"], all_tools)
    return {"intent": args["intent"], "confidence": args["confidence"],
            "routing": f"matched: {args['intent']}", "tools": tools}

model = init_chat_model("openai:gpt-4.1-mini")
for inp in ["Weather in Madrid?", "asdkjfhalskdf", "Calculate 10 * 5"]:
    r = dynamic_selector(inp, model, TOOL_ROUTES, ALL_TOOLS)
    print(f"'{inp}' → {r['intent']} ({r['confidence']:.2f}), routing={r['routing']}")
    print(f"  Tools: {[t.name for t in r['tools']]}")

Explanation: The low-confidence fallback is key: if the classifier isn't sure, better to give it more tools than the wrong ones. A threshold of 0.6 is a good starting point; tune it by measuring the classifier's precision.

Exercise 4: Full classify → route → execute pipeline (Hard)

Implement a pipeline that: (1) classifies the intent with a cheap model, (2) selects the tools, (3) executes with a capable model and processes the tool_calls. It must handle the case where the executor calls no tool at all.

View solution
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage
from pydantic import BaseModel, Field

class PipelineIntent(BaseModel):
    intent: str = Field(enum=["weather", "search", "math", "support", "general"])
    confidence: float = Field(ge=0.0, le=1.0)

@tool(args_schema=PipelineIntent)
def pipeline_classify(intent: str, confidence: float) -> str:
    """Classify the intent for the pipeline."""
    return f"{intent} ({confidence})"

def full_pipeline(user_input, classifier_model, executor_model, tool_map, all_tools):
    cls = classifier_model.bind_tools([pipeline_classify], tool_choice="pipeline_classify")
    cls_response = cls.invoke([HumanMessage(content=f"Classify: {user_input}")])
    intent = cls_response.tool_calls[0]["args"]["intent"]
    confidence = cls_response.tool_calls[0]["args"]["confidence"]

    selected = tool_map.get(intent, all_tools) if confidence >= 0.6 else all_tools
    tools_by_name = {t.name: t for t in selected}

    executor = executor_model.bind_tools(selected)
    messages = [HumanMessage(content=user_input)]
    response = executor.invoke(messages)
    messages.append(response)

    if not response.tool_calls:
        return {"intent": intent, "confidence": confidence,
                "tools_used": [], "response": response.content}

    tools_used = []
    for tc in response.tool_calls:
        tools_used.append(tc["name"])
        if tc["name"] in tools_by_name:
            result = tools_by_name[tc["name"]].invoke(tc["args"])
            messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
        else:
            messages.append(ToolMessage(content=f"Error: '{tc['name']}' not available",
                                        tool_call_id=tc["id"]))

    final = executor.invoke(messages)
    return {"intent": intent, "confidence": confidence,
            "tools_used": tools_used, "response": final.content}

cheap = init_chat_model("openai:gpt-4.1-nano")
powerful = init_chat_model("openai:gpt-4.1-mini")

for inp in ["Weather in Barcelona?", "How much is 256 * 3.14?", "What is a REST API?"]:
    r = full_pipeline(inp, cheap, powerful, TOOL_ROUTES, ALL_TOOLS)
    print(f"'{inp}' → {r['intent']} ({r['confidence']:.2f})")
    print(f"  Tools: {r['tools_used']}, Response: {r['response'][:80]}...")

Explanation: The cheap model classifies (~$0.0001), routing selects 2-3 tools, the capable model executes with less noise. The "general" case with 0 tools shows the routing works: if no tools are needed, the model answers directly.

Exercise 5: Hybrid router with metrics (Hard)

Build a MetricsRouter class that: (1) tries keywords first, (2) uses the LLM if the keywords don't match, (3) records metrics: how many times keyword vs LLM, average time for each.

View solution
import time
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field

class RoutingIntent(BaseModel):
    intent: str = Field(enum=["weather", "search", "math", "support", "general"])
    confidence: float = Field(ge=0.0, le=1.0)

@tool(args_schema=RoutingIntent)
def route_classify(intent: str, confidence: float) -> str:
    """Classify the intent for routing."""
    return f"{intent} ({confidence})"

class MetricsRouter:
    def __init__(self, model, tool_map, all_tools, keyword_routes):
        self.tool_map = tool_map
        self.all_tools = all_tools
        self.keyword_routes = keyword_routes
        self.classifier = model.bind_tools([route_classify], tool_choice="route_classify")
        self.metrics = {"kw_count": 0, "llm_count": 0, "kw_ms": 0.0, "llm_ms": 0.0}

    def route(self, user_input: str) -> dict:
        start = time.time()
        input_lower = user_input.lower()
        for cat, keywords in self.keyword_routes.items():
            if any(kw in input_lower for kw in keywords):
                ms = (time.time() - start) * 1000
                self.metrics["kw_count"] += 1
                self.metrics["kw_ms"] += ms
                return {"method": "keyword", "intent": cat,
                        "tools": self.tool_map.get(cat, self.all_tools), "time_ms": round(ms, 2)}

        response = self.classifier.invoke([HumanMessage(content=f"Classify: {user_input}")])
        args = response.tool_calls[0]["args"]
        ms = (time.time() - start) * 1000
        self.metrics["llm_count"] += 1
        self.metrics["llm_ms"] += ms

        tools = self.tool_map.get(args["intent"], self.all_tools)
        return {"method": "llm", "intent": args["intent"],
                "confidence": args["confidence"], "tools": tools, "time_ms": round(ms, 2)}

    def get_metrics(self):
        kw, llm = self.metrics["kw_count"], self.metrics["llm_count"]
        total = kw + llm
        return {
            "total": total,
            "keyword": {"count": kw, "pct": round(kw/total*100, 1) if total else 0,
                        "avg_ms": round(self.metrics["kw_ms"]/kw, 2) if kw else 0},
            "llm": {"count": llm, "pct": round(llm/total*100, 1) if total else 0,
                    "avg_ms": round(self.metrics["llm_ms"]/llm, 2) if llm else 0},
        }

router = MetricsRouter(
    model=init_chat_model("openai:gpt-4.1-nano"),
    tool_map=TOOL_ROUTES, all_tools=ALL_TOOLS,
    keyword_routes=KEYWORD_ROUTES
)

for inp in ["Weather in Madrid?", "Calculate 15*23", "Research about AI",
            "Bug in the login", "How does Python work?"]:
    r = router.route(inp)
    print(f"[{r['method']:7s}] '{inp[:35]}' → {r['intent']} ({r['time_ms']}ms)")

m = router.get_metrics()
print(f"\nKeyword: {m['keyword']['count']} ({m['keyword']['pct']}%, avg {m['keyword']['avg_ms']}ms)")
print(f"LLM:     {m['llm']['count']} ({m['llm']['pct']}%, avg {m['llm']['avg_ms']}ms)")
# Keyword: 3 (60.0%, avg 0.02ms)
# LLM:     2 (40.0%, avg ~350ms)

Explanation: The metrics reveal that keyword routing is ~17,000x faster (0.02ms vs 350ms). With 60% of requests resolved by keywords, the savings in latency and cost are significant. If keyword_routing is low, you need more keywords. If it's at 95%, maybe LLM routing isn't worth it.


Summary

In this capsule you learned:

  • The problem with many tools: with 10+ tools, the model chooses worse, burns more tokens and adds latency. Routing is the solution.
  • Dynamic tool_choice: change it per request. Force specific tools for classification, extraction, and the first steps of pipelines.
  • Static routing: fixed rules (keywords, roles, stages) that select tool subsets at no extra cost.
  • Dynamic routing: an LLM classifier detects the intent and selects the tools. More precise, but it adds one extra call.
  • Hybrid routing: keywords first (free), LLM as a fallback (precise). The most robust pattern for production.
  • A complete ToolRouter: classify → select_tools → execute, with a low-confidence fallback.

Next capsule: Structured Extraction via Function Calling — you'll use function calling not to execute actions, but to extract structured data from text. This pattern combines with the routing from this capsule to create the extraction + routing system of the final project.


Additional resources

  1. LangChain Tool Calling — tool_choice — Official guide on tool_choice with bind_tools
  2. OpenAI Function Calling — tool_choice — Configuring tool_choice in the OpenAI API
  3. Anthropic Tool Use — Forcing Tool Use — How to force tool use in Claude
  4. LangChain How to Route — Routing patterns in LangChain
  5. LangGraph Routing Tutorial — Advanced routing with conditional edges