Skip to content

Repository files navigation

🦜 LangChain-In-Depth

A hands-on learning repository for LangChain, working up from chat models and LCEL chains to retrieval-augmented generation (RAG) and tool-calling agents.

Perfect for: developers learning LangChain step by step, using each script as a standalone reference for one concept.


πŸ“š Overview

Eight standalone, runnable scripts, each isolating one concept:

Script Concept
main.py Chat models, prompt templates, multi-turn conversation, first LCEL chain
rag-tooling.py RAG chain shape using a fake in-memory retriever (no API calls for retrieval)
real_rag.py Real RAG: text splitting, OpenAI embeddings, InMemoryVectorStore
tool_calling.py Tool-calling agent (create_agent) with a strict system prompt and free-form output
tool_calling_with_pydantic_schema.py Same agent pattern, but with structured Pydantic output (response_format)
tool_calling_manual.py The same job-search agent with create_agent removed β€” the tool-call loop written by hand
teach_tool_calling.py Minimal, single-tool version of the manual loop β€” isolates what a ToolCall dict is for and why BaseTool.invoke() needs the whole thing, not just args
tool_calling_manual_pydantic.py Same manual loop, but the tool is a bare Pydantic model, not @tool β€” isolates schema-only binding vs. execution

πŸ“‹ Requirements

  • Python 3.10+
  • uv – fast Python package manager (replaces pip + venv)
  • OpenAI API key – for chat models and embeddings
  • Tavily API key – for web search in the agent examples

Dependency management and locking are handled via uv (see pyproject.toml / uv.lock).


πŸš€ Quick Start

1. Clone the repository

git clone https://github.com/officialbidisha/Langchain-In-Depth.git
cd Langchain-In-Depth

2. Install dependencies

uv sync

3. Configure API keys

Create a .env file in the project root:

OPENAI_API_KEY=sk-your-openai-key-here
TAVILY_API_KEY=your-tavily-api-key-here

⚠️ Never commit .env – it's already in .gitignore

4. Run examples

uv run python main.py                              # chat models, prompt templates, and LCEL basics
uv run python rag-tooling.py                        # RAG pipeline shape using a mocked retriever
uv run python real_rag.py                           # real RAG: text splitting, embeddings, vector search
uv run python tool_calling.py                       # tool-calling job-search agent (Tavily search + extract)
uv run python tool_calling_with_pydantic_schema.py   # same idea, with structured Pydantic output
uv run python tool_calling_manual.py                 # the create_agent loop, written by hand
uv run python teach_tool_calling.py                  # minimal manual loop: ToolCall shape, tool_call_id matching
uv run python tool_calling_manual_pydantic.py         # same loop, tool defined via bare Pydantic model

πŸ“ Project Structure

.
β”œβ”€β”€ main.py                              # Chat models, prompts, multi-turn messages, LCEL chains
β”œβ”€β”€ rag-tooling.py                        # RAG chain shape with a fake in-memory retriever
β”œβ”€β”€ real_rag.py                           # RAG with real embeddings and vector store retrieval
β”œβ”€β”€ tool_calling.py                       # Tool-calling agent: searches + verifies job postings
β”œβ”€β”€ tool_calling_with_pydantic_schema.py  # Tool-calling agent with structured (Pydantic) output
β”œβ”€β”€ tool_calling_manual.py                # Same agent, with create_agent's loop written by hand
β”œβ”€β”€ teach_tool_calling.py                 # Minimal manual loop: ToolCall shape, tool_call_id matching
β”œβ”€β”€ tool_calling_manual_pydantic.py       # Same loop, tool schema as a bare Pydantic model (no @tool)
β”œβ”€β”€ pyproject.toml                        # Project metadata and dependencies
β”œβ”€β”€ uv.lock                               # Locked dependency versions
└── .env                                  # Local environment variables (not committed)

πŸ” Example Breakdown

main.py – Foundations

  • Initialize a chat model (ChatOpenAI)
  • Send SystemMessage / HumanMessage / AIMessage for multi-turn conversation
  • Build a first LCEL chain: prompt | model | parser

rag-tooling.py – RAG shape, no external calls

  • A FakeRetriever stands in for a real vector store, so the chain's shape can be studied without hitting an API
  • RunnableParallel runs two branches on the same input: one formats retrieved docs into context, the other passes the question through untouched
  • The prompt's {context} / {question} placeholders must match the RunnableParallel dict keys exactly, or the chain raises KeyError at runtime

real_rag.py – Production RAG

  • RecursiveCharacterTextSplitter chunks a raw text blob
  • OpenAIEmbeddings embeds each chunk into InMemoryVectorStore
  • vectorstore.as_retriever() performs real cosine-similarity search
  • Same RunnableParallel β†’ prompt β†’ model β†’ parser shape as rag-tooling.py, now backed by real retrieval

tool_calling.py – Agent with a strict system prompt

  • Two tools: get_jobs (Tavily search) and get_job_details (Tavily extract, to pull full posting content)
  • create_agent(model, tools=[...], system_prompt=...) builds the agent
  • The system prompt encodes hard verification rules (explicit LangChain/LangGraph/LangSmith mention, explicit remote status, explicit India eligibility) so the agent can't hedge its way to a target count
  • Output is the raw agent message trace, printed via message.pretty_print()

tool_calling_with_pydantic_schema.py – Structured output

  • Same job-search idea, single get_new_jobs(query: str) tool with a free-form query
  • response_format=AgentResponse (a Pydantic model) makes create_agent return result["structured_response"] as a typed AgentResponse instead of free text
  • Job / AgentResponse Pydantic models define the exact shape (title, company, location, url) the agent must fill in

tool_calling_manual.py – create_agent, unwrapped

  • Same two tools and equivalent rules as tool_calling.py, but no create_agent β€” model.bind_tools(TOOLS) plus a hand-written loop
  • The loop: call the model β†’ if response.tool_calls is non-empty, run each tool and append a ToolMessage (matched back via tool_call_id) β†’ call the model again β†’ repeat until no tool calls remain
  • A MAX_STEPS cap guards against the loop never terminating β€” the same kind of recursion limit create_agent/LangGraph applies internally
  • Shows exactly what create_agent buys you: this version has no built-in response_format coercion, streaming, or checkpointing

teach_tool_calling.py – The ToolCall shape, isolated

  • One tool (get_new_jobs), no system prompt engineering β€” strips away agent behavior to focus purely on the request/execute/respond mechanics
  • A single HumanMessage produced one AIMessage with 4 tool_calls (Meta, Google, Salesforce, Uber) β€” the model batches independent lookups into one turn instead of asking one at a time
  • BaseTool.invoke() branches on its input's shape (see _prep_run_args in langchain_core/tools/base.py): pass just tool_call["args"] and you get the tool's raw return value; pass the whole tool_call dict ({name, args, id, type}) and it unwraps args to run the function, then wraps the output in a ToolMessage tagged with tool_call_id
  • That tool_call_id tag is the only thing letting 4 parallel requests in one AI turn get matched back to their 4 correct answers once the message list is sent back to the model
  • The for tool_call in result.tool_calls: loop that runs the tools is sequential in your code (each Tavily call blocks before the next starts) even though the model's request for them was parallel β€” "parallel in the API's eyes" and "concurrent in your code" are different things

tool_calling_manual_pydantic.py – Schema-only binding, no @tool

  • GetNewJobs(BaseModel) defines only the input schema (fields + docstring) β€” no function body, no execution logic attached
  • bind_tools([GetNewJobs]) accepts the Pydantic class directly; the resulting result.tool_calls has the identical {name, args, id, type} shape as teach_tool_calling.py's @tool-based version β€” bind_tools doesn't care what shape the schema came from
  • Because there's no BaseTool, there's no .invoke() and no automatic ToolMessage wrapping β€” both are written by hand: route tool_call["name"] to the real get_new_jobs() function, call it with **tool_call["args"], then manually build ToolMessage(content=..., tool_call_id=tool_call["id"])
  • get_new_jobs(**tool_args) (unpack into keyword args) vs. BaseTool.invoke(tool_call) (pass the whole dict) look similar but solve opposite problems β€” a plain function needs args spread across its named parameters; BaseTool.invoke() wants one object it can pattern-match on. Same tool_call/tool_args, opposite calling convention
  • Hit the same structural rule OpenAI's API enforces on every manual loop: a ToolMessage must directly follow an assistant message containing the matching tool_calls id, or the API 400s with "messages with role 'tool' must be a response to a preceeding message with 'tool_calls'" β€” forgetting messages.append(result) before appending ToolMessages breaks this

πŸ“– Suggested Learning Path

  1. main.py – chat models, messages, first LCEL chain
  2. rag-tooling.py – learn the RAG chain shape with no API cost
  3. real_rag.py – swap the fake retriever for real embeddings + vector search
  4. tool_calling.py – build an agent, see how much a system prompt has to constrain it
  5. tool_calling_with_pydantic_schema.py – same agent, structured output instead of free text
  6. tool_calling_manual.py – strip away create_agent and write the tool-call loop yourself, to see what it was doing
  7. teach_tool_calling.py – same loop, minimal single-tool version β€” the one to reread when the ToolCall dict / tool_call_id mechanics get fuzzy
  8. tool_calling_manual_pydantic.py – same loop again, but the tool is a bare Pydantic model instead of @tool β€” see what binding buys you (a schema) vs. what it doesn't (execution)

πŸ’‘ Learnings

Notes from building the tool-calling agents β€” things that weren't obvious going in:

  • Check the real SDK before wiring a tool to it. tavily-python's TavilyClient only exposes search, extract, crawl, map, etc. β€” there's no get_job_details method, so a tool calling it would fail at runtime the moment the agent tried to use it. Use tavily.extract(url) to pull full content from a specific posting instead.
  • create_agent's real kwargs: it's response_format (not response_schema) for structured output, and .invoke() expects {"messages": [...]}, not a bare string.
  • Agents under-explore by default. Given 10+ search results and a budget of 5 verified jobs, gpt-4o-mini would check just the first candidate, get one hit, and stop β€” instead of working through the list. The system prompt has to explicitly say "keep going through remaining candidates" and "search again with a different query if you're short," or the agent quits early.
  • Agents will rationalize instead of exclude. Once told to return "up to five," the model padded to five by hedging: labeling an on-site job "remote (but listed on-site)," or justifying weak evidence with "may include LangChain." Prompts need to state exclusion as the default and explicitly ban hedging language, or the LLM will bend the rules to hit the target count.
  • Model choice affects rule-following, not just quality. Swapping gpt-4o-mini β†’ gpt-4o measurably improved compliance (it correctly dropped an on-site job the mini model kept) β€” worth testing on a stricter model before assuming a prompt is broken.
  • Give tools a query the agent can actually use. A tool with a single location: str parameter can't express "software engineer roles at Meta, Google, Salesforce, Uber" β€” the agent ended up calling it 4 times with the exact same input, unable to encode what it actually wanted. A free-form query: str parameter let it compose the real intent in one call.
  • VS Code's Python interpreter is separate from the project's .venv. Imports that work fine via uv run can still fail in the IDE if python.defaultInterpreterPath isn't pointed at .venv/bin/python (see .vscode/settings.json).
  • create_agent's tool loop, unwrapped, is just: bind tools β†’ invoke β†’ if response.tool_calls, run each and append a ToolMessage keyed by tool_call_id β†’ invoke again β†’ repeat until no tool calls remain (see tool_calling_manual.py). What it hides is response_format coercion, streaming, and the LangGraph state graph/checkpointing underneath.
  • BaseTool.invoke() reads its input's shape to decide what to do. Pass a plain dict of args ({'query': ..., 'search_depth': ...}) and it runs the tool, returning the raw output. Pass a full ToolCall dict ({'name', 'args', 'id', 'type': 'tool_call'}) and it detects that shape, uses args to run the function, but wraps the return value in a ToolMessage carrying tool_call_id. There's no separate "tool call mode" flag β€” it's purely inferred from the keys present in the input (teach_tool_calling.py).
  • .invoke(**kwargs) isn't a thing for BaseTool. Its signature takes one positional input (str, dict, or ToolCall) β€” unpacking args as .invoke(**tool_args) throws TypeError: missing 1 required positional argument: 'input'. Pass the dict itself, not its unpacked keys.
  • bind_tools only cares about producing valid tool_calls β€” not what defined the schema. A bare Pydantic class (no @tool) binds and produces tool_calls with the exact same {name, args, id, type} shape as a decorated function. Execution is always on you; @tool just also hands you a convenient .invoke() to do it with (tool_calling_manual_pydantic.py).
  • Plain functions and BaseTool want opposite calling conventions for the same data. A raw Python function needs its args dict spread: get_new_jobs(**tool_args). BaseTool.invoke() wants the whole tool_call dict as one object so it can pattern-match on it internally. Passing a spread dict to invoke(), or an unspread dict to a plain function, both fail β€” just with different errors (TypeError vs. a 422 from the API receiving a dict where a string was expected).
  • OpenAI's "tool must follow tool_calls" rule is a bracket-matching check. An assistant message with tool_calls is the opening bracket for each call id; a ToolMessage is the closing bracket. Every manual loop needs messages.append(result) (the AIMessage) before appending any ToolMessages, or the API 400s on the first tool message it can't match to a preceding open.

