Tinker APIと限られたトークン予算内でのLLMの強化学習
この記事はCyberAgent AI Lab Advent Calendar 2025 13日目の記事です。
今日のテーマはLLM×強化学習です。
担当はリサーチエンジニアの三橋です!
はじめに
本記事では2つの内容を紹介します。
- 前半では、LLMの分散学習機能を提供するTinker APIの使用方法と実装例を紹介します。
- 後半では、Tinker APIを使って学習したLLMの挙動を追うことで得られた知見を共有します。
記事の要約
-
記事の前半では、LLMを分散学習してくれるTinker APIの使い方を解説しました。公式の実装例をなぞって、waitlistから招待を受けた後に、APIキーを作ってマルチノード学習するjobを手元のPCから投下する手順を紹介しました。Tinker APIの使い方を一言で表現すると、「トークナイズまでは自前の環境で処理して、分散学習とチェックポイントの管理はTinker API側に任せる」という理解になりました。
-
記事の後半では、Tinker APIで学習したQwen3-8B-math-RL-en, Qwen3-8B-math-RL-jaの挙動を確認しました。結論として、簡単な数学タスクにおいて、日本語でコンパクトにreasoningを行うモデルを学習できました。学習に使ったデータセットと学習後のモデルは以下で公開しています。どちらも元のモデルとデータセットのライセンスを継承して、Apache-2.0ライセンスで公開しています。
- Tinker APIを他社のAPIと比較した表です。Tinker APIの方が柔軟性がある分、自分で学習を行う前までのスクリプトを書く必要があります。
| API | インターフェース | メリット | デメリット |
|---|---|---|---|
| OpenAI Model Optimization API | データセットとモデル名を渡すと学習済みモデルが返ってくる | 高性能なクローズドモデルを平易にチューニングできる。Visionモデルにも対応。 | モデルはOpenAI製モデルに限られる。強化学習についてはDPOとRFTのみ対応。RFTはreasoningモデルのみ対応。 |
| Tinker API | tokenizeしたデータセットとモデル名を渡すと、学習済みモデルとチェックポイントが返ってくる。※tokenizeは公式のコードで流用可能 | 強化学習の実装例が豊富 | 特定のオープンウェイトモデルのみ対応。Visionモデルは記事作成時点では未対応。 |
経緯(記事の前半)
2025年10月1日にThinking machinesからTinker APIが発表されました。記事投稿時点ではwaitlistに登録したユーザーから順次アクセス権が付与されるようです。
Tinker APIはオープンウェイトLLMの分散学習(マルチGPU学習)をよしなに行ってくれるサービスです。
LLMのファインチューニングサービスは、OpenAIなどの他のプロバイダからもサポートされています。OpenAIのファインチューニングサービスとの違いは、オープンウェイトモデルのチューニングに対応している点と、DPO以外の強化学習にも対応している点です。
そこで、今回はTinker APIを使ったシンプルな強化学習でLLMを訓練する手順をご紹介します。
10/3に申請して11月中頃に登録完了のメールを受け取ったので、実際に動かしてみた感想を書いていきます。$150の無料クレジットをいただいたので、このクレジットの中でやりくりしていきます。
Tinker APIとは
Tinkerは言語モデルをファインチューニングする機能を提供しているAPIです。GPUの操作をAPI側で処理してくれるため、GPU周りのコーディングを考えずに言語モデルが学習できます。
waitlistから招待を受けたら、メールに記載されているTinker Consoleにアクセスしましょう。以下の画面からAPIキーを取得して、環境変数に登録しておきます。

Tinker APIの実装例はtinker-cookbookという公式リポジトリに書かれているため、こちらと公式ドキュメントを元に動作確認しました。
openai/gsm8kの学習
tinker_cookbookで用意されている中で最もシンプルな強化学習スクリプトrl_basic.pyを動かします。
データセットには、小学校レベルの数学タスクが収集されたopenai/gsm8kを使用します。
rl_basicでは、gsm8kに対して、回答の正答率と出力形式の一致率(=最終回答を\boxed{answer}の形式で書けたか)を報酬とみなし、報酬が最大となるようにモデルのパラメータを更新します。
以下の設定で学習します。
rl_basicの中の学習時のパラメータを指定している箇所はbuild_config_blueprintに以下のように実装されています。model_nameとlog_path以外は、一旦デフォルトの値をそのまま使用することにします。今回はデフォルトのモデルであるLlama-3.1-8B Baseから、Qwen/Qwen3-8Bに変更しました。
def build_config_blueprint() -> chz.Blueprint[train.Config]:
model_name = "Qwen/Qwen3-8B"
renderer_name = model_info.get_recommended_renderer_name(model_name)
builder = Gsm8kDatasetBuilder(
batch_size=128,
group_size=16,
renderer_name=renderer_name,
model_name_for_tokenizer=model_name,
)
return chz.Blueprint(train.Config).apply(
{
"model_name": model_name,
"log_path": "./logs/tinker-examples/rl_basic_base_ja",
"dataset_builder": builder,
"learning_rate": 4e-5,
"max_tokens": 256,
"eval_every": 0,
}
)
上記の変更のみ行った後、以下のコマンドで学習スクリプトを実行します。
uv run -m tinker_cookbook.recipes.rl_basic
train splitの学習結果は以下です。学習は7000件程の数学データ1epochが1時間ほどで学習できました。

各グラフの見方を表にまとめました。
| タイトル | 説明 |
|---|---|
learning_rate |
学習率, 4.0e-05に固定 |
reward_total |
reward_correctとreward_formatを9:1の重みで合算した値の推移 |
reward_format |
指定した回答形式\boxed{answer}との一致率の推移 |
reward_correct |
最終回答の正答率の推移 |
frac_all_good |
1バッチの内、フォーマットが守れてかつ正しい答えを書けた割合の推移 |
entropy |
生成されたトークン毎の、負の平均対数確率の推移 |
学習結果の確認
reward_totalのグラフを見ると、15ステップ付近で大きな報酬を獲得できていることが見て取れます。学習が進むにつれ、指定されたフォーマットを守りながら正答を生成できるようになっていることが期待できます。
- また、20ステップ付近では、
reward_formatはreward_correctよりも早く報酬値が飽和していることが見て取れます。 -
entropyの推移をみると、正答率が向上した20ステップ付近で若干entropyの上昇が見られます。
上記の学習結果の考察は後述するとして、先に学習したモデルの重みをダウンロードします。
モデルの重みをダウンロードする
学習したモデルのチェックポイントはTinker APIのコンソール画面から確認できます。

Full Pathカラムのcopyボタンを押すと重みをダウンロードするためのパスがコピーされます。
次のコマンドの<full_path>をこのパスに差し替えて重みをダウンロードします。8BモデルのLoRA adapterのため、今回の設定では180MB程の容量でした。
uv run tinker checkpoint download <full_path>
LoRAアダプターの重みがダウンロードされました。以下の設定でLoRA Adapterが学習されています。
ダウンロードしたファイルの一つであるadapter_config.jsonを確認します。
rもlora_alphaも32、学習対象の層は全ての全結合層を指定して学習されていることが確認できます。
{
"alpha_pattern": {},
"auto_mapping": null,
"base_model_name_or_path": null,
"bias": "none",
"corda_config": null,
"eva_config": null,
"exclude_modules": null,
"fan_in_fan_out": false,
"inference_mode": false,
"init_lora_weights": true,
"layer_replication": null,
"layers_pattern": null,
"layers_to_transform": null,
"loftq_config": {},
"lora_alpha": 32,
"lora_bias": false,
"lora_dropout": 0,
"megatron_config": null,
"megatron_core": "megatron.core",
"modules_to_save": null,
"peft_type": "LORA",
"r": 32,
"rank_pattern": {},
"revision": null,
"target_modules": "all-linear",
"task_type": "CAUSAL_LM",
"trainable_token_indices": null,
"use_dora": false,
"use_rslora": false
}
この重みを読み込んで推論すれば、Tinker APIで学習したモデルが手元の環境で動かすことができます。筆者はAdapterをマージして使うのが好みのため、以下のコードを使ってAdapterをマージしてHuggingFaceにアップロードしました。
merge_and_upload.py
import torch
import os
from transformers import AutoModelForCausalLM, AutoTokenizer
from loguru import logger
from peft import PeftModel
def merge_and_upload(
base_model_id: str,
adapter_path: str,
repo_id: str,
trust_remote_code: bool = True,
push_tokenizer: bool = True,
torch_dtype: torch.dtype = torch.bfloat16,
private: bool = True,
):
logger.info(f"Loading base model: {base_model_id}")
base_model = AutoModelForCausalLM.from_pretrained(
base_model_id,
torch_dtype=torch_dtype,
device_map="auto",
trust_remote_code=trust_remote_code,
)
logger.info(f"Loading adapter: {adapter_path}")
model = PeftModel.from_pretrained(base_model, adapter_path)
logger.info("Merging adapter into base model...")
merged_model = model.merge_and_unload()
logger.info(f"Pushing merged model to: {repo_id}")
merged_model.push_to_hub(
repo_id,
private=private,
safe_serialization=True,
hf_token=os.environ.get("HF_TOKEN"),
)
if push_tokenizer:
logger.info("Pushing tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(
base_model_id,
trust_remote_code=True,
)
tokenizer.push_to_hub(
repo_id,
private=private,
hf_token=os.environ.get("HF_TOKEN"),
)
logger.info(f"Done! Model uploaded to: https://huggingface.co/{repo_id}")
return merged_model
def main():
merged = merge_and_upload(
base_model_id="Qwen/Qwen3-8B",
adapter_path="./results/qwen3-8b-adapter",
repo_id="your-hf-user-name/your-repo-name",
)
if __name__ == "__main__":
main()
記事の前半のまとめ
若干かけ足になりましたが、学習設定を書いて、APIを含むスクリプトを実行すれば、比較的簡単に強化学習を用いたLLMの学習ができることを確認しました。
以上がこの記事の前半の内容です。
Tinker APIを使ったLLMの学習方法と実装例を紹介しました。
もし今後Tinker APIを使う方の参考になれば幸いです!
モデル挙動の確認(記事の後半)
ここからが記事の後半の内容です。
モデルの挙動を追うことで得られた知見を共有します。
openai/gsm8kの学習結果の考察
結論から言うと、Qwen3-8Bにopenai/gsm8kタスクは役不足であったこと、max_tokensのパラメータに256という小さすぎる値(=限られたトークン予算)を使用していたことに実験後に気づきました。しかし、モデルの興味深い挙動を観測し気づきを得ることができました。
-
Qwen3 Technical Reportによると、
Qwen/Qwen3-8B-BASEの事前学習時のopenai/gsm8kの評価値は89.84%です。であれば、(上記の実験で使用した)Qwen/Qwen3-8B-BASEに事後学習を適用したQwen/Qwen3-8Bが学習初期に正答率がほぼ0.0%になることは考えにくいです。実験設定で何か誤りがありそうです。 -
rl_basicではmax_tokensが256に設定されていました。デフォルトのモデルがLlama-3.1-8B baseであったことから、このモデルでgsm8kを解くために十分なトークン数が256であったことが予想されます。このことから、long CoTなどの事後学習が適用されたQwen/Qwen3-8Bに変更する場合は、より大きいmax_tokensに設定すべきだったことが想定されます。
学習前のモデルの出力を確認します。
The Christmas tree has 8 shelves for decorations and each holding 45 ornaments. Currently two-thirds of the total are decorated. How many ornaments are on the tree in total? Write your answer in the format \boxed{answer}.
<think>
Okay, let me try to figure out this problem. So, the question is about a Christmas tree with 8 shelves for decorations, and each shelf holds 45 ornaments. Currently, two-thirds of the total are decorated. We need to find out how many ornaments are on the tree in total. Hmm, let me break this down step by step.
First, let me understand the problem. The tree has 8 shelves, each can hold 45 ornaments. So, the total number of ornaments that the tree can hold is 8 multiplied by 45. Let me calculate that first. 8 times 45... Let me do 8 times 40, which is 320, and then 8 times 5, which is 40. Adding those together, 320 + 40 equals 360. So, the total capacity of the tree is 360 ornaments.
But wait, the problem says that currently two-thirds of the total are decorated. Wait, does that mean two-thirds of the total capacity are decorated? Or is there another way to interpret this? Let me check the wording again: "Currently two-thirds of the total are decorated." The "total"
学習前のQwen3-8Bの出力は思考途中でreasoningが途切れていることが見て取れます。特に最後の\boxed{answer}を書けていないことが出力から確認できます。これが学習初期にモデルの正答率が低かった理由だと考えられます。
しかし、20ステップを超えたあたりで報酬が大きく増加していることが、前半の記事で述べたグラフから見て取れます。
この時の実際のモデルの出力も同様に確認します。
<think>
Okay, let's see. The Christmas tree has 8 shelves, each holding 45 ornaments. So the total capacity is 8 times 45. Let me calculate that: 8*45 is 360. So the total capacity is 360 ornaments. Now, two-thirds of that is decorated. So two-thirds of 360 is (2/3)*360. Let me compute that: 360 divided by 3 is 120, times 2 is 240. So there are 240 ornaments on the tree. The answer is \boxed{240}.
**Final Answer**
\boxed{240}
</think>
To determine how many ornaments are currently on the tree, we start by calculating the total capacity of the tree:
- There are 8 shelves, and each shelf holds 45 ornaments.
- Total capacity = $ 8 \times 45 = 360 $ ornaments.
Next, we are told that two-thirds of this total capacity is currently decorated:
- Two-thirds of 360 = $ \frac{2}{3} \times 360 = 240 $
こちらも文章が途切れてしまっていますが、学習前のモデルと比べると、max_tokens以内でreasoningを行い\boxed{answer}まで書き切っていることが見て取れます。<think></think>内で\boxed{answer}まで書くことは想定外でしたが、おそらくモデルのreasoning結果の中に、簡潔に回答を生成したサンプルが偶然生成され、その回答に報酬が与えられたことで、コンパクトに回答を述べることで高い報酬が獲得できる、ということを学習してモデルの重みが更新されていったことが考えられます。
gsm8k-jaの学習と学習結果の考察
この記事はもともと、日本語の算術推論能力を検証するために書いていました。そのため、この考察を書く前に、gsm8kの日本語版データセットを作成していました。作成手順は以下にまとめたので、ご興味がある方はご一読ください。
gsm8k-jaの作成手順
日本語版データセットの作成方法は非常にシンプルで、openai/gsm8kデータセットをopenai/gpt-oss-120bで翻訳しました。
翻訳結果を確認したところ、train/test splitの両方で、answerカラムの、#### [回答]が翻訳から抜けているサンプルが確認されたため、翻訳元の回答を参照しながら#### [回答]を追加する後処理を行い、最終的なデータセットを作成しました。
翻訳したtrainデータセットの一例です。
ナタリアは4月に48人の友達にクリップを販売し、5月にはその半分の量を販売しました。ナタリアが4月と5月に合わせて販売したクリップの総数はいくらですか?
ナタリアは5月に 48/2 = <<48/2=24>>24本のクリップを販売した。
ナタリアは4月と5月の合計で 48+24 = <<48+24=72>>72本のクリップを販売した。
#### 72
ウェインはベビーシッティングで時給12ドルです。昨日、彼女はベビーシッティングを50分だけしました。彼女はどれくらい稼ぎましたか?
Wengは1分あたり12/60 = $<<12/60=0.2>>0.2を稼ぎます。
50分働くと、0.2 x 50 = $<<0.2*50=10>>10を稼ぎました。
#### 10
ベティは新しい財布(価格は100ドル)を買うためにお金を貯めています。ベティは必要な金額の半分しか持っていません。両親はその目的のために15ドルを渡すことにし、祖父母は両親の2倍の金額を渡します。ベティが財布を買うためにあとどれだけのお金が必要ですか?
最初、ベティは 100 ÷ 2 = $<<100/2=50>>50 しか持っていません。
ベティの祖父母は彼女に 15 × 2 = $<<15*2=30>>30 をくれました。
つまり、ベティは 100 − 50 − 30 − 15 = $<<100-50-30-15=5>>5 がまだ必要です。
#### 5#### 5
-
#### [回答]が重複して生成されているサンプルも確認されました。今回は質問文と、回答の中から数値だけを抽出して利用したため、最終回答の重複は学習に影響がないと判断し、そのまま検証を進めています。
せっかくなので、上記のgsm8k-jaも同様にQwen/Qwen3-8Bに学習させてみます。
学習させるデータセットを変更した点以外は、openai/gsm8kの学習と全く同じ設定です。
Tinker APIでのtrain splitの学習結果は以下です。

こちらも英語のデータセットを学習した時と同様に、reward_totalのグラフを見ると、20ステップ付近で大きな報酬を獲得できていることが見て取れます。学習が進むにつれ、指定されたフォーマットを守りながら正答を生成できるようになっていることが期待できます。
- また、30ステップ付近では、
reward_formatはreward_correctよりも早く報酬値が飽和していることが見て取れます。 - 一方で、
reward_correctやfrac_all_goodは、全ての学習ステップを終えても若干ですが正答率が向上しており、改善の余地が見られます。 -
entropyの推移をみると、正答率が向上した20ステップ付近でentropyの上昇が見られます。
こちらも英語データセットの時と同様に、学習初期は正答率がほぼ0.0%であることから、reasoning結果が途切れている可能性が考えられます。しかし、学習するにつれて正答率が1.0に近づいていることが見て取れます。日本語のデータセットでも同様にコンパクトなreasoningが行われていそうです。
まずは、学習前のモデルの出力を確認します。問題は筆者がgsm8k風の問題を自作しました。
クリスマスツリーには飾り棚が8段あり、各段には45個のオーナメントを飾れます。現在、全体の3分の2が飾られています。ツリーに飾られているオーナメントは全部で何個ですか?回答は\boxed{answer}の形式で書きなさい。
<think>
Okay, let's see. I need to figure out how many ornaments are currently on the Christmas tree. The problem says there are 8 tiers on the tree, and each tier can hold 45 ornaments. Right now, two-thirds of all the ornaments are decorated. Hmm, wait, actually, let me make sure I understand correctly. Is the total number of ornaments that can be placed on the tree 8 tiers times 45 each? And then two-thirds of that total is currently decorated? Or is it that two-thirds of each tier is decorated? Wait, the wording says "全体の3分の2が飾られています" which translates to "two-thirds of the entire" are decorated. So probably the total capacity is 8 tiers times 45 ornaments each, and two-thirds of that total is already placed. So I need to calculate the total capacity first, then multiply by two-thirds to get the current number of ornaments.
Let me check again. The problem says: "クリスマスツリーには飾り棚が8段あり、各段には45個のオーナメントを飾れます。現在、全体の3分の2が飾られています。ツリーに飾られているオーナメントは全部
途中の思考過程はあっているのですが、想定通り、max_tokensの制限により、思考過程でreasoningが途切れてしまっていることが見て取れます。
また、思考過程から、日本語を一度英語に翻訳してから思考したり、Hmm, wait, actually, let me make sure I understand correctly.のように、思考の見直しを行うように訓練されていることが確認できます。
次に、学習後のモデルの回答を確認します。
<think>
まず、クリスマスツリーの飾り棚の総数と各段のオーナメントの数を計算します。飾り棚が8段あり、各段には45個のオーナメントが飾られているため、総オーナメント数は8段 × 45個/段 = 360個です。
次に、現在の飾られているオーナメントの割合は全体の3分の2です。したがって、現在飾られているオーナメントの数は360個 × 2/3 = 240個です。
答えは240個です。
</think>
全体のオーナメントの数は $8 \times 45 = 360$ 個です。現在、その $\frac{2}{3}$ が飾られているため、飾られているオーナメントの数は $360 \times \frac{2}{3} = 240$ 個です。
答えは $\boxed{240}$ です。
まず、モデルが日本語で思考していることが見て取れます。次に、指定されたトークン予算内で思考と回答を書き切っていることが確認できます。
この出力も英語データセットを学習させた時と同様で、日本語かつ少ないトークン予算でreasoningと回答まで書き切ることで高い報酬を獲得し、最短経路で正答を生成する戦略に切り替えたのではないかと考えられます。
今回の実験では、結果論ではありますが、日本語訓練データ+少ないトークン予算内で訓練させたことで、日本語で思考する、かつ公開モデルよりもコンパクトなreasoningを行うモデルが学習されました。
学習パラメータの確認漏れは再発防止に努めようと思う反面、新たな経験も得られたため、個人的には非常に良い経験となりました。
得られた気づき
- タスクによって、公開済みモデルのreasoningは冗長な場合がある
- 冗長なreasoningは、タスクに特化した学習によってコンパクトにすることができる
- 事前学習で日本語を学習しているモデルであれば、学習の過程で日本語で思考する能力を引き出せることがある
まとめ
記事の前半では、Tinker APIの使い方をご紹介しました。
記事の後半では、意図せずですが、トークン予算を大きく絞って学習を行ったところ、モデルがコンパクトなreasoningを行う現象を確認しました。実際に手を動かしてみると、知らないことがまだまだたくさんあるなと実感しました。
今回の実験はモデル1回の学習に30$ほどかかっています。
記事の後半で、無料クレジットは(個人的には)知見に変わりました。
無駄にならなくてよかったです。
今回の検証に使用したデータセットと学習したモデルは以下で公開しています。
ぜひお手元で動かしてみてください。
宣伝
APIでなく、オンプレでモデルを強化学習したい場合は、Agentic Reinforcement Trainer(ART)という便利なライブラリがあります。2025/12/02にWeights&Biases様のmeetupで概要を発表させていただいたので、ご興味があればこちらもご確認ください。Weights&Biases様のInference, Training, Serverless RLの紹介やAIMO2の解法共有のご発表もあり、個人的にとても勉強になりました。
感想
記事作成中にZennの「新しいエディタを試す」ボタンが実装されたので、さっそくその機能を使ってこの記事を作成しました。約3年前にzennを使い始めたときから感じていたプレビュー画面との反復横跳びと記事のずれが解消され、とても良い体験でした。
Discussion