Skip to content
View as Markdown

Tinker Quickstart

Welcome to Tinker! This quickstart takes you from a new install to your first trained models, in about 30 minutes. By the end, you will have:

  • Installed the SDK, signed in, and set up billing.
  • Sampled from a base model.
  • Run a supervised fine-tuning (SFT) script and a reinforcement learning (RL) script, and compared each trained model with the original.

Installation

We recommend using uv (see setup instructions) to manage your Python environment for use with the Tinker SDK. The examples use uv, but you can also use any other package manager to install the tinker SDK.

uv init tinker-quickstart
cd tinker-quickstart
uv add tinker tinker-cookbook

Authentication

The easiest way to authenticate with Tinker is to use the Tinker CLI. The SDK includes it.

uv run tinker auth login

You can also create an API key in the Tinker Console and set it as the TINKER_API_KEY environment variable.

Billing

Set up payment info in Billing, and see Models & Pricing for current training, sampling, and checkpoint storage rates.

Using the Tinker SDK

All Tinker use cases start with the SDK clients. There are four clients for interacting with the Tinker server:

  • ServiceClient: Creates training and sampling clients, and groups them into one session for metrics and monitoring. View Sessions in the Tinker Console.

  • SamplingClient: Generates completions from a model.

  • TrainingClient: Trains a model.

  • RestClient: Queries server capabilities and manages checkpoints, sessions, and metadata without requiring a ServiceClient.

All examples use asyncio, which is our recommended way to use the SDK.

ServiceClient() and RestClient() are synchronous constructors. Accessors such as get_tokenizer() and get_console_url() do not need await. Initialize and reuse clients and tokenizers during setup; their initialization can perform blocking work.

...
service_client = tinker.ServiceClient(display_name="my-first-session")
training_client = await service_client.create_lora_training_client(
    base_model="Qwen/Qwen3.5-4B", rank=16
)
sampling_client = await service_client.create_sampling_client(
    base_model="Qwen/Qwen3.5-4B"
)
rest_client = tinker.RestClient()
...

Sampling

Use a SamplingClient to generate samples from a model. You can sample from any base model on Models & Pricing by its Tinker ID, and later from model checkpoints you train and save, by their tinker:// path:

...
# A base model, by its Tinker ID
sampling_client = await service_client.create_sampling_client(base_model="Qwen/Qwen3.5-4B")

# A model checkpoint you trained and saved, by its tinker:// path
sampling_client = await service_client.create_sampling_client(
    model_path="tinker://<run_id>/sampler_weights/<name>"
)
...

Each sample in the result's sequences has its tokens, the logprobs of those tokens, and a stop_reason:

  • stop_reason is "stop" when the model ends its answer, and "length" when the answer reaches max_tokens and is cut off.
  • For several answers to one prompt, set num_samples instead of sending the prompt several times. One call is more efficient (Efficient Sampling).
  • A chat model expects its chat template. Plain text gets a plain continuation, as in the first example below. For Qwen3.5, enable_thinking=False makes the model answer without a reasoning block.

The script below samples from Qwen3.5-4B twice: once from plain text, and once from a conversation formatted with the model's chat template.

import tinker
import asyncio


async def main():
    service_client = tinker.ServiceClient(display_name="my-first-sample")
    sampling_client = await service_client.create_sampling_client(
        base_model="Qwen/Qwen3.5-4B"
    )
    tokenizer = sampling_client.get_tokenizer()

    prompt = "The capital of France is"
    prompt_tokens = tokenizer.encode(prompt)
    model_input = tinker.types.ModelInput.from_ints(prompt_tokens)
    params = tinker.types.SamplingParams(max_tokens=50, temperature=0.7)

    result = await sampling_client.sample(
        prompt=model_input, num_samples=1, sampling_params=params
    )
    # Decode the returned sample
    response = tokenizer.decode(result.sequences[0].tokens)
    print(response)

    # For a conversation-formatted prompt
    conversation_tokens = tokenizer.apply_chat_template(
        conversation=[{"role": "user", "content": "What is the capital of France?"}],
        add_generation_prompt=True,  # set up the model to generate an assistant response
        enable_thinking=False,  # answer directly, without a reasoning block
        return_dict=False,  # return a plain list of token ids
    )
    model_input = tinker.types.ModelInput.from_ints(conversation_tokens)
    result = await sampling_client.sample(
        prompt=model_input, num_samples=1, sampling_params=params
    )
    response = tokenizer.decode(result.sequences[0].tokens, skip_special_tokens=True)
    print(response)


if __name__ == "__main__":
    asyncio.run(main())

Training

Tinker supports supervised fine-tuning and reinforcement learning using Low-Rank Adaptation (LoRA), with fine-grained control over which tokens to apply the loss to. See Loss Functions for details about available loss functions, LoRA Primer for details about training with LoRA, and Prepare Training Data for guidance on constructing Datum objects.

Tinker receives only what you pass to the TrainingClient and the SamplingClient: tokens and loss inputs for training, and prompt tokens for sampling. Your data preparation and reward functions run on your machine.

Supervised Fine-tuning (SFT)

SFT trains a model on example prompts and the exact answers you want, so it learns to answer like them. You can bring your own data, or use a sampling client to generate examples and transform them in code. The script below does the second: it samples the base model's own answers and lowercases them, to teach Qwen3.5-4B to respond only in lowercase.

import tinker
import asyncio


async def main():
    service_client = tinker.ServiceClient(display_name="my-first-sft")
    training_client = await service_client.create_lora_training_client(
        base_model="Qwen/Qwen3.5-4B", rank=16
    )
    base_client = await service_client.create_sampling_client(
        base_model="Qwen/Qwen3.5-4B"
    )
    tokenizer = training_client.get_tokenizer()

    # Generate training examples
    #
    # The base model answers each training prompt once, and `restyle` rewrites the answer.
    # `restyle` decides how the trained model writes, so you can change it later. `sample`
    # formats the prompt with the model's chat template and asks a model for an answer.
    TRAIN_PROMPTS = [
        "Describe the ocean in two sentences.",
        "Write a short email inviting a friend to dinner.",
    ]

    def restyle(output):
        return output.lower()

    async def sample(client, prompt):
        prompt_text = tokenizer.apply_chat_template(
            [{"role": "user", "content": prompt}],
            tokenize=False,
            add_generation_prompt=True,
            enable_thinking=False,
        )
        prompt_tokens = tokenizer.encode(prompt_text, add_special_tokens=False)
        sampling_params = tinker.SamplingParams(max_tokens=512, temperature=0.7)
        result = await client.sample(
            prompt=tinker.ModelInput.from_ints(prompt_tokens),
            num_samples=1,
            sampling_params=sampling_params,
        )
        return result.sequences[0]

    def decode(sequence):
        return tokenizer.decode(sequence.tokens, skip_special_tokens=True).strip()

    answers = await asyncio.gather(*[sample(base_client, prompt) for prompt in TRAIN_PROMPTS])
    examples = []
    for prompt, answer in zip(TRAIN_PROMPTS, answers):
        # Skip answers cut off at max_tokens: they teach the model to stop mid-answer.
        if answer.stop_reason == "stop":
            examples.append((prompt, restyle(decode(answer))))

    # Make training data
    #
    # `make_datum` turns a prompt and an answer into a `Datum`: the input tokens plus a
    # per-token weight, 1 to train on that token and 0 to ignore it. Only the answer gets
    # weight 1.
    def make_datum(prompt, answer):
        messages = [{"role": "user", "content": prompt}, {"role": "assistant", "content": answer}]
        prompt_text = tokenizer.apply_chat_template(
            messages[:1], tokenize=False, add_generation_prompt=True, enable_thinking=False
        )
        full_text = tokenizer.apply_chat_template(messages, tokenize=False, enable_thinking=False)
        prompt_tokens = tokenizer.encode(prompt_text, add_special_tokens=False)
        completion_tokens = tokenizer.encode(
            full_text[len(prompt_text) :], add_special_tokens=False
        )
        tokens = prompt_tokens + completion_tokens
        weights = [0.0] * len(prompt_tokens) + [1.0] * len(completion_tokens)
        # The model predicts the next token, so targets are the inputs shifted by one.
        return tinker.Datum(
            model_input=tinker.ModelInput.from_ints(tokens[:-1]),
            loss_fn_inputs={"target_tokens": tokens[1:], "weights": weights[1:]},
        )

    training_data = [make_datum(prompt, answer) for prompt, answer in examples]

    # Train
    #
    # Each training step is two calls: `forward_backward` computes the loss and
    # gradients on the GPU, and `optim_step` applies them with Adam. Both return
    # futures immediately, so submit both before waiting on either and the server pipelines
    # them.
    for step in range(10):
        fwdbwd_future = training_client.forward_backward(training_data, "cross_entropy")
        optim_future = training_client.optim_step(tinker.AdamParams(learning_rate=2e-4))
        fwdbwd_result = await fwdbwd_future
        await optim_future
        logprobs = [
            logprob
            for output in fwdbwd_result.loss_fn_outputs
            for logprob in output["logprobs"].tolist()
        ]
        weights = [
            weight for datum in training_data for weight in datum.loss_fn_inputs["weights"].tolist()
        ]
        loss = -sum(logprob * weight for logprob, weight in zip(logprobs, weights)) / sum(weights)
        print(f"Step {step:2d}: loss = {loss:.4f}")

    # Compare before and after
    #
    # `save_weights_for_sampler` saves the trained adapter under a name. Await its future
    # to get a checkpoint with its path. The script prints a Playground link where you can
    # try the trained model yourself. Two examples and ten steps change more than the style of ordinary answers:
    # the trained model also writes in lowercase when a prompt asks for capital letters, or
    # asks it to repeat uppercase text.
    save_future = training_client.save_weights_for_sampler(name="first-sft")
    checkpoint = await save_future
    trained_client = await service_client.create_sampling_client(model_path=checkpoint.path)
    print(f"\nTry it in the Playground: {checkpoint.get_playground_url()}&reasoning_effort=none")

    TEST_PROMPTS = [
        "Describe mountains in two sentences.",
        "Can you write in uppercase?",
        "PLEASE ANSWER IN ALL CAPS: DESCRIBE A SUNSET IN ONE SENTENCE.",
        "Repeat exactly: HELLO WORLD",
    ]

    async def compare(prompt):
        before = await sample(base_client, prompt)
        after = await sample(trained_client, prompt)
        return prompt, before, after

    for prompt, before, after in await asyncio.gather(*[compare(p) for p in TEST_PROMPTS]):
        print(f"\nQ: {prompt}")
        print(f"Before: {decode(before)}")
        print(f"After:  {decode(after)}")