πŸ—’οΈ Notes for Tomorrow

A living list, not a daily log β€” check items off or remove them as they're done instead of re-queuing under a new date.

Next up

  • Add structured output by hand to tool_calling_manual.py and tool_calling_manual_pydantic.py β€” once each loop ends, pass the final answer through model.with_structured_output(AgentResponse) (or a second call) and compare to what response_format= does automatically in tool_calling_with_pydantic_schema.py.
  • Make the manual tool-call loops actually concurrent β€” every for tool_call in result.tool_calls: loop so far runs its Tavily searches sequentially even though the model's request for them was parallel; try asyncio.gather (with .ainvoke) or a thread pool and compare wall-clock time against the sequential version.
  • Mix an @tool function and a bare Pydantic model in the same bind_tools([...]) call β€” confirm the model can request either shape of tool call in one turn, and that dispatch-by-tool_name handles both without special-casing.
  • Read the LangGraph basics β€” create_agent is a thin wrapper around a LangGraph StateGraph. Understanding nodes/edges/state directly will explain why the loop, message list, and stopping condition look the way they do.
  • Add streaming β€” swap .invoke(...) for .stream(...) in tool_calling_manual.py and print tokens as they arrive; note what has to change to still detect tool_calls.
  • Re-run tool_calling.py on gpt-4o-mini and diff the results against gpt-4o to see the rule-following gap firsthand (see Learnings above).
  • Skim LangChain's own agent docs on memory/checkpointing β€” create_agent supports persisting state across runs; the manual versions currently don't.

Done

  • Parallel tool calls β€” confirmed in teach_tool_calling.py: one HumanMessage produced a single AIMessage with 4 tool_calls (Meta/Google/Salesforce/Uber), and the manual loop resolved all 4 correctly, matched back via tool_call_id.
  • Stage 1 of tool_calling_manual_pydantic.py β€” same manual bind-tools loop as teach_tool_calling.py, tool schema defined as a bare Pydantic model instead of via @tool; confirmed bind_tools produces an identical tool_calls shape either way, and that execution/ToolMessage-wrapping has to be written by hand without a BaseTool.

πŸ› Troubleshooting

Issue Solution
ModuleNotFoundError Run uv sync to ensure all dependencies are installed
API key not found Check .env exists in the project root with OPENAI_API_KEY and TAVILY_API_KEY
KeyError in a RAG chain Make sure the prompt's placeholders match the RunnableParallel dict keys exactly
Agent returns too few / hedged results Tighten the system prompt's exclusion rules, or try a stronger model (see Learnings above)

πŸ”— Resources


πŸ“ License

Distributed under the terms of the LICENSE file included in this repository.

About

A langchain learning and RAG Based Repo

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages