Trivial Proof-of-Concept Data Analysis Agent using Qwen3-1.7B

SLM
agentic
data analysis
logistics
in a previous blog post, I used the Anthropic Python SDK with Sonnet 4.6 to create a trivial proof-of-concept data analysis agent with custom tools. In this blog post, I am seeing if I can use Qwen3-1.7B to achieve the same result.
Author

Vishal Bakshi

Published

August 24, 2026

In a previous blog post and corresponding notebook, I showed an example of how I see LLMs as an interface. In the example, I used the Blue Book for Bulldozers Kaggle dataset as my data source and wrote a couple of functions that aggregate the data and render it in an HTML file. I gave Sonnet 4.6 those functions as tools plus instructions, using the Anthropic Python SDK, and it was able to generate the desired report given a user prompt.

In this blog post, I’m going to see if I can recreate that pipeline with the Qwen 3-1.7B open-source model and my own code interpreter (copied from Jeremy Howard’s Hacker’s Guide to LLMs repo).

import ast
import os
import json
import shutil
from pathlib import Path
from google.colab import userdata
os.environ['KAGGLE_API_TOKEN'] = userdata.get('KAGGLE_API_TOKEN')

from kaggle import api
import pandas as pd
from transformers import AutoModelForCausalLM, AutoTokenizer
Warning: Looks like you're using an outdated `kaggle` version (installed: 2.0.2), please consider upgrading to the latest version (2.2.2)
comp = 'bluebook-for-bulldozers'
path = Path(f'/content/{comp}')
path.mkdir(parents=True, exist_ok=True)
api.competition_download_cli(comp, path=path)
shutil.unpack_archive(str(path/f'{comp}.zip'), str(path))
df = pd.read_csv("/content/bluebook-for-bulldozers/TrainAndValid.csv")
df.shape
bluebook-for-bulldozers.zip: Skipping, found more recently modified local copy (use --force to force download)
/tmp/ipykernel_813/2859010916.py:6: DtypeWarning: Columns (13,39,40,41) have mixed types. Specify dtype option on import or set low_memory=False.
  df = pd.read_csv("/content/bluebook-for-bulldozers/TrainAndValid.csv")
(412698, 53)
df["saledatetime"] = pd.to_datetime(df['saledate'])
subset_df = df.query("saledatetime > '12/31/2011 0:00'")
subset_df.shape
(11573, 54)
subset_df.to_csv("salesdata.csv")
model_name = "Qwen/Qwen3-1.7B"

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype="auto",
    device_map="auto"
)

I’ll wrap the model text generation code in a generate function.

def generate(prompt, enable_thinking=False):
    messages = [{"role": "user", "content": prompt}]
    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=enable_thinking
    )
    model_inputs = tokenizer([text], return_tensors="pt").to(model.device)


    generated_ids = model.generate(**model_inputs,max_new_tokens=32768)
    output_ids = generated_ids[0][len(model_inputs.input_ids[0]):].tolist()

    index = len(output_ids) - output_ids[::-1].index(151668) if enable_thinking else 0
    return tokenizer.decode(output_ids[index:], skip_special_tokens=True).strip("\n")

And create a simple constructor for my prompt:

def make_prompt(function_signature, user_request, data_source=None, data_dict=False, function1_output=None):
    prompt = f"""
You are given the following function and additional context.
Based on the user's request, output a python dict of the function name and arguments.
Output only a valid dict, nothing else.
Do not include function results in the dict.

FUNCTION:

{function_signature}
"""

    if data_source:
        prompt += f"""
DATA SOURCE: "{data_source}"
        """

    if data_dict:
        prompt += f"""
DATA DICTIONARY:

salesdata.csv contains the following relevant columns:
- state (str): the state where the sale took place
"""

    if function1_output:
        prompt += f"""
The following data

{function1_output}

should be an argument for the function, structured as

{{"data": {function1_output}}}
"""

    prompt += f"""

USER REQUEST:

{user_request}


OUTPUT FORMAT:

{{"function": "<name>", "arguments": {{<args>}}, ...}}

You are given the above function and additional context.
Based on the user's request, output a python dict of the function name and arguments.
Output only a valid dict, nothing else.
Do not include function results in the dict output.
    """
    return prompt

