Appearance
4.4 — Tools in LangChain
1. What is it?
LangChain's @tool decorator and BaseTool class are the framework's abstraction for defining tools (Part 3.3) — turning a Python function into a schema-described, model-callable tool with minimal boilerplate, while still producing exactly the same underlying tool-calling mechanism (name, description, JSON Schema, execution-then-result-return) covered in Part 3.3.
2. Why does it exist?
Part 3.3 showed that a tool definition needs a name, a description, and a JSON Schema for its arguments, and that this schema is typically derived from a Pydantic model. Hand-writing this schema for every tool is repetitive boilerplate when the information is already fully present in a well-typed Python function's signature and docstring. LangChain's @tool decorator exists to derive the tool definition automatically from your function's type hints and docstring, so the schema stays in sync with the implementation by construction, rather than as two separately-maintained artifacts that can drift out of sync.
3. What problem does it solve?
It solves "keep a tool's schema and its implementation as one single source of truth" — directly preventing the class of bug where a tool's documented schema and its actual Python implementation quietly diverge over time as one is updated and the other isn't.
4. How does it work internally?
@tool — deriving a schema from a function signature
python
from langchain_core.tools import tool
@tool
async def get_order_status(order_id: str) -> dict:
"""Looks up the current status of an order by its ID."""
return await db_lookup_order(order_id)Under the hood, the @tool decorator inspects the function's type hints (order_id: str) to build the argument schema (equivalent to hand-writing a Pydantic model with an order_id: str field, Part 3.3's example), uses the function's docstring as the tool's description (exactly the field Part 3.3 emphasized as doing real, measurable work in the model's tool-selection decision — meaning a vague docstring here produces exactly the unreliable tool-selection Part 3.3 warned about), and wraps the function so that calling the resulting BaseTool object executes your original function and returns its result in the tool-result format the model expects. This is precisely the same request/execute/result-return cycle from Part 3.3, section 4 — @tool only automates the schema-generation boilerplate, not the underlying mechanism.
Explicit schemas for more control
python
from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool
class OrderStatusArgs(BaseModel):
"""Arguments for looking up an order's status."""
order_id: str = Field(description="The order's unique identifier")
order_status_tool = StructuredTool.from_function(
coroutine=get_order_status_impl,
name="get_order_status",
description="Looks up the current status of an order by its ID.",
args_schema=OrderStatusArgs,
)When you need field-level descriptions (as Part 3.3 argued matters for reliable model tool-selection at the argument level, not just the tool level) or validation logic beyond what type hints alone express, defining an explicit Pydantic args_schema and using StructuredTool.from_function gives you that control directly, at the cost of slightly more boilerplate than the plain @tool decorator.
How bound tools reach the model
model.bind_tools([get_order_status]) (Part 4.2) takes your BaseTool objects, extracts their name/description/schema, and passes them to the underlying provider exactly the way Part 3.3's raw examples constructed the tools list manually — bind_tools is doing that schema-extraction and provider-specific formatting work for you, using the schema @tool (or StructuredTool) already derived.
5. Simple mental model
The @tool decorator is like generating a form automatically from a database table's column definitions instead of hand-typing the form's field labels and types separately from the table schema — the two artifacts (the function's real signature and the tool's advertised schema) can't drift apart because one is mechanically derived from the other.
6. Real-world example
A team maintaining dozens of internal tools initially hand-wrote each tool's JSON Schema separately from its Python implementation. A refactor that changed a function's parameter from order_id: str to order_id: int (a real type change) didn't get reflected in the separately-maintained schema, causing a subtle bug where the model would occasionally send a string that the underlying function's changed type expectations handled inconsistently. Migrating to the @tool decorator (schema derived directly from the function signature) made this entire class of drift-based bug structurally impossible, since there's no longer a second, separately-maintained artifact to forget to update.
7. Architecture diagram
Python functiontype hints, docstring, implementation
BaseTool objectname, description, schema, execution — all from ONE source
Modelprovider-specific format · Part 3.3 mechanism
8. Production considerations
- Write the docstring as carefully as you would a hand-written tool description (Part 3.3, section 4) — since it's the actual
descriptionfield the model sees and uses for tool-selection, a lazy one-line docstring produces exactly the unreliable tool-selection Part 3.3 warned against, regardless of how good the underlying implementation is. - Use explicit
args_schema(viaStructuredToolor a Pydantic model withField(description=...)) when argument-level descriptions matter for reliable model behavior — plain type hints alone don't carry the same semantic guidance aField(description=...)does. - Every security/authorization consideration from Part 3.3 applies identically —
@toolonly automates schema derivation; it does nothing to enforce authorization, validate business rules, or bound execution, all of which remain your explicit responsibility inside the function body.
9. Common mistakes
- Writing a minimal, vague docstring on a
@tool-decorated function, not realizing it directly becomes the tool description driving the model's (unreliable, as a result) tool-selection behavior. - Assuming
@tool's automatic schema derivation means argument validation is fully handled — type hints alone give you type-shape validation, not the semantic validation (Part 3.2, section 9's business-rule checks) your function still needs to implement explicitly. - Forgetting that a
@tool-decorated async function must actually be awaited correctly within whatever agent loop or chain invokes it — mixing sync and async tool implementations inconsistently within one agent's tool list can reproduce Part 1.1's blocking-call pitfall.
10. Security considerations
- Identical to Part 3.3 in full —
@tooldoesn't add or remove any security properties; authorization, argument validation beyond type-shape, and bounding of destructive actions must still be implemented explicitly inside the decorated function, exactly as in Part 3.3's examples.
11. Performance considerations
- No meaningful performance difference between
@tool-decorated functions and hand-builtBaseTool/StructuredToolinstances — the decorator is a schema-generation convenience, not a different execution path.
12. Cost considerations
- Identical to Part 3.3 — tool definitions (name, description, schema) are included in every request's token count (Part 3.3, section 12) regardless of whether they were defined via
@toolor hand-built; verbose docstrings across many registered tools still add up.
13. When to use it
For essentially all LangChain tool definitions where the function's type hints and a well-written docstring are sufficient to describe the tool accurately — which covers most tools in practice.
14. When NOT to use it
When you need field-level argument descriptions, custom validation logic beyond type hints, or non-function-shaped tool behavior — use an explicit args_schema with StructuredTool or a hand-built BaseTool subclass instead.
15. Alternatives and trade-offs
| Approach | Good for | Weak point |
|---|---|---|
@tool decorator | Fast, schema derived automatically from function signature | Less control over field-level descriptions/validation |
StructuredTool.from_function with explicit args_schema | Field-level descriptions, custom Pydantic validation | More boilerplate |
Hand-built BaseTool subclass | Maximum control over execution/schema | Most boilerplate; rarely needed given the above options |
16. Practical Python/code example
python
from langchain_core.tools import tool
@tool
async def get_order_status(order_id: str) -> dict:
"""
Looks up the current status and estimated delivery date of an order by its ID.
Use this whenever the user asks about the status or location of a specific order.
"""
order = await db_lookup_order(order_id=order_id)
if order is None:
return {"error": "order not found"}
return {"status": order.status, "eta": str(order.eta)}Note the docstring is deliberately specific about when to use the tool, not just what it does — directly applying Part 3.3's guidance on writing tool descriptions that produce reliable model tool-selection.
17. Production-quality example
An explicit args_schema tool with field-level descriptions and enforced user-scoping, combining this chapter's schema-control option with Part 3.3's authorization discipline:
python
from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool
import logging
logger = logging.getLogger("order_tool")
class OrderStatusArgs(BaseModel):
"""Arguments for looking up an order's status."""
order_id: str = Field(description="The numeric or alphanumeric order identifier, exactly as shown to the customer")
async def _get_order_status_impl(order_id: str, current_user_id: str) -> dict:
"""
Looks up an order's status, enforced to only return orders belonging to
the current authenticated user.
Args:
order_id (str): The order identifier requested.
current_user_id (str): The authenticated user, injected by the caller,
never trusted from model-generated arguments.
Returns:
dict: The order's status, or an explicit not-found/unauthorized result.
"""
order = await db_lookup_order(order_id=order_id, user_id=current_user_id)
if order is None:
logger.info("order lookup denied or not found: order_id=%s user=%s", order_id, current_user_id)
return {"error": "order not found or not accessible"}
return {"status": order.status, "eta": str(order.eta)}
def build_order_status_tool(current_user_id: str) -> StructuredTool:
"""
Builds a per-request, user-scoped order status tool — current_user_id is
bound at tool-construction time from the authenticated session, not left
for the model to supply as an argument.
Args:
current_user_id (str): The authenticated user this tool instance is scoped to.
Returns:
StructuredTool: A tool bound to this specific user's authorization scope.
"""
async def bound_impl(order_id: str) -> dict:
return await _get_order_status_impl(order_id=order_id, current_user_id=current_user_id)
return StructuredTool.from_function(
coroutine=bound_impl,
name="get_order_status",
description="Looks up the current status of an order belonging to the current user, by order ID.",
args_schema=OrderStatusArgs,
)Binding current_user_id at tool-construction time (a closure over the authenticated session) rather than exposing it as a model-suppliable argument is the concrete implementation of Part 3.3's core security principle: authorization scope comes from your application's trusted context, never from something the model could be manipulated into supplying.
18. Short exercise
Take the _get_order_status_impl function above and add a second tool, cancel_order, following the same per-user-scoped construction pattern. Then write a short note on what additional safeguard (beyond user-scoping) you'd add specifically because this tool has a real, destructive side effect — referencing Part 3.3's exercise on the same distinction.
19. Interview questions
- What does the
@tooldecorator actually derive automatically, and from what source, ensuring it stays in sync with the implementation? - Why would you choose
StructuredTool.from_functionwith an explicitargs_schemaover the plain@tooldecorator? - Why should a tool's user-scoping (e.g.,
current_user_id) be bound at construction time via a closure rather than exposed as a model-suppliable argument?
20. FDE/customer scenario
Customer's engineering team: "Our agent sometimes calls the wrong tool even though the code is correct — what's going on?"
Given this chapter's emphasis, a strong first diagnostic question: what do the tools' docstrings actually say? Since @tool's derived description is exactly what drives the model's tool-selection judgment (Part 3.3), a vague or overlapping docstring across multiple tools is a very common, easily-fixed root cause of unreliable tool selection — often a five-minute fix once correctly diagnosed, rather than a deeper architectural problem.
Key takeaways
@toolderives a tool's schema from its function signature and docstring, keeping schema and implementation as one source of truth.- The docstring becomes the tool's actual
descriptionfield — write it with the same care Part 3.3 argued for, since it directly drives model tool-selection reliability. - Authorization and business-rule validation remain entirely your responsibility inside the tool's implementation — the decorator only automates schema generation.
Things you should be able to explain
- What
@toolderives automatically and from what. - Why binding user-scoping at tool-construction time (closure) is safer than a model-suppliable argument.
Things you should be able to build
- A per-user-scoped
StructuredToolwith field-level argument descriptions and enforced authorization.
Common mistakes
- Vague docstrings producing unreliable tool selection.
- Assuming type-hint-derived schemas provide semantic/business-rule validation.
Recommended next chapter
05-retrievers-document-processing.md