PromptisePromptise
Docs
GitHubGet started
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Models and providers

Small Local Models for Tool Calling: What Breaks

Why small local models fail at tool calling: templates, parser bugs, short context, bad arguments and loops, with a fix for each and a runnable replay.

Level
Intermediate
Reading time
18 min
Published
Oct 22, 2026
Last updated
Oct 22, 2026
Tested with
Promptise 1.7.0
By
Promptise Team
  • Model Selection and Routing
  • Tool Calling
Contents · 11 sections↓
  1. 01What breaks when small local models call tools?
  2. 02Where a local model's tool call can break
  3. 03What the benchmarks say about small models
  4. 04When a small local model is the right choice (and when not)
  5. 05What you need
  6. 06Failure modes and fixes
  7. 07Running small models for tool calling in production
  8. 08Common problems and fixes
  9. 09Limitations
  10. 10FAQ
  11. 11Next steps

Small local models can call tools, but they break in ways hosted models rarely do: the call shows up as text, an argument has the wrong type, the model calls a tool that doesn't exist, or it repeats the same call until the loop gives up. This guide maps what breaks when small local models do tool calling, which layer each failure comes from (model, chat template, server parser, context window or agent loop), and the fix for each. A runnable replay, built on Promptise Foundry's scripted test model, shows how an agent loop handles every failure, without needing a GPU.

[01]

What breaks when small local models call tools?

Five things, in rough order of how often they bite. The model or its chat template doesn't support tools, so you get prose or a "does not support tools" error. The server's parser misses the call, so it arrives as plain text. The arguments are wrong or missing. The model calls tools it shouldn't, or tools that don't exist. And in longer runs it loses the question or loops, because the context is too short or because most small general-purpose models are much weaker at multi-turn tool use than at single calls. Most of these are fixed in configuration and in your agent loop, not by switching to a bigger model.

Key takeaways:

  • Single calls are easy, conversations are hard. On the Berkeley Function Calling Leaderboard, Qwen3-8B scores 87.6% on single-call tests but 41.8% on multi-turn ones; Llama-3.2-3B drops from 82.7% to 4.0%. Models fine-tuned for function calling hold up better: xLAM-2-8b-fc-r reaches 70.0% on multi-turn.

  • Many failures are not the model's fault. A missing template, a parser bug in one server version, or a 4k-token default context produce the same symptoms as a weak model.

  • Validate and answer back. An agent loop that answers bad arguments, unparseable JSON and invented tool names with a clear error lets a small model correct itself on the next turn.

  • Catch text tool calls. When a parser misses a call, the agent silently treats it as the final answer. Detect it, or recover it on the client.

  • Fewer tools, simpler schemas. Flat arguments, enums instead of free text, and only the tools the task needs.

This guide assumes you already run a local model. For choosing one and setting up Ollama, LM Studio, vLLM or llama.cpp, see the best local LLM for AI agents and Ollama AI agents with MCP.


[02]

Where a local model's tool call can break

With a local model you run every layer yourself, and each one can fail on its own:

  1. The model must be trained to emit tool calls (Ollama tags such models "tools").

  2. The chat template turns your tool definitions into text the model reads. Without a tool section, the model never sees them.

  3. The server's parser turns the model's own call format (JSON in tags, a Python-like call, special tokens) into structured tool_calls. If it can't, you get the text.

  4. The context window must hold the system prompt, tools, history and results, or the server drops messages.

  5. Your agent loop validates arguments, runs the tool, sends results back and decides when to stop.

A tool call from a local model passes through five layers: the model must be trained for tools, the chat template must render them, the server parser must turn text into tool calls, the context must fit, and the agent loop must validate, retry and stop; each layer has its own symptom when it breaks.
FigureWhere a local model's tool call can break, layer by layer

[03]

What the benchmarks say about small models

The Berkeley Function Calling Leaderboard (BFCL V4, last updated 2026-04-12) tests the same skills separately. Its published data shows where small models hold up and where they don't:

Model (function-calling mode)

Single calls (non-live AST)

Multi-turn

Irrelevance detection

Qwen3-8B

87.58%

41.75%

79.07%