I will provide the function signature for two functions separately, as I want Qwen to use each one sequentially.

function1_signature = """
aggregated_filtered_saleprice(path_to_csv: str, groupby_fields: list[str], filter_column: str, filter_values: list[str])
   Aggregate and filter the sales data to calculate the mean sale price.
"""

function2_signature = """
generate_report(data: dict)
   The data argument should be the entire dictionary passed as a single argument.
"""

data_source = "/content/salesdata.csv"

user_request = "Using the provided tools and data source, generate an HTML report that shows the mean sale price in Alabama and Missouri."

Let’s make sure the prompts look good.

prompt1 = make_prompt(function1_signature, user_request, data_source=data_source, data_dict=True)
print(prompt1)

You are given the following function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict.

FUNCTION:


aggregated_filtered_saleprice(path_to_csv: str, groupby_fields: list[str], filter_column: str, filter_values: list[str])
   Aggregate and filter the sales data to calculate the mean sale price.


DATA SOURCE: "/content/salesdata.csv"
        
DATA DICTIONARY:

salesdata.csv contains the following relevant columns:
- state (str): the state where the sale took place


USER REQUEST:

Using the provided tools and data source, generate an HTML report that shows the mean sale price in Alabama and Missouri.


OUTPUT FORMAT:

{"function": "<name>", "arguments": {<args>}, ...}

You are given the above function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict output.
    
prompt2 = make_prompt(function2_signature, user_request, function1_output={"blah": "boop"})
print(prompt2)

You are given the following function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict.

FUNCTION:


generate_report(data: dict)
   The data argument should be the entire dictionary passed as a single argument.


The following data

{'blah': 'boop'}

should be an argument for the function, structured as

{"data": {'blah': 'boop'}}


USER REQUEST:

Using the provided tools and data source, generate an HTML report that shows the mean sale price in Alabama and Missouri.


OUTPUT FORMAT:

{"function": "<name>", "arguments": {<args>}, ...}

You are given the above function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict output.
    
content1 = generate(prompt1)
parsed_content1 = ast.literal_eval(content1)
parsed_content1
{'function': 'aggregated_filtered_saleprice',
 'arguments': {'path_to_csv': '/content/salesdata.csv',
  'groupby_fields': ['state'],
  'filter_column': 'state',
  'filter_values': ['Alabama', 'Missouri']}}

I was surprised. I did not expect the size of this model to be able to handle this. And that’s without thinking! This goes to show how far small models have advanced! Or maybe it just shows how out of touch I am with small models of this size (I spent most of my time on tiny models last year).

Let’s create a function that will execute a Python function given JSON arguments. Then I’ll define my aggregate filtered sale price function and call it with the parsed content.

def call_func(name, arguments):
    f = globals()[name]
    return f(**json.loads(arguments) if isinstance(arguments, str) else arguments)
def aggregated_filtered_saleprice(path_to_csv, groupby_fields, filter_column, filter_values):
    """
    Aggregate and filter the sales data to calculate the mean sale price.
    inputs:
        path_to_csv: str
        groupby_fields: list of str
        filter_column: str
        filter_values: list of str
    output:
        json: str
    """
    df = pd.read_csv(path_to_csv)
    data = df.groupby(groupby_fields).agg({"SalePrice": "mean"}).query(f"`{filter_column}` in @filter_values")
    return data.to_dict()
function1_output = call_func(parsed_content1['function'], parsed_content1['arguments'])
function1_output
{'SalePrice': {'Alabama': 37775.08474576271, 'Missouri': 27467.972350230415}}

Beautiful. It works.

Now, let’s see if Qwen can do the same with the second function.

prompt2 = make_prompt(function2_signature, user_request, function1_output=function1_output)
print(prompt2)

You are given the following function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict.

