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.
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 |
- 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).
git clone https://github.com/officialbidisha/Langchain-In-Depth.git
cd Langchain-In-Depthuv syncCreate 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
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.
βββ 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)
- Initialize a chat model (
ChatOpenAI) - Send
SystemMessage/HumanMessage/AIMessagefor multi-turn conversation - Build a first LCEL chain:
prompt | model | parser
- A
FakeRetrieverstands in for a real vector store, so the chain's shape can be studied without hitting an API RunnableParallelruns 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 theRunnableParalleldict keys exactly, or the chain raisesKeyErrorat runtime
RecursiveCharacterTextSplitterchunks a raw text blobOpenAIEmbeddingsembeds each chunk intoInMemoryVectorStorevectorstore.as_retriever()performs real cosine-similarity search- Same
RunnableParallel β prompt β model β parsershape asrag-tooling.py, now backed by real retrieval
- Two tools:
get_jobs(Tavily search) andget_job_details(Tavilyextract, 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()
- Same job-search idea, single
get_new_jobs(query: str)tool with a free-form query response_format=AgentResponse(a Pydantic model) makescreate_agentreturnresult["structured_response"]as a typedAgentResponseinstead of free textJob/AgentResponsePydantic models define the exact shape (title, company, location, url) the agent must fill in
- Same two tools and equivalent rules as
tool_calling.py, but nocreate_agentβmodel.bind_tools(TOOLS)plus a hand-written loop - The loop: call the model β if
response.tool_callsis non-empty, run each tool and append aToolMessage(matched back viatool_call_id) β call the model again β repeat until no tool calls remain - A
MAX_STEPScap guards against the loop never terminating β the same kind of recursion limitcreate_agent/LangGraph applies internally - Shows exactly what
create_agentbuys you: this version has no built-inresponse_formatcoercion, streaming, or checkpointing
- One tool (
get_new_jobs), no system prompt engineering β strips away agent behavior to focus purely on the request/execute/respond mechanics - A single
HumanMessageproduced oneAIMessagewith 4tool_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_argsinlangchain_core/tools/base.py): pass justtool_call["args"]and you get the tool's raw return value; pass the wholetool_calldict ({name, args, id, type}) and it unwrapsargsto run the function, then wraps the output in aToolMessagetagged withtool_call_id- That
tool_call_idtag 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
GetNewJobs(BaseModel)defines only the input schema (fields + docstring) β no function body, no execution logic attachedbind_tools([GetNewJobs])accepts the Pydantic class directly; the resultingresult.tool_callshas the identical{name, args, id, type}shape asteach_tool_calling.py's@tool-based version βbind_toolsdoesn't care what shape the schema came from- Because there's no
BaseTool, there's no.invoke()and no automaticToolMessagewrapping β both are written by hand: routetool_call["name"]to the realget_new_jobs()function, call it with**tool_call["args"], then manually buildToolMessage(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. Sametool_call/tool_args, opposite calling convention- Hit the same structural rule OpenAI's API enforces on every manual loop: a
ToolMessagemust directly follow an assistant message containing the matchingtool_callsid, or the API 400s with"messages with role 'tool' must be a response to a preceeding message with 'tool_calls'"β forgettingmessages.append(result)before appendingToolMessages breaks this
main.pyβ chat models, messages, first LCEL chainrag-tooling.pyβ learn the RAG chain shape with no API costreal_rag.pyβ swap the fake retriever for real embeddings + vector searchtool_calling.pyβ build an agent, see how much a system prompt has to constrain ittool_calling_with_pydantic_schema.pyβ same agent, structured output instead of free texttool_calling_manual.pyβ strip awaycreate_agentand write the tool-call loop yourself, to see what it was doingteach_tool_calling.pyβ same loop, minimal single-tool version β the one to reread when theToolCalldict /tool_call_idmechanics get fuzzytool_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)
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'sTavilyClientonly exposessearch,extract,crawl,map, etc. β there's noget_job_detailsmethod, so a tool calling it would fail at runtime the moment the agent tried to use it. Usetavily.extract(url)to pull full content from a specific posting instead. create_agent's real kwargs: it'sresponse_format(notresponse_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-miniwould 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-4omeasurably 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: strparameter 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-formquery: strparameter 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 viauv runcan still fail in the IDE ifpython.defaultInterpreterPathisn't pointed at.venv/bin/python(see.vscode/settings.json). create_agent's tool loop, unwrapped, is just: bind tools β invoke β ifresponse.tool_calls, run each and append aToolMessagekeyed bytool_call_idβ invoke again β repeat until no tool calls remain (seetool_calling_manual.py). What it hides isresponse_formatcoercion, 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 fullToolCalldict ({'name', 'args', 'id', 'type': 'tool_call'}) and it detects that shape, usesargsto run the function, but wraps the return value in aToolMessagecarryingtool_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 forBaseTool. Its signature takes one positionalinput(str, dict, orToolCall) β unpacking args as.invoke(**tool_args)throwsTypeError: missing 1 required positional argument: 'input'. Pass the dict itself, not its unpacked keys.bind_toolsonly cares about producing validtool_callsβ not what defined the schema. A bare Pydantic class (no@tool) binds and producestool_callswith the exact same{name, args, id, type}shape as a decorated function. Execution is always on you;@tooljust also hands you a convenient.invoke()to do it with (tool_calling_manual_pydantic.py).- Plain functions and
BaseToolwant 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 wholetool_calldict as one object so it can pattern-match on it internally. Passing a spread dict toinvoke(), or an unspread dict to a plain function, both fail β just with different errors (TypeErrorvs. 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_callsis the opening bracket for each call id; aToolMessageis the closing bracket. Every manual loop needsmessages.append(result)(the AIMessage) before appending anyToolMessages, or the API 400s on the first tool message it can't match to a preceding open.
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.pyandtool_calling_manual_pydantic.pyβ once each loop ends, pass the final answer throughmodel.with_structured_output(AgentResponse)(or a second call) and compare to whatresponse_format=does automatically intool_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; tryasyncio.gather(with.ainvoke) or a thread pool and compare wall-clock time against the sequential version. - Mix an
@toolfunction and a bare Pydantic model in the samebind_tools([...])call β confirm the model can request either shape of tool call in one turn, and that dispatch-by-tool_namehandles both without special-casing. - Read the LangGraph basics β
create_agentis a thin wrapper around a LangGraphStateGraph. 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(...)intool_calling_manual.pyand print tokens as they arrive; note what has to change to still detecttool_calls. - Re-run
tool_calling.pyongpt-4o-miniand diff the results againstgpt-4oto see the rule-following gap firsthand (see Learnings above). - Skim LangChain's own agent docs on memory/checkpointing β
create_agentsupports persisting state across runs; the manual versions currently don't.
Done
- Parallel tool calls β confirmed in
teach_tool_calling.py: oneHumanMessageproduced a singleAIMessagewith 4tool_calls(Meta/Google/Salesforce/Uber), and the manual loop resolved all 4 correctly, matched back viatool_call_id. - Stage 1 of
tool_calling_manual_pydantic.pyβ same manual bind-tools loop asteach_tool_calling.py, tool schema defined as a bare Pydantic model instead of via@tool; confirmedbind_toolsproduces an identicaltool_callsshape either way, and that execution/ToolMessage-wrapping has to be written by hand without aBaseTool.
| 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) |
Distributed under the terms of the LICENSE file included in this repository.