Programming Notes
nacho4d avatar

Programming Notes

@nacho4d

LangChain basics — tool calling from scratch

A practical summary of how LangChain works under the hood, built up step by step.

1. Initialising the model

from langchain.chat_models import init_chat_model

llm = init_chat_model("gpt-4o-mini", model_provider="openai")
init_chat_model wraps the underlying provider SDK. No network call happens here — it just configures a client object.

2. Defining tools with @tool

from langchain_core.tools import tool

tools = []

@tool
def extract_video_id(url: str) -> str:
    """Extracts the 11-character YouTube video ID from a URL."""
    ...

tools.append(extract_video_id)
@tool is a LangChain-specific decorator — not a Python built-in. The moment Python reads the @tool line, it:
  1. Reads __name__ → becomes the tool's name
  2. Reads __doc__ → becomes the description sent to the LLM
  3. Reads type annotations → builds a Pydantic input schema
  4. Wraps everything into a StructuredTool object
The original function is preserved inside `.func` and is never lost. Your variable (`extract_video_id`) immediately becomes a `StructuredTool` — it never exists as a plain function in your namespace. Calling a tool directly (outside of LLM flow):
extract_video_id.invoke({"url": "https://youtube.com/watch?v=abc"})
extract_video_id.func("https://youtube.com/watch?v=abc")  # raw function

3. Binding tools to the model

llm_with_tools = llm.bind_tools(tools)

This is pure preparation — no network call. It creates a new runnable that remembers the tools as default kwargs. When you later call .invoke(), LangChain serialises the tool definitions into the OpenAI JSON format:

{
  "model": "gpt-4o-mini",
  "messages": [...],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "extract_video_id",
        "description": "...",
        "parameters": { "type": "object", "properties": { "url": { "type": "string" } } }
      }
    }
  ]
}

4. Message types

LangChain uses typed message classes. Each serialises differently into the JSON sent to the API. The direction is always consistent:

ClassDirectionWho creates it
SystemMessageclient → LLMdeveloper (instructions, persona)
HumanMessageclient → LLMend user
AIMessageLLM → clientthe model
ToolMessageclient → LLMyour tool execution code

The classes are not enforced by Python's type system — they're self-documenting structs with serialisation logic. They give you IDE autocomplete, Pydantic validation, and isinstance checks that LangChain's internals rely on.

5. The tool call loop

When you call llm_with_tools.invoke(messages), the LLM does not execute your tools. It returns an AIMessage that says "I want to call this tool with these arguments". Your code must execute the tool and send the result back.

sequenceDiagram
    participant P as Your program
    participant L as LLM API

    P->>L: `① POST /v1/messages — messages:[{role:"user",content:"summarize this video"}], tools:[extract_video_id,fetch_transcript]`

    L-->>P: `② 200 stop_reason:"tool_use" — content:[{type:"tool_use",id:"tu_abc",name:"extract_video_id",input:{url:"youtube.com/..."}}]`

    Note over P: `AIMessage(tool_calls) → invoke extract_video_id → returns "T-D1OfcDW1M"`

    P->>L: `③ POST /v1/messages — role:"assistant":{type:"tool_use",id:"tu_abc"}, role:"user":{type:"tool_result",tool_use_id:"tu_abc",content:"T-D1OfcDW1M"}`

    L-->>P: `④ 200 stop_reason:"tool_use" — content:[{type:"tool_use",id:"tu_xyz",name:"fetch_transcript",input:{video_id:"T-D1OfcDW1M"}}]`

    Note over P: `AIMessage(tool_calls) → invoke fetch_transcript → returns "[full transcript]"`

    P->>L: `⑤ POST /v1/messages — role:"user":{type:"tool_result",tool_use_id:"tu_xyz",content:"[full transcript]"}`

    L-->>P: `⑥ 200 stop_reason:"end_turn" — content:[{type:"text",text:"Here is a summary..."}]`

Key rules:

  • Always append the AIMessage to messages[] before appending the ToolMessage. The API requires them to appear in order.
  • finish_reason: tool_calls means the loop continues. finish_reason: stop means the LLM is done.
  • AIMessage.content is empty ('') while the LLM is in tool-calling mode — it has nothing to say to the user yet.

6. The tool mapping

tool_mapping = {t.name: t for t in tools}

Built from the tools list automatically — no need to maintain it by hand. Used to look up the callable from the string name the LLM returns:

tool_fn = tool_mapping[tool_call["name"]]
result  = tool_fn.invoke(tool_call["args"])

7. A reusable execute_tool helper

def execute_tool(tool_call):
    try:
        result = tool_mapping[tool_call["name"]].invoke(tool_call["args"])
        return ToolMessage(content=str(result), tool_call_id=tool_call["id"])
    except Exception as e:
        return ToolMessage(content=f"Error: {str(e)}", tool_call_id=tool_call["id"])

The try/except matters: if the tool throws, you still need to return a ToolMessage with the matching tool_call_id, otherwise messages[] becomes malformed and the next API call fails. Returning an error message lets the LLM see what went wrong and potentially recover.

8. A minimal agent loop

Putting it all together:

def run_agent(messages):
    while True:
        response = llm_with_tools.invoke(messages)
        messages.append(response)

        if not response.tool_calls:   # finish_reason: stop
            return response

        for tool_call in response.tool_calls:
            messages.append(execute_tool(tool_call))

The LLM can request multiple tools in a single response — hence the for loop over response.tool_calls. All results are appended before the next invoke().

This is essentially what LangGraph's ToolNode does internally — the tutorial is showing you the manual version so you understand what the framework automates.

Quick reference

ClassDirection
@tool decoratorconverts a function into a StructuredTool at decoration time
bind_tools(tools)stores tool definitions locally, no API call
invoke(messages)sends full message history + tool schemas to the API
AIMessage.tool_callslist of tool calls the LLM wants executed
ToolMessagecarries tool result back to the LLM
finish_reason=stopLLM is done, no more tool calls needed
messages[]the LLM's entire working memory, grows with every exchange

0 comments:

This work is licensed under BSD Zero Clause License | nacho4d ®