ARCHININGEN TECH BLOG
← 記事一覧へ戻る

Azure OpenAI Responses API の会話状態設計 — サーバに預けられるもの、手元に残るもの

CONTENTS

会話状態を自前とサーバのどちらが持つかを対比した図。左の「自前で持つもの」には thread_id → response_id という 1 ポインタだけが大きく置かれ、その下に「+ endpoint 付きの会話ログ」が添えられる。右の「サーバが持つもの」には turn 1・turn 2・turn 3 と積み上がった会話本体があり、両者は previous_response_id と書かれた矢印 1 本だけで繋がる。下部には預けても手元に残る 3 つ — instructions の再送 (継承されない)、失効のフォールバック (既定 30 日で消える)、会話ログの自前保持 (サーバからは読み出せない) — が横に並ぶ。図の結びは「預けられるのは『モデルに渡す文脈』だけ」

Azure OpenAI の Responses API には previous_response_id があり、会話履歴をサーバ側に持たせられます。ならば自前のストレージには何を持てばいいのか。筆者が 4 ヶ月ほど本番で回しているシステムの答えは「thread_idresponse_id の 1 ポインタだけ」でした。メッセージ配列を組み立てるコードは、このリポジトリに 1 行も存在しません。

題材は B2B の業務 SaaS のバックエンドで動く AI エージェント API です。Azure Functions v4 (Node.js 22 / TypeScript) の上で 14 のエンドポイントが動き、チャットでヒアリングした内容を、条件の抽出・配分の試算・設計書の出力へと引き渡していきます。モデルは gpt-5.4-mini、API バージョンは 2025-04-01-preview です。

ただし、自前のストレージが空になったわけではありません。会話ログは別に持っています。同じ会話を二重に持っているように見えて、実は役割がまったく違うというのが本記事のいちばん言いたいことです。サーバ側の履歴はモデルに文脈を渡すためだけのもので、アプリケーションからは読み出せません。だから「前回の続きを画面に出す」「会話が十分に進んだか判定する」「後段の抽出に渡す」といった処理は、預けただけでは 1 つも作れません。

本記事では、その線引きをどこに引いたかと、振り切った結果として踏んだもの(instructions の非継承・response_id の失効・保存サイズの数え方の間違い)を書きます。

Responses API と Chat Completions の違いや、Responses API の入門的な使い方は書きません。公式のマイグレーションガイドで足ります。SSE ストリーミングの実装と Structured Outputs の話も本記事では扱わず、別記事に分けます。

自前のストレージには 1 ポインタしか持たない

メッセージ配列を組むコードが存在しない

セッションテーブルは 1 スレッドにつき 1 エンティティです。RowKey"session" という固定文字列で、実質「この thread_id の現在位置」を指すだけの 1 行になっています。

JAVASCRIPT
// src/shared/responses.ts  upsertSession()
await table.upsertEntity({
  partitionKey: threadId,
  rowKey: "session",
  response_id: responseId,
  updated_at: now,
  created_at: createdAt,
});

そして API を呼ぶ側です。ここで見てほしいのは input に渡しているものが、常に「今回のユーザー発話 1 件」だけである点です。

JAVASCRIPT
// src/shared/responses.ts  sendMessage()
const createParams = {
  model: AZURE_OPENAI_MODEL,
  input: params.userMessage,
  instructions: params.instructions,
  ...(params.maxOutputTokens && { max_output_tokens: params.maxOutputTokens }),
  ...(previousResponseId && { previous_response_id: previousResponseId }),
  ...(params.schema && { text: buildTextParam(params.schema) }),
  ...(params.tools && { tools: params.tools }),
  ...(params.reasoningEffort && { reasoning: { effort: params.reasoningEffort } }),
};

過去のメッセージを配列に積む処理は、探しても見つかりません。トークンを節約するために要約しているのでもなければ、直近 N 件に絞っているのでもなく、そういうコードが構造的に存在しないという状態です。会話の連なりは previous_response_id の 1 本の線だけで表現されています。

