0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qwen3.6-35B-A3B Escha-W2(2bit)+Oscar(KV2bit) SGLang独自フォーク(リンク有) ローカルLLMの無限ループを本気で止める

0
Last updated at Posted at 2026-09-15

RTX 5070 12GB × 2(WSL2)という構成で、Qwen3.6-35B-A3B の2bit量子化版を運用しています。
ある日、生成が「銘柄銘柄銘柄……」と数百回繰り返して止まらなくなりました。

ループガードは、その間ずっと有効になっていました。

調べたら、ガードが効かない理由が3つ重なっていて、さらに上流のモデル自体に未修正の既知欠陥がありました。
この記事は、その調査と対策を最終的な設定ごとまとめたものです。まず動く形を全部載せて、そのあとに理由を書きます。

前提の構成

モデル Qwen3.6-35B-A3B-Escha-W2 — 2bit量子化 MoE、256エキスパート、アクティブ3B
モデル容量 12GB(ディスク上)
GPU RTX 5070 12GB × 2(sm_120)、tensor並列2 + expert並列2
VRAM使用量 12.0GB中 11.3GB / 11.7GB
コンテキスト長 262,144トークン
KVキャッシュ OSCAR INT2、グループサイズ32(K 0.21GB + V 0.21GB)
KVプール 350,257トークン(全同時リクエストで共有)
同時実行 12(16を指定してもエンジンが12に切り下げる)
ランタイム escha-runtime-qwen3moe + OSCAR INT2 KV (PR #32129)
OS WSL2 / Ubuntu

速度の実測はこうです。

単発デコード 87.5 → 56.8 tok/s(コンテキストが伸びるにつれ低下)
並列8・400トークン生成 295 tok/s 集約
180Kのコールドプロンプト TTFT 46.2秒
同じ180Kを再送(prefix cacheヒット) 1.87秒
長文prefill中に届いた短い要求 遅延 1.10倍、p50 0.29秒

エンジン側の構成・導入手順・「変えてはいけない設定」は別途まとめてあります:
https://github.com/Nakanokensetsu/sglang
この記事はその上で動かすプロキシ側、つまりループ・言語混入・ツール失敗の対策が主題です。


0. 最終形(設定・プロンプト・パラメータ)

0-1. エンジン(SGLang)起動フラグ

export MODEL=/path/to/Qwen3.6-35B-A3B-Escha-W2
export PORT=8081
export MEM=0.88 CTXLEN=262144 MAXMAMBA=64 MAXREQ=16 RADIX=1
export MAMBA_SCHED=extra_buffer
export ATTN_BACKEND=triton
export GRAPHS=1 CUDA_GRAPH_BS="1 2 4 8 16"

# 長文prefill中に他のストリームが止まるのを抑える(自前パッチ)
export SGLANG_CHUNK_DECODE_CAP=1536
export SGLANG_CHUNK_FAIR_FLOOR=256

# ★これが無いと思考予算が効かない
export ENABLE_CLP=1

実際にサーバへ渡っている主要フラグ:

--tp-size 2 --ep-size 2
--kv-cache-dtype int2 --kv-cache-quant-group-size 32
--context-length 262144 --chunked-prefill-size 2048
--max-running-requests 16 --max-mamba-cache-size 64
--attention-backend triton --mamba-scheduler-strategy extra_buffer
--tool-call-parser qwen3_coder --reasoning-parser qwen3
--enable-custom-logit-processor        # ★思考予算に必須
--disable-piecewise-cuda-graph --cuda-graph-bs "1 2 4 8 16"
--enable-metrics --enable-cache-report

0-2. 思考予算 — 最も効いた対策

結論から言うと、ループを実際に止めているのはこれです。

SGLang では thinking_token_budget は黙って無視されます(Aphrodite専用パラメータ)。
同等の制御は custom_logit_processor でやります。

# モデルのトークナイザから区切りトークンを引いてペイロードを生成(1回だけ)
python thinking_budget.py --model /path/to/model > clp_thinking_budget.json

リクエスト側:

{
  "messages": [ ... ],
  "max_tokens": 8000,
  "chat_template_kwargs": {"enable_thinking": true},
  "custom_logit_processor": "<clp_thinking_budget.json の中身>",
  "custom_params": {"thinking_budget": 4000}
}

N トークン思考したところでサーバが </think> を強制するので、モデルは残り予算で答えるしかなくなります。

実測(そのまま再現できます):

budget 実際の reasoning_tokens
400 401
4000 2970(予算内で自然終了)

thinking_budget と max_tokens の間は最低512トークン空けること。
私は min(6000, max_tokens // 2) で算出しています(max_tokens 8000 なら予算4000、本文に4000残る)。

⚠️ SGLang内蔵のQwen3向けCLPはトークンIDを 151667/151668 にハードコードしていて、
Qwen3.5/3.6 の248k語彙では間違った位置を見ます。必ずモデルのトークナイザから生成したペイロードを使ってください。

0-3. プロキシ側のガード一覧

OpenAI互換のプロキシを自前で挟んでいます。最終的に有効にしているもの:

環境変数 役割
PROXY_LOOP_GUARD=1 出力の反復を検出して打ち切る(後述の3つの穴を塞いだ版)
PROXY_LOOP_GUARD_RETRY=1 打ち切ったあと「繰り返してるよ」と伝えて1回だけ答え直させる
PROXY_LOOP_GUARD_NOTICE=1 打ち切ったことを本文に1行出す(黙って切ると原因不明になる)
PROXY_THINK_BUDGET_CLP_PATH=... 上記の思考予算
PROXY_TOOL_ERROR_HINT=1 ツールが1回失敗したら原因分類つきで手を変えさせる
PROXY_SEARCH_DIVERSIFY=1 / _AFTER=1 検索が1回失敗したら試したクエリを突きつける
PROXY_SEARCH_GIVEUP_GUARD=1 / _ROUNDS=4 最後の砦。検索系ツールだけを外す
PROXY_REPETITION_GUARD=1 repetition_penalty / repeat_penalty = 1.15

0-4. システムプロンプト

常時のルールは最小限にしています。具体的な指示は失敗した瞬間に注入します(理由は §6)。

用途別に2系統を運用しています。どちらも「検索が1回失敗したら手を変える」という
1行を持っている点が共通で、そこが今回の追加分です。

(a) 日本語主体の窓口

1)ユーザーが書いた言語と同じ言語で、その言語だけを使って回答する。英語で聞かれたら英語のみ、
  日本語なら日本語のみ、インドネシア語ならインドネシア語のみ。対訳や併記は付けない。
  検索結果は回答する言語に翻訳してから述べる。
2)教科書レベルの一般常識は検索せず自身の知識で答える。固有名詞の実在・URL・最新情報・
  不確実な事柄は憶測せずWeb検索やページ取得で確認してから答える。
  検索が1回失敗したら、同じクエリや言い換えで繰り返さず別の手段に切り替える
  (別のキーワード、別の検索ツール、ページを直接取得する)。
  切り替える先が無ければ、確認できない理由を述べて今わかる範囲で答える。
3)ツールは必ずtool calling機構で呼ぶ。「<tool_call>」等のタグを本文として書き出さない。
  ツールを呼ばずに検索結果や数値を作り上げて答えない。
4)固有名詞・学名・製品名・型番は原綴りのまま書く(例: Felis catus、Qwen3-72B)。
  それ以外で第三の言語の単語を混ぜない。
5)思考タグを閉じてから最終回答を出す。

(b) インドネシア語話者向けの窓口

こちらは指示文自体を「インドネシア語の一文 → その日本語訳」の対で並べています。
求める挙動(話しかけられた言語で答える)を、指示の書き方そのもので示す狙いです。

Untuk hal yang tidak pasti, lakukan pencarian. Jika setelah mencari tetap tidak diketahui, laporkan alasannya.
不確定要素は検索を行う。検索をしてもわからない場合はわからない理由を報告する。
Jawab dalam bahasa yang ditanyakan.
話しかけられた言語で答える。
Jika pencarian gagal sekali, jangan ulangi kata kunci yang sama; ganti caranya (kata kunci lain, alat pencarian lain, atau ambil halaman langsung).
検索が1回失敗したら同じキーワードを繰り返さず、別の手段に切り替える(別のキーワード、別の検索ツール、ページを直接取得する)。
思考では英語(my, me)を使ってよい。

最後の 思考では英語(my, me)を使ってよい。 は意図的に残しています。
このモデルは日本語で質問しても思考はほぼ英語で走ります(実測: 思考予算400トークン時に
思考内の英字571文字に対し日本語0文字)。そこを塞ぐ指示はモデルの素の挙動と正面から衝突するので、
英語の逃げ道を思考側に残し、最終回答だけ言語を合わせる形にしています。

注意: 過去に「使用できる言語は日本語と英語のみ」という書き方を試したところ、
インドネシア語の使用そのものが禁止されたと解釈され、モデルがインドネシア語を一切書かなくなりました。
言語の限定列挙に第3言語を足す形は成立しません(「日本語と英語のみ」という限定と
インドネシア語の要求が字面上矛盾するため)。
このときの中国語混入0%は、§8 と同じ偽の成功でした。

0-5. 失敗した瞬間に注入するメッセージ

システムプロンプトではなく、会話末尾に user ロールで1回だけ差し込みます。

ツールが失敗したとき

{ツール名} が失敗しました: 「{エラー文の抜粋}」。
まず、なぜ失敗したのかを一言で特定してください。
そのうえで、成功する見込みのある別の方法を**実際に実行してください**。
{エラー種別ごとのヒント}
手順を説明するだけで終わらせず、必ずツールを呼んでください。
原因を特定せずに、同じ引数のまま同じツールを呼び直すことはしないでください。
試せる方法が本当に無い場合に限り、何が原因で進めないのかを述べて報告してください。

エラー種別のヒントは7種を正規表現で分類しています:

検出 ヒント
command not found / is not recognized コマンド名自体が存在しない。別のコマンドかフルパスに
no such file or directory / パスが見つかり パスが違う。まず親ディレクトリの一覧を取る
permission denied / アクセスが拒否 権限が無い。書ける別の場所か権限不要の手段に
syntax error / unterminated / 引用符 引用符やエスケープが壊れている。囲み方を変える
timed out / connection refused 接続先が応答しない。別の宛先・別の経路に
no module named モジュールが無い。別の実行環境か非依存の方法に
not found / 404 / 見つかりません 条件を緩めるか別の情報源に

検索が失敗したとき

すでに次の検索を試しました: 「A」「B」。
同じクエリや、語順を入れ替えただけ・助詞を変えただけの言い換えで再検索しないでください。
次は必ず別の方法に切り替えてください(別の言語のキーワードにする、固有名詞を外して
一般語で引く、上位概念や関連する別の事物から辿る、公式サイトのURLを直接取得する等)。
切り替える先がもう無い場合は、検索では確認できなかったこととその理由を述べて、
今わかる範囲で回答してください。

1. 何が起きていたか

症状は2種類ありました。

(A) トークンレベルの反復

…銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄銘柄…(数百回)

(B) 段落まるごとの再生成

確認しました。まず共有からローカルにコピーします。
cp "//<ファイルサーバ名>/…" "C:/tmp/…" -r その後に python app.py を実行します。 notify: true background: true timeout: 60

確認しました。まず共有からローカルにコピーします。
cp "//<ファイルサーバ名>/…" "C:/tmp/…" -r その後に python app.py を実行します。 notify: true background: true timeout: 60

(以下延々)

(B) が厄介でした。ツールの呼び出し文を「本文のテキストとして」書いているだけで、実際には何も実行していません。何も起きないので状態が進まず、同じ計画を立て直し続けます。

さらに検索を伴うタスクでは「さらに調べます(46回目)」まで行きました。


2. まず疑うべきは自分の設定ではなく、上流だった

Qwen 公式リポジトリに、ほぼ同じ症状の Issue がありました。

  • QwenLM issue #88 — Qwen3.5-35B-A3B(我々のモデルの直系前世代、同じA3B MoE)の LiveCodeBench 評価で、出力の17.4%(244/1400)が思考内ループ。打ち切られた出力の84%で反復率30%超。</think> を一度も出さずトークン予算を使い切る。カスタムプロンプトもファインチューンも無関係で、公式の apply_chat_template() でも再現 → closed as not planned
  • issue #145 — 公式推奨サンプリング値でも再発 → closed as not planned、メンテナ回答なし
  • HF discussions #20 — Qwen3.6-35B-A3B の「Poor tools use and endless reasoning loop」。ツール使用時に特に起きる。Mac/Windows/LM Studio、BF16 でも再現

つまりモデル側の欠陥で、上流に修正の予定もありません。

我々の環境で実際に測った数字も、#88 の記述と一致しました。

本文 0字 / 思考 9,777字 / finish_reason=length / 82秒

8000トークン全部を思考で使い切り、本文を1文字も出していません。

「ではプロンプトで止めよう」と考えたくなりますが、当時すでにループ対策を3つ書いたプロンプトが本番で動いていました。

6)同じツール呼び出しが繰り返し失敗したら、パラメータを変えて闇雲に再試行せず、
  エラーメッセージから原因を整理してから次の手を決める。
9)同一の論点を2回以上検討している自分に気づいたら、直ちに思考を打ち切り
  今ある結論で回答する。
10)思考が長くなりすぎたと感じたら、検討を打ち切ってその時点までの結論で
  本文を書き始める。

これだけ書いてあって46回ループしました。 プロンプトで止まる種類の問題ではありません。


3. 自前ガードに空いていた3つの穴

ループガードは有効でした。なのに1度も発火していませんでした。理由は3つ、全部別々でした。

3-1. 検出窓が「8〜40文字」しかなかった

for ul in range(8, 41, 4):     # ← 反復単位8〜40文字しか見ていない
    unit = tail[-ul:]
    if len(tail) >= ul * 6 and tail.endswith(unit * 6):
        hit = unit
  • 「銘柄」= 2文字 → 下限8未満で検出不能
  • 段落反復 = 約170文字 → 上限40超で検出不能

報告された2種類が、どちらも構造的に窓の外でした。

総当たりで窓を広げると1チャンクあたりの走査が数十倍になります。そこで
「末尾32文字が直前のどこに現れるか」を rfind で1回引いて周期を直接求める方式にしました。

def _detect_repeat_period(tail, probe_len=32, max_period=600):
    if len(tail) < probe_len * 3:
        return None
    probe = tail[-probe_len:]
    p = tail.rfind(probe, 0, len(tail) - 1)   # ← 終端は -1
    if p < 0:
        return None
    period = len(tail) - probe_len - p
    unit = tail[-period:]
    need = 24 if period < 8 else (6 if period <= 40 else 4)
    if len(tail) < period * need or not tail.endswith(unit * need):
        return None
    if not any(c.isalnum() or "\u3040" <= c <= "\u9fff" for c in unit):
        return None    # 罫線・記号だけの反復は正当な整形
    return unit

ハマりどころ: rfind の終端を len(tail) - probe_len にすると、
rfind は「一致が範囲内に収まる」ことを要求するため重なり合う一致が弾かれ、周期を過大に読みます
(周期9の反復を周期36と誤読するのを実測)。末尾の一致そのものだけを除外する -1 が正解です。

周期が短いほど偶然の一致が起きやすいので、必要な反復回数を周期で変えています(短周期24回 / 中6回 / 長周期4回)。

誤検出の確認(全て打ち切られないこと): 変化のある長文散文 / 4段落の説明文 / 表の罫線+データ行 /
箇条書き30行 / コードの同一インデント / 連続する句読点 / 罫線のみの区切り / 同じ文を3回引用。

3-2. 思考ブロックを1トークンも見ていなかった

tail += dl.get("content") or dl.get("reasoning") or ""

SGLang が返す delta のキーを実測したところ:

['content', 'reasoning_content', 'role', 'tool_calls']

reasoning というキーは存在しません。 つまりこの行は常に空文字を足していました。
think 系の暴走は思考の中で起きるので、全部素通りしていたわけです。

tail += (dl.get("content")
         or dl.get("reasoning_content")
         or dl.get("reasoning") or "")

これだけで、それまで素通りしていたケースが5.7秒で打ち切られるようになりました。

3-3. 非ストリーム経路にガードが存在しなかった

言語混入ガードを効かせるために、条件によっては上流へ stream=false で投げて全文を貯める経路があります。
この経路にはループガードが実装されていませんでした。

発動条件は「言語ガードが有効」かつ「tools 無し」かつ「疑似ストリームが有効」。
3つのポートのうち1つだけがこれに該当し、tools 無しの全リクエストがループ無防備でした。

全文が手元にあるので、反復の開始位置を特定して確実に切れます。
max_tokens で単位の途中が切れている場合に備え、末尾を1文字ずつ削って整列を探します。

def _truncate_runaway_repeat(text, keep_reps=2):
    for k in range(0, 601):
        t = text[:len(text) - k] if k else text
        unit = _detect_repeat_period(t[-4000:])
        if unit:
            break
    else:
        return None
    ...

実測: 3,849回の反復を検出して打ち切り、正当な196字の回答は無傷。

ただしこの経路は生成完了後にしか切れないので時間は節約できません(実測65.6秒)。
時間も節約したいならストリーム経路に寄せる必要があり、その場合は言語ガード側を諦めることになります。

3-4. 黙って切ると「文の途中で終わった」ようにしか見えない

打ち切りは finish_reason: "stop" を書くだけなので、クライアントからは正常終了と区別がつきません。
何が起きたか分からないので、1行出すようにしました。

---
*同じ内容の繰り返しを検出したため、ここで生成を打ち切りました。
質問を言い換えるか、手順を分けて指示し直してください。*

さらに、打ち切って終わりにせず**「繰り返してるよ」と伝えて1回だけ答え直させる**ようにしました。
2回目も反復したらそこで打ち切ります(無限再送の防止)。


4. 自分で作った悪化 — ツールを全部剥がしていた

検索の無限ループ対策として、「N往復したらツールを外して答えさせる」ガードを入れました。

if tool_rounds_since_user >= threshold:
    body.pop("tools", None)          # ← 種類を問わず全部
    body.pop("tool_choice", None)

検索専用の用途では妥当でした。ところがエージェント的に terminal を何度も呼ぶ作業では、
1回のユーザー発話で4往復した時点でツールが消滅します。

モデルは実行したいのに手段が無く、「powershell.exe を呼ぶ」と宣言し続けるだけになります。
§1 の (B) がまさにこれでした。私が入れた対策が症状を作っていました。

修正は、対象を検索系ツールに限定することです。

_SEARCH_TOOL_RE = re.compile(
    r"(search|web|browse|fetch|crawl|google|bing|duckduck|serp|wiki|lookup|query)", re.I)

# 検索系ツールの呼び出し回数だけを数える
# 剥がすのも検索系だけ。terminal 等は残すのでエージェント作業は続行できる
kept = [t for t in tools if not _SEARCH_TOOL_RE.search(t["function"]["name"])]

実測:

シナリオ 結果
terminal を5往復 ツール維持、6回目も正しく呼び出し
web_search が5回失敗 検索系だけ削除・terminal は残存。捏造せず「わかりません」

5. ループの手前で「手を変えさせる」

ガードは暴走を止めるだけで、治療ではありません。繰り返しの手前で方向を変えさせる層を足しました。

5-1. 検索 — 試したクエリを突きつける

プロンプトに「同じ検索を繰り返さない」と書くのは、実測で1〜2割しか効きませんでした。
なのですでに試したクエリをコード側で列挙します。ツールは取り上げません。

実測:

1回目: "ズィルファリア公国 人口 2024"   → 見つからず
2回目: "Principality of Zilfalia"       ← 英語キーワードへ自分で切り替えた

5-2. ツール全般 — 「説明して終わり」を「実行」に変えた一文

ここが一番の学びでした。

まずエラー文を分類して、直すべき箇所を名指しします。「違う方法にしろ」と強く言うだけでは、
何を変えればいいか分からず同じ発想のまま引数をいじるだけになります。

そして重要なのが、最後の一文です。

注入した文面 結果
「原因を特定して、別の方法に切り替えてください」 手順を説明して終わり。ツール呼び出し0件
「原因を特定して、別の方法を実際に実行してください。説明するだけで終わらせず、必ずツールを呼んでください」 ツール呼び出し2件

実際の挙動(cp がネットワークパスで失敗したあと):

修正前: 「WSLからは直接扱えません。mount.cifs が必要です。解決手順は…」(説明のみ)
修正後: terminal -> ls /mnt
        terminal -> ls //wsl.localhost/<ファイルサーバ名>/ || ls //localhost/<ファイルサーバ名>/ ...

同じ cp を繰り返さず、マウント状況を調べる別の方法に切り替えて実行しました。

自分で踏んだ穴: 最初、エラー判定の入口に汎用エラー語(error / failed / エラー)を
要求する作りにしました。すると
cp: cannot stat '//<ファイルサーバ名>/…': No such file or directory が素通りします。
——この記事の §7 で「テンプレートの判定条件が狭い」と指摘するのとまったく同じ穴を、
自分で作っていたわけです。テストしていなければ気づきませんでした。


6. なぜ「システムプロンプト」ではなく「注入」なのか

最終的に、常時のルールはシステムプロンプトに置き、具体的な指示は失敗した瞬間だけ注入する形にしました。理由は2つです。

実際のエラー文を引用できる。 システムプロンプトは常時固定なので「もし失敗したら」としか書けません。
注入なら cannot stat '//<ファイルサーバ名>/…' を名指しできます。

直前の発話ほど従いやすい。 過去に同種のガードで、指示を system ロールの末尾に追記したところ
5/5 で finish_reason=stop なのに本文が空になる事故がありました。
reasoning を見ると "Let me search to confirm this." まで考えたところで生成が終わっていて、
max_tokens には全く届いていません(197/2000トークン)。
会話末尾に user ロールの新しいメッセージとして追加する形に変えたら、5/5 で本文ありの回答が返るようになりました。


7. チャットテンプレートとパーサーは無実だった(が、別の穴があった)

Qwen3.5/3.6 の公式 jinja テンプレートにはバグがあり、修正版が出回っています。
我々のモデルがそれに該当するか確認しました。

ローカル      : qwen3.6-froggeric-v21.3   md5 292c497e…
配布元の上流  : qwen3.6-froggeric-v21.3   md5 292c497e…(完全一致)

モデル配布元がすでに修正版テンプレートを同梱していました(モデルカードの帰属表示にも明記されています)。
preserve_thinking、<|think_on|>/<|think_off|>、reasoning_content の型防御、
tool応答の打ち切り、連続失敗エスカレーションが実装済みです。

修正版の作者の現行バージョンは v22.5 ですが、そちらは qwen3.8 向けの別物(28KB vs 16KB)です。
バージョン番号が新しいからといって当ててはいけません。

パーサーも問題ありませんでした。

  • --tool-call-parser qwen3_coder はテンプレート既定の xml 形式(<function= / <parameter=)と一致
  • --reasoning-parser qwen3 は think_start_token / think_end_token を文字列で扱う実装。
    思考予算CLPで問題になったトークンIDのハードコードは無い
  • 実挙動でも reasoning_content と tool_calls(name/arguments付き)が正しく返っている

ただしテンプレートの連続失敗検知は、bash系ツールで死んでいる

テンプレートには本来ちょうど良い機構があります。tool応答を見て連続失敗を数え、
2回で警告を出し、生成プロンプトで思考を強制オフにします。

{%- if content | length < 500 and '$ ' not in content and 'took ' not in _content_lower
      and ('error:' in _content_head or 'traceback' in _content_head or ...) %}
    {%- set ns2.consecutive_failures = ns2.consecutive_failures + 1 %}
{%- else %}
    {%- set ns2.consecutive_failures = 0 %}
{%- endif %}

発動条件を読むと:

  1. '$ ' not in content — git-bash / MSYS のツール応答はプロンプト $ を含みやすく、
    含んだ瞬間 else 側に落ちてカウンタが0にリセットされます。
    エージェントの terminal はまさに git-bash なので、ループする当のツールでこの機構が死にます
  2. エラー判定語が英語のみを先頭80文字以内に要求。日本語のエラーは当たりません
  3. そもそも「検索したが0件」は成功応答なので、検索ループは最初から対象外
  4. 500文字以上の応答も対象外

テンプレートを直すとエンジン再起動 + prefix cache 全滅を伴うので、
同じ役割をプロキシ側(§5-2)で実装しました。


8. 測定の落とし穴 — 「中国語0%」は偽の成功だった

おまけですが、これが今回いちばん怖かった話です。

中国語混入を減らすため、システムプロンプトを6種類くらべました。
過去の記録に「日本語オンリー強制をやめ、英語を明示的に許可する方が良い(5.8%→1.7%)」とあったので、
現行の「その言語だけで回答する」は劣っているはずでした。

条件あたり104試行(日本語80 + インドネシア語24)で測った結果:

条件 本文への中国語混入 ID設問→ID回答 JA設問→JA回答
ID/JA併記版 0.0% 42% 56%
現行(その言語だけで回答) 4.8% 67% 84%
多言語対応版 4.8% 62% 79%
バイリンガル+英語許可 7.7% 62% 82%

混入率0%の条件が、日本語の質問に日本語で答えていませんでした。

日本語80問の内訳は、日本語のみ18 / 日本語+インドネシア語27 / 日本語でない(英語等)35。
実例として「小学生に重力を説明して」への回答がこれです。

**Bahasa Indonesia**
Bayangkan kamu melompat ke udara. Pasti kamu akan jatuh kembali ke tanah, kan?
Itulah yang disebut **gravitasi**...

中国語が0%なのは、そもそも日本語を書いていないからでした。
短答問題(水の化学式など)の測定誤差ではなく、説明系の8設問に分散しています。

指標をひとつだけ見て採用してはいけないという、痛いほど分かりやすい実例でした。
混入率は必ず言語一致率と併せて見ます。

なお、過去の知見が新しいモデルに移るとは限らない、という教訓でもあります。
9B時代に1300試行超で確定していた対策が、Qwen3.6 MoE では逆の結果になりました。


9. まとめ

**ループは、モデル側の既知欠陥です。**上流が closed as not planned にしている以上、
使う側でコード側の網を張るしかありません。

効いた順に並べると:

  1. 思考予算の強制打ち切り(custom_logit_processor)— これが本体。</think> を出さない暴走を物理的に止める
  2. ツール失敗時の注入 — 原因を特定させ、別の方法を実行させる。「実行してください」の一文が効く
  3. 出力の反復検出 — 最後の安全網。窓・思考ブロック・非ストリーム経路の3つを塞いで初めて機能する
  4. 検索の多様化 — 試したクエリを突きつける

逆に、やっても効かなかったこと:

  • プロンプトに「繰り返すな」「2回失敗したら諦めろ」と書く(実測1〜2割)
  • ループ対策を3項目書いたシステムプロンプト(46回ループした)
  • repetition_penalty 1.15(段落単位の反復は止まらない)

そして最大の教訓は、「ガードが有効」と「ガードが機能している」は別ということです。
私のループガードは、有効化されたまま何ヶ月も一度も発火していませんでした。
ログに発火記録が残る作りにしておいて本当に良かったと思います。


環境

GPU RTX 5070 12GB × 2(sm_120)
OS WSL2 / Ubuntu
モデル Qwen3.6-35B-A3B の2bit量子化版(MoE、256エキスパート、12GB)
エンジン SGLang(配布元のフォーク)+ OSCAR INT2 KV
KVキャッシュ INT2 量子化、グループサイズ32
コンテキスト 262,144
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?