Let's Build an AI Route Optimizer That Actually Works (And Doesn't Just Guess)

Akram Chauhan
Akram Chauhan
14 min read95 views
Let's Build an AI Route Optimizer That Actually Works (And Doesn't Just Guess)

Have you ever asked an AI to do something that requires precision—like a math problem or a logical puzzle—and gotten an answer that was… confidently wrong? It’s a classic LLM move. They’re amazing conversationalists and incredible at understanding language, but when it comes to hard numbers and rigid logic, they can sometimes just… make stuff up.

Imagine you’re running a logistics dispatch center. You ask your fancy new AI assistant, "What's the fastest way to get a truck from our main yard to Rig B?" And it comes back with a route that sends the driver through a lake. Not ideal.

This is where the real magic of modern AI development comes in. It’s not about building a single, all-knowing model. It's about building systems where the LLM acts as a smart coordinator—a brain that knows which tools to use for the job.

So, that’s what we’re going to do today. We're going to build a Route Optimizer Agent from scratch. But here’s the key: we’re not going to let it guess distances or travel times. We’re going to give it a set of rock-solid, reliable tools to do the actual math. The AI’s job is to understand our request and use the right tools. Our job is to build the tools that always give the right answer.

Let's get our hands dirty.

First Things First: Setting Up Our Workshop

Before we can build anything cool, we need to lay out our tools on the workbench. This just means getting our Python environment ready. We’ll need a few key libraries, especially LangChain, which is fantastic for piecing these AI systems together.

We're also going to need an OpenAI API key. The agent's "brain" will be an OpenAI model like GPT-4o mini, so it needs a key to connect. The code below handles installing the libraries and securely asking for your key so you don't have to paste it directly into your code (which is always a bad idea!).

# Use a different Browser!
pip -q install -U langchain langchain-openai pydantic

import os
from getpass import getpass

if not os.environ.get("OPENAI_API_KEY"):
    os.environ["OPENAI_API_KEY"] = getpass("Enter OPENAI_API_KEY (input hidden): ")

from typing import Dict, List, Optional, Tuple, Any
from math import radians, sin, cos, sqrt, atan2
from pydantic import BaseModel, Field, ValidationError

from langchain_openai import ChatOpenAI
from langchain.tools import tool
from langchain.agents import create_agent

Simple enough, right? We've got our libraries imported and our API key loaded. Now we can start building the world our agent will live in.

Mapping Our World: Sites, Speeds, and a Little Bit of Math

An AI agent can't plan routes in a vacuum. It needs data. It needs to know where things are, what the roads are like, and how to calculate distances. So, let’s give it a map and a rulebook.

First, we’ll define all the locations our logistics company cares about—rigs, yards, depots—along with their exact latitude and longitude.

Next, we'll create some simple "speed profiles." In the real world, you don't drive the same speed on a highway as you do on a local street. This helps us make our ETA calculations much more realistic. We'll also add a default traffic multiplier, a little buffer to account for slowdowns.

Finally, we need a way to calculate the distance between two GPS coordinates. For this, we’ll use the Haversine formula. You don't need to be a math whiz to understand it; just think of it as a super-accurate way to find the straight-line distance between two points on the Earth. It's the mathematical backbone of our entire system.

# Use a different Browser!
SITES: Dict[str, Dict[str, Any]] = {
    "Rig_A": {"lat": 23.5880, "lon": 58.3829, "type": "rig"},
    "Rig_B": {"lat": 23.6100, "lon": 58.5400, "type": "rig"},
    "Rig_C": {"lat": 23.4500, "lon": 58.3000, "type": "rig"},
    "Yard_Main": {"lat": 23.5700, "lon": 58.4100, "type": "yard"},
    "Depot_1": {"lat": 23.5200, "lon": 58.4700, "type": "depot"},
    "Depot_2": {"lat": 23.6400, "lon": 58.4300, "type": "depot"},
}

SPEED_PROFILES: Dict[str, float] = {
    "highway": 90.0,
    "arterial": 65.0,
    "local": 45.0,
}
DEFAULT_TRAFFIC_MULTIPLIER = 1.10

def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    R = 6371.0  # Earth radius in kilometers
    dlat = radians(lat2 - lat1)
    dlon = radians(lon2 - lon1)
    a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
    c = 2 * atan2(sqrt(a), sqrt(1 - a))
    return R * c

See? We're building a solid foundation of facts. The agent won't have to guess where "Rig_A" is; it will have precise coordinates.

