前シリーズ(構築編/不安定化の切り分け編/3モデル比較編)の続編です。
1台の DGX Spark(GB10, 128GB ユニファイドメモリ)で TensorFold + LiteLLM + opencode を運用中。
**「non-thinking(fast)だとエージェントが同じツールコールを延々と繰り返す」**問題の真因を、
エンジンのソースまで降りて突き止めた記録です。
IP・ホスト名・認証情報はプレースホルダに置き換えています。
TL;DR
- non-thinking でループするのは モデル(Qwen3.8)のせいではなく、エンジン(TensorFold 0.6.6)のせいだった。
- 理由: TensorFold 0.6.6 は
presence_penalty/repetition_penaltyを実装していない。litellm が送っても黙って破棄される。 - 公式Qwen3.8の非thinking推奨は
presence_penalty=1.5。ループ抑制の要がエンジン側で効かない=構造的な問題。 - 同じ症状は Flash-Next でも 27B でも再現(=モデル固有ではない)。
- おまけ: 「27BはMTPを内包しているのでは?」という疑問も調査。27BのチェックポイントにMTP重みは無く、TensorFoldは剥がしてDFlash2を使う実装だった。
1. 症状: non-thinking で同一ツールコールを延々と繰り返す
opencode(エージェント)で fast(non-thinking)を使うと、同じツールコールを延々と再実行します。
サーバ側ログを見ると、一目で異常と分かります。
done req-... prompt=70092 thinking=False tokens=132 sha=9ec1077d8ca5 finish=tool_calls
done req-... prompt=70292 thinking=False tokens=132 sha=9ec1077d8ca5 finish=tool_calls
done req-... prompt=70492 thinking=False tokens=132 sha=9ec1077d8ca5 finish=tool_calls
... (同一 sha が 86 回)
- 同一の出力(sha が完全一致)を 86 回出力。
- opencodeが再送するたびに プロンプトが +200 ずつ増加。
- 別のセッションでは 同一 sha を 147 回、+483/round で 106k→176k まで増殖。
つまり モデルが同じツールコールを出し続け、opencodeがそれを送り返すという古典的なループです。
2. 当初の誤解
最初は「モデル側・サンプリング設定の問題」だと思っていました。
- Qwen3.8 のモデルカードに従い、non-thinking の推奨値(
temperature=0.7, top_p=0.80, top_k=20, presence_penalty=1.5)を
litellm と opencode の両方に設定。 - それでもループは止まらない。
- 「
presence_penaltyを 0.5 にしていたのを 1.5 に直した」等の調整も、根本解決には至らず。
ここで「送っている値が本当にエンジンに届いているのか?」を疑い、エンジンのソースを読みに行きました。
3. エンジン内部の調査(決定的証拠)
TensorFold 0.6.6 のパッケージを grep すると、一目で分かりました。
# tensorfold/cuda/server.py
_SAMPLING_FIELDS = ("temperature", "top_p", "top_k", "min_p", "seed")
- エンジンが受け付けるサンプリングは
temperature/top_p/top_k/min_p/seedのみ。 -
presence_penaltyもrepetition_penaltyもコード自体が存在しない。
→ litellmが送っても黙って破棄される。
念のためパッケージ全体を検索しても penalty はヒットせず、1.0.x(ネイティブZig系)のserveフラグにも penalty はありません。
つまり 「公式レシピを送った」だけでは不十分で、エンジンがそのフィールドを実装しているかまで見る必要がある、という教訓です。
補足: 出力が決定的になりやすい
# エンジンはプロンプトから決定的にシードを引く
Sampling(int(seed) if seed is not None else seed_for(prompt), ...)
-
seed_for(prompt)により、同じ文脈では同じ出力になりやすい。 - これがループを安定化させ、いったん入ると抜けにくくなります。
4. なぜ Flash-Next でも 27B でも同じか
- Flash-Next(177B MoE)でも 27B(dense)でも、**同じエンジン(TensorFold 0.6.6)**を使っていました。
- したがって non-thinking のループはモデルサイズに依存せず再現します。
- 実際、27B に切り替えても non-thinking はループし、**「モデルを変えても直らない」**ことが確認できました。
→ 真因はエンジン。モデルを替えても解決しない、というのがこの調査の最大の収穫です。
5. おまけ: 「27BはMTPを内包しているのでは?」の真相
ループ調査の流れで「27BはMTP(Multi-Token Prediction)を内包しているのでは?」という疑問が出ました。結論:
-
27Bの config.json は MTP を宣言している(
"mtp_num_hidden_layers": 1)。 - しかし チェックポイントに MTP 重みが入っていない(
model.safetensors.index.jsonに MTPテンソル 0個、最大 layer=63=MTP層なし)。 - さらに TensorFold は MTP を意図的に剥がす実装:
# families/qwen3_5/__init__.py
def sanitize_without_mtp(self, weights):
kept = {k: v for k, v in weights.items() if not (k.startswith("mtp.") or ".mtp." in k)}
# families/qwen3_5/family.py
self.mtp = drafter # ← drafter(=DFlash2) を「mtp」として扱う
self.head_drafts = DFlashHead(drafter, ...)
- 27Bの投機はDFlash2ドラフタ(block_size 8, 5層)が担当し、MTPは関与していません。
-
--mtp-draftsのヘルプ対象も Flash-Next / Nemotron / Qwen3.6 のみ(27Bは列挙されていない)→ だから27Bで効果ゼロでした。 - DFlash2 は lossless(greedy出力はターゲットと厳密一致)なので、改善しても品質は変わらず速度だけが動きます。
6. 学び
- 「公式推奨を送った」で満足しない。エンジンがそのフィールドを実際に実装しているかまで確認する。
- ループの真因はエンジンの
presence_penalty未実装。モデルを替えても直らない。 - MTPの有無はチェックポイントとエンジン実装の両方を見る(configの宣言だけでは不十分)。
まとめ
- Qwen3.8 の non-thinking ループは、TensorFold 0.6.6 が penalty を実装していないことが真因でした。
- これは エンジン由来の構造問題で、モデル(Flash-Next / 27B)を替えても解決しません。
- 次回(②エンジン比較編)では、この問題を踏まえて TensorFold と SGLang を同一条件でA/Bします。
数値は筆者環境での実測です。ドライバ・イメージ・チェックポイント・負荷で変動します。