四つのサイトのプロンプトを、同じ晩にまとめて書き換えたことがありました。
意図は良かったのです。導入部の言い回しを揃え、口調をもう一段ていねいにする。手元で二、三本試すと、確かに読みやすくなっていました。そのまま全サイトの生成タスクへ反映し、私は眠りました。
翌朝、四つのログが揃って同じ表情で失敗していました。品質ゲートの一つが、変更後のプロンプトが生む微妙な構造の崩れを弾いていたのです。手元の数本では出なかった問題が、夜間の実データではきれいに再現していました。全サイトを一度に変えたので、無事だった対照群がどこにもありません。「本当に新しい書き方が原因なのか」を切り分ける材料が、自分の手で消されていました。
無人で走るものほど、変更は一気に当ててはいけない。この記事は、その教訓を設計に落とし込んだ記録です。
なぜ「全適用」が無人運用で特に危ないのか
対話しながら使うツールなら、出力が変なら人がその場で気づいて止められます。無人のエージェントにはその目がありません。深夜二時に走る処理の劣化は、翌朝ログを開くまで誰も見ていないのです。
しかも失敗は必ずしもエラーで現れません。例外は投げず、生成は完了し、けれど記事の密度が落ちている。そういう「静かな劣化」は、機械的なゲートをすり抜けた瞬間に本番へ流れます。
私は四つの技術サイトと、AdMob で収益を得ているいくつかのアプリを、個人開発で回しています。どれも夜間に無人のジョブが走るため、この「見ていない時間帯の劣化」は、私自身が何度も踏んできた地面です。
だからこそ、変更を全実行へ即時反映するのではなく、一部だけに新しい挙動を割り当てて、残りを対照群として温存するという発想が要ります。ソフトウェアのカナリアリリースを、プロンプトやエージェントの挙動という「振る舞い」に対して適用する、という言い方が近いかもしれません。
最小のフラグ定義
まず、挙動の分岐を一枚の設定ファイルに集約します。コードの中に if (newBehavior) を散らすと、いま何が何%で走っているのかが誰にも分からなくなります。正本は一つに保ちます。
| フィールド | 型 | 役割 |
key | string | 分岐の識別子。コードから参照する名前 |
enabled | boolean | この分岐を評価するかどうかの大元のスイッチ |
rollout | 0–100 | 新挙動を割り当てる実行の割合 |
killed | boolean | 緊急停止。true なら rollout に関わらず旧挙動へ固定 |
scope | string[] | 対象を絞る軸(サイト名など)。空なら全体 |
owner | string | 誰が入れたか。棚卸しの手がかり |
createdAt | ISO date | いつ入れたか。寿命管理に使う |
// flags.json — 挙動分岐の正本
{
"gentler-intro-phrasing": {
"enabled": true,
"rollout": 25,
"killed": false,
"scope": ["claudelab"],
"owner": "masaki",
"createdAt": "2026-07-08"
}
}
rollout: 25 かつ scope: ["claudelab"] は、「まず一サイトの、さらにその四分の一の実行にだけ新しい言い回しを試す」という意味になります。四サイトを一晩で全部変えた昨日の自分とは、ここが決定的に違います。
決定論的にバケットを割り当てる
rollout を単純な乱数で判定すると、同じ記事の再生成が走るたびに新旧を行き来してしまい、観測が濁ります。同じ処理は常に同じ群へ入ることが、比較を成立させる前提です。
そこで、実行を一意に識別するキー(サイト名とスラッグ、あるいはジョブID)をハッシュし、0–99 のバケットへ写します。フラグごとに key を混ぜるのは、複数のフラグが同じ実行群にばかり当たって偏るのを防ぐためです。
import { createHash } from "node:crypto";
// 実行キー + フラグキー から 0..99 の安定したバケットを得る
function bucketOf(runKey: string, flagKey: string): number {
const h = createHash("sha256").update(`${flagKey}:${runKey}`).digest();
// 先頭2バイトを 0..99 へ写像(分布は十分に一様)
return ((h[0] << 8) | h[1]) % 100;
}
type Flag = {
enabled: boolean;
rollout: number;
killed: boolean;
scope?: string[];
owner?: string;
createdAt?: string;
};
// 新挙動を割り当てるべきか。副作用なし・完全に決定論的
export function isCanary(
flag: Flag | undefined,
runKey: string,
flagKey: string,
siteScope: string,
): boolean {
if (!flag || !flag.enabled) return false; // 未定義・無効は旧挙動
if (flag.killed) return false; // 緊急停止が最優先
if (flag.scope?.length && !flag.scope.includes(siteScope)) return false;
return bucketOf(runKey, flagKey) < flag.rollout; // 25 なら 0..24 が canary
}
killed を rollout より先に評価している点が肝心です。夜間に問題が起きたとき、rollout の数字を下げる操作と、一発で旧挙動へ戻す操作は、性質が違います。前者は「割合の調整」、後者は「無条件の退避」。緊急時に迷わないよう、退避スイッチは他のどの条件より前に置きます。
挙動を出し分ける
判定ができれば、分岐そのものは薄く保てます。プロンプトの変数を差し替えるだけで、下流の生成ロジックは共通のままにします。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const INTRO_CONTROL =
"記事は、読者が直面している具体的な課題の描写から書き始めてください。";
const INTRO_CANARY =
"記事は、著者自身がその課題に出会った瞬間の場面から、静かな一人称で書き始めてください。";
export async function generateArticle(opts: {
runKey: string; // 例: "claudelab/agent-feature-flag-rollout"
site: string; // 例: "claudelab"
flags: Record<string, Flag>;
topic: string;
}) {
const useCanary = isCanary(
opts.flags["gentler-intro-phrasing"],
opts.runKey,
"gentler-intro-phrasing",
opts.site,
);
const variant = useCanary ? "canary" : "control";
const introRule = useCanary ? INTRO_CANARY : INTRO_CONTROL;
const msg = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 4096,
system: `あなたは技術記事の執筆者です。\n${introRule}`,
messages: [{ role: "user", content: opts.topic }],
});
// どちらの群で生成したかを必ず記録する(後の比較の生命線)
return { variant, text: msg.content, runKey: opts.runKey };
}
生成結果に variant を必ず添えて返すのを忘れないでください。どちらの群だったかを記録し損ねると、後から control と canary を突き合わせられず、フラグを入れた意味が消えます。
カナリアと対照群を比べる
新挙動が「良さそう」かどうかは、印象ではなく、両群の同じ指標を並べて判断します。私の場合、まず見るのは品質ゲートの通過率です。これは主観が入らず、その日のうちに集計できます。
type RunLog = {
runKey: string;
variant: "control" | "canary";
gatePassed: boolean; // 品質ゲートを一度で通ったか
charCount: number; // 生成本文の文字数
ts: string;
};
// 直近ログから両群の通過率を出す
function compare(logs: RunLog[]) {
const rate = (v: string) => {
const g = logs.filter((l) => l.variant === v);
const pass = g.filter((l) => l.gatePassed).length;
return { n: g.length, passRate: g.length ? pass / g.length : 1 };
};
return { control: rate("control"), canary: rate("canary") };
}
観測の窓は、日次で一本ずつしか生成しないなら三日ほど見ます。canary の母数が小さいうちに一喜一憂しないためです。実際、私の運用では一晩に一本のため、25%の canary が実際に引かれるのは数日に一度です。そこを踏まえ、最低でも canary が三本たまるまでは判定を保留する、と決めています。
回帰を検知したら、次の実行でフラグを閉じる
比較して canary の通過率が対照群を明確に下回ったら、人が起きるのを待たずに退避させます。ここでも killed を立てるだけにして、フラグ定義の他の値は触りません。原因調査のために、いつどの数字で閉じたのかを残したいからです。
import { readFileSync, writeFileSync } from "node:fs";
// canary が control より REGRESSION_PT 以上低ければ kill する
const REGRESSION_PT = 0.05; // 5ポイント
const MIN_CANARY = 3; // 最低サンプル数
export function autoRollback(flagKey: string, logs: RunLog[], path: string) {
const { control, canary } = compare(logs);
if (canary.n < MIN_CANARY) return { acted: false, reason: "サンプル不足" };
const gap = control.passRate - canary.passRate;
if (gap < REGRESSION_PT) return { acted: false, reason: "回帰なし" };
const flags = JSON.parse(readFileSync(path, "utf8"));
flags[flagKey].killed = true;
flags[flagKey].killedAt = new Date().toISOString();
flags[flagKey].killedReason =
`canary passRate ${canary.passRate.toFixed(2)} < control ${control.passRate.toFixed(2)}`;
writeFileSync(path, JSON.stringify(flags, null, 2));
return { acted: true, gap };
}
大事なのは、この関数を生成の直後、次の実行が始まる前に走らせることです。夜間バッチの最後に評価を挟めば、劣化は最大でも一晩ぶんに封じ込められます。四サイト同時被弾で丸一日を失った、あの朝のようにはなりません。
公式には書かれていない、運用の勘所
バケットの安定性を壊さない。 実行キーの作り方を途中で変えると、それまで control だった処理が canary へ移り、比較の連続性が切れます。キーの定義は一度決めたら固定し、変えるなら新しいフラグとして切り直します。
kill は割合調整と別物として扱う。 rollout: 0 と killed: true は結果が似て見えますが、意味が違います。前者は「今日は広げない」、後者は「この挙動は危険だと判断した」。監査の際、この差が原因追跡の速さを分けます。
フラグには寿命がある。 いちばん見落としやすいのがこれでした。新挙動が良いと分かったら、フラグを消して新挙動を既定に昇格させます。放置すると分岐が積み上がり、コードのどの経路が生きているのか誰にも分からなくなります。私は月に一度、createdAt が三十日を超えたフラグを棚卸しし、「昇格・撤去・保留」のいずれかへ必ず決着させる時間を取っています。
可逆な変更ほど小さく始めることを、私は強く推奨します。
| 状況 | 推奨 |
| 複数サイト・複数ジョブで同じ変更を試す | まず一つの scope に限定し、rollout も小さく始める |
| 変更が可逆(プロンプトの言い回しなど) | rollout を段階的に上げる。25→50→100 の三段が扱いやすい |
| 変更が不可逆に近い(外部への書き込み挙動) | フラグ以前に、まず shadow で結果だけ記録し比較する |
| 品質シグナルが主観的で自動集計しにくい | 自動ロールバックは付けず、人の確認を挟む半自動に留める |
むすび
無人で回る仕組みに変更を当てるときは、「全部に効かせる」より前に、「一部で確かめて、悪ければ静かに退避できる」経路を用意しておく。フィーチャーフラグはそのための、ごく素朴な道具です。
次の一歩として、以下の順に一つだけ試してみてください。
- いま全実行へ即時反映している変更を、一つだけ選ぶ
- それを scope を一つに絞り、rollout 25% の分岐として置き直す
- canary が三本たまったら control と比べ、良ければ 50、100 へと広げる
翌朝の景色が、少しだけ穏やかになります。
拙い運用記ですが、どなたかの夜間バッチが一つでも静かになれば嬉しいです。お読みいただき、ありがとうございました。