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?

pmpt_ ID終了に備える音声AI設計:プロンプトを自前管理し、会話中の差し替えを防ぐ

0
Last updated at Posted at 2026-10-26

OpenAIのプロンプトオブジェクト終了予定を見て、pmpt_... の本文をコピーすれば移行できる、と考えたくなります。

しかし、リアルタイム音声AIでは別の緊張があります。

  • 古いプロンプトを残すと、廃止後に呼び出せなくなる
  • 急いで差し替えると、会話の途中で人格や応答方針が変わる
  • 新版に問題があっても、どのセッションへ反映されたか分からない

問題はプロンプトの保存場所だけではありません。誰が変更を承認し、どの会話がどの版を使い、問題時にどこまで戻すかを決める必要があります。

本稿では、プロバイダー固有のプロンプトIDを実行時依存から外し、音声AIのセッション単位でプロンプトを固定する最小実装を作ります。

終了日や移行条件は変更される可能性があるため、実際の移行計画ではOpenAIの公式な廃止予定も確認してください。本稿の主題は、特定の日付に依存しないプロンプト運用設計です。

結論:pmpt_ は移行台帳に残し、実行キーにはしない

次の4点を分けます。

  1. プロンプト本文:アプリケーション側で不変バージョンとして保存する
  2. 本番エイリアス:新しく開始するセッションが使う版を示す
  3. セッション固定情報:開始時のバージョンとハッシュを保持する
  4. 旧ID:pmpt_... は移行元を追跡するメタデータとしてだけ残す

本番エイリアスを更新しても、進行中の音声セッションは旧版を使い続けます。ロールバックした場合も、原則として次のセッションから反映します。

緊急の安全修正だけは例外です。失効対象のバージョンを明示的に停止し、利用者へ会話の再開始を案内します。通常更新と緊急停止を同じ仕組みにしないことが重要です。

前提:音声経路とプロンプト管理を分離する

全体を次の責任境界に分けます。

利用者の音声
  ↓
RTC / メディア転送
  ↓
音声認識
  ↓
会話オーケストレーター
  ├─ プロンプト版の解決
  ├─ セッション固定
  ├─ 割り込み・失効判定
  └─ request IDの発行
  ↓
LLM
  ↓
音声合成
  ↓
RTC / 再生

Tencent Conversational AIは、リアルタイム音声対話と複数のLLMプロバイダーを組み合わせるシナリオを扱います。構成の全体像は公式概要を参照してください。

LLM設定ではOpenAI互換モデルやエージェントプラットフォームとの接続、リクエスト識別子を使ったルーティング・観測について案内されています。

本稿のレジストリはTencent RTCの代替ではなく、音声経路からLLMへ渡すプロンプトをアプリケーション側で統制する層です。AIコンパニオンやキャラクター対話という利用形態については、Social Entertainmentの公式ページも参考になります。

先に決める:どの管理方式を選ぶか

方式 利点 主な問題 適する段階
プロバイダーのIDを直接参照 実装が短い 廃止・障害・移行に実行系が引きずられる 短期検証
Git内のファイルを直接読む 差分をレビューしやすい 配布とロールバックの仕組みが別途必要 小規模運用
自前レジストリ+不変バージョン セッション固定と監査がしやすい 保存、承認、キャッシュの運用コストが増える 継続運用

重要なのは「自前システムなら安全」という話ではありません。自前管理にすると、可用性、アクセス制御、バックアップ、変更承認を自分たちで引き受けます。

手順1:プロンプトを不変データとして定義する

今回はNode.js 20以降だけで動く検証コードを作ります。

mkdir voice-prompt-rollout
cd voice-prompt-rollout
cat > package.json <<'JSON'
{
  "type": "module",
  "scripts": {
    "test": "node prompt-rollout.mjs"
  }
}
JSON

プロンプトのデータモデルは次のようにします。

{
  key: "voice-companion",
  version: "2026-10-26.1",
  system: "...",
  recovery: "...",
  legacyRefs: ["pmpt_xxx"],
  approvedBy: "reviewer@example.invalid"
}

legacyRefs は監査・移行確認用です。実行時にこの値を使って外部プロンプトを取得しません。

コード:セッション固定、承認、割り込みを一つの契約にする

prompt-rollout.mjs を作成します。

import assert from "node:assert/strict";
import { createHash } from "node:crypto";

function bundleHash(bundle) {
  const executable = {
    key: bundle.key,
    version: bundle.version,
    system: bundle.system,
    recovery: bundle.recovery
  };

  return createHash("sha256")
    .update(JSON.stringify(executable))
    .digest("hex");
}

class PromptRegistry {
  constructor() {
    this.bundles = new Map();
    this.releases = new Map();
    this.aliases = new Map();
    this.sessions = new Map();
  }

  bundleId(key, version) {
    return `${key}@${version}`;
  }

  publish(bundle) {
    if (!bundle.approvedBy) {
      throw new Error("承認者がないプロンプトは公開できません");
    }

    const id = this.bundleId(bundle.key, bundle.version);
    if (this.bundles.has(id)) {
      throw new Error(`不変バージョンを上書きできません: ${id}`);
    }

    const stored = structuredClone(bundle);
    const hash = bundleHash(stored);

    this.bundles.set(id, stored);
    this.releases.set(id, {
      hash,
      approvedBy: bundle.approvedBy,
      publishedAt: new Date().toISOString()
    });

    return { id, hash };
  }

  activate(alias, key, version) {
    const id = this.bundleId(key, version);
    if (!this.releases.has(id)) {
      throw new Error(`未公開のバージョンは有効化できません: ${id}`);
    }
    this.aliases.set(alias, { key, version });
  }

  startSession(sessionId, alias = "production") {
    if (this.sessions.has(sessionId)) {
      throw new Error(`セッションIDが重複しています: ${sessionId}`);
    }

    const active = this.aliases.get(alias);
    if (!active) throw new Error(`エイリアスがありません: ${alias}`);

    const id = this.bundleId(active.key, active.version);
    const release = this.releases.get(id);

    const session = {
      sessionId,
      promptKey: active.key,
      promptVersion: active.version,
      promptHash: release.hash,
      generation: 0
    };

    this.sessions.set(sessionId, session);
    return structuredClone(session);
  }

  resolvePinned(sessionId) {
    const session = this.sessions.get(sessionId);
    if (!session) throw new Error(`未知のセッションです: ${sessionId}`);

    const id = this.bundleId(
      session.promptKey,
      session.promptVersion
    );
    const bundle = this.bundles.get(id);
    if (!bundle) throw new Error(`固定済みプロンプトがありません: ${id}`);

    if (bundleHash(bundle) !== session.promptHash) {
      throw new Error(`プロンプトの改変を検出しました: ${id}`);
    }

    return { session, bundle };
  }

  buildLlmRequest(sessionId, turnId, userText) {
    const { session, bundle } = this.resolvePinned(sessionId);

    return {
      requestId: `${sessionId}:${turnId}:${session.generation}`,
      generation: session.generation,
      promptVersion: session.promptVersion,
      messages: [
        { role: "system", content: bundle.system },
        { role: "user", content: userText }
      ]
    };
  }

  interrupt(sessionId) {
    const session = this.sessions.get(sessionId);
    if (!session) throw new Error(`未知のセッションです: ${sessionId}`);
    session.generation += 1;
  }

  canPlay(sessionId, llmResponse) {
    const session = this.sessions.get(sessionId);
    return Boolean(
      session && session.generation === llmResponse.generation
    );
  }

  planOrRecover(sessionId, turnId, userText) {
    try {
      return {
        kind: "llm",
        request: this.buildLlmRequest(sessionId, turnId, userText)
      };
    } catch (error) {
      return {
        kind: "local_recovery",
        text: "会話を続けられませんでした。もう一度話すか、終了してください。",
        reason: error.message
      };
    }
  }
}

const registry = new PromptRegistry();

registry.publish({
  key: "voice-companion",
  version: "v1",
  system: "短く応答し、不明な場合は確認する。操作を実行したとは言わない。",
  recovery: "不明点を一つだけ質問する。",
  legacyRefs: ["pmpt_example_old"],
  approvedBy: "reviewer-a@example.invalid"
});
registry.activate("production", "voice-companion", "v1");

// セッションAはv1へ固定される
const sessionA = registry.startSession("session-a");
assert.equal(sessionA.promptVersion, "v1");

// v2を公開して本番エイリアスを切り替える
registry.publish({
  key: "voice-companion",
  version: "v2",
  system: "一文ずつ応答し、曖昧な依頼は実行せず確認する。",
  recovery: "聞き取れなかった箇所だけを質問する。",
  legacyRefs: ["pmpt_example_old"],
  approvedBy: "reviewer-b@example.invalid"
});
registry.activate("production", "voice-companion", "v2");

// 既存セッションAはv1、新規セッションBはv2
const requestA = registry.buildLlmRequest("session-a", "turn-1", "続けて");
const sessionB = registry.startSession("session-b");
const requestB = registry.buildLlmRequest("session-b", "turn-1", "こんにちは");

assert.equal(requestA.promptVersion, "v1");
assert.equal(requestB.promptVersion, "v2");

// LLM処理中に利用者が割り込んだ場合、古い応答を再生しない
const oldRequest = registry.buildLlmRequest("session-b", "turn-2", "予定を教えて");
registry.interrupt("session-b");
assert.equal(
  registry.canPlay("session-b", { generation: oldRequest.generation }),
  false
);

// 保存済み本文が上書きされた事故を再現する
const tampered = registry.bundles.get("voice-companion@v2");
tampered.system = "承認されていない変更";

const recovery = registry.planOrRecover(
  "session-b",
  "turn-3",
  "もう一度話して"
);
assert.equal(recovery.kind, "local_recovery");

console.log("all checks passed");

実行します。

npm test

次の表示になれば、最小の受入条件を満たしています。

all checks passed

手順2:リアルタイム音声経路へ接続する

実環境では、上記のMapを永続ストアへ置き換えます。ただし、LLMやRTCのSDK固有オブジェクトをレジストリへ直接保存しない方が移行しやすくなります。

接続点は次のように限定します。

音声認識の確定結果
  ↓
buildLlmRequest(sessionId, turnId, text)
  ↓
LLM設定層へ messages と requestId を渡す
  ↓
LLM応答に generation を対応付ける
  ↓
canPlay(sessionId, response) が true の場合だけTTSへ渡す

requestIdはプロンプト本文に埋め込まず、ルーティングや観測のためのメタデータとして扱います。Tencent RTC側の具体的なLLM接続設定は、利用中の構成に合わせて公式のLLM設定ドキュメントを確認してください。

この分離により、OpenAI互換の接続先を変更しても、セッション固定と承認履歴はアプリケーション側に残ります。

手順3:更新とロールバックの運用を固定する

プロンプト更新は次の順序にします。

  1. 新しい不変バージョンを作る
  2. 人が差分、禁止事項、復旧文を確認する
  3. 検証環境で音声認識結果を入力として再生する
  4. 承認者付きで公開する
  5. productionエイリアスを変更する
  6. 新規セッションだけで利用を開始する
  7. 問題があればエイリアスを旧版へ戻す

AIは文案作成や差分整理には使えますが、本番へ出すかどうかは決められません。特に次は人が判断する項目です。

  • キャラクターの人格や距離感
  • 医療、金融、安全に関する回答範囲
  • 利用者へ確認せず実行してよい操作
  • 有人対応へ切り替える条件
  • 緊急時に進行中セッションを停止する条件

「モデルが自然に話せた」ことと、「運用上の責任境界が守られた」ことは別です。

確認方法:本文の正しさより、版の境界を壊して試す

リリース前に最低限、次を確認します。

  • 本番エイリアス変更後も、既存セッションの版が変わらない
  • 新規セッションだけが新版を使う
  • 同じkey + versionを上書きできない
  • 保存済み本文の改変をハッシュ不一致として検出できる
  • 割り込み前のLLM応答がTTSへ渡らない
  • レジストリ障害時に固定文で終了・再試行を案内できる
  • pmpt_ IDを削除しても実行経路が成立する
  • request IDからセッション、ターン、世代を追跡できる
  • AIであること、停止方法、音声利用への同意が画面上で分かる

加えて、LLM、音声認識、音声合成、RTCを一括で「遅い」と記録しないでください。それぞれの開始・終了時刻を分けて計測し、どの区間で待ち時間が生じたか確認します。

注意点

1. セッション固定は安全修正を遅らせる

通常更新では一貫性を守れますが、危険な指示を含む版まで維持してはいけません。失効リストを別に持ち、該当セッションを停止して再開始を案内する経路が必要です。

2. プロンプトのバージョン管理だけでは権限を制御できない

「外部送信しない」とプロンプトへ書いても、LLMに送信ツールの権限があれば誤実行の可能性は残ります。ツール権限、引数検証、利用者の確認はコード側で制御します。

3. ハッシュは秘密化ではない

ハッシュは改変検知に使えますが、プロンプト本文や会話内容を保護しません。保存時のアクセス制御、暗号化、ログのマスキング、保持期限は別途必要です。

4. 毎ターン遠隔レジストリを引かない

音声会話のたびに遠隔ストアへ問い合わせると、その待ち時間が応答開始へ加算されます。セッション開始時に版とハッシュを固定し、本文は検証済みキャッシュから取得する構成を検討します。キャッシュ障害時の動作も決めておきます。

5. プロバイダーを変えても出力は完全には固定できない

同じプロンプトでも、モデルや設定、提供側の更新によって結果は変わり得ます。プロンプトの固定は再現性を改善しますが、出力の同一性を保証するものではありません。重要な禁止事項は出力後の検証でも守ります。

まとめ

pmpt_ IDの移行で本当に残すべきなのは、IDそのものではなく、どの内容を誰が承認し、どの会話で使ったかを再現できる契約です。

リアルタイム音声AIでは、単に最新版へ切り替えるよりも、次の設計が効きます。

  • プロンプトを不変バージョンとして保存する
  • 新規セッションと既存セッションを分ける
  • 割り込み後の古い応答を再生しない
  • 障害時はLLMを再試行し続けず、固定の復旧経路へ移る
  • 通常ロールバックと緊急失効を分離する

これにより、プロバイダー側のプロンプト機能が変わっても、利用者との会話まで同時に不安定になることを避けやすくなります。


関係性の開示: 筆者はTencent RTCのコンテンツ制作に関与しています。本稿では実装上の確認資料としてTencent RTCの公式ドキュメントを参照しました。

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?