初執筆となります。
先日、DGX Spark互換機であるThinkStation PGXを購入しました。
そこで意気揚々とQwen 3.8 27Bをホストし、VS CodeのCoding Agentにつないで使っていたところ、Thinkingが同じ内容を延々と繰り返し、最終回答にもTool Callにも進まなくなる問題にかなり苦しみました。
GPUは動いています。SSEも流れ続けています。エラーも出ません。
ただ、仕事だけがいつまで経っても終わりません。
最初はプロンプトやVS Code側の問題だと思い、New Chat、空のworkspace、Toolの削減、reasoning effortの変更、anti-loop promptなど、思いつくものを一通り試しました。
しかし、多少マシになることはあっても、決定打にはなりませんでした。
最終的にはThinking自体を無効化するのではなく、vLLM側に複数の安全装置を持たせる形で、私の環境ではかなり安定して使えるようになりました。
この記事では、最終的に採用した設定だけでなく、何を疑い、どのように測定し、どの数字を見て判断したのかまでまとめます。
先に結論
最終的に採用したのは、次の組み合わせです。
{
"temperature": 0.6,
"top_p": 0.95,
"top_k": 20,
"min_p": 0.0,
"thinking_token_budget": 2048,
"repetition_detection": {
"min_pattern_size": 32,
"max_pattern_size": 256,
"min_count": 3
},
"max_tokens": 8192
}
考え方としては、次の3段構えです。
- greedy decodingを避ける
- 明白なtoken blockの反復は、vLLMのnative repetition detectionで止める
- 同じ意味を言い換え続けるような長考は、Thinkingにtoken budgetを持たせて被害を制限する
さらに、その外側にcompletion全体の上限を置きました。
固定した20個のrequest seedで比較した結果は、次のようになりました。
| 結果 | 対策前 | 対策後 |
|---|---|---|
| strict success | 0/20 | 16/20 |
| output上限への到達 | 19/20 | 3/20 |
| native repetition stop | 0/20 | 1/20 |
| final contentが空 | 6/20 | 0/20 |
| harness-side repeated lexical block検出 | 3/20 | 0/20 |
Thinkingを切ったわけではありません。
明白な反復はnative detectorに止めてもらい、完全一致ではない長考にはThinkingの予算を持たせています。
そして、どちらからも漏れた場合に備えて、completion全体の上限を残しています。
注意
この記事は、Qwen全般の不具合を一般的に証明するものではありません。
ThinkStation PGX上で、特定のQwen 3.8 27B派生モデル、NVFP4量子化、vLLM開発build、DSpark speculative decodingを組み合わせた環境での測定結果です。
また、20 seedと16件の正常系コーパスは、今回の閾値を決めるために私が用意した標本です。一般的なループ発生率や誤停止率を示すものではありません。自然終了したからといって、回答内容の正しさまで保証されるわけでもありません。
今回の環境
今回の構成は、ざっくり次のとおりです。
- Lenovo ThinkStation PGX / NVIDIA GB10
- Qwen 3.8 27BのUncensored派生モデル
- ModelOpt NVFP4
- vLLMのOpenAI互換server
- Thinking有効
- reasoning parserは
qwen3 - Tool Call有効
- DSpark speculative decoding有効
- Prefix cacheとCUDA Graph有効
- VS Code AgentからGateway経由で利用
公開記事のため、内部hostname、IP、Bearer key、volume path、service固有名などは省いています。
発生していた症状
今回困ったのは、1回のassistant responseの中でThinkingが崩れ、同じ文章や検討項目を延々と繰り返す現象です。
たとえば、既知のテトリスAIを比較させると、ある固有名詞を何度も書き続けます。
別の実行では、次のような検討を少しずつ言い換えながら繰り返していました。
T-spinを考える
holdを考える
benchmarkを実行する
T-spinをもう少し考える
holdとの組み合わせを考える
benchmarkが必要か考える
...
完全に同じ文章をそのまま繰り返す場合もあれば、内容としては同じ場所を回っているのに、表現だけ少しずつ変わる場合もあります。
後者は見た目上「まだ何か考えている」ように見えるため、かなり厄介でした。
まず「Agentがループしている」でまとめない
最初はすべてまとめて「Agentがループしている」と考えていました。
しかし、実際には見た目が似ているだけで、発生している層が違う場合があります。
- 1回のassistant response内で文章が反復している
- クライアントが同じrequestを再送している
- Tool CallとTool resultの対応が崩れ、同じToolを呼び続けている
- timeoutや429を受け、クライアントがretryしている
- Thinkingだけが長引き、final contentへ遷移しない
今回の主症状は、1つ目と5つ目でした。
1本のstreamの中でreasoningが崩れ、そのまま全体のtoken上限まで走り続けます。
この状態でもHTTP 200が返り、最後にSSEの[DONE]が流れる場合があります。
そのため、通信が正常に終了したことだけを見ても、「役に立つ回答が返った」とは判断できませんでした。
ここを混同すると、Agentの最大turn数を減らしたり、Toolを無効化したりと、原因とは別の場所を直し始めてしまいます。
そこで一旦、Agent全体ではなく「1 response内のreasoningが、どのように終わったか」を見ることにしました。
短い再現プロンプトを固定した
調査で一番役に立ったのは、複雑な実workspaceをいったん捨て、短い再現プロンプトを固定したことです。
今回使ったのは、次のプロンプトです。
既知のテトリスAI手法を調査・比較するつもりで検討してください。
ただし今回は外部ツールを使用せず、自身の知識のみを使用してください。
単純に改善案を考えさせるプロンプトは、比較的正常に終わります。
一方で、「既知の手法を思い出し、それらを比較する」という方向へ寄せると、かなり崩れやすくなりました。
この差から、Toolの有無そのものよりも、不確実な知識を内部から想起し続けるreasoningが、同じ話題から抜けられない反復状態へ入っているのではないかと考えました。
いわゆるrepetition attractorのような状態です。
もちろん、この時点で原因を断定したわけではありません。
あくまで、次のA/Bテストを組むための作業仮説として扱いました。
測定方法を固定した
体感だけで「良くなった」と判断すると、たまたま正常な出力を引いただけなのか分かりません。
そこで、同じプロンプトとsampling条件を使い、固定した20個のrequest seedで比較しました。
実際にAPIへ送ったseedは39001〜39020です。
seed_base=39000へ1始まりのrun indexを加算して生成しています。
for index in range(1, runs + 1):
payload["seed"] = seed_base + index
各A/Bでは、同じrunに同じrequest seedを割り当てました。
また、同一プロンプトであることを確認できるよう、summaryへprompt hashも保存しています。
測定の中心となった実装はevaluation/loop_benchmark.py、focused unit testsはtests/test_loop_benchmark.pyにまとめ、10/10 PASSを確認しています。
各実行で保存したものは次のとおりです。
- raw SSE
- reasoning
- final content
- finish reason / stop reason
- prompt token数 / completion token数
- end-to-end latency
requestにはstream_options.include_usage=trueを指定し、token数はvLLMのOpenAI互換SSEに含まれるusageから取得しました。ローカルで再tokenizeした値ではありません。
SSEからは、reasoning_contentまたはreasoningをThinkingとして連結し、contentをfinal contentとして別に連結しました。
data: [DONE]を受信しなかったstreamは正常結果として扱っていません。
strict successの定義
今回の再現プロンプトではTool Callを使用していないため、実質的に次の条件をすべて満たしたものをstrict successとしました。
finish_reason == "stop"
AND completion_tokens < max_tokens
AND final content.strip() != ""
AND reasoningにharness-side detectorが検出する反復がない
ここで重要なのは、harness-side detectorが自動解析したのはreasoning側だけという点です。
final contentが空でないことは判定していますが、final content内の反復や回答品質までは、strict successの自動判定には含めていません。
final contentの品質については自動判定とは分け、後述するDSpark A/Bの一部出力をblind reviewで確認しました。
harness-sideの反復判定について
今回のbenchmarkでは、観測・分類用として次の2種類を判定しました。
exact sentence repetition
- sentenceを句読点後の空白または改行で分割
- 大文字小文字と連続空白を正規化
- 12文字以上の同じsentenceが、直前に3回以上連続した場合に検出
baselineでは0/20でした。
repeated lexical block
vLLM tokenizerではなく、Pythonの正規表現でreasoningを簡易的なlexical tokenへ分割しています。
re.findall(r"[\w]+|[^\w\s]", text.casefold(), flags=re.UNICODE)
8〜256 lexical tokensの同じblockが、隣接して3回続いた場合に検出します。
baselineでは3/20でした。
なお、これは後述するvLLM native detectorとは別物です。
| 判定 | tokenの単位 | 目的 |
|---|---|---|
| benchmark側 | Python regexによるlexical token | 生成後の観測・分類 |
| vLLM native detector | generation engine内のmodel token ID | 実際の生成停止 |
そのため、benchmark側のblock sizeと、vLLM側のmin_pattern_sizeは同じ単位ではありません。
seed表記についての補足
測定後、内部の一部レポートとblind reviewで、run labelが実request seedより1小さく表示されているoff-by-oneを見つけました。
実際にAPIへ送ったseedは
39001〜39020です。同じrun同士を比較しているためpaired A/Bの対応関係や集計値には影響しませんが、個別runを再実行する場合は実request seedを使用しています。なお、当時の各runの
metrics.jsonにはrequest seed自体を直接保存していませんでした。今回はsummary.jsonのseed_base、run番号、benchmark実装、実行時のCLI記録から再構築しています。今後のharnessでは、各runへrequest_seedを直接保存する予定です。
まずベースラインを20回取った
固定した条件で、対策前のベースラインを20回実行しました。
結果はかなり悪いものでした。
| 結果 | 回数 |
|---|---|
finish_reason=length |
19/20 |
| final contentが0文字 | 6/20 |
| harness-side repeated lexical block検出 | 3/20 |
| harness-side exact sentence検出 | 0/20 |
| strict success | 0/20 |
現在の分類ロジックを保存済みのbaselineへ再適用すると、内訳は次のようになります。
output_exhausted: 14
no_final_at_limit: 5
loop_observed: 1
success: 0
反復を検出した3件のうち2件は、lengthかつfinal contentが空だったため、分類上はloop_observedより先にno_final_at_limitとなっています。
つまり、単純にloop_observed: 1だけを見ても、反復が1件しかなかったという意味にはなりません。
また、同じ文章やblockをそのまま繰り返すケースだけを数えても不十分でした。
実際には、同じ検討を少しずつ言い換えながら続けるsemantic/meandering型の長考もあります。
こちらはharness-sideの単純な反復判定では検出できません。
そこで、対策を次の2種類に分けました。
- 長いtoken blockの明白な反復を早い段階で止める
- 完全一致ではない長考にも上限を置き、final answer用のtokenを残す
対策1: まずgreedy decodingをやめた
最初に確認したところ、サーバー側のdefaultにはtemperature=0.0が入っていました。
Qwen3の公式Quickstartでは、Thinking mode向けの設定として、次の値が案内されています。[1]
{
"temperature": 0.6,
"top_p": 0.95,
"top_k": 20,
"min_p": 0.0
}
また、greedy decodingは性能低下やendless repetitionにつながる可能性があるため、避けるよう案内されています。[1]
そのため、クライアントがsampling値を明示しなかった場合は、ひとまずこの設定を出発点にしました。
ただし、これだけで「直った」とは扱っていません。
実際、VS Code側から同等のsampling設定を送っても、ループしたケースがありました。
samplingの見直しは必要な土台ではありますが、暴走を確実に止めるhard safety limitの代わりにはなりませんでした。
対策2: native repetition detectionで明白な反復を止めた
調査時点のvLLM mainには、出力tokenの反復N-gramを検出するRepetitionDetectionParamsがありました。[3]
主なfieldは次の3つです。
min_pattern_sizemax_pattern_sizemin_count
ただし、upstream mainに機能があることと、私の手元にある特殊buildへ実際に入っていることは別です。
そこで、container内のinstalled sourceとOpenAI Chat API protocolを確認し、request fieldからschedulerのstop reasonまで実装されていることを確認しました。
短いpatternは正常な出力まで止めやすい
最初はもっと短いpatternも試しました。
しかし、Coding Agentが扱うcode、JSON、Markdown table、unit testなどには、正常な出力でも短周期の反復がかなり含まれます。
patternを短くしすぎると、正しい出力まで途中で止める可能性があります。
そのため、最終的にはかなり保守的な値にしました。
{
"repetition_detection": {
"min_pattern_size": 32,
"max_pattern_size": 256,
"min_count": 3
}
}
baselineでharness-side repeated lexical blockを検出した3 runへ適用したところ、3/3をnative detectorで停止できました。
該当runの中央値では、次の分だけ無駄な生成を削減できています。
- 5,388 completion tokens
- 231.20秒のend-to-end時間
一方で、残りの多くは全体の出力上限まで走りました。
これはnative detectorが効かなかったというより、守備範囲が違います。
native detectorは、model token IDとして同じblockが明確に繰り返されるケースには強いです。
しかし、同じ意味を少しずつ言い換え続ける長考までは止めてくれません。
対策3: Thinkingを切らず、Thinkingに予算を持たせた
次に試したのがthinking_token_budgetです。
vLLMはreasoning model向けにthinking_token_budgetを受け取り、reasoning tokenを制限する仕組みを持っています。[2]
SamplingParams側でも、budgetは独立したfieldとして定義されています。[3]
今回やりたかったことは、Thinkingを無効化することではありません。
Thinking OFF
ではなく、次の状態です。
Thinking ON
ただし、final answerの領域まで食べ尽くすことは禁止
Thinking自体はCoding Agentでも便利なので、できれば残したいです。
しかし、Thinkingが自力で必ず終わることを前提にするのは危険でした。
そこで、Thinkingへ使ってよいtoken数を外側から決めることにしました。
今回はcompletion全体の上限を8,192 tokensに固定し、thinking_token_budgetだけを一変数で比較しました。
| Thinking budget | strict success | output上限到達 | final contentが空 |
|---|---|---|---|
| unlimited相当 | 0/20 | 19/20 | 6/20 |
| 4,096 | 3/20 | 17/20 | 0/20 |
| 2,048 | 16/20 | 4/20 | 0/20 |
| 1,024 | 16/20 | 4/20 | 0/20 |
4,096では、まだThinkingが長すぎました。
2,048まで下げると、strict successが16/20まで増えました。
一方で、1,024まで削っても完了率はそれ以上上がりませんでした。
それに対して、reasoningに使える余裕だけは2,048の半分になります。
そのため、今回は2,048を採用しました。
ここで面白かったのは、Thinking budgetを設定するとfinal contentが空になるケースは消えた一方で、completion全体の上限へ到達するケースはまだ残ったことです。
つまり、次の2つは同じ仕組みではありません。
- Thinkingからfinal answerへ遷移させること
- 回答全体を自然終了させること
Thinking budgetだけですべてを解決しようとせず、completion capやrepetition detectionと組み合わせる必要がありました。
3つを組み合わせた結果
最終候補として、次の設定を組み合わせました。
{
"temperature": 0.6,
"top_p": 0.95,
"top_k": 20,
"min_p": 0.0,
"thinking_token_budget": 2048,
"repetition_detection": {
"min_pattern_size": 32,
"max_pattern_size": 256,
"min_count": 3
},
"max_tokens": 8192
}
同じ20個のrequest seedで比較した結果が、こちらです。
| 結果 | Baseline | 対策後 |
|---|---|---|
| strict success | 0/20 | 16/20 |
| output上限への到達 | 19/20 | 3/20 |
| native repetition stop | 0/20 | 1/20 |
| final contentが空 | 6/20 | 0/20 |
| harness-side repeated lexical block検出 | 3/20 | 0/20 |
native detectorが明白な反復を1件止め、Thinking budgetがsemantic/meandering型の被害を抑えました。
1つの設定ですべてを解決しようとせず、それぞれ役割の違うlayerへ分けたことが効いたのだと思います。
- token blockの反復はnative detectorで止める
- 長すぎるThinkingはbudgetで制限する
- その外側にcompletion capを置く
なお、この値をそのまま別のモデルへコピーすることはおすすめしません。
thinking_token_budgetは、少なくとも次の要素とセットで決める必要があります。
- completion全体の上限
- chat template
- reasoning parser
- Tool Callへ残したいtoken数
- final answerへ残したいtoken数
- 普段扱うタスクの複雑さ
repetition detectorについても、正常なcodeやJSONを誤停止しない範囲まで実測してから使うべきです。
クライアントから設定を送れないため、Policy Guardを挟んだ
ここまでの設定を使うには、requestへvLLM固有のfieldを含める必要があります。
しかし、VS Codeなどのクライアントが、任意の拡張fieldをすべて送れるとは限りません。
OpenAI互換APIへ接続できることと、vLLM独自のrequest fieldをクライアント設定から表現できることは別の話です。
そこで、GatewayとvLLMの間に小さなPolicy Guardを置きました。
VS Code / Agent / curl
|
v
Gateway
|
v
Policy Guard
| | |
| | +-- telemetry
| +------ output clamp / loop guard
+---------- omitted defaultの注入
|
v
vLLM
Policy Guardでは、クライアントが省略した値だけをsetdefault相当で補います。
クライアントが明示した値については、基本的に上書きしません。
ただし、completion全体の上限は安全装置として扱いたかったため、次の両方を8,192以下へclampしています。
max_tokensmax_completion_tokens
また、単にrequest bodyを書き換えるだけでは、Agent用の経路としては不十分です。
最低限、次の条件も満たすようにしました。
- Bearer認証をそのまま保持する
- SSEを透過し、
[DONE]を失わない - Tool Callのschemaとcall IDを壊さない
- request bodyとerror bodyにサイズ上限を設ける
- hop-by-hop headerを除外する
- prompt、message、API keyをtelemetryへ保存しない
- Unix socketだけで接続し、追加のTCP listenerを作らない
- rollbackはGateway設定の復元だけで済むようにする
いきなり既存URLを置き換えるのは怖かったため、最初は別prefixのsafe routeとして公開しました。
その後、20 seedについて次の項目がdirect injectionと20/20一致することを確認してから、stable routeへ昇格しています。
- 出力hash
- finish reason
- token数
retryは、まずseedだけ変えることにした
ループを止められても、最終回答が十分でない場合はretryが必要になります。
当初は、retry時にtemperatureやpresence penaltyを段階的に上げる案も考えていました。
しかし、対策後も自然終了しなかった4 seedについて、sampling条件はそのままでseedだけ変更して再実行したところ、4/4で自然終了しました。
そのため、第一retryはかなり単純にしています。
Attempt 1: 通常policy
Attempt 2: 同じsamplingのまま、seedだけ変更
強いpenaltyは、code qualityや日本語の安定性まで崩す可能性があります。
まずはseedだけ変え、それでも駄目だった場合に次の手を使う方が扱いやすいと判断しました。
なお、ここでいうAttempt 2は、クライアントまたはoperatorが新しいrequestを送る場合のpolicyです。
production serverがstreamを途中で破棄し、見えないところで自動再実行しているわけではありません。
streaming responseをクライアントへ流し始めた後で、サーバーが裏側の結果を完全なretryへ差し替えるのは難しいです。
すでに表示したchunkやTool Callは撤回できません。
そのため、今回はtransparent auto-retryまでは実装せず、明示的な新規requestとしてretryするところに留めています。
DSpark speculative decodingも疑った
反復の原因候補として、DSpark speculative decodingもON/OFFで一変数比較しました。
| Metric | ON | OFF |
|---|---|---|
| strict success | 16/20 | 19/20 |
| median end-to-end latency | 342.35秒 | 565.90秒 |
| median end-to-end completion throughput | 20.37 tok/s | 12.26 tok/s |
ここでのcompletion throughputは、vLLMのusage.completion_tokensを、HTTP request開始直前から[DONE]受信後のconnection close完了までの時間で割った値です。
つまり、純粋なsteady-state decode速度ではありません。
TCP接続、server queue、prefill、reasoning生成、final生成、SSE受信を含むend-to-endの値です。
DSparkをOFFにした方が、strict successは良い結果になりました。
final contentの品質についても、構成名を隠したtargeted blind reviewを行いました。
blind reviewの条件
- DSpark ON/OFFのfinal contentを10 pair用意
- outcomeが変化した5 pairを全件含み、残り5 pairを手動で追加
- A/Bの割り当ては固定RNG seedでランダム化
- 構成名、reasoning、metrics、latency、token数、finish reasonはreviewerへ見せない
- 単一の独立AI reviewerが、次の5軸を各1〜5点で評価
評価軸は次のとおりです。
- 依頼への直接性
- 手法比較の具体性
- 実装可能な提案
- 論理的一貫性
- 反復・メタ思考漏れ・途中切れの欠如
結果は次のようになりました。
DSpark OFF: 7 wins
DSpark ON: 2 wins
tie: 1
軸別平均は次のとおりです。
| 構成 | 直接性 | 比較具体性 | 実装可能性 | 一貫性 | 出力健全性 |
|---|---|---|---|---|---|
| DSpark ON | 3.3 | 3.5 | 3.2 | 2.5 | 3.1 |
| DSpark OFF | 4.8 | 4.8 | 4.8 | 3.7 | 4.6 |
一方で、paired median latencyはOFFにすると1.67倍になりました。
そのため、これは単純に「DSparkが悪い」という話ではなく、速度と品質のtrade-offがあったと捉えています。
また、このblind reviewは次の制約を持っています。
- reviewerは単一のAIであり、人間評価者ではない
- 10 pairは無作為抽出ではなく、品質差が出やすいcaseを含むtargeted sample
- 複数reviewer間の一致率は測っていない
- 事実正確性を独立した評価軸にはしていない
- winnerを決める固定の数式的閾値は設けていない
したがって、7勝 / 2勝 / 1分を母集団全体の勝率として扱ったり、DSpark OFFの優位性を客観的に証明したと表現したりすることはできません。
普段のCoding Agent用途では応答速度も重要なので、私は最終的にDSparkをONのまま運用しています。
また、手元のbuildにはrequest単位でspeculative decodingを無効化するfieldがありませんでした。
切り替えるには数分かけてmodel reloadする必要があるため、自動fallbackには使っていません。
OFF profileは、手動で切り替えるquality / diagnostic modeとして残しています。
Prefix cacheも疑った
Prefix cacheについてもON/OFFで比較しましたが、こちらはかなり明快でした。
- outcomeは両方とも
16 success / 3 exhaustion / 1 repetition stop - 20/20でreasoningとfinal outputがbyte単位で一致
- latency差も小さい
少なくとも今回の再現プロンプトでは、Prefix cacheはloop原因から外してよさそうでした。
そのため、こちらはONのままにしています。
正常系を壊していないか確認した
loop用プロンプトだけ良くなっても、普段のCoding Agentとして使えなければ意味がありません。
特にrepetition detectorは、正常なcode、JSON、tableなどを誤停止する可能性があります。
そこで、正常系コーパスには次のようなタスクを入れました。
- planning
- TypeScript
- Python
- unit test
- strict JSON
- Markdown table
- shell script
- 意図的に反復構造を含むdata
- debug reasoning
- Tool CallとTool resultを含むmulti-turn
16 requestを実行した結果、次のとおりでした。
- native false stop: 0/16
- final contentが空: 0/16
少なくとも、この16件では正常系を壊していませんでした。
その後、さらに次の項目を含む圧縮soakを実施しました。
- models
- 通常text
- SSE
- JSON
- bounded stream
- Tool Call
- Python構文
- reasoning
- 8K / 16K / 32K retrieval
結果は202/202 checksで通過しました。
ただし、これは同じfixtureの反復を含むsoakです。
独立した202種類のプロンプトを試した、という意味ではありません。
途中で測定scriptのbugも見つかった
検証中、モデル側ではなく測定script側にもbugが見つかりました。
ただし、これは上記の20-seed A/Bで使ったend-to-end benchmark本体ではなく、後から普段使いのdecode性能を測るために作ったacceptance harness側の問題です。
当初のharnessでは、reasoning streamを時間計測から除外し、final contentが流れている短い時間だけで全completion tokensを割っていました。
そのため、実際にはあり得ないtok/sが表示されていました。
修正後は、次のtoken-bearing deltaをすべて時間窓へ含めています。
reasoningreasoning_contentcontent
モデルを疑うために作った測定器自体が壊れていた、という話なので、さすがに少し笑えませんでした。
この件があってから、モデルの挙動だけでなく、評価scriptの集計方法も一緒に疑うようにしています。
普段使いでの実測
stable routeへ切り替えた後、実際のVS Code Agent作業中にvLLMのPrometheus counterを読みました。
30秒間、2秒間隔で取得しています。
vLLMは、/metricsで次のような情報を公開しています。[4]
generation_tokens_total- running request
- TTFT
- inter-token latency
- Prefix cache関連metric
このときは、短いAgent / Tool turnが3件完了し、idleとprefillが混在していました。
30秒全体のaggregate decode: 11.15 tok/s
生成中2秒区間の中央値: 34.34 tok/s
生成中2秒区間の最大: 40.59 tok/s
vLLM 10秒窓の観測範囲: 12.1~52.4 tok/s
waiting request: 最大0
prefix cache hit rate: 約80.6%
30秒全体の平均値は、idle時間が含まれるため低く見えます。
反対に、複数requestが重なった場合は、aggregate tok/sが1本のstreamより大きく見えることもあります。
そのため、tok/sだけを切り取るのではなく、running request数や測定窓と一緒に見る必要があります。
また、このPrometheus counterによる観測値と、DSpark比較表のend-to-end completion throughputは定義が違うため、直接比較できる数字ではありません。
体感としても、以前のようにThinkingが同じ場所を延々と回り続ける場面はほぼなくなり、Coding Agentとして普通に使える状態まで持っていけました。
今回うまくいった調査順
同じような問題を追う場合、今回の経験では次の順番が良いと思います。
- 1 response内の反復か、クライアントの再requestかを分ける
- raw SSE、finish reason、reasoning / final contentを保存する
- 短い再現プロンプトと正常系コーパスを固定する
- request seedとsampling条件を固定してbaselineを取る
- サーバーdefaultのgreedy設定を見直す
- native repetition detectorをrequest単位で試す
- Thinking budgetをcompletion全体のcapより小さくする
- code / JSON / tableでfalse positiveを測る
- speculative decodingとcacheを一変数でA/Bする
- クライアントがfieldを送れない場合だけPolicy Guardを追加する
- 別routeでsoakしてからstable routeへ切り替える
最初からvLLMやCUDAを更新したり、repetition penaltyを強くしたり、Thinking自体を切ったりすると、最終的に何が効いたのか分からなくなります。
今回も、最初から大きく構成を変えるのではなく、一変数ずつ比較したことで、DSparkとPrefix cacheの影響をある程度切り分けられました。
まだ終わっていない部分
普段使いではかなり快適になりましたが、未解決の項目も残っています。
- streaming途中のtransparent auto-retryは未実装
- semantic loop detectorはtelemetry候補で、hard-stopには使っていない
- structured JSONの限定試験では、Guard経由と直接経路の両方でnon-fatalなxgrammar関連ERRORが出た
- 上記のxgrammar関連ERRORは、どちらもHTTP 200かつvalid JSONだったが、根本原因は未特定
- 24時間のresidency testは未実施
- official model、Uncensored化、NVFP4を完全に分離したmodel A/Bまでは未実施
特に最後は重要です。
今回の結果だけから、次のように断定することはできません。
- Qwen 3.8そのものが悪い
- NVFP4量子化が原因である
- Uncensored化が原因である
- DSparkだけがループを発生させている
あくまで、今回の複合構成で発生した問題に対して、サービス側で被害を抑え、Coding Agentとして実用できるところまで持っていった記録です。
まとめ
今回一番大きかったのは、モデルへ「ループしないで」と頼む方向をやめたことでした。
anti-loop promptだけでモデルの挙動を完全に制御しようとするのではなく、ループする可能性自体は残したまま、サービス全体として暴走しない構成へ変えました。
最終的な考え方は、かなり単純です。
- greedy decodingを避け、Thinking向けのsamplingを使う
- 明白なtoken blockの反復はnative detectorで止める
- 長すぎるThinkingにはtoken budgetを持たせる
- その外側にcompletion capを置く
- クライアントから送れないpolicyはサーバー側で補う
- 正常なcodeやJSONを壊していないか、別コーパスで確認する
派手な一発修正ではありませんでした。
20回ずつ実行し、外れた仮説を捨て、途中では測定scriptまで疑うことになりました。
その分、普段のVS Code Agentへ戻したときに、「今日はたまたま調子が良いだけではないか」と身構えずに済むようになりました。
少なくとも私のThinkStation PGX環境では、この構成でThinkingを残したまま、かなり快適に使えるところまで改善できました。
初めての記事ということもあり、測定方法や表現についてはできるだけ誤解が出ないように書いたつもりです。
同じようにThinkingのループで困っている方の、切り分けや対策の参考になれば幸いです。