DEV Community

Cover image for From Software Engineer to AI Engineer - Part 3: Giving it a hand
BjornvdLaan
BjornvdLaan

Posted on

From Software Engineer to AI Engineer - Part 3: Giving it a hand

As we have seen, models are conceptually quite simple: a list of input messages goes in and the model predicts the next token repeatedly to form the output message. It can't reach the internet or your database or Slack. And it cannot reliably do arithmetic or (cringe example incoming) tell you the number of r's in 'strawberry' - remember from part 1 that it never sees the letters, only token IDs.

In that sense, the model is like a brain without a body. It can reason through a problem, describe the steps to take, even recite all of Shakespeare. But without a body, the model cannot perform any action. This is where tools come in, and making them is very much like normal software engineering.

What a tool actually is

Tools are just functions or methods in a programming language. How tool use works under the hood depends on the model, but let's walk through it conceptually. It starts with a catalog of tools that the model can use, often a json list. In the API you pass this catalog as a separate tools parameter and the provider injects it for you:

[
  {
    "name": "multiply",
    "description": "Multiplies two numbers and returns the result.",
    "parameters": {
      "type": "object",
      "properties": {
        "a": {
          "type": "number",
          "description": "The first number."
        },
        "b": {
          "type": "number",
          "description": "The second number."
        }
      },
      "required": ["a", "b"]
    }
  },
  [..more tools..]
]
Enter fullscreen mode Exit fullscreen mode

If the model decides that it wants to use a tool, its output message will be a structured message like we saw in article 2:

{
  "name": "multiply",
  "arguments": {
    "a": 24,
    "b": 57
  }
}
Enter fullscreen mode Exit fullscreen mode

Your job as an AI engineer is to take such outputs and pass them to (Python) functions that actually perform the action. Then you respond with a structured message:

Tool result (multiply):

{
  "result": 1368
}
Enter fullscreen mode Exit fullscreen mode

The tool result is now part of the model's context window and can, therefore, be used for further reasoning.

So how did the model learn to do this? The model was trained on large volumes of tool-use transcripts, so requesting a tool and reading back a result is a pattern it has seen many times. Your tool catalog is the variable part, the runtime configuration if you will. Let's now look at a real example.

PayIQ gets its first tool

Payment operations requires deterministic arithmetic, and models are not reliable calculators. Create app/tools.py (and an empty app/__init__.py so we can import our tools). We will create a tool that calculates refund costs.

from langchain_core.tools import tool

@tool
def calculate_refund_cost(
    original_charge_eur: float,
    refund_amount_eur: float,
    processing_fee_pct: float,
    processing_fee_fixed_eur: float,
    refund_admin_fee_eur: float = 0.25,
) -> dict:
    """Calculate what refunding a payment actually costs the merchant.

    Use this whenever the user gives you (a) the original charge amount
    and (b) the payment method's fee structure (percentage plus fixed fee
    per transaction). Do not guess the fee structure yourself if the user
    hasn't provided it.

    Processing fees paid on the original charge are NOT returned by the
    processor when you refund, and most processors charge a small admin
    fee per refund on top.

    The returned total_cost_of_refund_eur represents the merchant's total
    out-of-pocket cost after the refund is processed:
    refunded amount + non-refundable original processing fee + refund admin fee.

    Limitations:
    - Assumes the original payment processing fee is fully retained by the
      processor after a refund. Some processors, payment methods, or regions
      may have different refund fee policies.
    - Does not account for currency conversion costs, exchange-rate changes,
      taxes, accounting impacts, subscription adjustments, or other
      business-specific costs.

    Args:
        original_charge_eur: The amount of the original charge.
        refund_amount_eur: The amount being refunded (full or partial).
        processing_fee_pct: Percentage fee on the original charge,
            e.g. 1.8 for 1.8%.
        processing_fee_fixed_eur: Fixed fee on the original charge.
        refund_admin_fee_eur: Processor's per-refund admin fee, default
            €0.25.

    Returns:
        A dictionary containing:
        - refund_amount_eur: The amount sent back to the customer.
        - processing_fee_eur: The original payment processing
          fee that the merchant still pays after issuing the refund.
        - total_cost_of_refund_eur: The merchant's total out-of-pocket cost.
          This includes the money returned to the customer and the processing fees.
    """
    processing_fee = (
        original_charge_eur * processing_fee_pct / 100
        + processing_fee_fixed_eur
    )

    total = refund_amount_eur + refund_admin_fee_eur + processing_fee

    return {
        "refund_amount_eur": round(refund_amount_eur, 2),
        "processing_fee_eur": round(processing_fee, 2),
        "total_cost_of_refund_eur": round(total, 2),
    }
Enter fullscreen mode Exit fullscreen mode

Docstrings have always been important guidance for programmers deciding which function to use, and they now fulfill the same role for models choosing between tools. And models actually read the docs, so docstrings are arguably more important than ever.

Three tips for writing tool docstrings:

  1. Say when, not just what. "Use this whenever the user gives you (a)... and (b)..." - that is routing logic helping the model choose between tools. A docstring that only describes the computation leaves the when to chance.

  2. Set boundaries. "Do not guess the fee structure yourself" might be the most crucial logic in the whole tool definition. Hallucinating the fee structure will impact the calculation heavily. Another example is to explicitly tell the model not to underestimate the costs even if the user keeps asking for a better answer. Assistants without such boundaries have made real headlines: a car dealership's chatbot was talked into "agreeing" to sell a Chevy Tahoe for one dollar, and Air Canada was held liable in court for a refund policy its chatbot invented. Boundaries matter.

  3. Be frank about limitations. Every piece of logic has assumptions. For example, our simple refund tool assumes euros and does not take into account cross-border payments with multiple currencies. Making these limitations explicit is useful because it prevents the model from over-trusting the calculation. These limitations are often passed in the output to the user as well.

Let's try our new calculate_refund_cost tool. Create 03_tool_manual.py, which imports the tool we just wrote and binds it to the model using bind_tools:

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from app.tools import calculate_refund_cost
import pprint

load_dotenv()

model = init_chat_model("anthropic:claude-sonnet-5")
model_with_tools = model.bind_tools(
    [calculate_refund_cost]
)

question = ("Customer paid €480 on a European consumer card (1.8% + €0.25). "
            "Full refund - what does it cost us?")
msg = model_with_tools.invoke(question)

pprint.pp(msg)
Enter fullscreen mode Exit fullscreen mode

And you'll see something like (I truncated longer texts for your reading convenience):

$ python3 03_tool_manual.py

AIMessage(
    content=[...],
    tool_calls=[
        {
            "name": "calculate_refund_cost",
            "args": {
                "original_charge_eur": 480,
                "refund_amount_eur": 480,
                "processing_fee_pct": 1.8,
                "processing_fee_fixed_eur": 0.25,
            },
            "id": "toolu_011GofEYU4Vz1...",
            "type": "tool_call",
        }
    ],
    invalid_tool_calls=[],
    usage_metadata={
        "input_tokens": 1259,
        "output_tokens": 224,
        "total_tokens": 1483,
        "input_token_details": {[...]},
    },
)
Enter fullscreen mode Exit fullscreen mode

The output is not an answer to your question. It's a tool request. Your application logic should check if tool_calls is set, invoke the tools, and then pass the results back to the model to resume. Note that tool_calls is a list: a model can request several tools in one turn, so always loop over it instead of grabbing the first entry.

The snippet below, 03_tool_manual_2.py, does two rounds with the model. First, we pass it the question and the model decides to respond with a tool request. Then, we respond with the tool results and the model gives us the final answer.

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.messages import ToolMessage

from app.tools import calculate_refund_cost

load_dotenv()

model = init_chat_model("anthropic:claude-sonnet-5")
model_with_tools = model.bind_tools([calculate_refund_cost])

question = (
    "Customer paid €480 on a European consumer card (1.8% + €0.25). "
    "Full refund - what does it cost us?"
)
msg = model_with_tools.invoke(question)
print(msg)

tool_map = {
    "calculate_refund_cost": calculate_refund_cost
}

tool_messages = []
for tool_call in msg.tool_calls:
    # retrieve function by name
    tool = tool_map[tool_call["name"]]
    # call the function
    result = tool.invoke(tool_call["args"])
    # store result
    tool_messages.append(
        ToolMessage(content=str(result), tool_call_id=tool_call["id"])
    )

# give all messages as input, including the tool messages
next_msg = model_with_tools.invoke(
    [{"role": "user", "content": question}, msg, *tool_messages]
)
print(next_msg.content)
Enter fullscreen mode Exit fullscreen mode

Pro tip: if a tool raises, don't let the exception escape. Catch it and send the error text back as the ToolMessage content. The model can then fix its arguments or explain the problem to the user, whereas an unhandled exception just kills the turn.

And the result:

$ python3 03_tool_manual_2.py

Here's the breakdown for a full refund on the €480 charge
(European consumer card, 1.8% + €0.25):

| Item                                      | Amount  |
|-------------------------------------------|--------:|
| Refunded to customer                      | €480.00 |
| Original processing fee (non-refundable)  | €8.89   |
| Refund admin fee                          | €0.25   |
| **Total out-of-pocket cost**              | **€489.14** |

So issuing the full refund actually costs you **€489.14**:
- €480.00 returned to the customer
- €8.89 original processing fee (not refunded)
- €0.25 refund processing/admin fee
Enter fullscreen mode Exit fullscreen mode

Our calculate_refund_cost tool is a simple example, but I hope you see how powerful this tool usage can be. You can make tools to search on the internet, to select data from your database, you could even let it send out emails or write to a queue. Anything you could do with normal Python code can now be integrated with the model. Tool usage is the integration point between these two worlds.

The next two articles dive into advanced tool categories: knowledge retrieval via RAG and API integration via MCP. You might have heard about these three-letter acronyms and we'll learn what they actually mean.

Top comments (0)