前回までのおさらい
前回の記事で、Sonar(Aphrodite Engine)+ Ornith-1.0-9B(EXL3 6bpw量子化)の本番投入後に、</think>や<tool_call>タグの断片(hink>、call>)が応答本文に漏れる、悪化すると空思考の反復ループに陥るバグを踏んだ。
根本原因: Aphrodite標準のqwen3推論パーサーとqwen3_coderツールコールパーサーが、タグの境界判定を「トークンIDがデルタに含まれるか」だけで行っていた。EXL3量子化下ではモデルがタグをアトミックな特殊トークンではなく複数の通常サブワードトークンに分解して生成することがあり、その場合トークンID一致が起きずタグ断片が漏れる。
vLLM本家は約1ヶ月前(v0.23.0)にこの問題をテキスト権威の統一パーサーエンジンで解決済みだったが、前回時点では「Aphroditeへの移植は9ファイル規模、独自パッチ(qwen3_textfix)は実機再現テストで効果不確定」として見送り、StrayTagFilterによるプロキシ層での対症療法(観測済みの断片パターンを文字列フィルタで除去)に一本化していた。
今回はこの続き。実は前回記事執筆後の別セッションで、この移植作業自体は一度完了していた——ただし一度も本番投入されず、存在すら忘れられて放置されていた。今回それを発掘し、検証・デプロイし、その過程で当初想定していなかった3つの追加バグを踏んだ記録。
環境: WSL2 Ubuntu, RTX 5070 ×2(12GB×2、うち1枚はライザーケーブル接続) — 前回と同一。
発掘: 未デプロイの移植版
aphrodite_plugins/ディレクトリを掘り返したところ、qwen3_engine_parser.py(1540行)という、vLLM 0.25.1の統一パーサーエンジン(TokenIDScanner → IncrementalLexer → StreamingParserEngine → ParserEngine)をAphrodite用に丸ごと移植したファイルが見つかった。設計のポイントは以下の2つ:
-
単一名での二重登録: Aphroditeの
ParserManager.get_parser()は、--reasoning-parserと--tool-call-parserに同じ名前を渡すと、2つを別々にラップするDelegatingParserではなく単一の統合Parserクラスを直接解決する(Strategy 1)。この移植版はqwen3_engineという単一名で3つのレジストリ(ReasoningParserManager/ToolParserManager/ParserManager)すべてに自分自身を登録することで、これを狙って発火させる設計になっていた。 -
隠れた第二のバグの回避: 調査の過程で、Aphroditeの
DelegatingParser.parse_deltaが持つ「reasoning→tool-call引き継ぎゲート」自体もトークンID依存だと判明した(self.is_reasoning_end(delta_token_ids)を、現在のデルタのトークンIDだけで判定している)。これは前回の独自パッチ(qwen3_textfix)が効果不確定だった理由そのものだった——reasoningパーサー単体を直しても、この引き継ぎゲートが別に壊れていれば意味がない。単一名で登録してDelegatingParser自体を経由しない設計は、この問題を回避する。
Ornith-1.0公式が実は「逆」のパーサーを推奨していた
作業に着手する前に、Ornith-1.0公式リポジトリ(github.com/deepreinforce-ai/Ornith-1)の推奨構成を確認したところ、意外な事実が判明した。
# 公式のvLLM推奨構成
--tool-call-parser qwen3_xml --reasoning-parser qwen3
# 公式のSGLang推奨構成
--tool-call-parser qwen3_coder --reasoning-parser qwen3
Aphrodite(vLLM系エンジン)なのに、これまで本番はSGLang向け推奨のqwen3_coderを使っていた。Aphroditeに既存・未使用のQwen3XMLToolParser(qwen3_xml)を読んだところ、xml.parsers.expatによる本物のXMLパーサーで、境界判定がほぼ完全にテキストベース(トークンIDを見るのは無関係なエッジケース1箇所のみ)。ワイヤーフォーマット(<tool_call>, <function=, <parameter=)はqwen3_coderと完全同一で、独自の型変換ロジック(_convert_param_value)まで実装済みだった。
そこでまずノーコストの改善として--tool-call-parserをqwen3_xmlに切り替え、これを先行して本番投入した。ツールコール側の堅牢性が上がり、公式推奨にも準拠する。ただし</think>断片漏れの根本原因である推論パーサー側は直らないので、これは本題への布石という位置づけ。
型変換ギャップの発見
qwen3_engine_parser.pyの中身を精査したところ、ツール引数の型変換ロジックが完全に欠落していることが分かった。<parameter=KEY>value</parameter>から生の文字列を抽出してjson.dumpsするだけで、int/bool/array/object型のパラメータが全部文字列として出力される状態だった。
実際に自作エージェント(Hermes)のツール定義を数えてみると、string 213件に対しobject 93件・integer 40件・boolean 25件・array 22件と、非string型が全体の4割を占めていた。これは架空の懸念ではなく実在の問題だった。
qwen3_coder(object/array型をjson.loads→ast.literal_evalフォールバックで処理)とqwen3_xml(int/float/bool/nullの扱いが丁寧)、両方の型変換ロジックを比較した上で、独立した関数として統合・移植した。
オフラインテストで見つけた1つ目のバグ: strict判定
移植版の検証のため、フェイクtokenizer/requestを使ったオフラインテストハーネスを書いた。GPU不要で、</think>や<tool_call>を意図的に非アトミックなサブワードトークンに分割して供給し、断片漏れが起きないかを全分割位置でfuzzテストする。
結果、まさに直したかったバグと同じ症状が再現した。<think>や</think>のタグそのものがcontentにそのまま漏れる。
原因を追ったところ、StreamingParserEngine._process_lex_tokensに以下のロジックがあった:
def _process_lex_tokens(self, tokens):
events = []
strict = self._token_id_terminal_names if self._ever_had_token_ids else None
for tok in tokens:
if tok.terminal == CONTENT_TERMINAL or (strict and tok.terminal in strict):
events.extend(self._on_content(tok.value)) # ← content扱いに格下げ
else:
events.extend(self._on_terminal(tok.terminal, tok.value))
return events
一度でも実トークンID(何でもよい)がストリームに現れると、THINK_START/THINK_END/TOOL_START/TOOL_ENDの4terminalsについて、テキストレクサーの一致結果を無視してcontent扱いに格下げするという設計だった。実際のストリーミング生成では最初の数トークンで_ever_had_token_idsがTrueになるため、これはまさに「サブワード分解されたタグ」シナリオでテキストフォールバックが機能しない設計になっていた。
念のため、ローカルにあった実際のvLLM 0.24.0インストールの本物のソースで全く同じテストをしたところ、同一の挙動を確認した。つまり移植バグではなく、vLLM本家の設計そのものだった。
# vLLM 0.24.0 実ソースでの再現テスト結果
ev1: [SemanticEvent(type=REASONING_CHUNK, value='<think>', ...),
SemanticEvent(type=REASONING_CHUNK, value='reasoning text', ...)]
ev2: [SemanticEvent(type=REASONING_CHUNK, value='</think>', ...), # ← タグそのものが漏れる
SemanticEvent(type=REASONING_CHUNK, value='final answer', ...)]
対応として、_process_lex_tokensからstrict判定を除去した。実機で142試行(適用前後合計)テストしたが、EXL3の断片化自体が低確率事象のため、このパッチ自体の効果はオフラインでのみ確認でき実機では未再現だった。それでも保険として除去する判断をした(理由はコード内にコメントで明記)。
実機カナリアで見つけた2つ目のバグ: extract_reasoningのツールコール消失
オフラインテスト通過後、実機カナリア(別ポート)でテストしたところ、今度は非ストリーミング+tool_choice=requiredのリクエストでfinish_reason=tool_callsなのにtool_calls=[](空)という不可解な症状に遭遇した。
Aphroditeの非ストリーミング応答生成(chat_completion_full_generator)を読んだところ、ストリーミング(parse_delta一発で完結)とは全く違う2段階方式だと判明した:
# 非ストリーミングの実際のフロー
reasoning, content = reasoning_parser.extract_reasoning(output.text, request=request)
tool_calls, content = self._parse_tool_calls_from_content(content=content, ...)
先にextract_reasoningでreasoningとcontentを分離し、そのcontentを別途ツールパーサーに渡している。ところが移植版のextract_reasoningはイベントベースでcontentを再構築する実装で、REASONING_CHUNK/TEXT_CHUNK/REASONING_ENDしか処理せず、TOOL_CALL_START等のイベントを一切拾わず握りつぶしていた。つまりツールコールのXML自体がcontentから消えていた。
p.extract_reasoning('<think>reasoning here</think><tool_call>...</tool_call>', req)
# → reasoning: 'reasoning here'
# → content: None ← ツールコールXMLが消失
vLLM本家のextract_reasoningも全く同じロジック(検証済み)だが、本家は非ストリーミングでこの2段階分離を使わない(統合parse()を1回呼ぶ)ため本家では顕在化しない。Aphrodite固有のアーキテクチャ差分が生んだバグだった。
修正は、イベント再構築をやめてテキスト直接分割方式に置き換え。</think>または暗黙的な<tool_call>で分割し、以降のテキストをそのままcontentに保持するようにした。
本番投入後、プロキシ経由テストで発覚した3つ目・4つ目のギャップ
本番投入して満足していたところ、「8083(プロキシ)経由でテストした?」という指摘を受けた。実はバックエンド(8081)への直接テストしかしておらず、実際にHermes/OpenWebUIが使う経路(プロキシ8082/8083)は一度も通していなかった。
やってみると案の定、thinking_token_budget(反復ループ対策として8083プロキシがリクエストに注入している値)を含むリクエストが400 Bad Requestで拒否された。
{"error":{"message":"thinking_token_budget is set but reasoning_config is not configured.
Please set --reasoning-config to use thinking_token_budget.", ...}}
調べたところ2つの問題が重なっていた:
-
qwen3_engine_parser.pyにreasoning_start_str/reasoning_end_strプロパティが欠落していた(vLLM本家のParserEngineにはあるが移植時に漏れていた)。これが無いとAphroditeのReasoningConfigが推論トークンIDを自動導出できず、起動時に警告が出ていた。 - 起動スクリプトに
--reasoning-config自体が設定されていなかった。これが無いとReasoningConfigオブジェクトそのものが存在せず、thinking_token_budgetを使うリクエストが門前払いになる。
両方とも今回のパーサー移行より前から存在していた可能性が高い、独立したギャップだった(プロキシ自体がこの日のWSL再起動以降一度も再起動されていなかったため、誰も気づいていなかった)。
最終検証
修正を全て適用した上で、実際にプロキシ経由(8083、StrayTagFilter+thinking_token_budget有効)で、Hermesの実利用トラフィックと同時に大規模検証を行った。
断片漏れfuzzテスト
ループ誘発プロンプト8種 × 25回 = 200回のストリーミングリクエスト。
| 項目 | 結果 |
|---|---|
| 試行数 | 200 |
| 断片漏れ | 0件 |
| エラー | 0件 |
| 総所要時間 | 1094.4秒 |
| 平均レスポンス時間 | 32.5秒/リクエスト |
型変換マトリクス(全JSON Schema型)
streaming/non-streaming両方で、string/integer/number/boolean/array/object(ネスト含む)の全7項目がPASS。
{"query": "quantum computing", "limit": 5, "score_threshold": 0.75,
"include_archived": true, "tags": ["physics", "2026"],
"filters": {"author": "Feynman", "year": 2026}}
並列負荷
N=1/8/16/32いずれもエラー0件。
まとめ
- vLLM本家がv0.23.0で導入したテキスト権威の統一パーサーエンジンをAphroditeに移植することで、Ornith-1.0-9B(EXL3量子化)で発生していたタグ断片漏れバグを根本修正できた
- 移植作業自体は過去に一度完了していたが、デプロイされず存在すら忘れられていた。「動くコードがある」と「本番で検証済み」の間には大きな距離がある
- オフラインのfuzzテストで見つけた
strict判定バグはvLLM本家の実ソースでも再現した——移植先だけでなく移植元(upstream)にも同じ脆弱性が存在しうる - 実機カナリアでのみ見つかった
extract_reasoningのツールコール消失バグは、バックエンド単体のテストでは検出不可能だった。Aphroditeが非ストリーミングで独自の2段階分離アーキテクチャを持っていたことが原因で、これはvLLM本家のテストスイートを流しても見つからない類のバグ -
バックエンド直叩きのテストだけでは不十分。実際の利用経路(プロキシ)を通さないと気づけない設定ギャップ(
--reasoning-config)が本番投入後に発覚した。プロキシが独自にリクエストへパラメータを注入している場合は特に注意が必要 - 公式ドキュメントの推奨設定(今回はvLLM向けとSGLang向けでツールコールパーサーが違う)を鵜呑みにせず実機検証する価値は今回もあった。ただし逆に、過去に「実機で確認したから」と自己流の選択をしていた箇所(
qwen3_coder採用)が、実は公式推奨と食い違っていたことに気づかなかった——定期的に upstream の推奨構成を洗い直す価値がある