Tool call round trip from the eyes of a model

Anil Turaga July 11, 2026

A rendering of the same conversation through the chat templates of GPT-OSS and Qwen3, with every special token annotated.

We know so little about how the current SoTA large language models process what they receive, and providers are only getting cagier about the internals. We're also losing the explicit knobs that used to control behavior. Parameters like temperature are being replaced by more semantic ones like reasoning effort, which I covered in my Understanding the deprecation of temperature parameter in LLMs post. Here's a non-exhaustive list of what we don't know or don't have access to:

  • Access to raw reasoning tokens [1]
  • How tools and tool results are parsed by the model [2]
  • Internals of reasoning effort parameter [3]
  • What does verbosity parameter do [4]

It's tempting to think this doesn't matter if you're just using the model as a product or building on top of it. But the lack of documentation costs you in two ways: directly in money, and in quality.

Prompt caching [5] is offered by every major provider and can cut time-to-first-token latency by up to 80% and input token costs by up to 90%. It works by caching the key and value tensors of a prompt across all layers and heads. The catch: the new request's prefix has to exactly match what's cached.

Several things silently break the cache even when the prefix and model stay the same that I found to be not very well documented:

  • Reasoning effort parameter
  • Changing any tool schema but it won't break the cache if you are adding a new tool via tool search
  • Changing the verbosity parameter
  • Caching thinking tokens is treated differently by different providers

It's hard to debug issues like the above without understanding how the prompt is processed by the model. Since we don't have access to closed-source chat templates, I'm using GPT-OSS and Qwen3 to render the same conversation through the chat templates of the models. GPT-OSS is trained on the Harmony response format [6], designed to mimic the OpenAI Responses API. Qwen3 is the most popular open-weight model, so it's a useful baseline.

Take one weather request, tool call, and the round trip back:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1")  # vLLM, Ollama, llama.cpp, ...

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get the current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "units": {"type": "string", "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city"],
        },
    },
}]

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What's the weather in Paris?"},
]

response = client.chat.completions.create(
    model="openai/gpt-oss-20b",  # or Qwen/Qwen3-8B, same payload
    messages=messages,
    tools=tools,
)

call = response.choices[0].message.tool_calls[0]

messages += [
    response.choices[0].message,  # the tool call, appended back verbatim
    {
        "role": "tool",
        "tool_call_id": call.id,
        "content": get_weather(**json.loads(call.function.arguments)),
    },
]

response = client.chat.completions.create(
    model="openai/gpt-oss-20b",
    messages=messages,
    tools=tools,
)

The tabs below render that same conversation through the unmodified chat templates of gpt-oss-20b and Qwen3-8B. Filled pills are special tokens. Dashed pills are vocabulary tokens not marked special.

gpt-oss-20b
<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.1 Knowledge cutoff: 2024-06 Current date: 2026-07-06 Reasoning: medium2 # Valid channels: analysis, commentary, final. Channel must be included for every message. Calls to these tools must go to the commentary channel: 'functions'.3 <|end|> <|start|>developer<|message|># Instructions You are a helpful assistant.4
4 annotations
  1. 1We never wrote this. It is the template's default model_identity, and the docs say to change identity through the developer message instead.
  2. 2Comes from the template's reasoning_effort kwarg. Effort is written into the prompt as text, there is no sampling parameter for it.
  3. 3Only present because tools were passed. Without tools the system message never mentions channels.
  4. 4Our system prompt. harmony moves it into the developer role and keeps the system slot for identity and channel rules.
# Tools ## functions namespace functions {1 // Get the current weather for a city.2 type get_weather = (_: { city: string, units?: "celsius" | "fahrenheit", }) => any;3 } // namespace functions <|end|>4
4 annotations
  1. 1Tools live in a TypeScript style namespace. The call syntax to=functions.get_weather points into it.
  2. 2The tool's `description` field, rendered as a code comment.
  3. 3The JSON Schema converted to a TypeScript signature. Optional params get ?, enums become unions.
  4. 4Closes the developer turn. Instructions and tool schema share one message.
<|start|>user<|message|>What's the weather in Paris? <|end|>
<|start|>assistant<|channel|>analysis1<|message|>User asks for the weather in Paris. We need to call the get_weather function.2 <|end|>
2 annotations
  1. 1The chain of thought channel. Not trained to the same safety standard as final output, so it should not be shown to users.
  2. 2Reasoning is dropped from history on the next turn, unless a tool call is pending like here, in which case it is passed back in.
<|start|>assistant to=functions.get_weather1<|channel|>commentary json2<|message|>{"city": "Paris", "units": "celsius"}3 <|call|>4
4 annotations
  1. 1to= sets the recipient. A tool call is a message addressed to the tool instead of the user.
  2. 2The channel selector plus a content-type suffix. Tool calls are required to use the 'commentary' channel.
  3. 3The template runs tojson on the arguments dict.
  4. 4A separate stop token from <|end|>. Inference stops here and waits for the tool result.
<|start|>functions.get_weather1 to=assistant2<|channel|>commentary<|message|>"{\"temperature\": 18, \"condition\": \"cloudy\"}"3 <|end|>
3 annotations
  1. 1The sender is the function name itself, not a generic tool role.
  2. 2Routed back to the assistant, mirroring the to= on the call.
  3. 3The template runs tojson on the content no matter what. We passed an already encoded JSON string, so it got double encoded.

OpenAI built harmony from scratch, so every boundary here is a dedicated special token: <|channel|>, <|message|>, <|call|>. Reasoning is a message on the analysis channel, and the tool response is routed back with the same to= addressing the call used.

For comparison, the other families I looked at while researching this:

FamilyCall boundaryDedicated token?Response marker
Harmony<|channel|>commentary json<|message|>Yes, specialto=assistant<|channel|>commentary
Mistral (Nemo)[TOOL_CALLS]Yes, special[TOOL_RESULTS], also special
Qwen3<tool_call>Yes, not marked specialreuses the user role, wrapped in <tool_response>
Llama (custom fn)none, bare JSON in contentNono standard convention

Providers differ in more than tool-call syntax. Reasoning traces and multi-modal input get handled differently too.

Historically, Anthropic has used a variant of XML called ANTML for tool calls and tool results, with a different hierarchy of roles. OpenAI orders System -> Developer -> Tools -> Messages, while Anthropic orders Tools -> System -> Messages. That ordering changes how you'd compose system instructions around tool use.

Worth having a mental model of this before assuming prompt caching, cost, or output quality will behave the same way across providers.

Thanks for reading!