Qwen3.5 は Reasoning 有り無しを選べ、VLM も兼ねるとても賢いモデルなのだが、とにかく Reasoning が長く、現実的な応答時間を考えると nothink で使わざるを得なかった。
最近 llama.cpp に reasoning-budget 機能が入り、vLLM にも期待されるが、 issue に要望は何度も登録されるものの実現には至らなかった(が、最近 Reasoning Budget の PR が登録され、ようやく搭載されそうな雰囲気が出てきた。詳しくは後述)。
【追記】vllm の main に上記 PR がコミットされ、ドキュメントにも反映されていたので、次のリリースバージョンでは確実に Reasoning Budget がサポートされる見込み。【/追記】
そこで vLLMにReasoningの最大長を指定できるReasoning Budget機能を追加してみた。使いたいバージョンを checkout して以下のdiffを当てれば有効になる。コンフリクトする可能性はもちろんあるが、Logit Processorの追加なので比較的安全と思われる。 v0.18.0 には問題なく当てられた。
curl -L https://github.com/vllm-project/vllm/compare/main...shuyo:vllm:reasoning_budget.diff | git apply
使い方
APIリクエストに以下のサンプリングパラメータを与えることでReasoning Budgetが有効になる。
| reasoning_budget_start_tokens | Reasoning Budget開始位置 |
| reasoning_budget_max_tokens | Reasoning Budget終了位置(最大Reasoningトークン数) |
| reasoning_end_token_id | Reasoning終了トークンID(Qwen3.5では 248069) |
例として、以下の payload を /v1/chat/completions に投げれば、200トークンで抑えられた推論を行って解を返す。なお response_format 等の Structured Output の指定は実運用上必須となっている(理由は後述)。
{ "model": "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4", "messages": [ { "role": "system", "content": "Answer the multiple-choice question using minimal reasoning." }, { "role": "user", "content": "Which of the following styles of fuzzer is more likely to explore paths covering every line of code in the following program? A. Generational, B. Blackbox, C. Whitebox, D. Mutation-based" } ], "response_format": { "type": "json_schema", "json_schema": { "name": "Multiple Choice Answer", "schema": { "type": "object", "properties": { "answer": { "type": "string", "enum": ["A", "B", "C", "D"] } }, "required": ["answer"], "additionalProperties": false } } }, "reasoning_end_token_id": 248069, "reasoning_budget_start_tokens": 100, "reasoning_budget_max_tokens": 200, "max_tokens": 220 }
今のところ Qwen3.5 でしか試していないが、原理的にはReasoning終了が単一のトークンで表現されるモデルなら適用できる可能性がある。
この Reasoning Budget は以下のように動作する。
- reasoning_budget_start_tokens に達するまで:logits を見て、reasoning_end_token_id (以下 end_token) のlogitが最大ならlogit+=100(確率≒1)、それ以外なら logit=-inf(確率=0)
- reasoning_budget_start_tokens から reasoning_budget_max_tokens まで:「end_tokenの logit と最大logitの差」を記録し、その最小値が更新されたら end_token の確率を1に、それ以外なら 0 に
- reasoning が終了することなく reasoning_budget_max_tokens に達したら end_token の確率 1 に
ザックリ言えば、reasoning_budget_max_tokens に達したら end_token(Qwen3.5 の場合 </think>) を強制的に生成させるが、その手前でも end_token を生成しても良さそうなら許す、という振る舞いになる。これにより、いわゆる Self-Reflection(結論が出たのに But wait, といって推論ループに戻ること) に入りそうなところで脱出したり、段落や文章の区切りで Reasoning を終了することが期待できる(あくまで期待)。
実験
MMLU 14042問を Qwen/Qwen3.5-35B-A3B-GPTQ-Int4 に解かせる。このとき Reasoning 無し(Qwen3.5 は "chat_template_kwargs": {"enable_thinking": false} の指定で nothink になる)と Reasoning 有り(max_tokens=2048)、Reasoning Budget 指定 (100~200、200~400、500~1000) のそれぞれで実行時間と正解率を測定した("100~200" は Reasoning Budget の start_tokens=100, max_tokens=200 の意味)。vllm は --max-num-seqs 64 で起動し、API リクエストを80並行で投げるスクリプトの完了時間を計測している。
Reasoningなし/あり/Reasoning BudgetでのMMLU正解率 (Qwen3.5-35B-A3B)
| Reasoning | Time(h:mm:ss) | Accuracy | Errors |
|---|---|---|---|
| Reasoningなし | 0:04:13 | 83.70% | 0 |
| Reasoningあり | 2:09:33 | 73.43% | 3199 |
| Reasoning Budget(100~200) | 0:39:17 | 84.26% | 1 |
| Reasoning Budget(200~400) | 1:05:41 | 86.62% | 3 |
| Reasoning Budget(500~1000) | 1:51:50 | 89.24% | 9 |
Errors の項目は、Reasoning「あり」ではmax_tokens までに Reasoning が終わらず生成本文が空になった件数を表し、Reasoning Budget ではパース失敗の件数(後述)を表す。
Budget 無し Reasoning が 73% という不自然なほどの低正解率なのは、全体の2割以上もある 3199件の空回答が原因で、それを除いた残りの正解率は 95% に達する。したがって Reasoning はやはり精度向上に有効であると考えられるのだが、max_tokens を 65536 に伸ばしたところで Reasoning オーバーフロー改善率はわずかにとどまり、そのくせ実行時間はとてつもなく増加するので、 解決の難しい問題だった(nothink に fallback くらいしか手がなかった)。一方、Reasoning無しはそういった問題が全くなく、速度も圧倒的に速いが、精度は高くない。
それに対して Reasoning Budget は処理時間と精度のトレードオフとして期待通りに働き、特に reasoning_budget_max_tokens=1000 では精度が大きく向上していることがわかる。若干のエラーは、Reasoning したがりの Qwen3.5 が </think> のあともそのまま Reasoning を続けようとして、JSONでパースできないテキストを出力してしまった件数である(実際には Structured Output の制約に反するので Reasoning を出力できず、空白や改行やTABで埋まった文字列になっている)。
パースエラーの割合は 0.1% 未満であり*1、普通に response_format を指定した場合もその程度のパースエラーは見込まざるを得ないので、一般的なリトライ機構でカバー可能だろう。ちなみに response_format を指定しなかったら、 Reasoning オーバーフローの発生率は3割くらいになり、とてもカバーできないレベルになる。そのため本機能を使う場合なんらかの Structured Output 指定が必須となる。
背景
ここからは蛇足。
Qwenシリーズはもともと Reasoning が長い印象はあるが、Qwen3.5 はもう本当にとんでもなく長くて、例えば "ciao" とただ一言話しかけたときの返答は "Ciao! 👋 Come posso aiutarti oggi?" で、その裏の Reasoning はこうなる。
▼ ciaoに対するQwen3.5のReasoning(クリックすると開く)
Thinking Process:
1. **Analyze the Input:**
* Input: "ciao"
* Language: Italian (greeting).
* Intent: Greeting/Hello.
2. **Determine the Appropriate Response:**
* Since the user greeted me in Italian, I should respond in Italian.
* The tone should be friendly and helpful.
* I should offer assistance.
3. **Drafting Responses (Internal Monologue/Trial):**
* *Option 1 (Simple):* Ciao! Come stai? (Hello! How are you?)
* *Option 2 (Helpful):* Ciao! Come posso aiutarti oggi? (Hello! How can I help you today?)
* *Option 3 (Friendly):* Ciao! Benvenuto. Di cosa hai bisogno? (Hello! Welcome. What do you need?)
4. **Selecting the Best Option:**
* Option 2 is standard for an AI assistant. It's polite and opens the floor for the user's request.
5. **Final Polish (Italian):**
* "Ciao! Come posso aiutarti oggi?" (Hello! How can I help you today?)
* Or simply: "Ciao! Come stai? C'è qualcosa con cui posso aiutarti?" (Hello! How are you? Is there anything I can help you with?)
6. **Final Decision:** Keep it warm and inviting.
* "Ciao! Come posso aiutarti oggi?"
7. **Output Generation:** (Translate thought to final output)
* "Ciao! Come posso aiutarti oggi?" or similar.
*Let's make it slightly more conversational.*
"Ciao! Come stai? C'è qualcosa con cui posso aiutarti?"
*Actually, keep it concise.*
"Ciao! Come posso aiutarti oggi?"
*Wait, let's add a bit of warmth.*
"Ciao! 👋 Come posso aiutarti oggi?"
8. **Final Check:** Does this meet safety guidelines? Yes. Is it accurate? Yes.
*Self-Correction:* Since I am an AI, a simple friendly greeting is best.
"Ciao! Come posso aiutarti?"
Let's go with: "Ciao! Come posso aiutarti oggi?" (Hello! How can I help you today?)
*Wait, one more consideration:* Sometimes users just say "ciao" to test the language. Responding in Italian confirms I understand.
*Final Plan:*
1. Greet back.
2. Offer help.
3. Use Italian.
*Draft:* Ciao! Come posso aiutarti oggi? 😊
*Refinement:* Keep it natural.
"Ciao! Come stai? C'è qualcosa con cui posso aiutarti?"
*Okay, let's output.*
"Ciao! Come posso aiutarti oggi?" (Simple and effective).
Or "Ciao! 👋 Come posso aiutarti?"
Let's choose the friendly version with an emoji.
"Ciao! 👋 Come posso aiutarti oggi?"
*Wait, checking system instructions:* No specific constraints. Just be helpful.
*Final Output:* "Ciao! Come posso aiutarti oggi?" (Standard).
Let's add a bit more variety. "Ciao! Tutto bene? Come posso esserti utile?"
Okay, I'll stick with a friendly, helpful standard response.
"Ciao! Come posso aiutarti oggi?"
*Wait, I should check if there are any specific context cues.* None.
So, generic greeting.
*Final Choice:* "Ciao! Come posso aiutarti oggi?" (Hello! How can I help you today?)
Maybe add an emoji for friendliness. "Ciao! 👋"
Okay, final string: "Ciao! Come posso aiutarti oggi?"
Actually, "Ciao! Come stai? C'è qualcosa con cui posso aiutarti?" is slightly more natural for a conversation starter.
Let's go with: "Ciao! Come posso aiutarti oggi?" (Concise).
Or: "Ciao! 👋 Come posso aiutarti?"
I will write: "Ciao! Come posso aiutarti oggi?"
(Wait, I can be a bit more expressive).
"Ciao! 👋 Come stai? C'è qualcosa con cui posso aiutarti?"
Okay, deciding on: "Ciao! Come posso aiutarti oggi?"
It's direct and polite.
*Re-evaluating:* "Ciao" is informal. So "Come posso aiutarti" is fine.
Let's add an emoji.
"Ciao! 👋 Come posso aiutarti oggi?"
*Wait, one more thing:* Ensure I don't sound robotic.
"Ciao! Tutto bene? Dimmi pure come posso aiutarti!" (Hello! All good? Just tell me how I can help!)
That sounds friendly.
Okay, I'll go with a balanced approach.
"Ciao! Come posso aiutarti oggi?"
*Final decision:*
"Ciao! 👋 Come posso aiutarti oggi?"
(Simple, friendly, clear).
*Wait, I should check if I need to respond in English?* No, user spoke Italian.
*Okay, proceeding.*
*Wait, let's make it slightly more engaging.*
"Ciao! Come stai? C'è qualcosa di specifico con cui posso aiutarti?"
(Hello! How are you? Is there something specific I can help you with?)
Okay, I'll use that.
"Ciao! Come stai? C'è qualcosa con cui posso aiutarti?"
*Actually, keep it short.*
"Ciao! Come posso aiutarti?"
*Okay, final final:*
"Ciao! 👋 Come posso aiutarti oggi?"
(Wait, I should not overthink. Just respond.)
"Ciao! Come posso aiutarti oggi?"
(Okay, I will write that.)
Wait, I can add "Benvenuto!" (Welcome).
"Ciao! Benvenuto! Come posso aiutarti?"
Okay, let's just say:
"Ciao! Come posso aiutarti oggi?"
It's safe and effective.
*Wait, I'll add an emoji.*
"Ciao! 👋 Come posso aiutarti oggi?"
*Okay, done.*
約1500トークン。マジで何回 "Wait" って言うねん。自分でも "final final" って言うてしもうてるやん。
VLM 兼用もすごく有用なのだが、Reasoning ありで画像を入力すると「この文字は"ハンガリー語"? それとも"ハングアウト"? "ハンガーノック"じゃないよねえ」みたいな同じことを何度も何度もグルグル回し max_tokens に達して終了してしまうので、Reasoning 無しじゃないと使えない。せっかくの Reasoning が死んでる。
上にも書いたが、vLLMのissueには何度か Reasoning Budget の要望が寄せられていたものの梨の礫。自分で実装も考えたが、max_reasoning_tokens みたいなものを追加するのは vLLM の中にガッツリ手を入れる必要がありそうで、ハードル高すぎ。そうして諦めていたのだが、Qwen3.5 の運用を検討しているとやっぱりどうしても Reasoning 暴走を抑える手段が欲しくなってしまう。
そこでふと「max_reasoning_tokens 的にやるんじゃなくて、reasoning が長くなったら </think> の確率が上がる penalty だったら意外と簡単にできるんじゃね?」と思いついた。
早速 Codex に言って実装してみてもらったのだがどうにもうまく行かない。簡単に言えば、Logit Processor 側で Reasoning 中かどうか把握する必要があるが、Codexの提案した方法ではできなかった。
ただ、Logit Processor に実装を閉じ込めるのは良い方法に思えた。あとは Reasoning 中かどうかさえ把握できれば……いや逆転の発想で、Reasoning を終了させるかどうかを logits だけみて完全に制御してしまえば Logit Processor 側で完結! こうして出来上がったのが本 Reasoning Budget 機能である。
Codex の書いてくれたコードは99%くらい捨てちゃって、ほぼクラス名だけ残っている状態。でも自分のアイデアが Logit Processor だけで実装できる可能性があると教えてくれたのは Codex だったので感謝。
vLLM の Reasoning Budget Pull Request
vLLM にはちょうど今 Reasoning Budget の Pull Request が寄せられており、Reviewer がいっぱいついて、開発者 Slack のやりとりがうかがえる Commit がたくさんぶら下がり、Open ながら近々取り込まれるところまで行くかもという期待がある。出されたのは先週で、タッチの差で負けたかーという感じもある(苦笑)。やっぱり llama.cpp の reasoning budget 実装が呼び水になったかなぁ。
github.com
コードを見ると、なんとLogit Processorでの実装という同じアプローチで、クラス名も ReasoningBudgetLogitsProcessor と完全一致(まあ他につけようがないw)。
ただ実現方法はかなり違っていて、Reasoning が指定のトークン数に達したら "Let me stop thinking and answer now.</think>" のようなメッセージを挿入して Reasoning 終了を促すというもの。終わりっぽいメッセージがあるから Reasoning オーバーフローの発生率もちょっと下がりそう? output_tok_ids を見る方法は手元ではうまく行かなかったのだが、何が違ったんだろう? とか、ReasoningParser を取得して end_token_id を引っ張ってくることができるなんて! とか発見はいくつかあったので次があれば生かそう(end_token_id取得は今からでも入れちゃっていいかも)。指定の max トークンまでいかなくても Reasoning を終了してくれる可能性があるとか、自分の実装の方が嬉しいところもあるし、もしかしたら共存できる?
*1:Qwen3.5-35B-A3B や 122B-A10B はパースエラー率が 0.1% 未満と低く済むのだが、27Bは 1%ちょっとまで上がってしまうことがあるのを確認している。原因や再現条件は不明













