LangChain agents: tools, the loop and capping spending

We build an agent with create_agent and two tools and run it on a model through an OpenAI-compatible gateway, then cap the steps and count tokens.

5 min · · агенты · development

LangChain agents are a model that calls your tool functions in a loop until it solves the task. The loop is simple, but its bill grows non-linearly: every step resends the whole accumulated history. In this article we build an agent with create_agent and two tools, run it on a model through an OpenAI-compatible gateway, and go through three layers of protection against extra steps. The code follows the LangChain documentation (the Agents and Tools sections); packages change quickly, so check versions and signatures against it.

LangChain agents: how the loop works

The documentation defines an agent as a model that calls tools in a loop until the task is done, and everything around the loop (prompt, tools, middleware) is called the harness. One turn looks like this:

  1. The model receives the messages and the tool descriptions.
  2. If the reply contains tool calls, LangChain runs them and appends the results to the history as result messages.
  3. The model is called again, now with the results.
  4. When the reply has no tool calls, that is the final answer.

This scheme is known as ReAct (reasoning and acting). In older examples you will see create_react_agent from LangGraph; the current LangChain documentation uses create_agent for this.

Connecting a model through the gateway

In the documentation examples the model is given as a "provider:model" string. For your own address pass a ready ChatOpenAI object. For OpenAI clients the Zukko base ends with /v1:

bash
pip install langchain langchain-openai
export ZUKKO_API_KEY="fetch_your_key"
bash
import os
from langchain_openai import ChatOpenAI

model = ChatOpenAI(
    model="gpt-6.1-sol",
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["ZUKKO_API_KEY"],
    max_tokens=500,
)

The max_tokens parameter limits the length of each model reply, which already cuts the upper bound of output. You can put any other gateway model into model; the list comes from GET https://api.zukko.pro/v1/models. Connection is covered in more detail in LangChain: how to set our base_url.

Two tools with @tool

A tool is an ordinary function with the @tool decorator from langchain.tools. The model sees the name, the typed arguments and the docstring, so the description must be precise.

bash
from langchain.agents import create_agent
from langchain.tools import tool

ORDERS = {
    "A-1001": {"status": "in transit", "total": 4200.0},
    "A-1002": {"status": "being packed", "total": 980.0},
}


@tool
def get_order(order_id: str) -> dict:
    """Find an order by number and return its status and total in rubles."""
    return ORDERS.get(order_id, {"error": "order not found"})

A function with type annotations is enough: LangChain builds the argument schema itself. If the model mixes up tools or does not call them, start with the docstring and the system prompt, and then see the article the agent answers "no access to files". Models do not all call tools equally well, so test your choice on your own set of tasks.

How to cap steps and token spending

An unrestricted agent can loop: the model calls the same tool again and again. There are three layers of protection, and it is best to enable all of them.

1. Limit middleware

LangChain has ready-made middleware: ModelCallLimitMiddleware limits the number of model calls and ToolCallLimitMiddleware limits the number of tool calls. Both accept run_limit (per invoke call) and thread_limit (for the whole conversation; it needs a checkpointer).

bash
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware

agent = create_agent(
    model=model,
    tools=[get_order, apply_discount],
    system_prompt="You are a shop assistant. Take order data only from the tools.",
    middleware=[
        ModelCallLimitMiddleware(run_limit=6, exit_behavior="end"),
        ToolCallLimitMiddleware(run_limit=8),
    ],
)

exit_behavior="end" finishes gracefully, "error" raises an exception. In ToolCallLimitMiddleware by default the calls over the limit are blocked with an error message, and the model decides what to do next.

2. Graph step limit

A LangChain agent runs on the LangGraph runtime, which has a recursion_limit: the maximum number of steps in one run. According to the LangGraph documentation, exceeding it raises GraphRecursionError, and the default in new versions is 1000, which is far too high to protect money. Set your own in the call config:

bash
result = agent.invoke(
    {"messages": [{"role": "user", "content": "How much will the customer pay for order A-1001 with a 15% discount?"}]},
    config={"recursion_limit": 15},
)

The recursion_limit key sits at the root of the config, not inside configurable. Graph steps are counted, not model calls: one "model, then tools" turn takes roughly two steps.

3. Key limit on the gateway side

The first two protections live in your code and will not save you from a bug in that code. The third works independently: every Zukko key has a daily and a monthly limit in dollars and a list of allowed models. When the limit is used up, the API returns 429 with the code key_daily_limit_reached or key_monthly_limit_reached. Create a separate key for the agent: key limits and cache, and also the article spending limits for agents.

Counting tokens after a run

Every model message stores usage_metadata. Let us sum it over the whole history and convert it to dollars at Zukko prices as of October 18, 2026:

bash
from langchain_core.messages import AIMessage

PRICES = {"gpt-6.1-sol": (0.27, 1.32)}  # $ per 1M tokens: input, output


def report(result, model_name):
    calls = tokens_in = tokens_out = 0
    for m in result["messages"]:
        if isinstance(m, AIMessage) and m.usage_metadata:
            calls += 1
            tokens_in += m.usage_metadata["input_tokens"]
            tokens_out += m.usage_metadata["output_tokens"]
    p_in, p_out = PRICES[model_name]
    cost = (tokens_in * p_in + tokens_out * p_out) / 1_000_000
    print(f"model calls: {calls}, input: {tokens_in}, output: {tokens_out}, ${cost:.6f}")


report(result, "gpt-6.1-sol")

What an agent costs: the math

Say the agent makes three model calls: it calls a tool twice and answers once. Input grows with each step because the history is resent: 400, 550 and 700 tokens, 1,650 in total. Output is 40, 40 and 80, 160 in total. Prices as of October 18, 2026, OpenAI format:

ModelPrice in / out, $ per 1MMathTotal
gpt-6.1-sol0.27 / 1.321650 × 0.27 + 160 × 1.32 = 445.5 + 211.2 micro-dollars$0.00066
claude-sonnet-5-50.4 / 21650 × 0.4 + 160 × 2 = 660 + 320$0.00098
deepseek-v4.1-flash0.013 / 0.0381650 × 0.013 + 160 × 0.038 = 21.45 + 6.08$0.0000275

The math is in millionths of a dollar and is divided by 1,000,000. Now the worst case under the limits above: six model calls, input growing by 150 tokens per step (400, 550, 700, 850, 1000, 1150), 4,650 in total, and output capped by max_tokens=500 per call, up to 3,000. For gpt-6.1-sol: 4650 × 0.27 / 1M + 3000 × 1.32 / 1M = 0.00126 + 0.00396 = $0.0052. The numbers are small, but a production agent gets thousands of requests a day, and without limits the cost of a loop grows faster than the number of steps, because every step resends the history. If you use Claude in the OpenAI format, remember that the cache discount applies only in the Anthropic format.

What to choose: create_agent or Deep Agents

create_agent fits when you have a few tools and a clear scenario. For long tasks with files, subagents and context summarization LangChain has the Deep Agents library. If a framework feels like too much, the same model-tool loop can be written by hand on the OpenAI SDK in a few dozen lines.

FAQ

How does create_agent differ from create_react_agent?

They are the same class of solution (a model-tool loop), but the current LangChain documentation recommends create_agent. It is configured through system_prompt, tools, middleware and response_format.

The model does not call the tool. What should I check?

The docstring and argument names, the wording of the system prompt, and the model itself: tool-calling support differs between models, so compare two or three on your own requests.

Do I need a checkpointer?

For memory between runs and for thread_limit, yes. For a single run with run_limit, no.

Can I see spending without counting in code?

Yes, the balance and spending history are in the "Usage" section of the dashboard and via the API (the balance and usage endpoints are described in the Zukko docs).

Create a separate key with a daily limit in the dashboard and run the example on your model.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

LangChain agents: tools and spending limits · Zukko