n4nAI

Building an agent with open-weight models

Step-by-step guide to building a production-ready agent using open-weight models like Llama 4 and Mistral with OpenAI-compatible APIs and tool calls.

n4n Team4 min read770 words

Audio narration

Coming soon — every post will get a voice note here.

Building an open-weight model agent gives you control over cost, data residency, and model behavior without locking into a proprietary API. This guide walks through standing up a functional agent using open-weight models such as Llama 4, Mistral, Qwen, or DeepSeek behind an OpenAI-compatible interface, so your existing SDK code works unchanged.

Step 1: Choose a model and stand up an endpoint

You have two practical paths: self-host with vLLM or TGI, or route through a gateway that already aggregates open-weight providers. Self-hosting gives maximum isolation; a gateway reduces operational burden and hides provider outages.

If you want a single OpenAI-compatible endpoint that addresses 240+ models—including the open-weight ones in this cluster—and automatically falls back when a provider is rate-limited or degraded, point your client at a service like n4n.ai. That removes the need to write your own fallback logic for every provider.

For self-hosting, launch vLLM with tool choice enabled:

vllm serve meta-llama/Llama-4-Scout --port 8000 --enable-auto-tool-choice

Then point the OpenAI client at http://localhost:8000/v1.

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",  # or "https://api.n4n.ai/v1"
    api_key="EMPTY_OR_REAL_KEY",
)

MODEL = "meta-llama/Llama-4-Scout"  # or "mistralai/Mistral-Large", "Qwen/Qwen2.5-72B", "deepseek-ai/DeepSeek-V3", "xai/Grok-1"

Picking among Llama 4, Mistral, Qwen, DeepSeek, Grok

All five publish weights and support instruction tuning. Llama 4 and Mistral-Large have the most consistent function-calling behavior in my experience. Qwen and DeepSeek handle tools well but sometimes need a stricter system prompt. Grok-1 is viable for experimentation but has a smaller ecosystem of ready-made serving templates. Choose based on your GPU budget and latency target; none of these require a proprietary account.

Step 2: Define the agent’s tools

An open-weight model agent acts by emitting structured tool calls. Define each tool as a JSON schema and map it to a Python callable.

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Return current temperature in Celsius for a city.",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {"type": "string", "description": "City name"}
                },
                "required": ["location"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "celsius_to_fahrenheit",
            "description": "Convert a Celsius temperature to Fahrenheit.",
            "parameters": {
                "type": "object",
                "properties": {"celsius": {"type": "number"}},
                "required": ["celsius"],
            },
        },
    },
]

def get_weather(location: str) -> str:
    # stub: replace with real API call
    return f"{{'temp_c': 22, 'location': '{location}'}}"

def celsius_to_fahrenheit(celsius: float) -> str:
    return str(celsius * 9/5 + 32)

Keep tool descriptions terse but unambiguous. Open-weight models infer intent from the schema; vague descriptions cause hallucinated arguments or skipped calls. Use concrete types and required fields wherever possible.

Step 3: Implement the agent loop

The loop sends messages, checks for tool_calls, executes them, and feeds results back. Stop when the model returns text without tools.

import json

def run_agent(user_prompt: str, max_steps: int = 5):
    messages = [
        {"role": "system", "content": "You are a precise agent. Use tools when needed."},
        {"role": "user", "content": user_prompt},
    ]

    for _ in range(max_steps):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=tools,
            tool_choice="auto",
            temperature=0,
        )
        msg = resp.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:
            return msg.content

        for tc in msg.tool_calls:
            fn = tc.function.name
            args = json.loads(tc.function.arguments)
            if fn == "get_weather":
                result = get_weather(**args)
            elif fn == "celsius_to_fahrenheit":
                result = celsius_to_fahrenheit(**args)
            else:
                result = "error: unknown tool"
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": result,
            })
    return "agent did not finish"

Some open-weight models emit arguments as a JSON string with escaped quotes; json.loads handles it. If you see parsing errors, enforce guided decoding in vLLM or add a small retry that asks the model to re-emit the call. Set temperature=0 for deterministic tool selection in agent loops.

Step 4: Manage context and memory

Open-weight models have finite context windows. For a long-running open-weight model agent, cap the message list and summarize old turns.

from collections import deque

class Memory:
    def __init__(self, max_tokens: int = 4000):
        self.max_tokens = max_tokens
        self.buffer = deque()

    def add(self, msg):
        self.buffer.append(msg)
        # crude token estimate: ~4 chars per token
        while sum(len(m.get("content", "")) // 4 for m in self.buffer) > self.max_tokens:
            self.buffer.popleft()

    def get(self):
        return list(self.buffer)

Replace the inline messages list with Memory calls. For production, use the model’s actual tokenizer to count tokens. When summarizing, ask the model to compress earlier tool results into a single system note; this keeps the active context small without losing state.

Step 5: Add resilience and caching

Network failures and rate limits are normal. Wrap the model call in a retry with backoff. If you use a gateway, it may already handle provider fallback; but you should still handle 429s in your code.

import time
from openai import APIError

def chat_with_retry(**kwargs):
    for attempt in range(3):
        try:
            return client.chat.completions.create(**kwargs)
        except APIError as e:
            if e.status_code == 429:
                time.sleep(2 ** attempt)
            else:
                raise
    raise RuntimeError("exhausted retries")

When routing through a gateway that honors client directives, you can forward cache-control hints to the upstream provider by setting request headers. n4n.ai forwards provider cache-control hints, so you can mark static system prompts as cacheable to cut latency and cost. Do this only if your client supports extra headers.

# example with httpx, if using openai client with custom http client
# import httpx
# client = OpenAI(base_url=..., http_client=httpx.Client(headers={"x-cache-control": "ephemeral"}))

Also log resp.usage on every call. Per-token metering matters when you self-host on shared GPUs or use a metered gateway; without it you cannot track agent cost per task.

Step 6: Verify the agent end to end

Write a smoke test that asserts the agent combines tool outputs correctly.

def test_agent():
    out = run_agent("What is 30C in Fahrenheit and the weather in Paris?")
    assert "86" in out  # 30C -> 86F
    assert "Paris" in out
    print("PASS:", out)

if __name__ == "__main__":
    test_agent()

Success means the agent called both tools, received results, and produced a final natural-language answer referencing both. If the model skips a tool, tighten the system prompt or switch to a stronger open-weight model like Llama 4 or Mistral-Large.

Beyond the unit test

Run the agent against three real queries that require different tool orderings. Confirm that:

  • The tool_call_id values match between call and result.
  • The context window never overflows (watch for truncation errors).
  • Retries trigger correctly when you artificially throttle the endpoint.

An open-weight model agent that passes these checks is ready for internal automation tasks. For external user-facing deployment, add input sanitization on tool arguments and a human-in-the-loop confirmation for side-effecting tools.

Operational notes

  • Log every tool_call_id and result; debugging open-weight model agent failures is mostly trace analysis.
  • Start with a single-loop design. A planner/executor split helps for 10+ step tasks but adds complexity.
  • Track resp.usage.total_tokens to spot runaway loops early.
  • If you serve multiple models, keep the tool schemas identical across them so the same agent code works when you swap MODEL.

You now have a runnable open-weight model agent that you can point at Llama 4, Mistral, Qwen, DeepSeek, or Grok weights without rewriting your integration.

Tagsopen-weight-modelsai-agentstutorialopen-source

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All open & emerging agent models: llama 4, mistral, qwen, deepseek, grok posts →