Function calling on a 2B model: build a local agent that actually runs tools
August 2026 · SearchAI Inference Server team
function callingagents Qwen 2BMCP-compatible real captured output
Agents are just a loop: the model decides to call a tool, your code
runs it, the result goes back, and the model uses it to answer. The only
hard requirement is a model that emits clean, correct tool calls — and
the assumption is that you need a big one. You don't. Every model
the server ships does OpenAI-style function calling, down to the 2B
— the one that fits a 16 GB box. This post builds a working agent loop on
q35-2b, with the exact API calls and real captured output,
including the one small-model gotcha and its one-line fix.
Step 1 — define a tool, get a tool call
Tools are declared exactly as in the OpenAI API — a JSON-schema description of each function. Send them alongside the message:
curl http://<host>:8081/v1/chat/completions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"model": "q35-2b",
"messages": [{"role":"user","content":"What is the weather in Paris? Use celsius."}],
"tools": [{"type":"function","function":{
"name":"get_weather",
"description":"Get the current weather for a city.",
"parameters":{"type":"object",
"properties":{"city":{"type":"string"},
"unit":{"type":"string","enum":["celsius","fahrenheit"]}},
"required":["city"]}}}]
}'
When the model decides the tool applies, it replies with
content: null and a structured tool_calls array
— name and JSON-string arguments, ready to execute:
{"role":"assistant","content":null,
"tool_calls":[{"id":"call_18cedd1d…","type":"function",
"function":{"name":"get_weather",
"arguments":"{\"city\":\"Paris\",\"unit\":\"celsius\"}"}}]}
Your code parses arguments, runs the real function, and
hands the result back — that's the loop.
Step 2 — the agent loop, on the 2B
Return each tool result as a role: "tool" message keyed by
the tool_call_id, call the model again, and repeat until it
stops calling tools and answers. Here's the whole loop:
def run_agent(user_msg, tools, run_tool):
msgs = [{"role":"user","content":user_msg}]
while True:
m = chat("q35-2b", msgs, tools=tools) # POST /v1/chat/completions
msgs.append(m)
if not m.get("tool_calls"):
return m["content"] # final answer
for tc in m["tool_calls"]:
result = run_tool(tc["function"]["name"],
json.loads(tc["function"]["arguments"]))
msgs.append({"role":"tool",
"tool_call_id": tc["id"],
"name": tc["function"]["name"],
"content": json.dumps(result)})
Give the 2B a request that needs two tools and watch it work — this is the real captured run:
> "I'm traveling to Tokyo. What's the weather there,
and how much is 100 USD in JPY?"
[step 1] get_weather({"city":"Tokyo","unit":"celsius"})
-> {"city":"Tokyo","temp_c":18,"condition":"partly cloudy"}
[step 1] currency_convert({"amount":100,"from":"USD","to":"JPY"})
-> {"result":14820.0}
[step 2] FINAL:
The current weather in Tokyo is 18°C (64°F) with a partly cloudy
condition. For your travel budget, 100 USD is equivalent to 14,820 JPY.
Enjoy your trip to the vibrant city of Tokyo!
The 2B called both tools in one turn (parallel tool calls), took both results, and composed a correct final answer. That's a complete agent, driven by a 1.9 GB model on your own hardware.
The small-model gotcha — and the one-line fix
There's one place a small model trips: a single question it
thinks it can answer from memory. Ask the 2B for Paris weather with the
default tool_choice: "auto" and it sometimes skips the tool
and guesses:
> "What's the weather in Paris? Use celsius."
{"role":"assistant","content":"The weather in Paris is currently 15°C."}
# no tool call — that number is invented
When you know a tool must run, don't hope — require it. Set
tool_choice to force a call:
"tool_choice": "required" # must call some tool
# or pin a specific one:
"tool_choice": {"type":"function","function":{"name":"get_weather"}}
Same 2B, same question, with tool_choice:"required" — now
it calls the tool, and after you return the result it answers from real
data:
get_weather({"city":"Paris","unit":"celsius"})
-> {"temp_c":18,"condition":"partly cloudy"}
FINAL: The weather in Paris is partly cloudy with a temperature of 18°C.
Rule of thumb: leave tool_choice on auto for open
agent loops (the 2B handles multi-tool tasks fine), and switch to
required — or pin the function — on the steps where a tool is
mandatory (a lookup, a write, a calculation). That single parameter is the
difference between a small model that guesses and one that grounds every
answer in your tools.
Where MCP fits — and how to wire it
First, the honest part: there is no MCP switch on the inference
server. The server is a pure OpenAI-compatible endpoint — it
speaks the tools / tool_calls protocol and
nothing more. MCP (the Model Context Protocol) lives on the
client side: an MCP host connects to MCP servers
that expose tools, and hands those tools to a language model. Because our
tool-call format is exactly the format MCP hosts already emit, the server
slots in as the model behind any MCP setup — you point the host's model
endpoint at us; you don't configure MCP in the engine.
Two ways that looks in practice:
1. An MCP-aware client/framework. If your agent framework already speaks MCP (many do), just set its OpenAI base URL and key to the server and pick a model:
OPENAI_BASE_URL=http://<host>:8081/v1
OPENAI_API_KEY=<your-key>
OPENAI_MODEL=q35-2b
The framework discovers tools from its MCP servers, sends them in the
tools array, and relays our tool_calls back to
the MCP servers — no server-side change.
2. A tiny bridge, if you're wiring it yourself. An MCP client lists a server's tools; convert each to an OpenAI function schema, run the loop from earlier, and dispatch calls back over MCP:
# pseudocode — MCP tools in, tool_calls out, results back over MCP
mcp = MCPClient("stdio://your-mcp-server") # connect the MCP server
tools = [to_openai_schema(t) for t in mcp.list_tools()] # MCP -> OpenAI tools
def run_tool(name, args): # dispatch a call back to MCP
return mcp.call_tool(name, args)
answer = run_agent(user_msg, tools, run_tool) # the same loop, unchanged
The run_agent loop is identical to Step 2 — the only new
code is translating tool definitions and routing calls to MCP. The model,
the endpoint, and the tool-call protocol don't change.
Why do it on the small local model
- Agents are chatty. A single user turn can fan out into many model calls. Per-token, that's the workload you least want on a metered API — and the one where a fast, free local model compounds the most.
- Tools do the heavy lifting. In an agent, the model is a router and composer, not a knowledge base — exactly the job a 2B does well. The function fetches the fact; the model just needs to call it correctly and phrase the result.
- Your tools touch private systems. Agent tools hit your databases, your APIs, your files. Running the model locally keeps the whole loop — prompts, tool arguments, results — inside your network.
- Fast turns matter more here. Every tool round-trip adds a model call; the 2B's speed (≈45 tokens/sec on a Graviton node) keeps multi-step agents responsive.
Start on the 2B, add tool_choice where a call is
mandatory, and step up to the 4B only if your tools or reasoning get
genuinely harder — the same test-locally-first move from our
prompt-optimization
post.
Install the server (Linux, one line) — function calling is built into every model, no flag required:
curl -fsSL https://inference-server.searchblox.com/install | sudo bash
Then declare your tools, run the loop above against
http://<host>:8081/v1/chat/completions, and you have a
private agent — no per-call bill, no data leaving the box.
Every tool call and answer above was captured from
q35-2b on a running SearchAI Inference Server, greedy
decoding, August 2026, with stubbed tool backends. See the
model catalog (every model does function
calling) and the companion posts on
vision extraction and
small-model efficiency.