← Back to DevBytes

Building a SQL Query Agent with vLLM: Complete Guide

Building a SQL Query Agent with vLLM: Complete Guide

Text-to-SQL agents are one of the most practical applications of large language models in enterprise environments. Instead of forcing business users to learn SQL, these agents translate natural language questions into executable queries, run them against a database, and return human-readable answers. When you pair this capability with vLLM — a high-throughput, memory-efficient inference engine — you get a system that is fast, private, and cost-effective enough to run in production.

This tutorial walks you through building a complete SQL Query Agent from scratch using vLLM as the inference backend. We will cover the architecture, database schema extraction, prompt engineering, query execution with safety guards, and deployment best practices.

What Is a SQL Query Agent?

A SQL Query Agent is an LLM-powered system that accepts natural language input, generates a SQL query, executes it against a target database, and returns the result in a conversational format. Unlike a simple text-to-SQL converter, an agent can iterate: it inspects errors, refines queries, and explains results.

The core loop typically looks like this:

Why vLLM?

vLLM is an open-source inference engine optimized for throughput and memory efficiency. It uses PagedAttention to manage the KV cache, allowing it to serve models much faster than naive HuggingFace Transformers. For a SQL agent, this matters for several reasons:

Architecture Overview

Our agent will consist of four components:

Prerequisites and Installation

You will need a machine with a CUDA-capable GPU (an A10G or L4 is sufficient for 7B–14B models). Install the required Python packages:

pip install vllm sqlalchemy psycopg2-binary openai python-dotenv tabulate

For this tutorial we will use SQLite for simplicity, but the same code works with PostgreSQL, MySQL, or Snowflake by changing the connection string.

Starting the vLLM Server

Launch vLLM as a standalone server. We will use Qwen2.5-Coder-7B-Instruct, a model that performs exceptionally well on SQL generation tasks:

vllm serve Qwen/Qwen2.5-Coder-7B-Instruct \
  --port 8000 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.9 \
  --dtype auto

Once running, the server exposes an OpenAI-compatible endpoint at http://localhost:8000/v1. You can test it with a simple curl request:

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen/Qwen2.5-Coder-7B-Instruct",
    "messages": [{"role": "user", "content": "Say hello"}]
  }'

Setting Up the Sample Database

Let us create a small e-commerce database to demonstrate the agent. Save this as setup_db.py and run it once:

import sqlite3

def create_database():
    conn = sqlite3.connect("ecommerce.db")
    cur = conn.cursor()

    cur.executescript("""
    CREATE TABLE IF NOT EXISTS customers (
        customer_id INTEGER PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT UNIQUE,
        country TEXT,
        signup_date TEXT
    );

    CREATE TABLE IF NOT EXISTS products (
        product_id INTEGER PRIMARY KEY,
        name TEXT NOT NULL,
        category TEXT,
        price REAL,
        stock INTEGER
    );

    CREATE TABLE IF NOT EXISTS orders (
        order_id INTEGER PRIMARY KEY,
        customer_id INTEGER,
        order_date TEXT,
        total_amount REAL,
        status TEXT,
        FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
    );

    CREATE TABLE IF NOT EXISTS order_items (
        item_id INTEGER PRIMARY KEY,
        order_id INTEGER,
        product_id INTEGER,
        quantity INTEGER,
        unit_price REAL,
        FOREIGN KEY (order_id) REFERENCES orders(order_id),
        FOREIGN KEY (product_id) REFERENCES products(product_id)
    );

    INSERT INTO customers (name, email, country, signup_date) VALUES
        ('Alice Johnson', 'alice@example.com', 'USA', '2024-01-15'),
        ('Bob Smith', 'bob@example.com', 'UK', '2024-02-20'),
        ('Carol Lee', 'carol@example.com', 'USA', '2024-03-10'),
        ('David Kim', 'david@example.com', 'Korea', '2024-04-05');

    INSERT INTO products (name, category, price, stock) VALUES
        ('Laptop', 'Electronics', 1200.00, 50),
        ('Mouse', 'Electronics', 25.00, 200),
        ('Keyboard', 'Electronics', 75.00, 150),
        ('Notebook', 'Stationery', 5.00, 500),
        ('Pen', 'Stationery', 2.00, 1000);

    INSERT INTO orders (customer_id, order_date, total_amount, status) VALUES
        (1, '2024-05-01', 1225.00, 'completed'),
        (2, '2024-05-03', 100.00, 'completed'),
        (1, '2024-05-10', 80.00, 'pending'),
        (3, '2024-05-15', 1275.00, 'completed'),
        (4, '2024-05-20', 7.00, 'cancelled');

    INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES
        (1, 1, 1, 1200.00),
        (1, 2, 1, 25.00),
        (2, 3, 1, 75.00),
        (2, 2, 1, 25.00),
        (3, 3, 1, 75.00),
        (4, 1, 1, 1200.00),
        (4, 2, 3, 25.00),
        (5, 4, 1, 5.00),
        (5, 5, 1, 2.00);
    """)

    conn.commit()
    conn.close()
    print("Database created successfully.")

if __name__ == "__main__":
    create_database()

Building the Schema Extractor

The agent needs to understand the database structure. Rather than dumping the entire schema, we extract a compact, LLM-friendly representation including table names, columns, types, and foreign key relationships.

import sqlite3
from typing import Dict, List

class SchemaExtractor:
    def __init__(self, db_path: str):
        self.db_path = db_path

    def get_schema(self) -> str:
        conn = sqlite3.connect(self.db_path)
        cur = conn.cursor()

        cur.execute("""
            SELECT name FROM sqlite_master
            WHERE type='table' AND name NOT LIKE 'sqlite_%'
        """)
        tables = [row[0] for row in cur.fetchall()]

        schema_parts: List[str] = []
        for table in tables:
            cur.execute(f"PRAGMA table_info({table})")
            columns = cur.fetchall()

            col_lines = []
            for col in columns:
                col_id, col_name, col_type, not_null, default, pk = col
                constraints = []
                if pk:
                    constraints.append("PRIMARY KEY")
                if not_null:
                    constraints.append("NOT NULL")
                constraint_str = f" {', '.join(constraints)}" if constraints else ""
                col_lines.append(f"  {col_name} {col_type}{constraint_str}")

            cur.execute(f"PRAGMA foreign_key_list({table})")
            fks = cur.fetchall()
            for fk in fks:
                ref_table = fk[2]
                from_col = fk[3]
                to_col = fk[4]
                col_lines.append(f"  FOREIGN KEY ({from_col}) REFERENCES {ref_table}({to_col})")

            schema_parts.append(f"CREATE TABLE {table} (\n" + ",\n".join(col_lines) + "\n);")

        conn.close()
        return "\n\n".join(schema_parts)

    def get_sample_rows(self, table: str, limit: int = 3) -> str:
        conn = sqlite3.connect(self.db_path)
        cur = conn.cursor()
        cur.execute(f"SELECT * FROM {table} LIMIT {limit}")
        rows = cur.fetchall()
        col_names = [desc[0] for desc in cur.description]
        conn.close()

        lines = [", ".join(col_names)]
        for row in rows:
            lines.append(", ".join(str(v) for v in row))
        return "\n".join(lines)

Providing a few sample rows alongside the schema dramatically improves the LLM's ability to generate correct queries, because it sees real data patterns and value formats.

Building the Query Executor

Safety is critical. The executor must enforce read-only access and prevent destructive operations. Even in development, you should never let an LLM run arbitrary SQL without guards.

import sqlite3
import re
from typing import Tuple, Optional

class QueryExecutor:
    FORBIDDEN_KEYWORDS = [
        "DROP", "DELETE", "INSERT", "UPDATE", "ALTER",
        "CREATE", "TRUNCATE", "ATTACH", "DETACH", "PRAGMA"
    ]

    def __init__(self, db_path: str):
        self.db_path = db_path

    def validate_query(self, query: str) -> Tuple[bool, Optional[str]]:
        cleaned = re.sub(r"--.*$", "", query, flags=re.MULTILINE)
        cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL)
        upper = cleaned.upper().strip()

        if not upper.startswith("SELECT") and not upper.startswith("WITH"):
            return False, "Only SELECT or WITH queries are allowed."

        for keyword in self.FORBIDDEN_KEYWORDS:
            pattern = r'\b' + keyword + r'\b'
            if re.search(pattern, upper):
                return False, f"Forbidden keyword detected: {keyword}"

        if ";" in cleaned.rstrip().rstrip(";"):
            return False, "Multiple statements are not allowed."

        return True, None

    def execute(self, query: str, limit: int = 100) -> Tuple[bool, any, Optional[str]]:
        is_valid, error = self.validate_query(query)
        if not is_valid:
            return False, None, error

        try:
            conn = sqlite3.connect(self.db_path)
            conn.row_factory = sqlite3.Row
            cur = conn.cursor()

            if not query.upper().strip().startswith("WITH"):
                if "LIMIT" not in query.upper():
                    query = query.rstrip(";") + f" LIMIT {limit};"

            cur.execute(query)
            rows = cur.fetchall()
            columns = [desc[0] for desc in cur.description] if cur.description else []
            result = [dict(row) for row in rows]

            conn.close()
            return True, {"columns": columns, "rows": result}, None

        except Exception as e:
            return False, None, str(e)

Building the Agent Orchestrator

The orchestrator ties everything together. It constructs the prompt, calls vLLM, executes the query, and handles error correction. This is the heart of the system.

from openai import OpenAI
from tabulate import tabulate
import json

class SQLAgent:
    SYSTEM_PROMPT = """You are an expert SQL assistant. Given a database schema and a natural language question, you must:

1. Write a single, correct SQL query that answers the question.
2. Use only standard SQLite-compatible SQL.
3. Return ONLY the SQL query inside a sql code block. Do not include explanations before or after the code block.
4. If the question is ambiguous, make reasonable assumptions and proceed.
5. Always use table aliases for readability.
6. Never use SELECT * — always specify columns explicitly.

Database Schema:
{schema}

Sample data from key tables:
{samples}
"""

    CORRECTION_PROMPT = """The previous query failed with this error:

{error}

Original query:
{query}

Please fix the query and return only the corrected SQL inside a sql code block."""

    def __init__(self, db_path: str, vllm_url: str = "http://localhost:8000/v1", model: str = None):
        self.client = OpenAI(base_url=vllm_url, api_key="dummy")
        self.model = model or "Qwen/Qwen2.5-Coder-7B-Instruct"
        self.schema_extractor = SchemaExtractor(db_path)
        self.executor = QueryExecutor(db_path)
        self.max_retries = 3

    def _build_system_prompt(self) -> str:
        schema = self.schema_extractor.get_schema()
        samples = ""
        for table in ["customers", "products", "orders"]:
            samples += f"\n-- {table}:\n"
            samples += self.schema_extractor.get_sample_rows(table)
            samples += "\n"
        return self.SYSTEM_PROMPT.format(schema=schema, samples=samples)

    def _extract_sql(self, response: str) -> str:
        if "sql" in response:
            start = response.index("sql") + 6
            end = response.index("", start)
            return response[start:end].strip()
        elif "" in response:
            start = response.index("") + 3
            end = response.index("", start)
            return response[start:end].strip()
        return response.strip()

    def _generate_query(self, question: str, correction_context: str = None) -> str:
        messages = [
            {"role": "system", "content": self._build_system_prompt()},
            {"role": "user", "content": question}
        ]

        if correction_context:
            messages.append({"role": "assistant", "content": "sql\n" + correction_context["query"] + "\n"})
            messages.append({"role": "user", "content": self.CORRECTION_PROMPT.format(
                error=correction_context["error"],
                query=correction_context["query"]
            )})

        response = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            temperature=0.1,
            max_tokens=1024
        )

        return self._extract_sql(response.choices[0].message.content)

    def _format_results(self, result: dict, question: str) -> str:
        if not result["rows"]:
            return "The query returned no results."

        table_data = result["rows"]
        formatted_table = tabulate(
            table_data,
            headers="keys",
            tablefmt="grid",
            maxcolwidths=30
        )

        summary_prompt = f"""Based on these SQL query results, provide a concise natural language answer to the original question.

Original question: {question}

Results:
{formatted_table}

Answer the question directly and concisely. Mention specific numbers when relevant."""

        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": summary_prompt}],
            temperature=0.3,
            max_tokens=512
        )

        answer = response.choices[0].message.content
        return f"{answer}\n\n--- Query Results ---\n{formatted_table}"

    def ask(self, question: str) -> str:
        print(f"\nQuestion: {question}")

        correction_context = None
        for attempt in range(self.max_retries):
            query = self._generate_query(question, correction_context)
            print(f"Attempt {attempt + 1} - Generated query:\n{query}\n")

            success, result, error = self.executor.execute(query)

            if success:
                return self._format_results(result, question)
            else:
                print(f"Error: {error}")
                correction_context = {"query": query, "error": error}

        return f"Failed to generate a valid query after {self.max_retries} attempts. Last error: {error}"

Running the Agent

Now let us put it all together in a main script:

from sql_agent import SQLAgent

def main():
    agent = SQLAgent(
        db_path="ecommerce.db",
        vllm_url="http://localhost:8000/v1",
        model="Qwen/Qwen2.5-Coder-7B-Instruct"
    )

    questions = [
        "What are the top 3 customers by total order amount?",
        "Which product category has the highest total revenue?",
        "How many orders were cancelled and what was their total value?",
        "List all customers from the USA who have placed at least one completed order."
    ]

    for question in questions:
        answer = agent.ask(question)
        print(f"Answer: {answer}")
        print("=" * 80)

if __name__ == "__main__":
    main()

When you run this, you should see output like:

Question: What are the top 3 customers by total order amount?
Attempt 1 - Generated query:
SELECT c.name, SUM(o.total_amount) AS total_spent
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.customer_id, c.name
ORDER BY total_spent DESC
LIMIT 3;

Answer: The top 3 customers by total completed order amount are:
1. Alice Johnson with $1,225.00
2. Carol Lee with $1,275.00
3. Bob Smith with $100.00

Carol Lee is the highest spender with a total of $1,275.00 in completed orders.

Adding Few-Shot Examples

For complex schemas, few-shot examples significantly improve accuracy. You can embed example question-query pairs in the system prompt:

FEW_SHOT_EXAMPLES = """
Example Questions and Queries:

Q: How many products are in each category?
sql
SELECT category, COUNT(*) AS product_count
FROM products
GROUP BY category
ORDER BY product_count DESC;
Q: What is the average order value for completed orders?
sql
SELECT AVG(total_amount) AS avg_order_value
FROM orders
WHERE status = 'completed';
Q: Which customers have not placed any orders?
sql
SELECT c.name, c.email
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
"""

Append this to the system prompt after the schema. The LLM will learn your preferred query style and common patterns specific to your database.

Best Practices

1. Always Enforce Read-Only Access

Never trust LLM-generated SQL blindly. Use a dedicated read-only database user in production. In PostgreSQL, for example:

CREATE USER agent_reader WITH PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE ecommerce TO agent_reader;
GRANT USAGE ON SCHEMA public TO agent_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO agent_reader;

2. Limit Result Sets

Always inject a LIMIT clause if one is not present. Returning millions of rows will consume excessive context window tokens and slow down the response. A reasonable default is 100 rows for display and 1000 for aggregation checks.

3. Use Low Temperature for Query Generation

Set temperature between 0.0 and 0.2 for SQL generation. You want deterministic, precise output. Use a slightly higher temperature (0.3–0.5) only for the natural language summary step where creativity is acceptable.

4. Cache the Schema

Extracting the schema on every request is wasteful. Cache it with a TTL or invalidate it only when the schema changes:

import time

class CachedSchemaExtractor(SchemaExtractor):
    def __init__(self, db_path: str, ttl_seconds: int = 3600):
        super().__init__(db_path)
        self.ttl = ttl_seconds
        self._cache = None
        self._cache_time = 0

    def get_schema(self) -> str:
        if self._cache is None or (time.time() - self._cache_time) > self.ttl:
            self._cache = super().get_schema()
            self._cache_time = time.time()
        return self._cache

5. Log Everything for Debugging

Log every generated query, execution result, and error. This helps you identify systematic failure patterns and build better few-shot examples over time:

import logging

logging.basicConfig(
    filename="sql_agent.log",
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s"
)

# Inside the ask method:
logging.info(f"Question: {question}")
logging.info(f"Query: {query}")
logging.info(f"Success: {success}, Error: {error}")

6. Choose the Right Model

Model selection has a huge impact on accuracy. For SQL tasks, these models perform well with vLLM:

For production, benchmark several models on your specific schema using a held-out test set of question-query pairs.

7. Handle Ambiguous Questions Gracefully

Not every user question maps cleanly to a query. Add a clarification step where the agent can ask follow-up questions before generating SQL. You can implement this by adding a special "CLARIFY" output token that the orchestrator detects and routes back to the user.

Extending the Agent

Once the basic agent works, consider these enhancements:

Conclusion

Building a SQL Query Agent with vLLM gives you a powerful, private, and cost-effective way to let non-technical users interact with your databases. By combining vLLM's high-throughput inference with careful prompt engineering, robust query validation, and an iterative error-correction loop, you can create a production-grade system that handles real-world questions reliably. The key to success lies in giving the model rich context — schema, sample data, and few-shot examples — while maintaining strict safety guardrails around query execution. Start with the simple architecture described here, benchmark different models on your schema, and iteratively improve accuracy by logging failures and adding targeted examples. With vLLM handling inference efficiently, your agent can scale to serve entire organizations without the ongoing costs of external API calls.

— Ad —

Google AdSense will appear here after approval

← Back to all articles