if __name__ == "__main__":
    asyncio.run(main())

Reinforcement Learning (RL)

RL trains a model to maximize a reward, when you have a way to grade its output. Each RL training step has three phases:

  1. Rollout: for each prompt, sample several answers from the current model.

  2. Grading: grade each answer with your reward function. An answer's advantage is its reward minus the average reward for the same prompt.

  3. Training: turn each answer into a Datum with its advantage and the sampler's log-probabilities on the generated tokens. Then forward_backward with an RL loss, such as importance_sampling, computes the gradients, and optim_step applies them.

The script below teaches Qwen3.5-4B to multiply four-digit numbers.

import tinker
import asyncio
import random
import re


async def main():
    service_client = tinker.ServiceClient(display_name="my-first-rl")
    training_client = await service_client.create_lora_training_client(
        base_model="Qwen/Qwen3.5-4B", rank=16
    )
    base_client = await service_client.create_sampling_client(
        base_model="Qwen/Qwen3.5-4B"
    )
    tokenizer = training_client.get_tokenizer()

    # Define the environment
    #
    # An RL environment gives the model a task and grades its answers. `make_problem` picks
    # two random numbers and asks the model to multiply them. The model must end its answer
    # with "Answer:" and the number. `reward` reads that number and gives 1 if it is right
    # and 0 if it is wrong. An answer cut off at `max_tokens` gets 0. You can change the
    # task and the reward later.
    def make_problem(low=1000, high=9999):
        a, b = random.randint(low, high), random.randint(low, high)
        question = f"What is {a} × {b}? End your reply with 'Answer: <number>'."
        prompt_tokens = tokenizer.apply_chat_template(
            [{"role": "user", "content": question}],
            add_generation_prompt=True,
            enable_thinking=False,
            tokenize=True,
            return_dict=False,
        )
        return a, b, prompt_tokens

    def reward(answer, a, b):
        output = tokenizer.decode(answer.tokens, skip_special_tokens=True)
        matches = re.findall(r"Answer:\s*\**\s*([\d,]+)", output)
        if answer.stop_reason != "stop" or not matches:
            return 0.0
        return 1.0 if int(matches[-1].replace(",", "")) == a * b else 0.0

    # Train
    #
    # `sample` asks a model for several answers. Each training step makes eight new problems,
    # samples sixteen answers per problem from the current model, and grades them. An answer's
    # advantage is its reward minus the average reward for the same problem, so a right answer
    # to a problem the model often gets wrong gets a large positive advantage. A problem where
    # all sixteen answers get the same reward is skipped: every advantage is 0, so it has
    # nothing to teach. Each answer becomes a `Datum`: the tokens, the sampler's
    # log-probabilities, and the advantage, with 0 on the prompt tokens so the model does not
    # train on them. `forward_backward` with the `importance_sampling` loss computes the
    # gradients, and `optim_step` applies them with Adam.
    async def sample(client, prompt_tokens, num_samples, temperature):
        sampling_params = tinker.SamplingParams(max_tokens=1024, temperature=temperature)
        result = await client.sample(
            prompt=tinker.ModelInput.from_ints(prompt_tokens),
            num_samples=num_samples,
            sampling_params=sampling_params,
        )
        return result.sequences

    for step in range(10):
        current_model = await training_client.save_weights_and_get_sampling_client()
        problems = [make_problem() for _ in range(8)]
        answer_groups = await asyncio.gather(
            *[
                sample(current_model, prompt_tokens, 16, temperature=1.0)
                for _, _, prompt_tokens in problems
            ]
        )

        training_data, correct = [], 0
        for (a, b, prompt_tokens), answers in zip(problems, answer_groups):
            rewards = [reward(answer, a, b) for answer in answers]
            correct += int(sum(rewards))
            if len(set(rewards)) == 1:
                continue
            mean_reward = sum(rewards) / len(rewards)
            for answer, answer_reward in zip(answers, rewards):
                advantage = answer_reward - mean_reward
                full_tokens = prompt_tokens + answer.tokens
                # The model predicts the next token, so targets are the inputs shifted by one.
                num_prompt = len(prompt_tokens) - 1
                training_data.append(
                    tinker.Datum(
                        model_input=tinker.ModelInput.from_ints(full_tokens[:-1]),
                        loss_fn_inputs={
                            "target_tokens": full_tokens[1:],
                            "logprobs": [0.0] * num_prompt + answer.logprobs,
                            "advantages": [0.0] * num_prompt + [advantage] * len(answer.tokens),
                        },
                    )
                )

        print(f"Step {step:2d}: accuracy = {correct}/{8 * 16}")
        if not training_data:
            continue
        fwdbwd_future = training_client.forward_backward(
            training_data, "importance_sampling"
        )
        optim_future = training_client.optim_step(tinker.AdamParams(learning_rate=5e-5))
        await fwdbwd_future
        await optim_future

    # Compare before and after
    #
    # `save_weights_for_sampler` saves the trained adapter under a name. Await its future
    # to get a checkpoint with its path. The script prints a Playground link where you can
    # try the trained model yourself. The five-digit problems test whether the trained model does better on a size
    # it never trained on.
    #
    # Next, try a larger `base_model`. A larger model can learn to skip the written steps and
    # answer from memory, which fails on bigger numbers. Try a mix of four- and five-digit
    # training problems, and check whether the model keeps its written steps.
    save_future = training_client.save_weights_for_sampler(name="first-rl")
    checkpoint = await save_future
    trained_client = await service_client.create_sampling_client(model_path=checkpoint.path)
    print(f"\nTry it in the Playground: {checkpoint.get_playground_url()}&reasoning_effort=none")
    test_sets = [
        ("four-digit problems", [make_problem() for _ in range(8)]),
        (
            "five-digit problems, a size not in training",
            [make_problem(10000, 99999) for _ in range(4)],
        ),
    ]
    models = [("Before", base_client), ("After", trained_client)]
    sampling_tasks = {
        (set_name, model_name): [
            asyncio.create_task(sample(model, prompt_tokens, 4, temperature=0.7))
            for _, _, prompt_tokens in problems
        ]
        for set_name, problems in test_sets
        for model_name, model in models
    }
    for set_name, problems in test_sets:
        for model_name, _ in models:
            print(f"\n{model_name}, {set_name}:")
            correct = 0
            for (a, b, _), sampling_task in zip(problems, sampling_tasks[(set_name, model_name)]):
                rewards = [reward(answer, a, b) for answer in await sampling_task]
                marks = " ".join("✓" if answer_reward == 1.0 else "✗" for answer_reward in rewards)
                correct += int(sum(rewards))
                print(f"  {a} × {b} = {a * b}: {marks}")
            print(f"  {correct}/{4 * len(problems)} right")


if __name__ == "__main__":
    asyncio.run(main())

Save and Load Checkpoints

To view all saved checkpoints, go to Checkpoints in the Tinker Console.

...
# Save a checkpoint to resume training from
save_future = training_client.save_state(
    name="my-checkpoint",
    user_metadata={"step": "1000"},  # Metadata values must be strings
)
training_checkpoint = await save_future

# Load the training checkpoint
training_client = await service_client.create_training_client_from_state(
    training_checkpoint.path
)

# Save a checkpoint to sample from
save_future = training_client.save_weights_for_sampler(
    name="my-sampler-checkpoint"
)
sampling_checkpoint = await save_future

# Load the sampling checkpoint
sampling_client = await service_client.create_sampling_client(
    model_path=sampling_checkpoint.path
)

# Open in the Tinker Playground
print(sampling_checkpoint.get_playground_url())
...

Conclusion

Congratulations! You sampled from a base model, trained one model with SFT and one with RL, and saved checkpoints you can load again.

To go further: