結論
- OpenAI 互換の
/responsesには、function_callの直後にfunction_call_outputが無いと 400 を返す実装があります。call_idが完全に一致していても、間に別のパートが 1 つ挟まるだけで落ちます。 - 厄介なのは、これが「1 回のリクエストが失敗して終わり」ではない点です。壊れた履歴はクライアント側に残るので、**同じ会話は以後ずっと 400 で固まります。**新しいセッションを作る以外に復帰できません。
- 対策は送信前の履歴正規化です。①結果を呼び出しの隣に並べ直す、②結果が来なかった呼び出しに「結果が無い」という事実を補う。この 2 つで実運用上の詰まりはほぼ解消します。
何が起きるか
エージェントを本番で回していると、ある会話だけが突然進まなくなります。
400 No tool output found for tool call call_00_aSbzf1Gq7g4WVfU4LT922151
「output が無い」と言われていますが、output はあります。call_id も完全に一致しています。違うのは、function_call と function_call_output の間に別のパートが 1 つ挟まっていることだけです。
この挙動は公開のバグ報告でも再現手順つきで上がっています。
-
DeepSeek-V3 #1588 — Codex クライアントで、PostToolUse フックが
developerメッセージを 1 行注入した結果、function_callとfunction_call_outputの間が埋まり、次のターンから 400。報告者は「出力は実在し、call_idも完全一致している」と明記しています。 -
DeepSeek-V3 #1611 — 別経路(初回のツール呼び出し)でも同じ 400 が出ています。
/responsesのペア判定が OpenAI の意味論より厳しい、という指摘です。
両方に共通するのは、壊れた会話が以後ずっと 400 になり、スレッドを継続できなくなるという被害です。#1588 では、同じ call_id を名指しした 400 がその後のターンでも繰り返し出ると書かれています。エラー本文は「output を送れ」と言っているので出力を送り直したくなりますが、送り直しても直りません。原因は output の不在ではなく順序だからです。
履歴が壊れる 2 つの経路
経路 1:ストリームから履歴を組み直すと平坦化される
エージェントのクライアントは、サーバーから流れてくる chunk を自分で畳んで「次のターンに送る履歴」を作ります。このときステップの境界が落ちると、1 ターン分(十数ステップのツールループ全体)が 1 本の assistant メッセージに潰れます。結果、送る履歴がこうなります。
assistant: [思考, 呼び出しA, 本文, 呼び出しB]
tool: [結果A, 結果B]
結果A は 呼び出しA の隣ではありません。間に「本文」と「呼び出しB」が挟まっています。これが経路 1 です。
経路 2:途中で切れて結果が来ない
もう 1 つは、呼び出しは履歴に残ったのに結果が返らなかったケースです。上流の障害、ユーザーの停止、クライアントの終了——どれでも起きます。
assistant: [呼び出しA]
(結果が来ないまま履歴に保存される)
履歴がクライアント側に永続化されていると、この欠落もそのまま残ります。以後その会話は毎回「結果が無い呼び出し」を含んだまま送られ、毎回失敗します。
実測:どの形が通るか
こちらの環境で /responses に対して形ごとに投げて確認した結果です(2026-09-11 時点、思考の有無によらず同じ)。
| 送った形 | 結果 |
|---|---|
呼び出しA 呼び出しB → 結果A 結果B(同じステップの並列呼び出し) |
200 |
呼び出しA → 結果A → 本文 → 呼び出しB → 結果B
|
200 |
呼び出しA 本文 呼び出しB → 結果A 結果B(平坦化された形) |
400 No tool output found
|
**並列呼び出しそのものは合法です。**同じステップで複数本呼ぶのは構いません。落ちるのは、呼び出しの列の途中に非ツールパートが割り込んだ形だけです。
直し方
送信する直前に、履歴を「結果が呼び出しの隣にある」形へ正規化します。以下は依存ゼロで動く最小実装です。
型
export type Part =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
| { type: 'tool-result'; toolCallId: string; toolName: string; output: unknown };
export type Message =
| { role: 'system' | 'user'; content: string }
| { role: 'assistant' | 'tool'; content: Part[] };
① まず診断する
いきなり直すのではなく、まず「どこが壊れているか」を列挙します。会話全体を 1 回走査して、未解決の呼び出しを跨いで非ツールパートが割り込んだ瞬間を捕まえるのがポイントです。
export function findToolPairingErrors(messages: Message[]): string[] {
const errors: string[] = [];
/** まだ結果が返っていない呼び出し。会話全体をまたいで持ち回る */
const pending = new Set<string>();
const seenCalls = new Set<string>();
const resolved = new Set<string>();
/** 「隣接していない」として報告済みの呼び出し。結果が後から来ても二重に数えない */
const displaced = new Set<string>();
for (const m of messages) {
if (m.role === 'system' || m.role === 'user') continue;
for (const p of m.content) {
if (p.type === 'tool-call') {
pending.add(p.toolCallId);
seenCalls.add(p.toolCallId);
continue;
}
if (p.type === 'tool-result') {
if (!seenCalls.has(p.toolCallId)) {
errors.push(`orphan-result: ${p.toolCallId}`);
} else if (resolved.has(p.toolCallId)) {
errors.push(`duplicate-result: ${p.toolCallId}`);
} else if (!displaced.has(p.toolCallId)) {
resolved.add(p.toolCallId);
}
pending.delete(p.toolCallId);
continue;
}
// 思考・本文などの非ツールパートが、未解決の呼び出しを跨いで割り込むと
// 「この呼び出しには結果が無い」と判定される
if (pending.size > 0) {
for (const id of pending) {
errors.push(`not-adjacent: ${id}`);
displaced.add(id);
}
pending.clear();
}
}
}
for (const id of pending) errors.push(`dangling-call: ${id}`);
return errors;
}
前の例をかけると not-adjacent: 呼び出しA が返ります。これが「output は存在するのに output が無いと言われる」の正体です。
② 結果を呼び出しの隣に並べ直す
連続するツール呼び出しを 1 グループとし、その直後にそのグループの結果だけを置きます。呼び出しの後に非ツールパート(思考・本文)が出たら、そこでグループを切ります。
export function groupToolResultsWithCalls(messages: Message[]): Message[] {
// 全履歴の結果を callId で引けるようにする
const results = new Map<string, Part & { type: 'tool-result' }>();
for (const m of messages) {
if (m.role !== 'tool') continue;
for (const p of m.content) {
if (p.type === 'tool-result') results.set(p.toolCallId, p);
}
}
if (results.size === 0) return messages;
const moved = new Set<string>();
const out: Message[] = [];
for (const m of messages) {
if (m.role === 'tool') {
// 移し終えた結果は元の場所から消す。行き場の無い結果は残す
const left = m.content.filter(
(p) => p.type !== 'tool-result' || !moved.has(p.toolCallId),
);
if (left.length > 0) out.push({ ...m, content: left });
continue;
}
if (m.role !== 'assistant') {
out.push(m);
continue;
}
// 呼び出しの後に非呼び出しパートが出たら、そこでグループを切る
const groups: Part[][] = [];
let current: Part[] = [];
let hasCall = false;
for (const p of m.content) {
if (p.type !== 'tool-call' && hasCall) {
groups.push(current);
current = [];
hasCall = false;
}
current.push(p);
if (p.type === 'tool-call') hasCall = true;
}
if (current.length > 0) groups.push(current);
for (const group of groups) {
out.push({ ...m, content: group });
const groupResults: Part[] = [];
for (const p of group) {
if (p.type !== 'tool-call') continue;
const r = results.get(p.toolCallId);
if (!r) continue;
moved.add(p.toolCallId);
groupResults.push(r);
}
if (groupResults.length > 0) {
out.push({ role: 'tool', content: groupResults });
}
}
}
return out;
}
先ほどの平坦化された履歴は、こうなります。
assistant: [思考, 呼び出しA]
tool: [結果A]
assistant: [本文, 呼び出しB]
tool: [結果B]
同じステップの並列呼び出し(呼び出しA 呼び出しB)は1 グループのままなので、余計に分割されません。
③ 結果が来なかった呼び出しを補う
経路 2 への対処です。ここで大事なのは、成功を捏造しないことです。「この呼び出しは結果を返さなかった」という事実を、失敗結果として正直に書きます。モデルは普段ツールのエラーを受け取ったときと同じ振る舞いで、必要なら呼び直します。
export function repairDanglingToolCalls(messages: Message[]): Message[] {
const resolved = new Set<string>();
for (const m of messages) {
if (m.role === 'system' || m.role === 'user') continue;
for (const p of m.content) {
if (p.type === 'tool-result') resolved.add(p.toolCallId);
}
}
const dangling: string[] = [];
const repaired: Message[] = [];
let mergedInto = -1;
for (const [index, m] of messages.entries()) {
if (index === mergedInto) continue;
repaired.push(m);
if (m.role !== 'assistant') continue;
const fillers: Part[] = [];
for (const p of m.content) {
if (p.type !== 'tool-call' || resolved.has(p.toolCallId)) continue;
dangling.push(p.toolCallId);
fillers.push({
type: 'tool-result',
toolCallId: p.toolCallId,
toolName: p.toolName,
output: {
error:
'この呼び出しは結果を返していません。必要ならもう一度呼んでください。',
},
});
}
if (fillers.length === 0) continue;
// 直後に tool メッセージがあるならそこへ併合する(新しく挟むと隣接が崩れる)
const next = messages[index + 1];
if (next && next.role === 'tool') {
repaired.push({ role: 'tool', content: [...next.content, ...fillers] });
mergedInto = index + 1;
continue;
}
repaired.push({ role: 'tool', content: fillers });
}
if (dangling.length === 0) return messages;
console.error('[ai/history] 結果の無いツール呼び出しを補いました', {
dangling,
});
return repaired;
}
ハマりどころ
実装中に踏んだものを挙げます。
- 並列呼び出しを禁じてはいけません。「400 になるから並列をやめる」という回避策をよく見ますが、原因を取り違えています。並列は合法で、落ちるのは順序だけです。並列を潰すと性能だけが落ちます。
- **補うのは失敗結果であって成功結果ではありません。**ダミーの成功を入れると、モデルは「結果を受け取った」と解釈して先へ進み、実際には実行されていない処理を前提にした回答を返します。
- **併合先を間違えると隣接が壊れます。**直後に
toolメッセージがあるときに新しいtoolメッセージを挟むと、間に元の結果が入って隣接が崩れます。必ず既存のtoolメッセージへ足してください。 - **正規化は冪等にします。**毎ターン呼ぶので、2 回かけても結果が変わらないことを保証します。健全な履歴には触らない(内容が変わらない)ことも合わせて確認します。
- **既に壊れた履歴は、直すだけでは消えません。**履歴がクライアントに永続化されている場合、壊れた形はそのまま残っています。正規化を「送信前の必須ステップ」としてサーバー側に置くのが有効です。クライアント側の修正は、古いバージョンを使っているユーザーには届きません。
検証は、①平坦化された履歴が not-adjacent として検出される、②直した後はエラーが 0 になる、③健全な履歴は内容が変わらない、④2 回かけても同じ——の 4 点を押さえれば十分です。実際にこの 4 ケースをローカルで走らせて確認しました。
まとめ
-
/responsesのペア判定は実装によって厳しさが違います。call_id一致だけで通る実装もあれば、隣接まで求める実装もあります。 - どちらでも通る形に寄せるのが安全です。**結果は呼び出しの隣に置く。**それだけです。
- 壊れた履歴は会話を恒久的に殺します。エラー本文に釣られて「output を送り直す」方向に動くと、原因から遠ざかります。
- 直す場所は送信前の一箇所に寄せて、診断・並べ直し・欠落補充の 3 つを冪等に適用します。
筆者はリアルタイム面接支援のエージェントを運用するなかで、この形のエラーに何度か見舞われました。エラー本文と実際の原因が食い違うタイプの障害は、ログに残る情報だけを追うとどうしても遠回りになります。履歴の「形」を先に疑うと早いです。