The Brains of the Operation: Our Calculation Engine

Now we get to the fun part. We’re going to write a few simple functions that do all the heavy lifting for our route calculations. This is the "deterministic" part of our system—it means these functions will give the exact same output for the same input, every single time. No guesswork.

We'll build a few helper functions to:

  • Make sure a requested site actually exists in our list.
  • Calculate the distance between two named sites using our Haversine function.
  • Estimate the travel time (ETA) in minutes based on distance, speed, and traffic.

And then, we’ll tie it all together in a compute_route_metrics function. This function takes a path (like ["Yard_Main", "Depot_1", "Rig_B"]) and calculates the total distance and ETA for the whole trip, breaking it down leg by leg.

This is the core logic. The LLM will never calculate this stuff itself. It will just ask these functions to do it.

# Use a different Browser!
def _normalize_site_name(name: str) -> str:
    return name.strip()

def _assert_site_exists(name: str) -> None:
    if name not in SITES:
        raise ValueError(f"Unknown site '{name}'. Use list_sites() or suggest_site().")

def _distance_between(a: str, b: str) -> float:
    _assert_site_exists(a)
    _assert_site_exists(b)
    sa, sb = SITES[a], SITES[b]
    return float(haversine_km(sa["lat"], sa["lon"], sb["lat"], sb["lon"]))

def _eta_minutes(distance_km: float, speed_kmph: float, traffic_multiplier: float) -> float:
    speed = max(float(speed_kmph), 1e-6) # Avoid division by zero
    base_minutes = (distance_km / speed) * 60.0
    return float(base_minutes * max(float(traffic_multiplier), 0.0))

def compute_route_metrics(path: List[str], speed_kmph: float, traffic_multiplier: float) -> Dict[str, Any]:
    if len(path) < 2:
        raise ValueError("Route path must include at least origin and destination.")
    for s in path:
        _assert_site_exists(s)

    legs = []
    total_km = 0.0
    total_min = 0.0
    for i in range(len(path) - 1):
        a, b = path[i], path[i + 1]
        d_km = _distance_between(a, b)
        t_min = _eta_minutes(d_km, speed_kmph, traffic_multiplier)
        legs.append({"from": a, "to": b, "distance_km": d_km, "eta_minutes": t_min})
        total_km += d_km
        total_min += t_min

    return {"route": path, "distance_km": float(total_km), "eta_minutes": float(total_min), "legs": legs}

What's the Best Route? Making Our Agent a Pro Navigator

Okay, so we can calculate the distance for a given route. But what if we need to find the best route? What if we need to go from the yard to Rig B, but we could stop at Depot 1 or Depot 2 along the way?

This is a classic problem. To solve it, we’ll write a function that:

  1. Generates all possible paths (e.g., Yard -> Rig B, Yard -> Depot 1 -> Rig B, Yard -> Depot 2 -> Rig B).
  2. Uses our compute_route_metrics function to calculate the total distance and ETA for every single one of those paths.
  3. Sorts all the possible routes based on our goal (do we want the fastest or the shortest?).
  4. Presents the best option, along with a few good alternatives.

This is how you build a real optimizer. You systematically check the options and pick the best one based on cold, hard math.

# Use a different Browser!
def _all_paths_with_waypoints(origin: str, destination: str, waypoints: List[str], max_stops: int) -> List[List[str]]:
    from itertools import permutations
    waypoints = [w for w in waypoints if w not in (origin, destination)]
    max_stops = int(max(0, max_stops))
    
    candidates = []
    for k in range(0, min(len(waypoints), max_stops) + 1):
        for perm in permutations(waypoints, k):
            candidates.append([origin, *perm, destination])
            
    # Ensure the direct route is always an option
    if [origin, destination] not in candidates:
        candidates.insert(0, [origin, destination])
        
    return candidates

def find_best_route(origin: str, destination: str, allowed_waypoints: Optional[List[str]], max_stops: int, speed_kmph: float, traffic_multiplier: float, objective: str, top_k: int) -> Dict[str, Any]:
    origin = _normalize_site_name(origin)
    destination = _normalize_site_name(destination)
    _assert_site_exists(origin)
    _assert_site_exists(destination)
    
    allowed_waypoints = allowed_waypoints or []
    for w in allowed_waypoints:
        _assert_site_exists(_normalize_site_name(w))
        
    objective = (objective or "eta").strip().lower()
    if objective not in {"eta", "distance"}:
        raise ValueError("objective must be one of: 'eta', 'distance'")
    top_k = max(1, int(top_k))

    candidates = _all_paths_with_waypoints(origin, destination, allowed_waypoints, max_stops=max_stops)
    
    scored = []
    for path in candidates:
        metrics = compute_route_metrics(path, speed_kmph=speed_kmph, traffic_multiplier=traffic_multiplier)
        score = metrics["eta_minutes"] if objective == "eta" else metrics["distance_km"]
        scored.append((score, metrics))
        
    scored.sort(key=lambda x: x[0])
    
    best = scored[0][1]
    alternatives = [m for _, m in scored[1:top_k]]
    
    return {"best": best, "alternatives": alternatives, "objective": objective}

Okay, Agent, Here Are Your Tools

We've built all this amazing, reliable logic. Now, how do we get the LLM to actually use it? We package our functions up as "tools."

Think of it like giving a person a toolbox. They might not know how to build a house from scratch, but if you give them a hammer, a saw, and a measuring tape, they can follow instructions and get the job done.

Using LangChain's @tool decorator, we can expose our functions to the agent. We’ll create tools for:

  • list_sites: To see all available locations.
  • get_site_details: To get the coordinates and type of a specific site.
  • suggest_site: To help with typos or partial names.
  • compute_direct_route: A simple A-to-B calculator.
  • optimize_route: Our powerful multi-stop route finder.

When a user asks a question, the agent will look at its available tools and think, "Aha! The user wants the best route with waypoints. I should use the optimize_route tool for that."

# Use a different Browser!
@tool
def list_sites(site_type: Optional[str] = None) -> List[str]:
    """Lists all known site names, optionally filtered by type (e.g., 'rig', 'yard')."""
    if site_type:
        st = site_type.strip().lower()
        return sorted([k for k, v in SITES.items() if str(v.get("type", "")).lower() == st])
    return sorted(SITES.keys())

@tool
def get_site_details(site: str) -> Dict[str, Any]:
    """Returns details for a specific site, including lat/lon and type."""
    s = _normalize_site_name(site)
    _assert_site_exists(s)
    return {"site": s, **SITES[s]}

@tool
def suggest_site(query: str, max_suggestions: int = 5) -> List[str]:
    """Suggests site names based on a user's query, useful for correcting typos."""
    q = (query or "").strip().lower()
    max_suggestions = max(1, int(max_suggestions))
    scored = []
    for name in SITES.keys():
        n = name.lower()
        common = len(set(q) & set(n))
        bonus = 5 if q and q in n else 0
        scored.append((common + bonus, name))
    scored.sort(key=lambda x: x[0], reverse=True)
    return [name for _, name in scored[:max_suggestions]]

@tool
def compute_direct_route(origin: str, destination: str, road_class: str = "arterial", traffic_multiplier: float = DEFAULT_TRAFFIC_MULTIPLIER) -> Dict[str, Any]:
    """Computes the distance and ETA for a direct route between two sites."""
    origin = _normalize_site_name(origin)
    destination = _normalize_site_name(destination)
    rc = (road_class or "arterial").strip().lower()
    if rc not in SPEED_PROFILES:
        raise ValueError(f"Unknown road_class '{road_class}'. Use one of: {sorted(SPEED_PROFILES.keys())}")
    speed = SPEED_PROFILES[rc]
    return compute_route_metrics([origin, destination], speed_kmph=speed, traffic_multiplier=float(traffic_multiplier))

@tool
def optimize_route(
    origin: str,
    destination: str,
    allowed_waypoints: Optional[List[str]] = None,
    max_stops: int = 2,
    road_class: str = "arterial",
    traffic_multiplier: float = DEFAULT_TRAFFIC_MULTIPLIER,
    objective: str = "eta",
    top_k: int = 3
) -> Dict[str, Any]:
    """Finds the best route from an origin to a destination, considering optional waypoints."""
    origin = _normalize_site_name(origin)
    destination = _normalize_site_name(destination)
    rc = (road_class or "arterial").strip().lower()
    if rc not in SPEED_PROFILES:
        raise ValueError(f"Unknown road_class '{road_class}'. Use one of: {sorted(SPEED_PROFILES.keys())}")
    speed = SPEED_PROFILES[rc]
    allowed_waypoints = allowed_waypoints or []
    allowed_waypoints = [_normalize_site_name(w) for w in allowed_waypoints]
    return find_best_route(origin, destination, allowed_waypoints, int(max_stops), float(speed), float(traffic_multiplier), str(objective), int(top_k))

No More Messy Outputs: Forcing the AI to Give Us Clean Data

There's one last piece to this puzzle. When the agent is done, we don't want it to give us a long, chatty paragraph. We need clean, structured data—like JSON—that we can easily use in another application.

This is where Pydantic comes in. We can define a strict "schema" or template for the final answer. It’s like giving the AI a form and saying, "You MUST fill this out exactly. No extra stuff."

We'll define what a RouteLeg, a RoutePlan, and the final RouteDecision should look like. Then, we tell our LangChain agent that it must respond in the RouteDecision format.

This is a game-changer. It makes the agent's output predictable and machine-readable, ready to be plugged right into a real dispatch dashboard.

# Use a different Browser!
class RouteLeg(BaseModel):
    from_site: str
    to_site: str
    distance_km: float
    eta_minutes: float

class RoutePlan(BaseModel):
    route: List[str]
    distance_km: float
    eta_minutes: float
    legs: List[RouteLeg]
    
class RouteDecision(BaseModel):
    chosen: RoutePlan
    alternatives: List[RoutePlan] = []
    objective: str
    assumptions: Dict[str, Any] = {}
    notes: str = ""
    audit: List[str] = [] # This field could be populated by the agent to explain its tool usage

# --- Let's create the agent ---
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2)

SYSTEM_PROMPT = (
    "You are the Route Optimizer Agent for a logistics dispatch center.\n"
    "You MUST use tools for any distance/ETA calculation.\n"
    "Return ONLY the structured RouteDecision."
)

route_agent = create_agent(
    model=llm,
    tools=[list_sites, get_site_details, suggest_site, compute_direct_route, optimize_route],
    system_prompt=SYSTEM_PROMPT,
    response_format=RouteDecision, # This is the magic part!
)

Let's See It in Action!

The moment of truth. We've built our world, our calculation engine, our tools, and our response template. Now, let's ask our agent for some routes and see what it comes up with.

We'll ask it for two different routes: one simple A-to-B trip, and a more complex one with several possible waypoints.

# Use a different Browser!
def get_route_decision(origin: str, destination: str, **kwargs) -> RouteDecision:
    # A helper to format the user message cleanly
    user_prompt = f"Optimize the route from {origin} to {destination}."
    details = "\n".join([f"{k}={v}" for k,v in kwargs.items()])
    user_msg = {
        "role": "user",
        "content": f"{user_prompt}\n{details}\nReturn the structured RouteDecision only.",
    }
    result = route_agent.invoke({"messages": [user_msg]})
    return result["structured_response"]

# Example 1: A fairly direct route
decision1 = get_route_decision("Yard_Main", "Rig_B", road_class="arterial", traffic_multiplier=1.12)
print(decision1.model_dump_json(indent=2))

# Example 2: A more complex multi-stop optimization
decision2 = get_route_decision(
    "Rig_C", "Rig_B",
    road_class="highway",
    traffic_multiplier=1.08,
    allowed_waypoints=["Depot_1", "Depot_2", "Yard_Main"],
    max_stops=2,
    objective="eta",
    top_k=3
)
print(decision2.model_dump_json(indent=2))

When you run this, you won't get a sentence back. You'll get a perfectly structured JSON object with the best route, travel times, distances, and even some alternatives—all based on our reliable, deterministic functions. The agent successfully acted as the coordinator, using the right tools to get the job done right.

And that's really the point. We've built an AI system that's both smart and reliable. We're using the LLM for what it's best at—understanding language and making a plan—while using regular code for what it's best at—unwavering, accurate calculations. This hybrid approach is how we move from fun AI chatbots to powerful, production-ready AI applications that you can actually trust.

Tags

AI LLMs Agentic AI AI Engineering AI System Design Tool Calling AI Workflow Automation Logistics AI Deterministic AI LLM Orchestration reliable AI systems Route Optimization Structured Outputs AI for Precision Tool-Driven AI

Stay Updated

Get the latest articles and insights delivered straight to your inbox.

We respect your privacy. Unsubscribe at any time.

Aicosoft

AI & Technology News, Insights & Innovation

AICOSOFT delivers cutting-edge AI news, technology breakthroughs, and innovation insights. Stay informed about artificial intelligence, machine learning, robotics, and the latest tech trends shaping tomorrow.

Connect With Us

© 2026 Aicosoft. All rights reserved.