ここで先に誤解を潰しておきます。この設計でトークン課金が減ることはありません。公式ドキュメントは「previous_response_id を使っている場合でも、チェーン内の過去のレスポンスの入力トークンはすべて入力トークンとして課金される」と明記しています。手元のコードから配列組み立てが消えるのは、運用の責務が減るからであって、請求書が軽くなるからではありません。

もうひとつ、自覚しておくべきことがあります。このコードは store を明示していません。つまり Responses API 側の既定値に、システム全体の会話継続が乗っています。保存されたレスポンスは既定で 30 日保持される仕様なので、「明示していない既定値の上に本番が乗っている」という事実は、少なくともコードのどこかにコメントとして残しておく価値があります。

なお ...(cond && { key: value }) というスプレッドと短絡評価の組み合わせは、オプショナルなパラメータを条件付きで足す定番のイディオムです。cond が偽なら偽値そのもの (falseundefined) がスプレッドされますが、プリミティブや undefined のスプレッドは何も展開しないので結果的にキーごと消えます。

会話ログを自前で持つ理由 — サーバの履歴は「読み出せない」

「1 ポインタだけ」と書きましたが、実際にはもう 1 つテーブルがあります。会話ログです。

ここは一見すると矛盾しています。履歴をサーバに預けたのに、同じ会話をもう一度自前でも書いている。二重持ちであり、保存コストも増え、書き込み失敗という新しい失敗モードも増えます。それでも持っているのは、サーバ側の履歴には決定的にできないことがあるからです。

Responses API の会話は、モデルに文脈として渡すことしかできません。previous_response_id を渡せばモデルは読めますが、アプリケーションからは読めない。一覧も取れず、検索も絞り込みもできません。つまりサーバ側にあるのは「モデルのための状態」であって、「アプリケーションのためのデータ」ではないということです。

自前ログの用途は 4 つある

現在このシステムでは、7 つのエンドポイントが会話ログを読んでいます。用途は次の 4 系統に整理できます。

1. RAG の検索クエリを組み立てる。 ユーザーの発話が「はい」「それで」のような短文だと、その 1 文で埋め込み検索を投げても意味のあるチャンクは返りません。そこで直近の会話を繋いでクエリにします。

JAVASCRIPT
// src/functions/chatFacilitator.ts  buildFacilitatorSearchQuery()
const recent = (logs ?? [])
  .filter((l) => l.endpoint === "chat_facilitator")
  .slice(-SEARCH_QUERY_RECENT_TURNS);
if (recent.length <= 1) return fallback;
const joined = recent.map((l) => `${l.role}: ${l.content}`).join("\n").trim();
return joined.length > SEARCH_QUERY_MAX_CHARS
  ? joined.slice(-SEARCH_QUERY_MAX_CHARS)
  : joined;

直近 6 ターン・800 文字に切り詰めています。slice(-N)末尾側を残すのは、会話は後ろほど今の関心に近いからです。

2. フロントに会話を返す。 GET /thread/{threadId}/{endpoint} という専用のエンドポイントがあり、ユーザーがページを再訪したときに会話を復元するのに使われます。これは自前ログでしか実現できません。サーバ側の履歴は読み出せないので、「前回の続きを画面に表示する」という当たり前の機能が、預けただけでは作れないわけです。

3. 実行前のガードをかける。 これが個人的にいちばん効いている用途です。配分の試算や条件抽出のエンドポイントは、ヒアリングが十分に進んでいない状態で叩かれると意味のない出力を返します。そこで実行前に会話ログを数えます。

JAVASCRIPT
// src/functions/formExtract.ts
// 会話ログの最低限チェック(2往復以上: user>=2 かつ assistant>=2)
const logs = await getConversationLog(sessionThreadId);
const facilitatorLogs = logs.filter((l) => l.endpoint === "chat_facilitator");
const userCount = facilitatorLogs.filter((l) => l.role === "user").length;
const assistantCount = facilitatorLogs.filter((l) => l.role === "assistant").length;
if (userCount < 2 || assistantCount < 2) {
  return {
    status: 400,
    jsonBody: {
      status: "failed",
      error: "前提となる会話が不足しています。先にチャットでヒアリングを行ってください。",
    },
  };
}

注目してほしいのは、「会話が十分か」をモデルに判断させていない点です。同じ判定はプロンプトでもできます(「情報が足りなければその旨を返してください」と書けばよい)。しかしそれは確率的で、テストもできず、失敗すると「それらしいが根拠のない配分結果」が出てきます。ログの件数で数えれば決定的です。LLM を使うシステムでも、判定できるものはコードで判定する — そのための材料が自前ログでした。

4. 後段の処理に入力として渡す。 問い合わせの改善要望を抽出するエンドポイントは、会話ログそのものを AI に渡して構造化します。

JAVASCRIPT
// src/functions/inquiryComplete.ts
// 問い合わせチャットの会話ログを取得(同一スレッドの他エンドポイント発言は対象外)。
// 取得障害を「ログ不存在(404)」と誤報告しないため throwOnError で区別する
conversationLog = (await getConversationLog(threadId, { throwOnError: true })).filter(
  (m) => m.endpoint === "inquiry_chat",
);

throwOnError に注意してください。会話ログの取得は既定では失敗しても空配列を返します(チャットは黙って劣化してよい、という方針です)。しかしこのエンドポイントにとって会話ログは必須の入力なので、「ログが無い (404)」と「取得に失敗した (503)」を区別する必要があります。同じデータでも、読む側の用途によって fail-open と fail-closed を切り替えているわけです。

1 つの物理スレッドに、9 種類の論理会話が混ざっている

上に挙げたコードのすべてに filter((l) => l.endpoint === ...) が出てきました。これがこの設計の要です。

このシステムでは 4 つのエージェントが 1 つの thread_id を共有します(詳しくは後の章で書きます)。モデルから見れば 1 本の会話ですが、アプリケーションから見ると chat_facilitator / form_extract / inquiry_chat など 9 種類の論理的な会話が同じスレッドに混在しています。

そこで会話ログの各行に endpoint を持たせ、読むときに絞り込めるようにしています。「ヒアリングの往復だけ数えたい」「問い合わせチャットの発言だけ抽出したい」「この画面に表示する会話だけ返したい」— これらはすべて endpoint での絞り込みで実現しています。

サーバ側の履歴では、この切り出しは原理的にできません。 あちらにあるのは previous_response_id で繋がれた 1 本の線であり、そこから「この用途に関係する部分だけ」を取り出す手段がないからです。

二重持ちではなく、役割が違う

整理すると、2 つの履歴はそもそも別物です。

サーバ側の履歴 自前の会話ログ
誰のためのものか モデル アプリケーション
読み出し できない クエリできる
取り出す単位 チェーン全体(1 本の線) endpoint × role で絞り込める
寿命 既定 30 日で消える 消えない
主な用途 次のターンにモデルへ渡す文脈 検索クエリ / 画面への再表示 / 実行前のガード / 後段処理の入力

こう並べると、「マネージドに預けたのに自前でも持つ」のは冗長ではないと分かります。モデルに渡す文脈の管理は預けられるが、会話を業務データとして扱う部分は預けられない、というだけのことです。

この整理を先にしておくと、自前で持つものの範囲を決めやすくなります。このシステムが自前に置いているのは「1 ポインタ + 会話ログ」だけで、メッセージ配列の組み立て・要約・打ち切り位置の判断といった「モデルに渡すための加工」は一切持っていません。預けられる責務と預けられない責務の線が、そのままストレージの設計に出ている形です。

自前で持つと物理制約が返ってくる

自前で持つと決めた瞬間に、預けていれば無縁だった物理制約も返ってきます。会話ログの RowKey はミリ秒のタイムスタンプ + role + UUID 8 桁で組み、パーティション内の並び順をキーそのもので作っています。content には Table Storage の Edm.String の上限があり、これは UTF-16 でエンコードした 64 KiB(32,768 コードユニット)です。ここで一度、UTF-8 のバイト数で数えるという単位の取り違えをしました(JavaScript の String.prototype.length がそのまま UTF-16 のコードユニット数なので、閾値をコードユニットに置き換えるだけで直ります)。サイズ上限は必ず「何を数えた値か」まで読む — それだけの話です。

むしろ効いたのは、超えたときの見え方でした。logMessage()catchconsole.error を出すだけで例外を握り潰します(Table Storage が落ちても AI の応答は返す、という方針です)。つまり保存に失敗しても処理は流れ、会話ログだけが静かに欠けます。そのログは RAG の検索クエリ組み立てと後段の抽出に使われるので、欠落は障害ではなく「なんとなく精度が悪い」という形で現れます。預けていれば存在しなかった失敗モードであり、会話を自前で持つと決めることの本当のコストはここでした。

instructions は引き継がれない

previous_response_id を渡しても、instructions は前のターンから引き継がれません。これは実装の癖ではなく仕様で、公式のマイグレーションガイドも「previous_response_id は前のレスポンスのトップレベルの instructions を引き継がないので、安定した instructions は毎リクエスト送り直すこと」と書いています。テキスト生成ガイドも「instructions はそのリクエストの生成にのみ適用され、previous_response_id で会話状態を管理している場合、過去のターンで使った instructions は文脈に含まれない」と明記しています。

毎ターン全文を送り直すのは一見無駄ですが、この性質はそのまま利点になります。システムプロンプトの後ろに、そのターンだけの参考ナレッジを動的に足せるからです。

JAVASCRIPT
// src/functions/chatFacilitator.ts
const logs = await getConversationLog(session.threadId).catch(() => undefined);
const searchQuery = buildFacilitatorSearchQuery(logs, inputMessage);
const knowledgeChunks = await retrieveKnowledgeOrEmpty("chatFacilitator", searchQuery, 5, 0.30, context);
const instructions = knowledgeChunks
  ? `${BASE_INSTRUCTIONS}\n\n# 参考ナレッジ\n${knowledgeChunks}`
  : BASE_INSTRUCTIONS;

1 行目で会話ログを読んでいるのは、前に見たとおり検索クエリを組み立てるためです。ここで押さえておきたいのは、instructions の非継承と、履歴を読み出せないことが噛み合っている点です。

  • 毎ターン instructions を送り直す必要がある → だからそのターン用のナレッジを差し込める
  • しかし何を差し込むべきかは会話の流れで決まる → その流れはサーバから読み出せない → 自前ログが要る

つまり「毎回送り直せる」という自由度は、「毎回何を送るか決めなければならない」という責務とセットです。すでに見たとおり、その判断材料は自前で持つしかありません。

なお、送り直しにかかるコストは無視できません。システムプロンプトに毎ターン数千トークンのナレッジを足せば、その分が毎回入力トークンとして課金されます。retrieveKnowledgeOrEmpty の引数で上位 5 件・スコア 0.30 以上に絞っているのは、精度の話であると同時にコストの話でもあります。

保存した response_id が「あるのに使えない」日

ここが本記事の核です。Table に保存した response_id は、存在するのに使えなくなる日が来ます。サーバ側のレスポンスは既定で 30 日保持なので、ユーザーが 1 ヶ月ぶりにチャットを再開すれば、こちらが握っている ID はもう向こうにありません。

対処はシンプルで、404 が返ったら ID を捨ててやり直します。

JAVASCRIPT
// src/shared/responses.ts  sendMessage() の catch 節
} catch (e) {
  // タイムアウトはリトライせず即座にスロー
  if (e instanceof Error && e.name === "TimeoutError") throw e;

  const status = e.status;

  // previous_response_id が無効な場合のフォールバック
  if (status === 404 && previousResponseId) {
    console.warn(
      `previous_response_id '${previousResponseId}' が無効です。新規会話として再試行します。`,
    );
    previousResponseId = null;
    continue;
  }

  // 一時的エラーのリトライ
  if (attempt < retry && status && [429, 500, 502, 503].includes(status)) {
    console.warn(`sendMessage リトライ ${attempt}/${retry}: status=${status}`);
    await sleep(2000 * attempt);
    continue;
  }
  throw e;
}

この 10 行ほどがないと、API から返る 404 がそのまま throw され、久しぶりに戻ってきたユーザーの最初の 1 通がこちらの 500 として死にます。しかも再現しにくい。開発中は毎日触っているので、この失効は踏みません。

正直に書いておくと、この実装には副作用もあります。失効時の continue は一時的エラーと同じループを回るので、retry: 3 のうち 1 回が「ID を捨てるためのやり直し」に消えます。リトライ回数の意味が 2 種類混ざっている状態で、分けるなら失効時のやり直しはループの外に出すのが素直でしょう。バックオフも線形でジッターがなく、複数インスタンスから叩く構成に変えるなら足すことになります。

いずれにせよ本題はそこではなく、失効の検出とフォールバックは、預けても手元に残る責務だったということです。会話履歴を預けた見返りに、こちらは「預けた先から消える日」を自分で扱う必要があります。

推論トークン込みの上限は、こちらで見張る

もう 1 つ、預けたかどうかに関わらず手元に残るものがあります。reasoning 系のモデルでは max_output_tokens推論トークンも含んだ上限として効き、上限に達すると「JSON が空で返ってくる」という分かりにくい形で刺さります。このシステムでは、previous_response_id を渡さない単発の呼び出しで推論トークンが上限を食い切り、上限を上げたら今度は応答に 2 分近くかかるようになって effort を下げる、という往復を 1 日でやりました。上限と effort は独立に決められません

検出は status の確認で行っています。上限到達は例外ではなく status: incompleteincomplete_details.reasonmax_output_tokens)として返るため、completed 以外を一律で例外にしています。

JAVASCRIPT
// src/functions/inquiryComplete.ts
if (result.status && result.status !== "completed") {
  throw new Error(`Responses API が status=${result.status} を返しました(max_output_tokens 不足等の可能性)`);
}
const parsed = JSON.parse(result.outputText);

ここを見ずに JSON.parse へ進むと、空文字列のパースエラーという原因から最も遠いところでエラーが出ます。「モデルの応答が不完全でも HTTP は 200 で返ってくる」というのは、会話状態をどこに置くかとは無関係に、LLM API を扱うかぎり付いて回るパターンです。

1 スレッドを 4 エージェントで共有する

状態を DB でもステートマシンでもなく会話履歴に置く

このシステムには役割の違う 4 つのエージェント (Facilitator / Extractor / Simulation / Delivery) がいます。実装としては別々の HTTP エンドポイントですが、同じ thread_id・同じ previous_response_id チェーンをリレーします。モデルから見ると 1 本の会話に、役割の違う instructions が入れ替わり立ち替わり適用されている状態です。

そのため、全エージェント共通の前文をプロンプトの先頭に置いています。

JAVASCRIPT
// src/shared/prompts.ts
const THREAD_PROTOCOL = `## マルチエージェントスレッドプロトコル

このスレッドは複数の専門エージェント(Facilitator / Extractor / Simulation / Delivery)が共有するワークスペースです。あなたはその中の1エージェントとして動作しています。スレッド内の過去メッセージすべてを累積コンテキストとして活用し、他エージェントの出力を否定・修正せず前提として扱ってください。
`;

さらに Facilitator のプロンプトは、他のエージェントが出力した JSON の存在を、ワークフロー上の現在位置を判定するシグナルとして使います

MD
**ステップ3(設計書の出力)から開始する条件**:
- スレッド内にSimulationエージェントが出力したJSON(`allocation`等のフィールドを含む)が存在する

**ステップ2(提案)から開始する条件**:
- スレッド内にExtractorエージェントが出力したJSON(`weighted_candidates`等)が存在し、`allocation` はまだ存在しない

ワークフローの状態を、データベースのカラムでもステートマシンでもなく「会話履歴に何が書かれているか」に置いている、ということです。ユーザーが数日後に戻ってきてもモデルが履歴から再開位置を判断するので迷子になりませんし、状態遷移のテーブルを別に持って同期する必要もありません。一方で判定はモデルの読解に依存するため決定的ではなく、単体テストも書きにくい。分岐が増えればどこかで明示的な状態管理に移す判断が要るはずで、この規模だから割に合っている、という類の設計です(LangGraph のようなフレームワークも使っていません)。

この設計の帰結として、Extractor に渡す user message は固定文で足りるようになります

JAVASCRIPT
// src/functions/formExtract.ts
const userMessage = "これまでの会話内容を分析し、抽出属性を最適化してください。";

// 検索クエリは直近の会話ログから組み立てる(固定文だとカテゴリが文脈に合わない)。
// AI 本体への user message は固定文のまま:会話文脈は previous_response_id で引き継がれる。

モデルに渡す文は固定でよいのに、RAG の検索クエリは会話ログから組み直さなければならない。「モデルは覚えているが、こちらは覚えていない」という非対称が、ここでも顔を出しています。

チェーンは線形ではなく木になりうる

もう一点、観察として書いておきます。Facilitator が tool として他のエージェントを呼ぶとき、tool ハンドラに渡している previousResponseId は、セッションから読んだ「今回のユーザー発言より前」の ID です。一方 Facilitator 自身の tool 実行後の応答は、直前の response に繋がります。

JAVASCRIPT
// src/shared/responses.ts  sendMessage()
const followUp = await client.responses.create({
  model: AZURE_OPENAI_MODEL,
  input: toolOutputInput,
  instructions: params.instructions,
  previous_response_id: response.id,
  // ...(中略)
});

つまり tool 実行が作る response と Facilitator の follow-up response は、同じ親から伸びた別々の枝になります。「スレッド」という言葉からは 1 本の線を想像しますが、実体は木構造になりうるということです。最終的にセッションテーブルに残る response_id は書き込み順で決まります。これは実装からそう読めるというだけで、これが原因の不具合を観測したわけではありません。 現在の使い方では tool の同時実行が起きにくく実害は出ていませんが、並列度を上げるなら真っ先に見直す箇所になります。

シークレットを持たないと、何が固定されるか

このアプリケーションは API キーを 1 つも持ちません。Azure OpenAI への認証は DefaultAzureCredential から取得したトークンで行います。

JAVASCRIPT
// src/shared/responses.ts
const credential = new DefaultAzureCredential();

let _client = null;

function getClient() {
  if (_client) return _client;
  const tokenProvider = getBearerTokenProvider(
    credential,
    "https://cognitiveservices.azure.com/.default",
  );
  _client = new AzureOpenAI({
    azureADTokenProvider: tokenProvider,
    endpoint: AZURE_OPENAI_ENDPOINT,
    apiVersion: AZURE_OPENAI_API_VERSION,
  });
  return _client;
}

export { getClient, credential };

ポイントは最終行です。クライアントだけでなく credential そのものを export しています。ナレッジ検索用に別の AzureOpenAI クライアント(埋め込み生成用)を作っており、そちらが同じインスタンスを import するため、トークンのキャッシュが 1 個に集約されます。クライアントをトップレベルで作らず getClient() 内で遅延初期化しているのも、認証を必要としないエンドポイントをコールドスタート時に巻き込まないためです。

DefaultAzureCredential の本質的な価値は、本番の Managed Identity とローカルの az login を同一コードで通せることにあります。この 1 点でアプリケーションが管理するシークレットがゼロになり、Key Vault も不要になりました。

そしてこの「シークレットがない」という前提が、接続先の URL やモデル名を環境変数からコードの定数(config.ts)へ移す判断を成立させています。12-Factor App の教えには真っ向から反していますが、それが許されるのは隠すべき値が 1 つも残っていないからです。得たものと失ったものを並べると次のようになります。

内容
得たもの process.env.FOO ?? "" のようなフォールバックが消える。設定変更が PR の差分として レビューできる。定数に型が付く
失ったもの 単一の Function App が単一のリソースに固定される。環境ごとの向き先の差し替えはデプロイ単位になる

このシステムでは環境の違い (local / dev / stg / prod) を、接続先そのものではなくリクエストパラメータと Blob のコンテナ名で吸収する構成になっているため、失ったものが問題になっていません。逆に言えば、同じコードで複数のリソースに向ける必要が出た瞬間に、この判断は破綻します。設定をコードに置くかどうかは一般論では決まらず、「何を秘密として扱う必要があるか」と「向き先がいくつあるか」で決まる、というのが筆者の結論です。

まとめ — 預けられるのは「モデルに渡す文脈」だけ

会話状態をマネージドに預けるという判断は、input に 1 件しか渡さないという極端な形まで振り切れます。実際に振り切ってみると、預けられるものと預けられないものの線がはっきり見えました。

預けられたもの 預けられなかったもの
メッセージ配列の組み立て(要約・打ち切り・ロールの順序) 会話を読み返すこと全般 — 画面への再表示、実行前のガード、後段処理への受け渡し
会話本体の永続化 失効した response_id の検出とフォールバック
毎ターンの instructions 全文の再送
トークン課金(チェーン内の過去入力は入力トークンとして課金される)

左側は確かに大きな削減でした。メッセージ配列の組み立ては、要約の戦略・打ち切りの位置・ロールの順序といった判断が絡み、バグの温床になりやすい領域です。それが構造ごと消えました。

一方で右側は、預けたことで消えるものではなく、そもそも別の関心事でした。振り切ってみて分かったのはこの点です。「会話履歴を持つ」という一語には、少なくとも 2 つの違う仕事が含まれています。

  • モデルに渡す文脈としての履歴 — これは預けられる。むしろ預けたほうが単純になる
  • 業務データとしての会話の記録 — これは預けられない。読み出せない場所にあるデータは、アプリケーションにとって存在しないのと同じ

この 2 つを分けずに「Responses API が履歴を持ってくれるなら自前のストレージは要らない」と考えると、画面に会話を出したくなった時点で行き詰まります。逆に分けて考えれば、自前に置くべきものは驚くほど小さくなりました。1 ポインタと、endpoint 付きの会話ログだけです。加工済みのプロンプトも、要約済みの履歴も、状態遷移テーブルも要りませんでした。

マネージドに寄せるかどうかを判断するときは、「その機能を預けられるか」ではなく、**「預けた先から読み出せなくなって困るのは誰か」**を先に考えるのがよさそうです。困るのがモデルだけなら預けてよく、アプリケーションが困るなら手元に残す — この記事の設計は、結果的にその線で切れていました。

次回は、このシステムの SSE ストリーミングについて書きます。ReadableStream を返しているのに全チャンクが最後に一括で届く、という Azure Functions v4 (Node.js) 特有の症状と、その原因が 3 段階あった話です。


検証環境は Node.js 22 / Azure Functions v4 / Azure OpenAI api-version: 2025-04-01-preview / gpt-5.4-mini、2026 年 8 月時点のものです。本記事が使っている 2025-04-01-preview は日付付きのプレビュー版 api-version であり、Azure OpenAI は現在、api-version の指定が不要な v1 API を提供しています(Responses API のドキュメントも v1 前提で書かれています)。仕様は変わりえるので、最新の情報は公式ドキュメントで確認してください。

参考リンク

連載「AI エージェント API の本番設計」全 4 回

  1. Azure OpenAI Responses API の会話状態設計 — サーバに預けられるもの、手元に残るもの(この記事)
  2. SSE の done は「出力完了」ではなく「副作用の確定」 — ストリーミング API の完了をどう定義するか
  3. Structured Outputs のスキーマには、書いても効かない制約がある — それでも全部書き、保証はコードに置く
  4. LLM が出力した名前は、マスタと微妙に違う — 表記揺れを前提にした正規化と観測の設計

更新履歴

    • Responses API の会話状態設計の記事を追加