0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ツール結果は呼び出しの隣に置く:call_id が合っていても No tool output found になる

0
Posted at

結論

  • 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 つを冪等に適用します。

筆者はリアルタイム面接支援のエージェントを運用するなかで、この形のエラーに何度か見舞われました。エラー本文と実際の原因が食い違うタイプの障害は、ログに残る情報だけを追うとどうしても遠回りになります。履歴の「形」を先に疑うと早いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?