Qwen3-4B-Instruct-2507

87.88%

22.12%

84.93%

Qwen3-1.7B

82.92%

11.00%

76.54%

Llama-3.2-3B-Instruct

82.67%

4.00%

52.06%

Granite-3.2-8B-Instruct

79.77%

7.38%

80.53%

xLAM-2-8b-fc-r (fine-tuned for function calling)

84.58%

70.00%

63.28%

xLAM-2-3b-fc-r (fine-tuned for function calling)

82.96%

58.38%

63.45%

Three patterns stand out:

  • Single calls work for most small models. One or two independent calls per request is within reach.

  • Multi-turn use collapses for general-purpose small models. Carrying state across turns and reacting to tool results is exactly what an agent loop needs. Function-calling fine-tunes like xLAM-2 keep much more of it, but call a tool when none fits more often.

  • Some models call tools when they shouldn't. Llama-3.2-3B answers directly when no tool fits only about half the time.

These are BFCL's tasks, not yours, and newer small models aren't on the April leaderboard. Use the numbers to know where to look, then test on your own tools.

A note on this month's "small" models

DeepSeek-V4.1-Flash (October 2026) is not small: its model card lists 552B backbone parameters, and Ollama offers it only with :cloud tags that run on Ollama's servers. It also ships no Jinja chat template, which is a template risk for servers that rely on one.

LFM2.5-24B-A2B from Liquid AI is a real on-device option: 24B parameters with about 2B active per token, on Ollama as lfm2.5:24b. Its model card says it writes Pythonic tool calls by default, such as [get_candidate_status(candidate_id="12345")] between special tokens, so a server or client that expects JSON will miss them. The recovery code below handles both formats.


[04]

When a small local model is the right choice (and when not)

A small local model fits when:

  • Data must not leave your machine or network.

  • The agent makes few, simple calls per request: classify and route, extract and store, look one thing up.

  • You call it at high volume, and you can accept a fallback when it fails.

Prefer a larger or hosted model when:

  • The task needs many dependent calls in one conversation, the multi-turn case above.

  • Wrong arguments are expensive: payments, deletions, messages sent to people.

  • You offer the model dozens of tools at once.

Many teams mix both, with a fallback to a hosted model when the local one fails; the best local LLM guide shows it with Promptise Foundry.


[05]

What you need

  • A local model server: Ollama, LM Studio, vLLM or llama.cpp.

  • Python 3.10+ and uv pip install "promptise==1.7.0" for the replay and recovery code. It uses promptise.testing, new in 1.7.0.

We didn't have a local model server on the machine used for this guide, so the failure modes are described from each project's documentation and issue tracker, with links. The code runs without a model: Foundry's ScriptedModel plays the model and sends the kinds of replies small models produce, including malformed ones, so you can see exactly what the agent loop does with each. The tools are real; only the model's turns are scripted.


[06]

Failure modes and fixes

1. "does not support tools" or prose replies (model and template)

With Ollama, a model without tool support fails with does not support tools, often a GGUF file imported from Hugging Face. In an Ollama issue from January 2026, the maintainers' fix is a Modelfile with FROM and a TEMPLATE translated from the model's Jinja template.

llama.cpp fails more quietly: with a template it doesn't recognize, it falls back to a generic format and logs Chat format: Generic, which its function calling docs say may be less efficient. Pass --chat-template-file with the model's real template.

Before debugging prompts, check ollama show <model> for capabilities and llama.cpp's /props for the template.

2. The tool call arrives as text (parser)

The model produced a call, but the server didn't parse it, so your code receives something like this as the answer:

Text
<tool_call>
{"name": "get_order", "arguments": {"order_id": "A-1001", "quantity": 2}}
</tool_call>

Parser bugs in specific server versions cause this as well as unsupported formats. One Ollama issue from March 2026 reports qwen3.5:9b printing tool calls instead of executing them on 0.17.6 and 0.17.7, with users pinning 0.17.5 until a fix. vLLM needs --enable-auto-tool-choice and the right --tool-call-parser per model family (vLLM docs).

The dangerous part is what the agent does next. Here is the replay of that reply through a Promptise agent, alongside the other failures in this guide. Each scenario is a short script of model turns: ToolCall is a proper call, Malformed.tool_call_as_text writes the call into the text, and Malformed.unparseable_arguments sends arguments that aren't valid JSON. Where a real model would correct itself, the script's next turn is the corrected call:

Pythonreplay_failures.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
"""Replay typical small-model tool-calling failures through a Promptise agent.

No local model needed: promptise.testing.ScriptedModel plays the model, with the
kinds of replies small models produce (documented in Ollama, vLLM and llama.cpp
issues and docs), so you can see exactly what the agent loop does with each.
"""
import asyncio

from promptise import build_agent, tool
from promptise.engine import GraphExecutionError
from promptise.testing import Malformed, ScriptedModel, ToolCall


@tool
def get_order(order_id: str, quantity: int) -> dict:
    """Look up an order line.

    Args:
        order_id: The order ID, for example "A-1001".
        quantity: How many units to check.
    """
    return {"order_id": order_id, "quantity": quantity, "in_stock": True}


@tool
def cancel_order(order_id: str) -> str:
    """Cancel an order."""
    return f"Cancelled {order_id}."


GOOD = ToolCall("get_order", {"order_id": "A-1001", "quantity": 2})
DONE = "A-1001: 2 units in stock."
SCENARIOS = {
    "number sent as a string": [ToolCall("get_order", {"order_id": "A-1001", "quantity": "2"}), DONE],
    "required argument missing": [ToolCall("get_order", {"order_id": "A-1001"}), GOOD, DONE],
    "tool name misspelled": [ToolCall("get_ordr", {"order_id": "A-1001", "quantity": 2}), GOOD, DONE],
    "arguments not valid JSON": [Malformed.unparseable_arguments("get_order", "{'order_id': 'A-1001', 'quantity': 2}"),
                                 GOOD, DONE],
    "tool call written as text": [Malformed.tool_call_as_text(
        "get_order", {"order_id": "A-1001", "quantity": 2}, style="tagged")],
    "same call repeated forever": [GOOD],
}


async def run(name: str, replies: list) -> None:
    repeat = name == "same call repeated forever"
    model = ScriptedModel(replies, on_exhausted="repeat_last" if repeat else "raise")
    agent = await build_agent(model=model, extra_tools=[get_order, cancel_order], max_agent_iterations=6)
    print(f"--- {name}")
    try:
        result = await agent.ainvoke({"messages": [{"role": "user", "content": "Check order A-1001, 2 units."}]})
        for m in result["messages"]:
            if m.type == "tool":
                print(f"tool result ({m.status}): {m.content}")
        print(f"model calls: {len(model.calls)}; final reply: {result['messages'][-1].content!r}")
    except GraphExecutionError as exc:
        print(f"model calls: {len(model.calls)}; raised {type(exc).__name__}: {exc}")
    finally:
        await agent.shutdown()


async def main() -> None:
    for name, replies in SCENARIOS.items():
        await run(name, replies)


if __name__ == "__main__":
    asyncio.run(main())
Output
--- number sent as a string
tool result (success): {"order_id": "A-1001", "quantity": 2, "in_stock": true}
model calls: 2; final reply: 'A-1001: 2 units in stock.'
--- required argument missing
tool result (error): Invalid arguments for tool 'get_order':
- quantity: Field required
Correct the arguments and call the tool again.
tool result (success): {"order_id": "A-1001", "quantity": 2, "in_stock": true}
model calls: 3; final reply: 'A-1001: 2 units in stock.'
--- tool name misspelled
tool result (error): Error: Unknown tool 'get_ordr'. Did you mean 'get_order'? Call one of the available tools: get_order, cancel_order.
tool result (success): {"order_id": "A-1001", "quantity": 2, "in_stock": true}
model calls: 3; final reply: 'A-1001: 2 units in stock.'
--- arguments not valid JSON
tool result (error): Error: The arguments for 'get_order' were not valid JSON (Expecting property name enclosed in double quotes at line 1 column 2). Call the tool again with a JSON object that matches its parameters: order_id (required), quantity (required).
tool result (success): {"order_id": "A-1001", "quantity": 2, "in_stock": true}
model calls: 3; final reply: 'A-1001: 2 units in stock.'
--- tool call written as text
model calls: 1; final reply: '<tool_call>\n{"name": "get_order", "arguments": {"order_id": "A-1001", "quantity": 2}}\n</tool_call>'
--- same call repeated forever
model calls: 6; raised GraphIterationLimitError: Graph 'react' failed at node 'reason': the run reached the engine's max_iterations (6) before the model gave an answer; the run stopped after a call to get_order whose result the model never saw

