OllamaのOpenAI互換APIを実測——長い入力は黙って切られた

目次
開発者向け生成AIをアプリに組み込もうとして「OpenAI互換API」と書かれたエンドポイントを見つけたとき、既存クライアントの base URL を差し替えるだけで動くなら御の字だ。ただし200が返ってきたことと「同じ判定が返ってくる」ことは別の話で、互換の一番危ない部分はエラーになる違いではなく、エラーにならず結果だけが変わる違いにある。Ollama 0.31.1 が出す /v1/chat/completions に生の HTTP リクエストを投げ、OpenAI の仕様と実レスポンスを突き合わせてみた。環境は macOS 26.6.1(arm64)、Python 3.14.7、モデルは応答が速い qwen2.5-coder:1.5b を使った。
正常系はほぼOpenAI。ただし指紋とエラー封筒の中身が違う
まず普通のリクエストから。メッセージを1つ投げると、応答は見た目ほぼ OpenAI そのものだった。
$ python3 t01_basic.py
HTTP 200
{
"id": "chatcmpl-751",
"object": "chat.completion",
...
"usage": {
"prompt_tokens": 34,
"completion_tokens": 2,
"total_tokens": 36
}
}
# exit=0 (2029 ms)
chatcmpl- 形式の id、chat.completion の object、choices[].finish_reason、usage まで揃っている。違いは system_fingerprint が fp_ollama という固定値なことくらいで、実害はほぼ無い。
存在しないモデルを指定したときも、HTTP 404 と OpenAI 風のエラー封筒が返る。
$ python3 t02_badmodel.py
HTTP 404
{
"error": {
"message": "model 'no-such-model-xyz:latest' not found",
"type": "not_found_error",
"param": null,
"code": null
}
}
# exit=0 (97 ms)
封筒の形(message / type / param / code の4キー)は揃っているが、中身は Ollama 流儀だ。type が not_found_error で code と param が null なので、OpenAI が返すエラーコードで分岐している既存コードでは拾い損ねる可能性がある。「エラーの形式まで互換」と思って書いたエラーハンドリングは、こういう場所で黙って外れる。
ストリーミングは動く。ただし全チャンクが role を名乗る
stream: true にすると chat.completion.chunk の SSE が流れ、末尾に [DONE] が来る。大筋は正しいが、実測では全部のチャンクの delta に role: "assistant" が付いてきた。
$ python3 t03_stream.py
HTTP 200
[01] data: {"id":"chatcmpl-136","object":"chat.completion.chunk","created":1791723796,"model":"qwen2.5-coder:1.5b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":"There"},"finish_reason":null}]}
[02] data: {"id":"chatcmpl-136","object":"chat.completion.chunk","created":1791723796,"model":"qwen2.5-coder:1.5b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":" are"},"finish_reason":null}]}
...
[19] data: {"id":"chatcmpl-136","object":"chat.completion.chunk","created":1791723796,"model":"qwen2.5-coder:1.5b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop"}]}
[20] data: [DONE]
# exit=0 (638 ms)
OpenAI のストリーミング仕様では role が出るのは最初のチャンクだけで、以後の delta は content だけを持つ。寛容なパーサなら吸収できる差分だが、「role が来たチャンクを新しいメッセージの開始として扱う」ような実装では挙動が変わりうる。stream_options.include_usage については仕様どおりで、最終チャンクに choices: [] と usage が載った。
$ python3 t04_stream_usage.py
HTTP 200
...
[20] data: {"id":"chatcmpl-61","object":"chat.completion.chunk","created":1791723800,"model":"qwen2.5-coder:1.5b","system_fingerprint":"fp_ollama","choices":[],"usage":{"prompt_tokens":37,"completion_tokens":19,"total_tokens":56}}
[21] data: [DONE]
# exit=0 (606 ms)
本命の罠: コンテキスト超過がエラーにならず、末尾だけが残る
ここからが本題だ。OpenAI ではコンテキスト窓を超える入力は context_length_exceeded 系のエラーで弾かれるのが仕様だ。では「互換」を謳うこのエンドポイントは、同じ場面で何をするのか。実際に超える入力を作って投げた。
方法は「隠しコード探し」だ。単語の羅列(12605語)の中に The secret code is BLUE-ELEPHANT-7734. という文を1箇所に埋め込み、「秘密のコードを答えよ」と聞く。入力が全部読まれていれば見つかるはずで、読まれていなければ「not found」と答えるしかない。コードの置き場所を変えれば、どこが消えたかまで分かる。
status, headers, body = post("/chat/completions", {
"model": "qwen2.5-coder:1.5b",
"messages": [
{"role": "user", "content": body_text + "\n\nWhat is the secret code? Reply with only the code, or 'not found'."},
],
"temperature": 0,
"max_tokens": 50,
})
コードを真ん中に置いて投げた結果がこれだ。
$ python3 t05_overflow.py
prompt words: 12605 chars: 63040
HTTP 200
{
...
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "not found"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 2050,
"completion_tokens": 3,
"total_tokens": 2053
}
}
# exit=0 (4864 ms)
HTTP 200、エラーなし、警告なし、finish_reason は stop。ところが usage.prompt_tokens は 2050 しかない。12605語を送っているのに、処理したと報告してきたのはその一部の量だけだ。つまりサーバーは入力を切り詰めた上で、切り詰めた後のサイズを usage に報告している。usage を見ても「送ったものが全部通ったか」は分からない。
どこが消えたかを確かめるため、コードの位置を「真ん中」「先頭」「末尾」に変えて同じ量を3回投げた。
$ python3 t13_summary.py
position | words_sent | prompt_tokens | answer
middle | 12605 | 2050 | not found (HTTP 200)
start | 12605 | 2050 | not found (HTTP 200)
end | 12605 | 2050 | BLUE-ELEPHANT-7734 (HTTP 200)
# exit=0 (11695 ms)
末尾に置いたコードだけが見つかった。コンテキスト窓の後半側だけが保持され、先頭から真ん中は捨てられる設計のようだ。prompt_tokens がどの位置の試行でも 2050 で止まっていることから、切り詰め後に残る窓はこの経路では2000トークン前後と読める。モデルそのものが持つコンテキスト容量とは関係なく、互換レイヤーの既定値が先に効いている。
長い会話履歴をそのまま投げるエージェント型のコードでは、これは一番古い指示や前提から順に消えていくという意味だ。応答は平然と続くので、消えたことに気づく手段がクライアント側には無い。入力が黙って消えるという事象を、一切の通知なしに行うのがこのエンドポイントの実態だった。
逃げ道はないか試した: num_ctx は無視、n=2 も黙って1件
切り詰めを回避するため、Ollama 側のコンテキスト設定 num_ctx をリクエストに直接足してみた。OpenAI の仕様には無いフィールドだが、独自拡張として転送してくれないかという期待だ。
$ python3 t06_numctx.py
prompt words: 12605 chars: 63040
HTTP 200
{
...
"usage": {
"prompt_tokens": 2050,
"completion_tokens": 3,
"total_tokens": 2053
}
}
# exit=0 (600 ms)
num_ctx: 32768 を付けても prompt_tokens は 2050 のまま、答えも「not found」のままだった。仕様に無いフィールドはエラーでも警告でもなく、黙って捨てられる。複数候補を取る n も同じで、n: 2 を指定しても choices は1件しか返らなかった。
$ python3 t07_nparam.py
HTTP 200
{
...
}
choices returned: 1
# exit=0 (474 ms)
非対応パラメータは「拒否」ではなく「無視」だ。これがこの種の互換APIの性質で、引数名を1文字間違えても、効いていないことに気づけない。なお窓を広げる方法自体はあるにはある——Modelfile で num_ctx を変えるのが公式の手順だが、それはサーバー側のモデル設定であって、互換エンドポイントに投げるリクエストからは制御できない。「クライアント側の指定で越えられる」と期待してはいけない。
動くものもある: JSONモードとlogprobs
全部が怪しいわけではない。response_format: {"type": "json_object"} は期待どおり、パース可能な JSON を返した。
$ python3 t08_jsonmode.py
HTTP 200
{
...
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\n \"name\": \"John Doe\",\n \"age\": 30\n}"
},
"finish_reason": "stop"
}
],
...
}
content parses as JSON: True, keys: ['name', 'age']
# exit=0 (722 ms)
logprobs と top_logprobs もこのバージョン(0.31.1)では実装済みで、各トークンの対数確率が OpenAI と同じ入れ子構造で返ってきた。「互換」は機能ごとのグラデーションであって、ON/OFFの二者択一ではない。対応表を眺めるだけでは、この坂のどの位置に自分の使いたい機能があるかは分からない。
「互換」を信用する前に、自分で確かめるべき3箇所
読者の問いに戻る。「OpenAI互換」と書かれたエンドポイントに既存クライアントを向けて、どこまでが本当に互換か——今回の実測から答えられるのは、応答の形は互換でも、判定の挙動は互換でないということだ。200 が返っても入力は削られ、無いパラメータは捨てられ、エラー封筒の中身は別流儀だった。どれも失敗に見えないから、気づくのが一番遅れる種類のずれだ。
移行前に自分の環境で確かめるべきは3点ある。まず、実際に使う想定サイズを超える入力を投げて、200 が返るか・usage.prompt_tokens が送った量と見合うかを見る。次に、使う予定のパラメータを1つずつ曲げて、応答が本当に変わるかを確認する——n のように黙って無視されるものがある。最後に、存在しないモデル名などエラーケースをわざと踏んで、エラーの type / code が自前の分岐ロジックと合うかを確かめる。いずれも curl か10行のスクリプトで済む作業だが、「互換」の看板だけでは保証されない範囲を直接照らせる。
今回の結論は Ollama 0.31.1 と qwen2.5-coder:1.5b の組み合わせでの観測だ。別バージョン・別モデル、そして vLLM や LM Studio など別実装では結果が変わりうる。tools・vision・seed の決定性は今回測っていない。個々の差異の一覧よりも、「互換表示は自分で測るまでは信じない」という前提のほうが、この手の統合作業では守りになる。
関連書籍
