LLMs hallucinate parameters. Schema validation is your safety net.
The fundamental problem
When an LLM “calls a tool,” it generates JSON that looks like a function call. The platform parses it and hands you the dict. You execute the function.
But the JSON is generated by a probabilistic model. Sometimes it:
- Skips required fields
- Sends the wrong type (string
"true"instead of booleantrue) - Includes extra fields you didn’t ask for
- Hallucinates enum values (
"location": "Mars") - Constructs malformed nested objects
If you params["email"] without validating, you crash on KeyError. If you int(params["age"]) without validating, you crash on ValueError. Either way the call ends abruptly and the caller hears silence.
The safety net: Pydantic v2 schemas
For every tool, declare a Pydantic model:
1 | from pydantic import BaseModel, Field |
Required vs optional is explicit. Defaults absorb missing fields. Types coerce when sensible.
The dispatcher pattern
A single entry point validates and routes:
1 | TOOL_HANDLERS: dict[str, tuple[type[BaseModel], ToolHandler]] = { |
Three guards in twelve lines:
- Unknown tool → polite error to the model
- Bad params → validation error sent back as a tool result, model can retry with corrected args
- Handler crash → caught, logged, generic error to the model
The conversation never dies because of one bad tool call.
Adding a new tool: two steps
1 | # 1. Define the schema |
No if/elif chain to grow. No new error handling to write. The dispatcher already covers it.
Why Pydantic v2 specifically
- Speed — rewritten in Rust, ~10x faster than v1. Latency-critical on a voice call.
Field(pattern=...)— regex validation built-in (replaces v1’sregex).- Better error messages — sent verbatim back to the model, which often self-corrects.
- Type coercion —
"5"→5for int fields. Helpful because LLMs are inconsistent about quoting numbers.
What good error messages look like
When validation fails, you get something like:
1 | 1 validation error for ScheduleMeetingParams |
Send this verbatim to the model in the tool result. Modern models read this and try again with a valid value. You get free retry behavior without writing retry logic.
A trap: don’t validate happy-path responses
Validation belongs at the boundary — when data enters your code from outside (LLM, HTTP request, user input). Don’t re-validate when passing the model to internal functions. You already know the data is good; double validation is overhead.
Testing the schemas
These tests run in milliseconds and don’t need Twilio or Ultravox:
1 | import pytest |
You’re testing the contract, not the LLM. If the contract is right, the LLM either complies or gets a useful error.
Takeaway
LLM tool calls are untrusted input. Treat them like form submissions from the internet. Pydantic v2 + a registry-based dispatcher = small code, big safety net, easy to extend.