{"slug": "from-software-engineer-to-ai-engineer-part-3-giving-it-a-hand", "title": "From Software Engineer to AI Engineer - Part 3: Giving it a hand", "summary": "A developer's blog series on transitioning from software engineering to AI engineering demonstrates how to give large language models 'hands' through tool use. The latest installment explains that models are like brains without bodies and shows how to define tools as Python functions, using a payment refund cost calculator as an example. The post details the mechanics of tool catalogs, structured outputs, and how tool results become part of the model's context for further reasoning.", "body_md": "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.\n\nIn 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.\n\nTools 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`\n\nparameter and the provider injects it for you:\n\n```\n[\n  {\n    \"name\": \"multiply\",\n    \"description\": \"Multiplies two numbers and returns the result.\",\n    \"parameters\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"a\": {\n          \"type\": \"number\",\n          \"description\": \"The first number.\"\n        },\n        \"b\": {\n          \"type\": \"number\",\n          \"description\": \"The second number.\"\n        }\n      },\n      \"required\": [\"a\", \"b\"]\n    }\n  },\n  [..more tools..]\n]\n```\n\nIf the model decides that it wants to use a tool, its output message will be a structured message like we saw in article 2:\n\n```\n{\n  \"name\": \"multiply\",\n  \"arguments\": {\n    \"a\": 24,\n    \"b\": 57\n  }\n}\n```\n\nYour 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:\n\n```\nTool result (multiply):\n\n{\n  \"result\": 1368\n}\n```\n\nThe tool result is now part of the model's context window and can, therefore, be used for further reasoning.\n\nSo 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.\n\nPayment operations requires deterministic arithmetic, and models are not reliable calculators. Create `app/tools.py`\n\n(and an empty `app/__init__.py`\n\nso we can import our tools). We will create a tool that calculates refund costs.\n\n``` python\nfrom langchain_core.tools import tool\n\n@tool\ndef calculate_refund_cost(\n    original_charge_eur: float,\n    refund_amount_eur: float,\n    processing_fee_pct: float,\n    processing_fee_fixed_eur: float,\n    refund_admin_fee_eur: float = 0.25,\n) -> dict:\n    \"\"\"Calculate what refunding a payment actually costs the merchant.\n\n    Use this whenever the user gives you (a) the original charge amount\n    and (b) the payment method's fee structure (percentage plus fixed fee\n    per transaction). Do not guess the fee structure yourself if the user\n    hasn't provided it.\n\n    Processing fees paid on the original charge are NOT returned by the\n    processor when you refund, and most processors charge a small admin\n    fee per refund on top.\n\n    The returned total_cost_of_refund_eur represents the merchant's total\n    out-of-pocket cost after the refund is processed:\n    refunded amount + non-refundable original processing fee + refund admin fee.\n\n    Limitations:\n    - Assumes the original payment processing fee is fully retained by the\n      processor after a refund. Some processors, payment methods, or regions\n      may have different refund fee policies.\n    - Does not account for currency conversion costs, exchange-rate changes,\n      taxes, accounting impacts, subscription adjustments, or other\n      business-specific costs.\n\n    Args:\n        original_charge_eur: The amount of the original charge.\n        refund_amount_eur: The amount being refunded (full or partial).\n        processing_fee_pct: Percentage fee on the original charge,\n            e.g. 1.8 for 1.8%.\n        processing_fee_fixed_eur: Fixed fee on the original charge.\n        refund_admin_fee_eur: Processor's per-refund admin fee, default\n            €0.25.\n\n    Returns:\n        A dictionary containing:\n        - refund_amount_eur: The amount sent back to the customer.\n        - processing_fee_eur: The original payment processing\n          fee that the merchant still pays after issuing the refund.\n        - total_cost_of_refund_eur: The merchant's total out-of-pocket cost.\n          This includes the money returned to the customer and the processing fees.\n    \"\"\"\n    processing_fee = (\n        original_charge_eur * processing_fee_pct / 100\n        + processing_fee_fixed_eur\n    )\n\n    total = refund_amount_eur + refund_admin_fee_eur + processing_fee\n\n    return {\n        \"refund_amount_eur\": round(refund_amount_eur, 2),\n        \"processing_fee_eur\": round(processing_fee, 2),\n        \"total_cost_of_refund_eur\": round(total, 2),\n    }\n```\n\nDocstrings 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.\n\nThree tips for writing tool docstrings:\n\n**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.\n\n**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.\n\n**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.\n\nLet's try our new `calculate_refund_cost`\n\ntool. Create `03_tool_manual.py`\n\n, which imports the tool we just wrote and binds it to the model using `bind_tools`\n\n:\n\n``` python\nfrom dotenv import load_dotenv\nfrom langchain.chat_models import init_chat_model\nfrom app.tools import calculate_refund_cost\nimport pprint\n\nload_dotenv()\n\nmodel = init_chat_model(\"anthropic:claude-sonnet-5\")\nmodel_with_tools = model.bind_tools(\n    [calculate_refund_cost]\n)\n\nquestion = (\"Customer paid €480 on a European consumer card (1.8% + €0.25). \"\n            \"Full refund - what does it cost us?\")\nmsg = model_with_tools.invoke(question)\n\npprint.pp(msg)\n```\n\nAnd you'll see something like (I truncated longer texts for your reading convenience):\n\n``` bash\n$ python3 03_tool_manual.py\n\nAIMessage(\n    content=[...],\n    tool_calls=[\n        {\n            \"name\": \"calculate_refund_cost\",\n            \"args\": {\n                \"original_charge_eur\": 480,\n                \"refund_amount_eur\": 480,\n                \"processing_fee_pct\": 1.8,\n                \"processing_fee_fixed_eur\": 0.25,\n            },\n            \"id\": \"toolu_011GofEYU4Vz1...\",\n            \"type\": \"tool_call\",\n        }\n    ],\n    invalid_tool_calls=[],\n    usage_metadata={\n        \"input_tokens\": 1259,\n        \"output_tokens\": 224,\n        \"total_tokens\": 1483,\n        \"input_token_details\": {[...]},\n    },\n)\n```\n\nThe output is not an answer to your question. It's a tool request. Your application logic should check if `tool_calls`\n\nis set, invoke the tools, and then pass the results back to the model to resume. Note that `tool_calls`\n\nis a list: a model can request several tools in one turn, so always loop over it instead of grabbing the first entry.\n\nThe snippet below, `03_tool_manual_2.py`\n\n, 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.\n\n``` python\nfrom dotenv import load_dotenv\nfrom langchain.chat_models import init_chat_model\nfrom langchain_core.messages import ToolMessage\n\nfrom app.tools import calculate_refund_cost\n\nload_dotenv()\n\nmodel = init_chat_model(\"anthropic:claude-sonnet-5\")\nmodel_with_tools = model.bind_tools([calculate_refund_cost])\n\nquestion = (\n    \"Customer paid €480 on a European consumer card (1.8% + €0.25). \"\n    \"Full refund - what does it cost us?\"\n)\nmsg = model_with_tools.invoke(question)\nprint(msg)\n\ntool_map = {\n    \"calculate_refund_cost\": calculate_refund_cost\n}\n\ntool_messages = []\nfor tool_call in msg.tool_calls:\n    # retrieve function by name\n    tool = tool_map[tool_call[\"name\"]]\n    # call the function\n    result = tool.invoke(tool_call[\"args\"])\n    # store result\n    tool_messages.append(\n        ToolMessage(content=str(result), tool_call_id=tool_call[\"id\"])\n    )\n\n# give all messages as input, including the tool messages\nnext_msg = model_with_tools.invoke(\n    [{\"role\": \"user\", \"content\": question}, msg, *tool_messages]\n)\nprint(next_msg.content)\n```\n\nPro tip: if a tool raises, don't let the exception escape. Catch it and send the error text back as the `ToolMessage`\n\ncontent. The model can then fix its arguments or explain the problem to the user, whereas an unhandled exception just kills the turn.\n\nAnd the result:\n\n``` bash\n$ python3 03_tool_manual_2.py\n\nHere's the breakdown for a full refund on the €480 charge\n(European consumer card, 1.8% + €0.25):\n\n| Item                                      | Amount  |\n|-------------------------------------------|--------:|\n| Refunded to customer                      | €480.00 |\n| Original processing fee (non-refundable)  | €8.89   |\n| Refund admin fee                          | €0.25   |\n| **Total out-of-pocket cost**              | **€489.14** |\n\nSo issuing the full refund actually costs you **€489.14**:\n- €480.00 returned to the customer\n- €8.89 original processing fee (not refunded)\n- €0.25 refund processing/admin fee\n```\n\nOur `calculate_refund_cost`\n\ntool 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.\n\nThe 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.", "url": "https://wpnews.pro/news/from-software-engineer-to-ai-engineer-part-3-giving-it-a-hand", "canonical_source": "https://dev.to/bjornvdlaan/from-software-engineer-to-ai-engineer-part-3-2o9k", "published_at": "2026-08-01 22:00:00+00:00", "updated_at": "2026-08-01 22:12:23.978115+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools"], "entities": ["LangChain"], "alternates": {"html": "https://wpnews.pro/news/from-software-engineer-to-ai-engineer-part-3-giving-it-a-hand", "markdown": "https://wpnews.pro/news/from-software-engineer-to-ai-engineer-part-3-giving-it-a-hand.md", "text": "https://wpnews.pro/news/from-software-engineer-to-ai-engineer-part-3-giving-it-a-hand.txt", "jsonld": "https://wpnews.pro/news/from-software-engineer-to-ai-engineer-part-3-giving-it-a-hand.jsonld"}}