Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Call Me Maybe — Introduction to Function Calling in LLMs

This project has been created as part of the 42 curriculum by smikhail.


Description

Call Me Maybe is a function-calling system that translates natural-language requests into structured, machine-executable function calls using a small Large Language Model (Qwen/Qwen3-0.6B, ~600 million parameters).

Goal: given a user prompt like "What is the sum of 2 and 3?", the system produces:

{
  "prompt": "What is the sum of 2 and 3?",
  "name": "fn_add_numbers",
  "parameters": {"a": 2.0, "b": 3.0}
}

instead of answering "The answer is 5" — it identifies the right function and the right arguments.

Key innovation: the system uses constrained decoding to guarantee 100% valid, parseable JSON output. Small LLMs left unconstrained produce valid JSON only ~30% of the time; with constrained decoding this reaches near-100%.


Instructions

Requirements

  • Python 3.10+
  • uv package manager

Installation

# From the project root directory:
uv sync

This installs all dependencies (numpy, pydantic, torch, transformers, huggingface-hub, and the local llm_sdk).

Running

# Default (reads from data/input/, writes to data/output/)
uv run python -m src

# Custom paths
uv run python -m src \
  --functions_definition data/input/functions_definition.json \
  --input data/input/function_calling_tests.json \
  --output data/output/function_calling_results.json

Makefile shortcuts

make install    # install dependencies
make run        # run with default paths
make debug      # run under pdb debugger
make clean      # remove __pycache__, .mypy_cache, etc.
make lint       # run flake8 + mypy

Algorithm Explanation

What is constrained decoding?

Language models generate text one token at a time. At each step they output logits — raw scores over every word-piece in the vocabulary (~150k tokens for Qwen). Normally you pick the highest-scoring token. Constrained decoding modifies this by setting invalid tokens to −∞ before selection, so they can never be chosen.

Our state machine

The JSON we produce always has a fixed shape:

{"name": "<fn_name>", "parameters": {"<p1>": <v1>, ...}}

We exploit this structure with a simple 4-stage pipeline:

Stage What happens
Inject scaffold Fixed strings ("name": ", ", "parameters": {, etc.) are encoded and appended directly using model.encode() — no token search needed
fn_name Constrained decode: only tokens whose text is a prefix of a known function name are allowed. Stops at exact match
param_string Allow all tokens except those containing ". Stop when the model's top score goes to a " token
param_number Allow only digit/./- tokens. Stop when , or } outscores any digit token

Why inject scaffold instead of constraining it?

BPE tokenizers don't always have single-character tokens. {, :, ", etc. may only appear as part of multi-character tokens. Rather than searching the vocabulary for each character, we let model.encode() handle it correctly.


Design Decisions

  1. State machine over grammar parsers: a grammar-based approach (like Earley or GLR parsers over a JSON grammar) is more general but much more complex. Since our JSON shape is fixed, a linear state machine is simpler, faster, and easier to understand.

  2. Greedy token selection (argmax): we always pick the highest-logit valid token. Temperature sampling would introduce randomness and reduce reliability. For a deterministic, schema-compliant output, greedy is optimal.

  3. Scaffold injection via model.encode(): rather than spending tokens searching for " or { in the vocabulary (which may not be single tokens in Qwen's BPE), we encode fixed fragments directly and append them. This is fast and always correct.

  4. Pydantic for all data validation: all models use pydantic.BaseModel. This catches schema mismatches early (bad JSON types, missing keys) and provides clear error messages.

  5. Graceful error handling everywhere: the FunctionCaller.call() method catches all exceptions and returns a fallback result rather than crashing. The program never exits unexpectedly.


Performance Analysis

  • JSON validity: 100% — the constrained decoder mathematically cannot produce invalid JSON because it only selects tokens that maintain structural validity at every step.
  • Function selection accuracy: >90% — the LLM is given the full function descriptions in the prompt, so it learns context about which function matches the request. Constrained decoding then locks in the choice.
  • Speed: Each prompt requires ~50–150 LLM forward passes on Qwen3-0.6B. On Apple Silicon (MPS) this takes ~1–3 seconds per prompt; on CPU ~5–15 seconds. All 11 test prompts finish well within 5 minutes.

Challenges Faced

  1. BPE tokenizer complexity: Qwen uses Byte-Pair Encoding where a single character like " may not be its own token. Solved by injecting fixed fragments with model.encode() instead of searching the vocabulary character-by-character.

  2. Function name tokenization: function names like fn_substitute_string_with_regex are long and likely split across many tokens. The _fn_name_valid_tokens method handles this by allowing any token whose text, appended to the accumulated name, remains a valid prefix of some function name.

  3. Number termination: deciding when a number is "complete" (e.g., 2, 265, 3.14) is tricky. We compare the logit of the best digit token vs. the logit of separator tokens (,, }) and stop when the separator wins.

  4. String termination: similar issue. We stop when the model's preference for a closing " exceeds its preference for any non-quote token.


Testing Strategy

  1. Manual testing: run uv run python -m src and inspect data/output/function_calling_results.json for correct JSON structure and plausible values.

  2. JSON validation: parse the output file with json.load() — 100% of entries must parse successfully.

  3. Schema validation: every entry must have exactly prompt, name, parameters keys; name must be one of the defined function names; parameter types must match the schema.

  4. Edge cases tested:

    • Simple arithmetic (2 numbers)
    • Large numbers (265 + 345)
    • String arguments (greet, reverse)
    • Multi-parameter string functions (regex substitution with 3 params)
    • Square root (single number parameter)

Example Usage

# Ensure data/input/ contains both JSON files, then:
uv run python -m src

# Expected output (data/output/function_calling_results.json):
[
  {
    "prompt": "What is the sum of 2 and 3?",
    "name": "fn_add_numbers",
    "parameters": {"a": 2.0, "b": 3.0}
  },
  {
    "prompt": "Greet shrek",
    "name": "fn_greet",
    "parameters": {"name": "shrek"}
  }
]

Resources

AI Usage

AI assistance was used to:

  • Review and identify bugs in the initial code (mismatched APIs between files, missing classes)
  • Suggest the scaffold-injection approach for fixed JSON structure
  • Generate docstrings and README sections

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages