Use fewer tokens without lobotomizing your model
"# Use fewer tokens without lobotomizing your model\n\nWhen billed per token, the price of using any LLM depends on the cost per token\nand the total number of tokens. When using an open-source model, you can take\ncontrol over the cost per token by\n[owning your inference](https://modal.com/blog/introducing-auto-endpoints).\nHowever, open-source models are notorious for using significantly more tokens\nper request.\n\nHere, we train [Qwen3.5-4B](https://huggingface.co/Qwen/Qwen3.5-4B)\nto answer problems from [GSM8k](https://huggingface.co/datasets/skrishna/gsm8k_only_answer),\nwhich it can already do pretty well, but using fewer output tokens.\n\n```python\nimport re\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\n\nfrom transformers import AutoTokenizer\n\nfrom modal_dojo import (\n Endpoint,\n HuggingFaceDataset,\n Qwen3_5_4B,\n Qwen3_5_4B_Recipe,\n TrainConfig,\n)\n```\n\n## Get a baseline\n\nWe'll see how the model performs on the task, and how many tokens it reqiures to do so.\n\n```python\nmodel = Qwen3_5_4B()\n\n\ndef deploy_base_model():\n base_deployment = Endpoint.launch(\n model,\n unauthenticated=True,\n recreate_if_existing=True,\n endpoint_name=\"fewer-tokens-baseline\",\n )\n try:\n base_deployment.wait_until_ready()\n except BaseException:\n base_deployment.stop()\n raise\n print(f\"base model deployed to {base_deployment.url}\")\n return base_deployment\n\n\ndef _extract_gsm8k_answer(text: str) -> str:\n boxed = re.findall(r\"\\\\boxed\\{([^}]+)\\}\", text)\n if boxed:\n return boxed[-1]\n nums = re.findall(r\"-?[\\d,]+(?:\\.\\d+)?\", text)\n return nums[-1] if nums else \"\"\n\n\ndef score_gsm8k(response: str, label: str) -> float:\n pred = _extract_gsm8k_answer(response)\n try:\n return float(float(pred.replace(\",\", \"\")) == float(label))\n except ValueError:\n return 0.0\n\n\ntrain_dataset = HuggingFaceDataset(\n \"skrishna/gsm8k_only_answer\",\n hf_split=\"train[:200]\",\n input_column=\"text\",\n output_column=\"label\",\n input_format=\"text\",\n)\n\neval_dataset = HuggingFaceDataset(\n \"skrishna/gsm8k_only_answer\",\n hf_split=\"train[200:400]\",\n input_column=\"text\",\n output_column=\"label\",\n input_format=\"text\",\n)\n\n\ndef run_eval(deployment, max_concurrency: int = 16) -> tuple[float, float]:\n deployment.wait_until_ready()\n tokenizer = AutoTokenizer.from_pretrained(model.model_name)\n\n def _score_one(example):\n msg = deployment.chat(\n example[eval_dataset.input_key()],\n chat_template_kwargs={\"enable_thinking\": True},\n max_tokens=8192,\n )\n content = msg.get(\"content\") or \"\"\n reasoning = msg.get(\"reasoning_content\") or \"\"\n correct = score_gsm8k(content, example[eval_dataset.label_key()])\n tokens = tokenizer.encode(reasoning + content, add_special_tokens=False)\n return correct, len(tokens)\n\n with ThreadPoolExecutor(max_workers=max_concurrency) as executor:\n rows = list(executor.map(_score_one, eval_dataset.rows()))\n if not rows:\n return float(\"nan\"), float(\"nan\")\n accuracy = sum(c for c, _ in rows) / len(rows)\n mean_tokens = sum(n for _, n in rows) / len(rows)\n return accuracy, mean_tokens\n\n\ndef run_baseline_evals(deployment):\n print(\"running base model evaluation...\")\n accuracy, mean_tokens = run_eval(deployment)\n print(f\"percent correct: {accuracy:.1%}\")\n print(f\"mean output tokens: {mean_tokens:.0f}\")\n return accuracy, mean_tokens\n```\n\n## Defining the reward function\n\nWe score based on both correctness and an\n[ALP](https://arxiv.org/abs/2506.05256)-style hybrid length penalty. For each\ngroup, let $p$ be the group solve rate, $\\ell_i$ the sample's ``response_length``,\n$L=$ ``rollout_max_response_len``, and $c_i\\in\\{0,1\\}$ correctness. Then\n\n$$\nr_i = c_i \\left(1 - p \\dfrac{\\ell_i}{L}\\right)\n$$\n\n```python\nasync def gsm8k_correctness_rm(args, sample, **kwargs) -> float:\n if sample.status == sample.Status.TRUNCATED:\n return 0.0\n response = model.parse_response(sample.response or \"\")\n return score_gsm8k(response.content, sample.label)\n\n\ndef density_post_process(args, samples, **kwargs):\n n = args.n_samples_per_prompt\n max_len = args.rollout_max_response_len\n raw, processed = [], []\n for start in range(0, len(samples), n):\n group = samples[start : start + n]\n solve_rate = sum(bool(s.reward) for s in group) / len(group)\n rewards = [\n 1.0 - solve_rate * s.response_length / max_len if s.reward else 0.0\n for s in group\n ]\n mean = sum(rewards) / len(rewards)\n std = (sum((r - mean) ** 2 for r in rewards) / max(len(rewards) - 1, 1)) ** 0.5\n raw += rewards\n processed += [(r - mean) / (std + 1e-6) for r in rewards]\n return raw, processed\n```\n\n## Train\n\nSit back and watch it rip.\n\n```python\ndef deploy_trained_model(checkpoint):\n step = int(checkpoint.name.removeprefix(\"iter_\")) + 1\n trained_deployment = Endpoint.launch(\n model,\n checkpoint,\n unauthenticated=True,\n recreate_if_existing=True,\n endpoint_name=f\"fewer-tokens-{step}\",\n )\n try:\n trained_deployment.wait_until_ready()\n except BaseException:\n trained_deployment.stop()\n raise\n print(f\"checkpoint deployed to {trained_deployment.url}\")\n return trained_deployment\n\n\ndef run_trained_evals(trained_deployment):\n print(\"running checkpoint evaluation...\")\n accuracy, mean_tokens = run_eval(trained_deployment)\n print(f\"percent correct: {accuracy:.1%}\")\n print(f\"mean output tokens: {mean_tokens:.0f}\")\n return accuracy, mean_tokens\n\n\ndef eval_checkpoint(checkpoint):\n deployment = deploy_trained_model(checkpoint)\n try:\n step = int(checkpoint.name.removeprefix(\"iter_\")) + 1\n return (f\"Step {step}\", *run_trained_evals(deployment))\n finally:\n deployment.stop()\n\n\nconfig = TrainConfig(\n model=model,\n dataset=train_dataset,\n eval_dataset=eval_dataset,\n recipe=Qwen3_5_4B_Recipe(\n num_rollout=10,\n rollout_batch_size=16,\n n_samples_per_prompt=8,\n global_batch_size=16,\n rollout_max_response_len=8192,\n save_interval=1,\n apply_chat_template_kwargs='{\"enable_thinking\": true}',\n custom_rm_function=gsm8k_correctness_rm,\n custom_reward_post_process_function=density_post_process,\n ),\n)\n\n\ndef train(config):\n failure = None\n results = []\n with config.launch() as run, ThreadPoolExecutor() as evals:\n print(f\"run id: {run.training_run_id}\")\n pending, seen = [], set()\n terminal_status = terminal_error = None\n while True:\n done = run.done()\n if done and terminal_status is None:\n terminal_status = run.status.value\n terminal_error = run.error\n for ckpt in run.checkpoints():\n if ckpt.path in seen:\n continue\n seen.add(ckpt.path)\n print(f\"new checkpoint: {ckpt.path}\")\n pending.append(evals.submit(eval_checkpoint, ckpt))\n if done:\n break\n time.sleep(30)\n results = [f.result() for f in pending]\n if terminal_status != \"completed\":\n failure = RuntimeError(\n terminal_error or f\"training ended with status {terminal_status}\"\n )\n if failure is not None:\n raise failure\n return results\n\n\ndef print_results(rows):\n print(\"| | Accuracy | Mean output tokens |\")\n print(\"| --- | ---: | ---: |\")\n for label, accuracy, mean_tokens in rows:\n print(f\"| {label} | {accuracy:.1%} | {mean_tokens:.0f} |\")\n\n\nif __name__ == \"__main__\":\n base_deployment = deploy_base_model()\n try:\n rows = [(\"Baseline\", *run_baseline_evals(base_deployment))]\n finally:\n base_deployment.stop()\n rows.extend(train(config))\n print_results(rows)\n```\n\n## Results\n\n| | Accuracy | Mean output tokens |\n| --- | ---: | ---: |\n| Baseline | 67.0% | 4272 |\n| Step 1 | 71.5% | 3878 |\n| Step 2 | 72.0% | 3598 |\n| Step 3 | 80.0% | 2866 |\n| Step 4 | 83.5% | 2442 |\n| Step 5 | 82.0% | 2244 |\n| Step 6 | 83.5% | 1961 |\n| Step 7 | 85.5% | 1662 |\n| Step 8 | 85.0% | 1455 |\n| Step 9 | 86.5% | 1307 |\n| Step 10 | 85.5% | 1136 |\n"
When billed per token, the price of using any LLM depends on the cost per token and the total number of tokens. When using an open-source model, you can take control over the cost per token by owning your inference. However, open-source models are notorious for using significantly more tokens per request.
Here, we train Qwen3.5-4B to answer problems from GSM8k, which it can already do pretty well, but using fewer output tokens.
import reimport timefrom concurrent.futures import ThreadPoolExecutor
from transformers import AutoTokenizer
from modal_dojo import ( Endpoint, HuggingFaceDataset, Qwen3_5_4B, Qwen3_5_4B_Recipe, TrainConfig,)Get a baseline
We’ll see how the model performs on the task, and how many tokens it reqiures to do so.
model = Qwen3_5_4B()
def deploy_base_model(): base_deployment = Endpoint.launch( model, unauthenticated=True, recreate_if_existing=True, endpoint_name="fewer-tokens-baseline", ) try: base_deployment.wait_until_ready() except BaseException: base_deployment.stop() raise print(f"base model deployed to {base_deployment.url}") return base_deployment
def _extract_gsm8k_answer(text: str) -> str: boxed = re.findall(r"\\boxed\{([^}]+)\}", text) if boxed: return boxed[-1] nums = re.findall(r"-?[\d,]+(?:\.\d+)?", text) return nums[-1] if nums else ""
def score_gsm8k(response: str, label: str) -> float: pred = _extract_gsm8k_answer(response) try: return float(float(pred.replace(",", "")) == float(label)) except ValueError: return 0.0
train_dataset = HuggingFaceDataset( "skrishna/gsm8k_only_answer", hf_split="train[:200]", input_column="text", output_column="label", input_format="text",)
eval_dataset = HuggingFaceDataset( "skrishna/gsm8k_only_answer", hf_split="train[200:400]", input_column="text", output_column="label", input_format="text",)
def run_eval(deployment, max_concurrency: int = 16) -> tuple[float, float]: deployment.wait_until_ready() tokenizer = AutoTokenizer.from_pretrained(model.model_name)
def _score_one(example): msg = deployment.chat( example[eval_dataset.input_key()], chat_template_kwargs={"enable_thinking": True}, max_tokens=8192, ) content = msg.get("content") or "" reasoning = msg.get("reasoning_content") or "" correct = score_gsm8k(content, example[eval_dataset.label_key()]) tokens = tokenizer.encode(reasoning + content, add_special_tokens=False) return correct, len(tokens)
with ThreadPoolExecutor(max_workers=max_concurrency) as executor: rows = list(executor.map(_score_one, eval_dataset.rows())) if not rows: return float("nan"), float("nan") accuracy = sum(c for c, _ in rows) / len(rows) mean_tokens = sum(n for _, n in rows) / len(rows) return accuracy, mean_tokens
def run_baseline_evals(deployment): print("running base model evaluation...") accuracy, mean_tokens = run_eval(deployment) print(f"percent correct: {accuracy:.1%}") print(f"mean output tokens: {mean_tokens:.0f}") return accuracy, mean_tokensDefining the reward function
We score based on both correctness and an
ALP-style hybrid length penalty. For each
group, let be the group solve rate, the sample’s response_length,
rollout_max_response_len, and correctness. Then
async def gsm8k_correctness_rm(args, sample, **kwargs) -> float: if sample.status == sample.Status.TRUNCATED: return 0.0 response = model.parse_response(sample.response or "") return score_gsm8k(response.content, sample.label)
def density_post_process(args, samples, **kwargs): n = args.n_samples_per_prompt max_len = args.rollout_max_response_len raw, processed = [], [] for start in range(0, len(samples), n): group = samples[start : start + n] solve_rate = sum(bool(s.reward) for s in group) / len(group) rewards = [ 1.0 - solve_rate * s.response_length / max_len if s.reward else 0.0 for s in group ] mean = sum(rewards) / len(rewards) std = (sum((r - mean) ** 2 for r in rewards) / max(len(rewards) - 1, 1)) ** 0.5 raw += rewards processed += [(r - mean) / (std + 1e-6) for r in rewards] return raw, processedTrain
Sit back and watch it rip.
def deploy_trained_model(checkpoint): step = int(checkpoint.name.removeprefix("iter_")) + 1 trained_deployment = Endpoint.launch( model, checkpoint, unauthenticated=True, recreate_if_existing=True, endpoint_name=f"fewer-tokens-{step}", ) try: trained_deployment.wait_until_ready() except BaseException: trained_deployment.stop() raise print(f"checkpoint deployed to {trained_deployment.url}") return trained_deployment
def run_trained_evals(trained_deployment): print("running checkpoint evaluation...") accuracy, mean_tokens = run_eval(trained_deployment) print(f"percent correct: {accuracy:.1%}") print(f"mean output tokens: {mean_tokens:.0f}") return accuracy, mean_tokens
def eval_checkpoint(checkpoint): deployment = deploy_trained_model(checkpoint) try: step = int(checkpoint.name.removeprefix("iter_")) + 1 return (f"Step {step}", *run_trained_evals(deployment)) finally: deployment.stop()
config = TrainConfig( model=model, dataset=train_dataset, eval_dataset=eval_dataset, recipe=Qwen3_5_4B_Recipe( num_rollout=10, rollout_batch_size=16, n_samples_per_prompt=8, global_batch_size=16, rollout_max_response_len=8192, save_interval=1, apply_chat_template_kwargs='{"enable_thinking": true}', custom_rm_function=gsm8k_correctness_rm, custom_reward_post_process_function=density_post_process, ),)
def train(config): failure = None results = [] with config.launch() as run, ThreadPoolExecutor() as evals: print(f"run id: {run.training_run_id}") pending, seen = [], set() terminal_status = terminal_error = None while True: done = run.done() if done and terminal_status is None: terminal_status = run.status.value terminal_error = run.error for ckpt in run.checkpoints(): if ckpt.path in seen: continue seen.add(ckpt.path) print(f"new checkpoint: {ckpt.path}") pending.append(evals.submit(eval_checkpoint, ckpt)) if done: break time.sleep(30) results = [f.result() for f in pending] if terminal_status != "completed": failure = RuntimeError( terminal_error or f"training ended with status {terminal_status}" ) if failure is not None: raise failure return results
def print_results(rows): print("| | Accuracy | Mean output tokens |") print("| --- | ---: | ---: |") for label, accuracy, mean_tokens in rows: print(f"| {label} | {accuracy:.1%} | {mean_tokens:.0f} |")
if __name__ == "__main__": base_deployment = deploy_base_model() try: rows = [("Baseline", *run_baseline_evals(base_deployment))] finally: base_deployment.stop() rows.extend(train(config)) print_results(rows)Results
| Accuracy | Mean output tokens | |
|---|---|---|
| Baseline | 67.0% | 4272 |
| Step 1 | 71.5% | 3878 |
| Step 2 | 72.0% | 3598 |
| Step 3 | 80.0% | 2866 |
| Step 4 | 83.5% | 2442 |
| Step 5 | 82.0% | 2244 |
| Step 6 | 83.5% | 1961 |
| Step 7 | 85.5% | 1662 |
| Step 8 | 85.0% | 1455 |
| Step 9 | 86.5% | 1307 |
| Step 10 | 85.5% | 1136 |