The "tool call written as text" scenario shows it: one model call, no tool ran, and the raw tag became the final reply. Nothing raised an error, because to the agent loop a reply with no tool calls is a finished answer. The loop can't safely guess otherwise, since a model may legitimately answer with JSON, so this one stays your job. Two fixes:

  • Fix the server. Use and pin a version and parser that handle the model's format, with a smoke test after upgrades, as in the best local LLM guide.

  • Recover on the client. Check the final answer for a tool call written as text. If you find one for a tool that is safe to run outside the loop, such as a read-only lookup, run it, send the result back, and ask again. Anything else is an error, not an answer:

Pythonrecover.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
"""Recover tool calls that a small model wrote as text instead of as structured calls.

Handles the formats seen in local-model output:
  <tool_call>{"name": ..., "arguments": {...}}</tool_call>     (Hermes / Qwen style)
  {"name": ..., "arguments": {...}}, bare or in a ```json block
  <|tool_call_start|>[fn(a="x", b=2)]<|tool_call_end|>          (Pythonic, e.g. LFM2.5)
Only tools you offered and marked safe to run outside the loop are run.
"""
import ast
import json
import re

TAGGED = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)
FENCED = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.S)
PYTHONIC = re.compile(r"<\|tool_call_start\|>\s*(\[.*?\])\s*<\|tool_call_end\|>", re.S)
MARKERS = ("<tool_call>", "<|tool_call_start|>", '"arguments"')


def parse_text_tool_calls(text: str) -> list[dict]:
    """Return the tool calls written in a reply's text, as {"name": ..., "args": {...}}."""
    found = []
    blobs = TAGGED.findall(text) or FENCED.findall(text) or ([text.strip()] if text.strip().startswith("{") else [])
    for blob in blobs:
        try:
            data = json.loads(blob)
            args = data.get("arguments", data.get("parameters", {}))
            found.append({"name": data["name"], "args": json.loads(args) if isinstance(args, str) else args})
        except (ValueError, KeyError, TypeError):
            continue
    for blob in PYTHONIC.findall(text):
        for call in ast.parse(blob, mode="eval").body.elts:
            found.append({"name": call.func.id,
                          "args": {kw.arg: ast.literal_eval(kw.value) for kw in call.keywords}})
    return found


async def ask(agent, question: str, safe_tools: list, max_recoveries: int = 2) -> str:
    """Ask the agent; if it answers with a tool call written as text, run that call and ask again."""
    tools = {t.name: t for t in safe_tools}
    messages = [{"role": "user", "content": question}]
    for _ in range(max_recoveries + 1):
        result = await agent.ainvoke({"messages": messages})
        answer = result["messages"][-1].content
        calls = parse_text_tool_calls(answer)
        if not calls:
            if any(m in answer for m in MARKERS):
                raise RuntimeError(f"unreadable tool call in the answer: {answer[:80]!r}")
            return answer
        unknown = [c["name"] for c in calls if c["name"] not in tools]
        if unknown:
            raise RuntimeError(f"text tool call to {unknown}, which can't be run outside the agent loop")
        lines = []
        for c in calls:
            output = await tools[c["name"]].ainvoke(c["args"])   # the @tool still validates the arguments
            text = output if isinstance(output, str) else json.dumps(output)
            lines.append(f"{c['name']}({json.dumps(c['args'])}) returned {text}")
        messages += [{"role": "assistant", "content": answer},
                     {"role": "user", "content": "You wrote a tool call as text instead of calling the tool, "
                                                 "so it was run for you:\n" + "\n".join(lines) +
                                                 "\nUse this result to answer the question."}]
    raise RuntimeError("the model kept writing tool calls as text")
Output
--- Hermes/Qwen tags
sent back: get_order({"order_id": "A-1001", "quantity": 2}) returned {"order_id": "A-1001", "quantity": 2, "in_stock": true}
answer: 'A-1001: 2 units in stock.'
--- JSON in a code block
sent back: get_order({"order_id": "A-1001", "quantity": 2}) returned {"order_id": "A-1001", "quantity": 2, "in_stock": true}
answer: 'A-1001: 2 units in stock.'
--- Pythonic (LFM2.5)
sent back: get_order({"order_id": "A-1001", "quantity": 2}) returned {"order_id": "A-1001", "quantity": 2, "in_stock": true}
answer: 'A-1001: 2 units in stock.'
--- a tool that isn't safe to run here
not recovered: text tool call to ['cancel_order'], which can't be run outside the agent loop

All three formats were recovered: the tool ran, the model got the result as a follow-up message, and its next turn answered. The cancel_order call wasn't run, because a tool run this way skips anything the agent loop adds around a call, such as approvals; only list read-only tools in safe_tools. ask() works with any agent, including one built with model="ollama:qwen3.5:9b"; we tested it only with the scripted replies shown here.

3. Wrong types, missing arguments and broken JSON (model)

Small models follow schemas less reliably: a number arrives as a string, a required field is missing, an array comes back serialized as a string (the vLLM docs list that one as a known issue for Llama 3 models), or the arguments aren't JSON at all, such as a Python dict with single quotes. The replay shows what a validating agent loop can do:

  • `"2"` for an integer was coerced to 2 and the call succeeded, because Promptise validates arguments against the tool's schema with Pydantic's lenient conversion.

  • A missing `quantity` didn't reach the tool. The model got back Invalid arguments for tool 'get_order': - quantity: Field required. Correct the arguments and call the tool again. That message is what gives a small model a chance to fix the call on the next turn, and in the replay the next turn did.

  • Arguments that aren't valid JSON didn't end the run either. The model got back an error naming the parse problem and the tool's parameters, order_id (required), quantity (required), and called again. Foundry 1.7.0 added this; earlier versions ended the run silently.

Make the schema easy to get right: few, flat arguments, enums instead of free text that must match a list, and a one-line description with an example for each argument. Compare two definitions of the same tool, and the argument schemas the model receives for them:

Pythonschemas.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from typing import Literal

from promptise import tool


@tool
def search_orders(filters: dict) -> list[dict]:
    """Search orders. filters may contain status, customer and date range."""
    return []


@tool
def list_orders(status: Literal["open", "paid", "overdue"], customer_id: str | None = None) -> list[dict]:
    """List orders with one status, optionally for one customer.

    Args:
        status: Order status to list.
        customer_id: Customer ID such as "C-1042". Leave empty for all customers.
    """
    return []


if __name__ == "__main__":
    import json
    for t in (search_orders, list_orders):
        print(t.name, json.dumps(t.tool_call_schema.model_json_schema()["properties"]))
Output
search_orders {"filters": {"additionalProperties": true, "title": "Filters", "type": "object"}}
list_orders {"status": {"description": "Order status to list.", "enum": ["open", "paid", "overdue"], "title": "Status", "type": "string"}, "customer_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Customer ID such as \"C-1042\". Leave empty for all customers.", "title": "Customer Id"}}

The first leaves the model to guess the keys, their spelling and the date format. The second gives it a closed list and one described optional field, and validation can reject anything else with a clear message. Validate on the server side too; an MCP server can't assume the client checked anything.

4. Calls to tools that don't exist, or calls that aren't needed (model)

Small models invent plausible tool names, and some call a tool when they should answer directly. In the replay, the misspelled get_ordr came back as Error: Unknown tool 'get_ordr'. Did you mean 'get_order'? Call one of the available tools: get_order, cancel_order., and the next turn used the right name. Foundry 1.7.0 suggests a name only when it looks like a misspelling, and lists the available tools either way; older versions named only the unknown tool.

What reduces both problems:

  • Offer fewer tools. Load rarely used ones only for the steps that need them; the too many MCP tools guide covers selection.

  • Force the choice where the server supports it. vLLM supports tool_choice set to required or to a named tool, using structured outputs to guarantee a parsable call (its docs note that guarantees validity, not quality). llama.cpp constrains output with grammars once a call starts. Ollama's OpenAI-compatible API doesn't support tool_choice, according to its compatibility docs.

  • Use structured output for extraction. If the step is "fill in these fields", ask for JSON with a schema (Ollama's format parameter) instead.

5. The model forgets the question (context)

Ollama sets the default context by available VRAM: 4k tokens below 24 GiB, 32k from 24 to 48 GiB, and 256k at 48 GiB or more, and its context length docs recommend at least 64,000 tokens for agents and coding tools. A 4k context fills with the system prompt, a dozen tool definitions and a few tool results. When it overflows, older messages go, and with them the user's question, so the model answers from the last tool result or repeats calls. Set OLLAMA_CONTEXT_LENGTH or num_ctx; the Ollama guide shows both with Promptise.

6. The same call, over and over (model and loop)

A small model that doesn't understand a tool result often calls the same tool again with the same arguments. Without a limit, it runs until the request times out. In the replay, the script repeats the same call forever (on_exhausted="repeat_last"), and Promptise's iteration limit (max_agent_iterations=6) stopped the run after six model calls with a GraphIterationLimitError that says the model never saw the last result. Since 1.7.0, max_agent_iterations alone sets that limit; earlier versions also capped the default loop at 15. Catch that error and fall back, or answer with what you have. A guard that rejects an identical repeated call ("you already called this; use the result above") also helps.

7. Streaming and history (client)

If you stream responses and build the conversation yourself, accumulate the partial fields. Ollama's streaming docs say this matters most for tool calling, because the thinking, the tool call and its result must all go back in the next request. Drop one and the model sees an unanswered question and calls the tool again.


[07]

Running small models for tool calling in production

  • Test the combination, not the model, and pin it. The same weights behave differently across server versions, quantizations and templates. Pin the server version and model tag, and run a few tool-calling cases (a single call, a missing argument, an irrelevant question, a three-step task) after every upgrade; AI agent evals in CI shows how. Script the same cases with ScriptedModel to test your own loop and tools without a model.

  • Count text tool calls. Log every final reply that contains a tool-call marker. A rising count is usually a parser or template regression, not the model getting worse.

  • Keep a fallback. Route to a hosted model after a validation failure or an iteration-limit error, and decide in advance which data the fallback may see.

  • Watch arguments before risky actions. Small models get arguments wrong more often. Put human approval in front of tools that pay, delete or send.


[08]

Common problems and fixes

Symptom

Layer

Fix

does not support tools

Model or template

Use a tools-tagged model, or add a TEMPLATE in a Modelfile

Chat format: Generic in llama.cpp logs

Template

Pass --chat-template-file with the model's own template

Final answer contains <tool_call> or [fn(...)]

Parser

Fix the server version or parser; recover on the client; count these

Tool calls worked, then stopped after an upgrade

Parser regression

Pin the previous version; re-run your tool-calling tests

Field required, wrong types or arguments that aren't JSON

Model

Flatter schemas, enums, examples in descriptions; return the errors to the model

Unknown tool

Model

Fewer tools; return the valid names with the error (Foundry 1.7.0 does); tool_choice where supported

Tool called for small talk

Model

Fewer tools; tell it when not to use tools; test irrelevant questions

Model repeats calls, forgets the question

Context

Raise num_ctx or OLLAMA_CONTEXT_LENGTH to 32k to 64k

Run never ends

Loop

Set an iteration limit and handle the error; reject identical repeated calls

Tool result ignored in the next turn

Client

Send the tool call and its result back in the history


[09]

Limitations

  • No local model ran for this guide. The failure modes come from the cited docs and issues; the replay reproduces the replies, not the models. Your model and server versions may behave differently.

  • Benchmarks are a guide, not a forecast. BFCL's tasks differ from yours, and the newest small models aren't on the April 2026 leaderboard.

  • Client-side recovery is a patch. It knows only the formats it parses. Fix the server when you can.

  • Validation catches shape, not meaning. A well-formed call with the wrong order ID passes every check.


[10]

FAQ

Why does my local model print the tool call instead of running it?

The server's parser didn't recognize the model's tool-call format, so the call stayed in the text. Check that the server supports the model's format (the right vLLM --tool-call-parser, a recognized llama.cpp template, a current Ollama version) and look for known regressions. As a stopgap, parse the text on the client and run only read-only tools you offered.

Which small models are good at tool calling?

On the Berkeley Function Calling Leaderboard, small Qwen3 models do well on single calls, while models fine-tuned for function calling, such as xLAM-2-8b-fc-r, hold up much better on multi-turn tasks than general-purpose models of the same size. Test candidates on your own tools with your own server, because templates and parsers change results as much as the weights do.

Does Ollama support tool_choice?

Not on its OpenAI-compatible API, according to Ollama's compatibility documentation: tools is supported, tool_choice is not. vLLM supports required and named tool choice, and llama.cpp can constrain tool-call output with grammars.

How do I stop a small model from calling the same tool in a loop?

Set an iteration limit in your agent loop and handle the error it raises, reject an identical repeated call with a message that points the model at the earlier result, and make sure the tool result is actually in the history. A context window that is too short is a common hidden cause.

Is DeepSeek V4.1 Flash a local model?

No. Its model card lists 552B backbone parameters, and Ollama offers it only through cloud tags that run on Ollama's servers. Despite the name, it is not a model you can run on a laptop.

Can Promptise Foundry use a small local model for tools?

Yes. Pass an Ollama model to build_agent, for example "ollama:qwen3.5:9b", and add your MCP servers or @tool functions. Foundry validates each call's arguments and answers bad arguments, unparseable JSON and unknown tool names with errors the model can act on, and max_agent_iterations stops loops. It doesn't recover tool calls the server left as text, because the loop can't tell those from a real answer; use a check like ask() in this guide for that. To test all of this without a model, use promptise.testing.ScriptedModel.


[11]

Next steps

  • Choose and set up the model: Best Local LLM for AI Agents.

  • Connect it to MCP tools with Promptise: Ollama AI Agent with MCP.

  • Learn how tool calls work under the hood: What Is Tool Calling?

  • Keep the tool list small enough for a small model: Too Many MCP Tools.

  • Compare the same model in different agent loops: Test One Model Across Agent Harnesses.

Contents

  1. 01What breaks when small local models call tools?
  2. 02Where a local model's tool call can break
  3. 03What the benchmarks say about small models
  4. 04When a small local model is the right choice (and when not)
  5. 05What you need
  6. 06Failure modes and fixes
  7. 07Running small models for tool calling in production
  8. 08Common problems and fixes
  9. 09Limitations
  10. 10FAQ
  11. 11Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Models and providers

Related guides

All guides in this topic →
  • // 01Intermediate · 7 minLLM Routing: When to Use a Small vs Large ModelLLM routing explained: rules, LLM routers, learned routers and cascades to send each request to the cheapest model that works. A real router misroute.
  • // 02Intermediate · 15 minReasoning Effort for AI Agents: When High HurtsReasoning effort for AI agents: how low, medium, high and xhigh work at OpenAI, Anthropic and Google, when high effort hurts, and how to measure it.
  • // 03Intermediate · 20 minDecision Models for AI Agents: Route, Gate and ClassifyDecision models for AI agents return probabilities, not text. Learn how they work and how to route, gate and classify with them, with runnable Python code.
  • // 04Advanced · 25 minLLM Confidence Calibration: Test Scores on a Labeled SetLLM confidence calibration on a labeled set: measure Brier score and reliability, compare stated confidence, logprobs and decision models, then fix it.
  • // 05Intermediate · 18 minJev vs Clef vs OpenAI Decisions API Compared (2026)Jev vs Clef vs OpenAI Decisions API, from each vendor's docs: prices, limits, request formats, images and self-hosting, plus how to test them on your data.
  • // 06Beginner · 11 minClaude AI Agent with MCP: Use Anthropic Models in PromptiseBuild a Claude AI agent with MCP tools: set the Anthropic key and model id, use effort instead of temperature, add prompt caching, and fix common errors.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths