Qwenはchat.completionsのenable_searchでも検索したが、参照したURLはResponses APIでないと取れなかった
目次
M5Stackの音声アシスタント(async-voice-chat)に使っているQwenは、OpenAI互換のchat.completions.createを単発で叩いているだけで、検索は使っていない。
最新情報のいる質問をしたときにどう対応させるか考える前に、そもそもいまのQwen呼び出し環境で3.7と3.8の両方を呼べるか、検索をオンにしたら何が返ってくるかを確かめておくことにした。
接続まわりは、以前組んだModelScope経由のQwen3.7 Plusと、Qwenアンバサダー枠でアクセスをもらったQwen3.8 Max previewのものをそのまま使った。
検証環境
| 項目 | 内容 |
|---|---|
| クライアント | Apple M1 Max / 64GB / macOS |
| 言語・実行 | Python 3.10 / uv |
| SDK | openai 2.41 |
| モデルA | Qwen3.7 Plus / Qwen3.7 Max(ModelScope経由のOpenAI互換API) |
| モデルB | Qwen3.8 Max preview(Alibaba Cloud Model Studioの専用ワークスペースエンドポイント) |
| 呼び出し方式 | openaiパッケージのchat.completions.createとresponses.createの両方 |
まず素の呼び出しを確認
まずは3モデルとも自己紹介させるだけの一番簡単な呼び出しから。
from openai import OpenAI
client = OpenAI(api_key=..., base_url=...)
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "自己紹介を1文でしてください。"}],
)
3.7 Plus、3.7 Max、3.8のいずれも普通に応答が返り、レスポンスのmessageにはreasoning_contentが乗っていた。3体とも思考してから答えるタイプのモデルになっている。とりあえず3.7と3.8を同じ呼び出しコードで呼び分けられることが確認できた。
enable_searchを試す
まずは検索が必要な質問を、単純なものから投げてみる。
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "今日の日付を教えてください。また、直近1週間以内の生成AI関連ニュースを1つ具体的に教えてください。"}],
extra_body={"enable_search": True},
)
3.7 Plus、3.7 Maxともに、「2026年7月24日」という日付と、上海で開催された2026世界人工知能大会(WAIC)の話を具体的に返してきた。
これは実際の今日の日付と一致していた。
reasoning_contentを覗くと、中国語で「知识库」(知識ベース)という単語が繰り返し出てきて、検索結果らしきテキスト片を読みながら日付やニュースを絞り込んでいる過程が見えた。
素のOpenAI互換のchat.completionsにextra_bodyでenable_search: Trueを足すだけで、検索結果が反映された。
一方で回答オブジェクトのmessage.annotationsは常にnullだった。検索結果のURLらしきものはどこにも乗ってこない。日付もニュースも事実だったのに、どの記事を参照したのかをAPIレスポンス側から確かめる方法がない。
込み入った質問で比較する
次に、種類の違う要求を1つの質問に詰め込んでみた。
東京の今日の天気と、今日のドル円の為替レートを教えてください。
そのうえで、直近1週間以内に発表されたComfyUIまたはStable Diffusion系ツールの
アップデートを1つ具体的に教えてください。
enable_searchなしだと、3.7も3.8も「リアルタイムの検索機能を持たないため答えられない」と正直に断ってきた。
enable_search: Trueを足すと、3.7 Plusは天気とドル円のレートについては「知識ベースに具体的な数値データは含まれていない」と正直に答え、代わりにGitHub上の実在するツール「Multi-Platform Package Manager for Stable Diffusion」(2026年7月14日更新)を拾って回答した。
検索結果はニュース記事やGitHubのメタ情報のようなテキストの集合で、天気APIや為替APIのような構造化データは含まれていないらしく、数値そのものは出てこない。
flowchart TD
Q[質問を送信] --> P{extra_bodyの設定}
P -->|3.7: enable_searchのみ| S1{検索したか}
P -->|3.8: enable_search + forced_search| S2[検索する]
S1 -->|検索した| R1[実在の情報を回答]
S1 -->|見送り| R2[分からないと回答]
S2 --> R3[実在の情報を回答]
3.8だけenable_searchが効かなかった
同じextra_body={"enable_search": True}を3.8に投げると、検索しなかった。
reasoning_contentが「システム上の現在日は2026-06-22」のように、実際の日付(2026年7月24日)とずれた値を勝手に想定し、そのまま古い情報で回答を組み立てていた。
呼び出しのたびに想定日がぶれる(2026-06-15だったり2026-06-22だったり)のも、検索結果を参照せず自分で辻褄合わせ、ハルシネーションを起こしている。
DashScopeのsearch_optionsパラメータを足したところ、3.8でも検索するようになった。
extra_body = {
"enable_search": True,
"search_options": {"forced_search": True, "enable_source": True},
}
これで3.8も「2026年7月25日」(実際は7月24日)という日付と、具体的なニュースを返すようになった。enable_source: Trueを指定しても、こちらもannotationsはnullのままで参照したURLは返らなかった。
サイトを名指しして検索させる
次に、HuggingFace・Qwen公式ブログ・OpenAIブログ・Anthropicブログの4サイトを名指しして、「直近1週間に何が投稿されたか、分からなければ正直に分からないと答えて」という質問を投げた。
同じenable_search: Trueだけを付けた3.7 Plusは、今回は4サイトすべてについて「リアルタイムアクセスができないので分からない」と答えて終わった。
単純なニュース質問では検索したのに、複数サイトを名指しする込み入った質問では検索しなかった。
search_options.forced_searchを足した3.8は、この質問でも検索して、かなり具体的な内容を返してきた。
Hugging Faceについては、2026年7月16日に自律的に動作するAIエージェントによって本番インフラの一部が侵害されたと公表された件だった。悪意あるデータセット経由のコード実行から資格情報を窃取し、内部クラスタへ横展開したという内容で、7月21日にOpenAIが「自社の評価中モデルが原因」と説明したことにも触れていた。
Qwen公式のほうは、2026年7月21日の画像生成モデルQwen-Image-3.0の発表を挙げてきた。
この2件を実際にWeb検索で確認したところ、どちらも本当に報じられていた。
HuggingFaceの侵害はBleepingComputerやThe Hacker Newsが7月20〜21日ごろに報じており、OpenAIが自社モデルの関与を認めた件もAxiosの記事と一致した。
Qwen-Image-3.0も7月21日発表で、ベンチマークや重みを公開しない異例のリリースだったという点まで報道と同じだった。
API越しの検索結果は、単なるそれっぽい文章ではなく実際に現実の出来事を反映していた。
chat.completionsでは検索結果のURLが最後まで取れなかった
ここまでのchat.completions + enable_searchは、検索結果らしきテキストを読んで回答を組み立てているのは分かるが、message.annotationsは最後まで一度もnull以外にならなかった。
search_options.enable_source: Trueを指定しても変わらない。
答えの中身は事実どおりなのに、どの記事を参照したのかをレスポンス側から確認する方法がない。
念のため、3.8のAPIキーで公開のdashscope.aliyuncs.comのエンドポイントを直接叩けないかも試したが、401 Incorrect API keyで弾かれた。このキーはアンバサダー枠の専用ワークスペース向けに発行されたもので、比較対象になる「別の呼び出し方」がわからなかった。
Responses APIと組み込みweb_searchを試す
方法を変えて、chat.completionsではなくresponses.create+tools=[{"type": "web_search"}]という組み込みツールを試すことにした。
OpenAIやAnthropicにもある「モデル自身が検索を呼び出す」タイプの組み込みツールが、Qwen側にもResponses API経由で用意されている。
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["QWEN38_API_KEY"],
base_url="https://dashscope-intl.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1",
)
resp = client.responses.create(
model="qwen3.7-max",
input="今日の生成AIニュースを検索して、重要なものを3件教えて",
tools=[{"type": "web_search"}],
)
print(resp.output_text)
3.8のAPIキーで、このエンドポイントに対してqwen3.8-max-preview・qwen3.7-maxのどちらを指定しても応答が返った。
ModelScope側のキー(QWEN_API_KEY)で同じエンドポイントを叩くと401 InvalidApiKeyになったので、ModelScopeとAlibaba Cloud Model Studioは別の認証基盤ということも分かった。
返ってきた内容には、本文中に参照先のリンクが直接埋め込まれていた。
1️⃣ DeepSeek V4 安定版が本日リリース、旧モデルは退役
中国のDeepSeekが、V4の安定版を本日7月24日に正式リリース...
🔗 出典: [DeepSeek API Docs](https://api-docs.deepseek.com/news/news260424/) / [buildfastwithai.com](https://www.buildfastwithai.com/blogs/ai-news-today-july-23-2026)
2️⃣ ChatGPTとClaude、そろって音声機能を大幅強化
(ITmedia / Impress Watch ほか、7月24日)
Claude:音声モードが大幅強化され、Opus・Sonnetなど上位モデルにも対応...
🔗 [ITmedia記事](https://www.itmedia.co.jp/news/articles/2607/24/news078.html) / [Impress Watch記事](https://www.watch.impress.co.jp/docs/news/2127532.html)
chat.completions側では最後まで出てこなかった実在のURLが、Responses APIでは本文中に普通に埋め込まれてきた。
レスポンスの生の構造を確認すると、検索した経緯そのものが別の要素として残っていた。
type: reasoning # 検索前の思考
type: web_search_call # 実際に投げた検索クエリと、検索でヒットしたURL一覧
type: reasoning # 検索結果を読んだあとの思考
type: message # 最終回答
web_search_callの中身を確認すると、queriesに実際に投げた検索クエリ("ComfyUI update July 2026"など英語・日本語で複数パターン)、sourcesにforum.comfy.orgやdocs.comfy.org/changelog、github.com/comfy-org/ComfyUI/releasesなど実在するURLの一覧が入っていた。
chat.completionsのreasoning_contentに「知識ベース」という言葉だけがぼんやり出てきたのとは違い、何を検索して何を参照したかがオブジェクトとして分離されている。
ここでは最終messageのannotationsは空配列だったので、参照したURLはmessage.annotationsではなくweb_search_call側に残る場合があるようだ。
OpenAI互換のchat.completionsと、別建てのResponses API
Qwenで検索を効かせる方法は、確認できた範囲で2系統ある。
| 方式 | 呼び出し | 検索の効き方 | 検索結果のURL |
|---|---|---|---|
chat.completions + extra_body | OpenAI互換 | 3.7はenable_searchのみで検索するが、込み入った質問では検索しないことがある。3.8はsearch_options.forced_searchまで必要 | 取れない(annotationsが常にnull) |
responses.create + tools=[{"type":"web_search"}] | Responses API(組み込みツール) | 検索クエリと検索結果URLが構造化された別要素として残る | 本文中やweb_search_call.action.sourcesに実URLが乗る |
「OpenAI互換ではなく別の方法でないと検索できない」と最初は思っていたが、これは半分正しく半分正しくなかった。
chat.completionsのままでも検索自体は効いたが、参照したURLが分かる形では返ってこなかった。
実際に別建てで用意されていたのはResponses APIの組み込みweb_searchツールで、こちらは検索クエリと参照先URLをオブジェクトとして持っている。
enable_searchを渡しても検索するかどうかをモデル側・ハーネス側の判断に委ねる作りは、以前Geminiが「Do NOT issue search queries」という指示で検索を見送るケースを調べたときと似ている。
Qwenのchat.completions側も、「検索してよい」というフラグを立てても質問の内容によって検索したりしなかったりした。
M5Stackの音声アシスタントに組み込むなら、chat.completionsにenable_searchを足すより、Responses API+組み込みweb_searchを使うことになりそうだ。
検索のいる質問かどうかを自前で判定するコードを書かなくても、toolsを常に付けておけばモデル側が必要なときだけ呼んでくれる。
雑談を続けながら検索を混ぜるとどうなるか
Qwen3.8 Max previewの記事で、preserve_thinkingが既定で有効で、有効な場合は会話履歴のreasoning_contentを完全な形で次のリクエストへ返す必要がある、と書いていた。
まずこれを単純な2ターンの会話で確認した。「5桁の素数を1つ」→「さっきの数を2倍すると?」という流れを、reasoning_contentを保持した場合と落とした場合の両方で試したところ、どちらも普通に「20014」と正しく答えた。少なくとも今回試した単純なケースでは、reasoning_contentを落としてもエラーにはならなかった。
次に、これがそのまま雑談用の音声アシスタントとして続けられるかを見たかった。
天気の話や好きな食べ物の話を挟みつつ、途中に検索のいる質問を混ぜて、reasoning_contentを毎ターン保持したまま6ターンの会話にしてみた。extra_bodyは毎ターンenable_search+search_options.forced_searchを固定で付けている。
| ターン | 内容 | prompt_tokens | reasoning_tokens |
|---|---|---|---|
| 1 | 「今日は暑いね」という雑談 | 4386 | 156 |
| 2 | 「ComfyUIって触ってる?」という雑談 | 1624 | 234 |
| 3 | 「好きな食べ物を1つ」という雑談 | 4864 | 141 |
| 4 | さっきの食べ物のレシピ | 5055 | 190 |
| 5 | 今日の日付と直近1週間の生成AIニュース | 6710 | 2439 |
| 6 | さっきのニュースをもう少し詳しく | 9077 | 1619 |
雑談だけのターン(1〜4)はreasoning_tokensが150〜240程度で収まっているのに対し、検索が必要なニュースの質問(ターン5)でreasoning_tokensが2439まで跳ね上がった。
雑談の10倍以上のコストになる。
prompt_tokensも単調に増えるわけではなく、ターン1の4386からターン2では1624まで下がっている。forced_searchを毎ターン強制しているため、その回ごとに検索結果として注入される文章量が変わり、会話履歴の蓄積とは別の要因でトークン数が増減しているようだ。
さらに、ターン5では今日の日付を「2026年6月22日」と答えてしまい、単発で検索を試したときに出ていた正しい日付(2026年7月24日前後)からずれた。雑談の履歴が積み上がった状態で検索が入ったこの回は、単発の質問で試したときより日付の精度が落ちた。
forced_searchのかけっぱなしは、コストも精度もこの調子なのでなし。
組むとしたら、雑談の間はふつうにchat.completionsだけで返し、検索のいりそうな質問が来たターンだけ検索を有効にする、最初に想定していた「検索の要否をこちらで判定してから検索に振る」形になりそうだ。
画像入力(VLM)も試す
Qwen3.8 Max previewの記事で「画像・動画・文書も扱えるマルチモーダルモデル」と書いていたので、ついでに画像入力も試した。
公開済みの記事に使ったかなちゃんの画像をbase64にして、image_urlコンテンツとして送った。
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "この画像には何が写っていますか。髪型、服装、ポーズを具体的に説明してください。"},
{"type": "image_url", "image_url": {"url": f"data:image/webp;base64,{b64}"}},
],
}]
3.7 Plusと3.8は、どちらも画像の内容を正確に説明してきた。
サイドポニーテールと青いシュシュ、頭頂部のアホ毛、白シャツに赤いネクタイ、紺のプリーツスカート、黒いハイソックス、茶色のローファーという、実際の画像どおりの特徴を言い当てていた。
一方3.7 Maxは、openaiSDK越しにTypeError: 'NoneType' object is not subscriptableで落ちた。
requestsで生のレスポンスを見ると、HTTPステータスは200なのにchoicesがnull、usageも全部0という空の応答が返ってきていた。
3.7 MaxもVLM対応しているはずだと思っていたので、画像をJPEGに変換して再試行し、dashscope-intl側のResponses APIでも同じqwen3.7-maxを試した。
すると、responses.createのほうははっきりしたエラーメッセージを返してきた。
InvalidParameter: The current model only supports text modality and
does not support image input. Please remove the image content from
your messages and retry.
少なくともアンバサダー枠で触れるqwen3.7-maxは画像入力に対応していない。公開APIのqwen3.7-maxがどうなっているかは不明(そもそもキーが通らず試せない)。ModelScope経由の空レスポンスは、バグというより対応していない入力を受け取った結果のようだ。
そこでclient.models.list()でModelScope側のモデル一覧を取得すると、Qwen/Qwen3-VL-235B-A22B-InstructとQwen/Qwen3-VL-8B-Instructという、名前にVL(Vision-Language)を冠した専用モデルがアンバサダー枠とは別に存在し、この2つに同じ画像を投げるとどちらも正確に画像の内容を説明してきた。
3.8はこのあと使えるかどうかがちょっとわからないので、基本的には3.7 Plusで対応することになるが、現在の想定では問題なさそう。