FUNCTION:


generate_report(data: dict)
   The data argument should be the entire dictionary passed as a single argument.


The following data

{'SalePrice': {'Alabama': 37775.08474576271, 'Missouri': 27467.972350230415}}

should be an argument for the function, structured as

{"data": {'SalePrice': {'Alabama': 37775.08474576271, 'Missouri': 27467.972350230415}}}


USER REQUEST:

Using the provided tools and data source, generate an HTML report that shows the mean sale price in Alabama and Missouri.


OUTPUT FORMAT:

{"function": "<name>", "arguments": {<args>}, ...}

You are given the above function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict output.
    
content2 = generate(prompt2)
parsed_content2 = ast.literal_eval(content2)
parsed_content2
{'function': 'generate_report',
 'arguments': {'data': {'SalePrice': {'Alabama': 37775.08474576271,
    'Missouri': 27467.972350230415}}}}
def generate_report(data):
    """
    Generate an HTML report from dict.
    inputs:
        data: dict
    output:
        None
    """
    html = f"<h1>Data Report</h1><br>{data}"
    with open("/content/report.html", "w") as f:
        f.write(html)
    print("HTML report generated")
call_func(parsed_content2['function'], parsed_content2['arguments'])
HTML report generated

Amazing!

If we use a different user request, does this still work? Let’s first wrap this all in a function.

def data_analysis_agent(user_request, data_source):
    prompt1 = make_prompt(function1_signature, user_request, data_source=data_source, data_dict=True)
    print(prompt1)
    content1 = generate(prompt1)
    parsed_content1 = ast.literal_eval(content1)
    function1_output = call_func(parsed_content1['function'], parsed_content1['arguments'])
    prompt2 = make_prompt(function2_signature, user_request, function1_output=function1_output)
    print(prompt2)
    content2 = generate(prompt2)
    parsed_content2 = ast.literal_eval(content2)
    call_func(parsed_content2['function'], parsed_content2['arguments'])
    return "Done"
user_request = "I need to know what the mean sale price was in the two states of Alabama and Missouri."
data_analysis_agent(user_request, data_source)

You are given the following function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict.

FUNCTION:


aggregated_filtered_saleprice(path_to_csv: str, groupby_fields: list[str], filter_column: str, filter_values: list[str])
   Aggregate and filter the sales data to calculate the mean sale price.


DATA SOURCE: "/content/salesdata.csv"
        
DATA DICTIONARY:

salesdata.csv contains the following relevant columns:
- state (str): the state where the sale took place


USER REQUEST:

I need to know what the mean sale price was in the two states of Alabama and Missouri.


OUTPUT FORMAT:

{"function": "<name>", "arguments": {<args>}, ...}

You are given the above function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict output.
    

You are given the following function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict.

FUNCTION:


generate_report(data: dict)
   The data argument should be the entire dictionary passed as a single argument.


The following data

{'SalePrice': {'Alabama': 37775.08474576271, 'Missouri': 27467.972350230415}}

should be an argument for the function, structured as

{"data": {'SalePrice': {'Alabama': 37775.08474576271, 'Missouri': 27467.972350230415}}}


USER REQUEST:

I need to know what the mean sale price was in the two states of Alabama and Missouri.


OUTPUT FORMAT:

{"function": "<name>", "arguments": {<args>}, ...}

You are given the above function and additional context. 
Based on the user's request, output a python dict of the function name and arguments. 
Output only a valid dict, nothing else. 
Do not include function results in the dict output.
    
HTML report generated
'Done'

Cool! It wasn’t very challenging because my user request was very similar, but as a trivial example, I’m satisfied.

Post-Mortem

What did I learn?

  • I need more practice with these medium-sized small models because they are capable of a lot more than I expected!
  • Custom tool-calling capability requires more elbow grease than I expected. I went through multiple iterations of the prompt before call_func worked as expected.
  • If you have relatively stable executable scripts, such that you create deterministic prompts in your chain of tool-calls, Qwen3-1.7B might be worth giving a shot!