What an LLM actually outputs during a tool call — and how bind_tools gets it there
A tool call isn't the model 'running a function' — it's the model emitting a structured request that your code executes. Here's the exact anatomy of that payload, and how bind_tools puts the tools in the model's head.
The phrase 'the LLM calls a tool' hides what's really happening. The model doesn't run anything — it outputs a structured request, and your software executes it (the tool-calling post makes that case). But what exactly does that request look like, and how did the model know the tool existed? This is the anatomy, one layer down.
The output is structured data, not language
When a model decides to use a tool, it doesn't write 'please call get_weather for Paris' in prose. It emits a structured payload with three parts: the tool name, the arguments as JSON, and an id that later ties the result back to this specific call. In LangChain this surfaces as AIMessage.tool_calls.
ai_message.tool_calls
# [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_abc123'}]
#
# LangChain normalises this from the raw provider response, where the same
# call arrives as: an id, a function name, and arguments as a JSON *string*.
# The id matters — it's how the ToolMessage you return is matched to this call.The reason it's structured and not natural language is reliability: your code can parse a JSON object deterministically, but it can't reliably regex a sentence. That single design choice — native structured tool calls — is what made agents robust (the ReAct-vs-native post tells that story).
How the model knew the tool existed: bind_tools
The model can only request a tool it was told about, and that's bind_tools' job. It takes your tool definitions and serialises each into JSON Schema — the function's name, its description, and its parameters — then injects that schema into the request sent to the model, in the API's dedicated tools field. The model reads those schemas the way it reads the rest of the prompt, and picks from them.
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city.""" # -> becomes the tool's description
...
model.bind_tools([get_weather])
# serialised and injected into the request's `tools` field as JSON Schema:
# { 'name': 'get_weather',
# 'description': 'Get the current weather for a city.',
# 'parameters': { 'type': 'object',
# 'properties': { 'city': { 'type': 'string' } },
# 'required': ['city'] } }Why the docstring matters so much
Notice where the docstring went: it became the tool's description — and the description is the primary thing the model uses to decide whether and when to call the tool. A vague docstring gives the model a vague sense of when the tool applies, and it will under- or over-call it. A precise one ('Use this to look up current weather when the user asks about conditions in a city') is effectively a mini-prompt for tool selection. Type hints and Pydantic fields, meanwhile, become the parameter schema that constrains the arguments. Write the docstring like it's instructions, because to the model, it is.
A tool call is the model filling in a form you handed it — bind_tools is how you hand it the form, and the docstring is the label that tells it when to reach for that form at all.