01はじめに
この記事の対象読者
- LLMを使ったアプリケーションを本番運用している、または準備を進めている方
- プロンプトの変更をアドホックに管理していて、どこかで管理が破綻しそうと感じている方
- コードのリリースフローは整備できているが、プロンプトだけが「なんとなく更新」になっている方
前提として、Node.js(v20.x以降)とGitの基本操作を知っていることを想定しています。 TypeScriptのコード例はv5.4以降の構文を使っています。
この記事で得られること
- プロンプトをコードとして扱うべき理由と、その設計思想
- Gitを中心にしたファイル構成とバージョン識別子の埋め込み方
- ステージング環境での検証フロー
- カナリアリリースの考え方と実装の一例
- ロールバックを安全に行う手順
この記事のスコープ
プロンプトの品質評価(回帰テスト)は別のテーマとして切り出しており、この記事では扱いません。 (関連記事: プロンプト回帰テスト:変更で壊れていないかを検知する)
02TL;DR
- プロンプトはテキストファイルとしてGit管理し、アプリコードと同じレビュー・マージフローを通す
- ファイル名やメタデータにセマンティックバージョンを持たせ、実行時にログへ記録する
- ステージング環境でのスモークテストをCI/CDに組み込む
- カナリアリリースはトラフィック振り分けと指標監視を組み合わせて行う
- ロールバックは「古いバージョンをデプロイし直す」だけで済む設計にしておく
03プロンプトを「コード」として扱う理由
設定値という誤解が引き起こす問題
プロンプトを「少し変えるだけだから」とDBのフィールドやJSONファイルに手書きで更新していた時期が、筆者たちにもありました。 その結果として経験したのは、次のような問題でした。
- いつ・誰が・なぜ変えたのかが追跡できない
- 前のバージョンに戻したいが、何が「前」かわからない
- 同じ変更を開発・ステージング・本番の3環境に手作業で反映する必要がある
- 出力品質が落ちたことに気づいても、プロンプトの変更が原因かコードの変更が原因かを切り分けられない
これらはすべて、ソフトウェア開発がコードをGit管理するようになった理由と同じです。 プロンプトは「LLMへの命令文」であり、アプリケーションの振る舞いを決定する論理です。 コードと同等の変更管理が必要です。
プロンプトとコードは一緒に変わる
実際の開発では、プロンプトとコードは切り離せません。 出力フォーマットをJSONに変えるプロンプト修正は、パーサーのコード変更を伴います。 新しい入力パラメータを使うプロンプト変更は、呼び出し側の型定義変更を伴います。
プロンプトとコードが別々のリポジトリや管理ツールにあると、「このバージョンのコードにはどのバージョンのプロンプトが対応するか」を追いかけるだけで疲弊します。 同じGitリポジトリ・同じコミット・同じPRで変更を管理することが、もっとも自然な解決策です。
04ファイル構成とGit管理
ディレクトリ設計の考え方
プロンプトをソースコードとして扱うなら、まずファイル構成から設計します。 以下は一例です。プロジェクトの規模や運用スタイルに応じて調整してください。
prompts/
agents/
planner/
system.v1.0.0.md
system.v1.1.0.md
user.v1.0.0.md
summarizer/
system.v1.0.0.md
shared/
constraints.md
output-format-json.md
index.ts
agents/配下はエージェントまたは機能単位でディレクトリを分けますshared/には複数のプロンプトで使い回す共通部品を置きますindex.tsはプロンプトを読み込んで型付きで返すエントリーポイントです
バージョン識別子をファイル名に含める
ファイル名に v{major}.{minor}.{patch} を含めるのがシンプルで効果的です。
system.v1.0.0.md # 初版
system.v1.1.0.md # 後方互換な機能追加
system.v2.0.0.md # 破壊的変更(出力フォーマット変更など)
バージョニングのルールはセマンティックバージョニング(SemVer)の考え方を借用しています。
| 変更の種類 | 上げるべき番号 | 例 |
|---|---|---|
| バグ修正・言い回しの微調整 | patch | v1.0.0 → v1.0.1 |
| 後方互換な機能追加・指示の追加 | minor | v1.0.0 → v1.1.0 |
| 出力フォーマット変更・引数変更 | major | v1.0.0 → v2.0.0 |
ただし「後方互換」の定義はLLMの確率的な性質上、コードほど厳密ではありません。 minor変更でも出力の傾向が変わることはあるため、実際の判断はチームの運用方針に委ねてください。
プロンプトファイルにメタデータを持たせる
Markdownのフロントマターを使うと、プロンプト本体と一緒にメタデータを管理できます。
---
version: "1.1.0"
description: "事業計画の要点を3つに絞って要約するエージェント用システムプロンプト"
model: "claude-sonnet-4-6"
author: "yourname"
updated: "2026-06-01"
breaking: false
---
あなたは事業計画の要約を専門とするアシスタントです。
以下のルールに従って要約してください。
...
フロントマターはランタイムで読み取れるため、どのバージョンのプロンプトが実行されたかをログに残せます。
.gitignore と運用ルール
秘匿情報(APIキー・内部向けペルソナ設定)はプロンプトファイルに含めません。 環境変数や別の秘密管理サービスで分離してください。
# .gitignore(プロンプト関連の除外例)
# 内部向けの機密プロンプト
prompts/internal/
prompts/**/*.secret.md
05プロンプトローダーの実装
型付きでプロンプトを扱う
プロンプトをコードから読み込む部分を型付きで実装すると、バージョン不一致を実行時に検出できます。
// prompts/types.ts
export interface PromptMeta {
version: string;
description: string;
model: string;
author: string;
updated: string;
breaking: boolean;
}
export interface PromptContent {
meta: PromptMeta;
body: string;
}
// prompts/loader.ts
import { readFileSync } from "fs";
import { join } from "path";
import matter from "gray-matter"; // gray-matter@4.0.3
import type { PromptContent, PromptMeta } from "./types.js";
const PROMPTS_DIR = join(process.cwd(), "prompts");
export function loadPrompt(
agentName: string,
role: "system" | "user",
version: string
): PromptContent {
const filename = `${role}.v${version}.md`;
const filepath = join(PROMPTS_DIR, "agents", agentName, filename);
const raw = readFileSync(filepath, "utf-8");
const parsed = matter(raw);
const meta = parsed.data as PromptMeta;
const body = parsed.content.trim();
if (!meta.version) {
throw new Error(
`[loadPrompt] version field is missing in ${filepath}`
);
}
return { meta, body };
}
呼び出し側は次のようになります。
// src/agents/planner.ts
import { loadPrompt } from "../../prompts/loader.js";
import Anthropic from "@anthropic-ai/sdk"; // @anthropic-ai/sdk@0.27.0
const PLANNER_VERSION = "1.1.0";
const client = new Anthropic();
export async function runPlanner(userInput: string): Promise<string> {
const systemPrompt = loadPrompt("planner", "system", PLANNER_VERSION);
const response = await client.messages.create({
model: systemPrompt.meta.model,
max_tokens: 1024,
system: systemPrompt.body,
messages: [{ role: "user", content: userInput }],
});
const content = response.content[0];
if (content.type !== "text") {
throw new Error("Unexpected response type");
}
return content.text;
}
PLANNER_VERSION を定数として宣言しているため、バージョンを変える際はこの1行だけ変更します。
コードレビューで変更箇所が一目でわかります。
実行ログにバージョンを記録する
どのバージョンのプロンプトで何が起きたかを追跡するために、ログにバージョン情報を含めます。
// src/lib/prompt-logger.ts
import type { PromptContent } from "../../prompts/types.js";
export interface PromptExecutionLog {
traceId: string;
agentName: string;
promptVersion: string;
model: string;
inputLength: number;
outputLength: number;
durationMs: number;
timestamp: string;
}
export function buildPromptLog(
traceId: string,
agentName: string,
prompt: PromptContent,
inputLength: number,
outputLength: number,
durationMs: number
): PromptExecutionLog {
return {
traceId,
agentName,
promptVersion: prompt.meta.version,
model: prompt.meta.model,
inputLength,
outputLength,
durationMs,
timestamp: new Date().toISOString(),
};
}
実行ログの出力例は次のようになります。
{
"traceId": "req_01J3X8Y...",
"agentName": "planner",
"promptVersion": "1.1.0",
"model": "claude-sonnet-4-6",
"inputLength": 342,
"outputLength": 587,
"durationMs": 1824,
"timestamp": "2026-06-11T10:23:45.123Z"
}
promptVersion フィールドがあることで、障害発生時に「どのバージョンで起きたか」を即座に特定できます。
06Gitを使ったレビューフロー
ブランチ戦略
プロンプトの変更はコードの変更と同じブランチ・PRで管理します。 特別な扱いは不要で、通常の開発フローに乗せるだけです。
main
└── feature/planner-system-v1.1.0
├── prompts/agents/planner/system.v1.1.0.md (新規追加)
└── src/agents/planner.ts (PLANNER_VERSION の更新)
旧バージョンのファイル(system.v1.0.0.md)はリポジトリに残しておきます。
削除してしまうと、ロールバック時に困ります。
PRレビューで確認すること
プロンプトのPRをレビューする際、筆者たちが特に意識しているのは次の点です。
- バージョン番号が適切に上がっているか(変更の規模に対してpatch/minor/majorが合っているか)
- フロントマターの
breakingフラグが正しく設定されているか - 対応するコード側の
VERSION定数も同時に更新されているか - 旧バージョンのファイルが誤って削除されていないか
これらをPRテンプレートのチェックリストに含めておくと、レビュー漏れを防げます。
07ステージング環境での検証
ステージング環境の役割
本番へのリリース前に、ステージング環境でプロンプトを検証します。 ステージングでの検証フローは大きく2段階です。
flowchart TD
A["PRマージ"] --> B["ステージングへ自動デプロイ"]
B --> C["スモークテスト(自動)\n代表的な入力5〜10パターンで実行し、\n応答が返ること・形式エラーがないことを確認"]
B --> D["回帰テスト(自動)※別記事参照"]
B --> E["人手確認(任意)\n特にmajorバージョンアップや新機能追加では目視確認を推奨"]
スモークテストの実装
CI/CDパイプラインで実行するスモークテストの一例です。
// tests/smoke/planner-smoke.test.ts
import { describe, it, expect } from "vitest"; // vitest@2.1.0
import { runPlanner } from "../../src/agents/planner.js";
const SMOKE_INPUTS = [
"新しいSaaSプロダクトの事業計画を3点で要約してください。",
"既存事業の拡張戦略について、重要な論点を整理してください。",
"市場参入にあたってのリスク要因を列挙してください。",
];
describe("Planner agent smoke test", () => {
it.each(SMOKE_INPUTS)(
"should return non-empty string for input: %s",
async (input) => {
const result = await runPlanner(input);
// 空文字でないこと
expect(result.length).toBeGreaterThan(0);
// 極端に短すぎないこと(目安: 50文字以上)
expect(result.length).toBeGreaterThan(50);
},
30_000 // LLM呼び出しのタイムアウトは余裕を持って設定
);
});
スモークテストが通ることは「最低限の動作保証」であり、品質の保証ではありません。 あくまでデプロイの入口チェックとして位置づけています。
CI/CDへの組み込み
GitHub Actionsを使った例です。
# .github/workflows/staging-deploy.yml
name: Staging Deploy
on:
push:
branches:
- main
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to staging
run: npm run deploy:staging
env:
ANTHROPIC_API_KEY: ${{ secrets.STAGING_ANTHROPIC_API_KEY }}
- name: Run smoke tests
run: npm run test:smoke
env:
ANTHROPIC_API_KEY: ${{ secrets.STAGING_ANTHROPIC_API_KEY }}
NODE_ENV: staging
- name: Notify result
if: failure()
run: echo "Smoke test failed. Blocking production release."
スモークテストが失敗した場合は、後続の本番デプロイジョブが実行されないようにします。
08カナリアリリース
なぜカナリアリリースが有効か
プロンプトの変更は、一見問題なさそうでも実際の多様な入力に対して予期しない振る舞いをすることがあります。 すべてのトラフィックに一度に新バージョンを当てるのはリスクが高く、特にmajor変更では慎重さが求められます。
カナリアリリースは、全体の一部(たとえば5%)のリクエストだけ新バージョンのプロンプトを使い、指標を見ながら段階的にロールアウトする手法です。
バージョン振り分けの実装
// src/lib/prompt-router.ts
export interface VersionWeight {
version: string;
weight: number; // 0〜1の小数。全バージョンの合計が1になるように設定
}
/**
* 重みに基づいてバージョンを確率的に選択する。
* 同じrequestIdに対しては常に同じバージョンを返したい場合は
* ハッシュベースの決定論的振り分けを使うこと。
*/
export function selectPromptVersion(
weights: VersionWeight[],
requestId?: string
): string {
if (requestId) {
// リクエストIDのハッシュで決定論的に振り分ける
return selectByHash(weights, requestId);
}
// ランダム振り分け
const rand = Math.random();
let cumulative = 0;
for (const { version, weight } of weights) {
cumulative += weight;
if (rand < cumulative) {
return version;
}
}
// フォールバック(浮動小数点の誤差対策)
return weights[weights.length - 1].version;
}
function selectByHash(weights: VersionWeight[], seed: string): string {
// 簡易ハッシュ(本番ではmurmurHash等のより均一な実装を検討してください)
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
const normalized = (hash % 10000) / 10000; // 0〜1に正規化
let cumulative = 0;
for (const { version, weight } of weights) {
cumulative += weight;
if (normalized < cumulative) {
return version;
}
}
return weights[weights.length - 1].version;
}
呼び出し側での使い方は次のとおりです。
// src/agents/planner.ts(カナリア対応版)
import { loadPrompt } from "../../prompts/loader.js";
import { selectPromptVersion } from "../lib/prompt-router.js";
import type { VersionWeight } from "../lib/prompt-router.js";
import Anthropic from "@anthropic-ai/sdk";
// カナリア中: v1.0.0 が95%、v1.1.0 が5%
const PLANNER_VERSION_WEIGHTS: VersionWeight[] = [
{ version: "1.0.0", weight: 0.95 },
{ version: "1.1.0", weight: 0.05 },
];
const client = new Anthropic();
export async function runPlanner(
userInput: string,
requestId?: string
): Promise<{ result: string; promptVersion: string }> {
const version = selectPromptVersion(PLANNER_VERSION_WEIGHTS, requestId);
const systemPrompt = loadPrompt("planner", "system", version);
const response = await client.messages.create({
model: systemPrompt.meta.model,
max_tokens: 1024,
system: systemPrompt.body,
messages: [{ role: "user", content: userInput }],
});
const content = response.content[0];
if (content.type !== "text") {
throw new Error("Unexpected response type");
}
return {
result: content.text,
promptVersion: version,
};
}
監視すべき指標
カナリアリリース中は、バージョン別の指標を比較します。 以下は一般的な観測ポイントです。
| 指標 | 観測内容 |
|---|---|
| エラー率 | 形式エラー・例外発生率がv1.0.0比で有意に上がっていないか |
| レイテンシ | 応答時間の中央値・p95がv1.0.0比で許容範囲内か |
| 出力長 | 極端に短い・長い出力が増えていないか |
| ユーザーアクション | 再試行・フィードバック否定などの間接指標 |
指標の判断基準は事前に決めておくことが重要です。 「なんとなく問題なさそう」でロールアウトを進めると、後で判断の根拠を問われたときに答えられなくなります。
ロールアウトのステップ例
5% → 24時間監視 → 問題なし → 20% → 24時間監視 → 問題なし → 100%
問題が見つかった場合は即座に0%(ロールバック)に戻します。 段階的なロールアウト中に問題を検出できるのがカナリアリリースの利点です。
09ロールバック手順
設計思想:ロールバックを「退屈な作業」にする
ロールバックは緊急事態での作業です。 そのときに「どのバージョンに戻せばいいか」「どうやって戻すか」を考え始めると、対応が遅れます。 平時から「旧バージョンのファイルをGitに残す」「バージョン定数を変えるだけで切り戻せる」設計にしておくことで、ロールバックを退屈な作業に格下げできます。
コード変更によるロールバック
最もシンプルな方法は、バージョン定数を旧バージョンに戻すPRを出してマージすることです。
// src/agents/planner.ts
- const PLANNER_VERSION = "1.1.0";
+ const PLANNER_VERSION = "1.0.0";
この変更を含むPRをマージすれば、デプロイパイプラインが自動で旧バージョンに切り戻します。 「コードのデプロイ」と「プロンプトのロールバック」が同じフローで完結するのがポイントです。
環境変数による即時切り戻し
障害対応中にGitオペレーションの時間的余裕がない場合、環境変数でバージョンを上書きできる設計にしておくと便利です。
// prompts/loader.ts(環境変数オーバーライド対応版)
export function loadPrompt(
agentName: string,
role: "system" | "user",
version: string
): PromptContent {
// 環境変数でバージョンを強制上書きできる
// 例: PROMPT_OVERRIDE_PLANNER_SYSTEM=1.0.0
const envKey = `PROMPT_OVERRIDE_${agentName.toUpperCase()}_${role.toUpperCase()}`;
const overrideVersion = process.env[envKey];
const resolvedVersion = overrideVersion ?? version;
if (overrideVersion) {
console.warn(
`[loadPrompt] Version override active: ${agentName}/${role} → v${overrideVersion}`
);
}
const filename = `${role}.v${resolvedVersion}.md`;
const filepath = join(PROMPTS_DIR, "agents", agentName, filename);
const raw = readFileSync(filepath, "utf-8");
const parsed = matter(raw);
return {
meta: parsed.data as PromptMeta,
body: parsed.content.trim(),
};
}
環境変数 PROMPT_OVERRIDE_PLANNER_SYSTEM=1.0.0 を設定してプロセスを再起動するだけで、コードを変えずに旧バージョンに切り戻せます。
ただしこの方法はあくまで緊急措置です。 環境変数のオーバーライドが残ったままになると「どのバージョンが実際に動いているか」がコードを見てもわからなくなります。 緊急切り戻し後は必ずコードでの正式なロールバックPRを出し、環境変数を削除してください。
ロールバック後の記録
ロールバックを行った際は、次の情報を残しておきます。
## ロールバック記録 - 2026-06-11
- エージェント: planner
- 旧バージョン(問題あり): v1.1.0
- 現バージョン(切り戻し先): v1.0.0
- 切り戻し方法: コード変更 + デプロイ
- 発生した問題: 出力にJSON形式エラーが約3%の頻度で発生
- 影響範囲: 本番リクエストのうち5%(カナリア対象分のみ)
- 対応完了時刻: 2026-06-11 15:30 JST
- 根本原因: 調査中
この記録はポストモーテムの素材にもなります。 「同じ種類の問題」が繰り返されているかどうかを確認するためにも有用です。
10ここまでの全体像
プロンプトのバージョン管理とリリースフロー全体を図で整理します。
flowchart TD
Dev["開発者"] --> F1["新しいプロンプトファイルを作成\nprompts/agents/planner/system.v1.1.0.md"]
Dev --> F2["コードのバージョン定数を更新\nPLANNER_VERSION = \"1.1.0\""]
F1 --> PR["PR作成 → レビュー → マージ"]
F2 --> PR
PR --> CI["CI/CDパイプライン"]
CI --> Build["ビルド"]
CI --> StageDeploy["ステージングデプロイ"]
CI --> Smoke["スモークテスト"]
Smoke --> SmokeOK["OK → 本番デプロイへ"]
Smoke --> SmokeNG["NG → Slack通知・デプロイ中止"]
SmokeOK --> ProdDeploy["本番デプロイ"]
ProdDeploy --> Canary["カナリア(5%)\n監視 24h"]
Canary --> CanaryOK["OK → ロールアウト拡大"]
Canary --> CanaryNG["NG → ロールバック"]
CanaryOK --> Full["100% ロールアウト完了"]
11実運用で感じた注意点
プロンプトのドリフトに気をつける
バージョン管理が整っても「誰かがDBのプロンプトフィールドを直接書き換えた」という事態が起きると、Gitの管理から外れます。 プロンプトの編集経路をGitのみに一本化するルールを作り、チームで共有することが大切です。
「緊急だったので直接DBを変えました」という状況は起きやすいです。 緊急時の手順(環境変数オーバーライドなど)を事前に整備しておくと、DBを直接触る動機をなくせます。
バージョンファイルが増えすぎる問題
時間が経つとバージョンファイルがディレクトリに積み重なります。 古すぎるバージョン(1年以上使われていない、ロールバック先としても非現実的なもの)はアーカイブブランチや別ディレクトリに移すなど、整理の基準を決めておくと読みやすさが保てます。
ただし削除は慎重に行ってください。 GitのHEADには残っていても、旧バージョンのプロセスがまだ動いていて参照されている可能性があります。
非エンジニアがプロンプトを編集する場合
プロダクトマネージャーやドメイン専門家がプロンプトを書く体制では、Gitのフローがハードルになる場合があります。 その場合は次の選択肢があります。
- プロンプト編集専用のCMS・管理画面を用意し、保存時にGitコミットを自動生成する
- プロンプトの草案作成はGit外で行い、レビュー・マージはエンジニアが担当する
- GitHubのWeb UIのみで完結するフローに限定し、ローカル環境を不要にする
いずれの場合も「最終的にGitが正」という原則は変えないことをおすすめします。
12まとめ
プロンプトを「設定値」ではなく「コード」として扱うことで、変更履歴の追跡・ロールバック・段階的なリリースがすべて既存のGitフローで実現できます。
この記事で紹介した内容を整理します。
- ファイル構成: セマンティックバージョンをファイル名に含め、フロントマターにメタデータを持たせる
- 型付きローダー: TypeScriptで型付きのプロンプト読み込みを実装し、バージョン不一致を実行時に検出する
- 実行ログ:
promptVersionフィールドをログに含め、障害時のバージョン特定を容易にする - スモークテスト: CI/CDパイプラインに組み込み、デプロイの入口チェックとして使う
- カナリアリリース: 重みベースの振り分けで段階的にロールアウトし、指標を監視する
- ロールバック: バージョン定数を戻すだけで完結する設計にしておく
すべてを一度に整備する必要はありません。 まず「プロンプトをGit管理する」ことだけを始め、チームに合わせて少しずつ仕組みを足していくのが無理のない進め方だと感じています。

