はじめに
AIエージェントを実運用していて一番手を焼くのは、モデルが古くなることではありません。新しくなることです。
新しいモデルはたしかに賢い。文章は上手くなる。推論も伸びる。でも現場では決まって同じことが起きます。
- きのうまで守れていたトーンが急に変わる
- 「これは書かない」と決めていたことを、しれっと書いてくる
- 前に却下したはずの表現が、また戻ってくる
理由はシンプルです。新しいモデルは「文章を書く能力」は引き継ぎますが、「この発信元は何を大事にして、何を書かないか」という運用知は引き継ぎません。後者はモデルの重みの中ではなく、私たち側のシステムが持っておくしかない領域です。
この記事では、モデルを乗り換えても挙動が崩れない運用層を、TypeScriptの動くコードで設計します。扱うのは3層です。
- 層1: コンテキスト合成(house rules を毎回かならず注入する)
- 層2: 却下記憶(成功例より「ボツの理由」を効かせる)
- 層3: 機械ゲート+人の目視(モデルが想定外の外し方をした最後の砦)
前提として、モデル呼び出しは Anthropic の Messages API のような「system プロンプト+messages」構造を想定します。system を毎回どう組み立てるか、が運用の生命線になります。
なぜ「その場の指示」は壊れるのか
最初にやりがちな失敗が「会話の中で毎回ルールを言い直す」やり方です。
// アンチパターン: ルールが会話履歴に埋もれる
const messages = [
{ role: "user", content: "うちのトーンは丁寧で、断定しすぎない。盛った数字は禁止。記事を書いて。" },
// ...10往復後、ルールは履歴の彼方へ
];
これが壊れるのは2点です。
- 会話が長くなるとルールが希釈される。 履歴の先頭に置いた指示は、往復が増えるほど相対的な重みが落ちます。
- モデルを乗り換えると消える。 新バージョンは前のセッションを知りません。「うちのやり方」がどこにも永続化されていないと、毎回ゼロからになります。
対処は1つ。ルールを会話から切り離し、モデルに依存しない外部の「持ち物」にして、呼び出しのたびに system へ合成する。これだけで「振り回される側」がこちらでなくなります。
層1: コンテキスト合成 — house rules を毎回注入する
ルールはコードの中にハードコードしません。差し替え・レビューがgit差分でできるよう、**プレーンテキストの「持ち物」**として外に置きます。
house/
tone.md # 語尾・距離感・一人称
forbidden.md # 絶対に書かないこと
rejections.jsonl # 却下の記録(層2で使う)
これを読み込んで system を組み立てる合成器を作ります。肝は「毎回かならず読む」ことと「モデルIDに依存しない」ことです。
// src/context.ts
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { loadRecentRejections } from "./rejections.js"; // 層2で実装
const HOUSE_DIR = process.env.HOUSE_DIR ?? "./house";
/** house rules を毎回読み直して system プロンプトを合成する */
export async function buildSystemPrompt(): Promise<string> {
const read = async (f: string) =>
fs.readFile(path.join(HOUSE_DIR, f), "utf-8").catch(() => "");
const tone = await read("tone.md");
const forbidden = await read("forbidden.md");
const rejections = await loadRecentRejections(12); // 層2で実装
return [
"あなたはこの発信元の運用ルールに従って執筆します。",
"以下は『うちのやり方』です。文章の巧拙より、このルールの遵守を優先してください。",
"",
"## トーン", tone.trim(),
"",
"## 絶対に書かないこと", forbidden.trim(),
"",
"## 過去に却下した表現と理由(同じ轍を踏まないこと)",
rejections.map((r) => `- ${r.text} → 却下理由: ${r.reason}`).join("\n"),
].join("\n");
}
呼び出し側は、モデルを抽象化しておきます。乗り換えポイントを1箇所に閉じ込めるのがポイントです。
// src/llm.ts
import Anthropic from "@anthropic-ai/sdk";
import { buildSystemPrompt } from "./context.js";
const client = new Anthropic();
// モデルIDは環境変数で1箇所に集約。乗り換え=この値の変更だけ
const MODEL = process.env.AGENT_MODEL ?? "claude-sonnet-4-6";
export async function generate(userPrompt: string): Promise<string> {
const system = await buildSystemPrompt(); // 毎回かならず合成
const res = await client.messages.create({
model: MODEL,
max_tokens: 4096,
system, // ← ルールは system に固定
messages: [{ role: "user", content: userPrompt }],
});
return res.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
}
ここで設計判断が1つあります。ルールは messages(会話)ではなく system に置く。 system はモデルにとって会話履歴とは別格の指示で、往復が増えても希釈されにくい。そして MODEL を変数1つに集約しておけば、Opus系からSonnet系へ乗り換えても、フォールバック先に切り替えても、ルールの注入経路は一切変わりません。
実運用での教訓: 私たちは最初、ルールをユーザーメッセージの先頭に毎回貼っていました。モデルが新バージョンに上がった週、語尾とトーンが一斉にズレた。原因は「賢くなったモデルが、薄まった指示より自分の素の文体を優先した」こと。
systemへ移し、合成を毎回強制してから、乗り換え時のトーン崩れがほぼ消えました。
層2: 却下記憶 — 成功例より「ボツの理由」が効く
意外だったのは、見本(成功例)より失敗の記録のほうが効いたことです。
新しいモデルは、見本があると上手にマネします。でも、見本にない地雷は平気で踏む。だから「踏んだ地雷」を構造化して残し、毎回 system に注入します。
// src/rejections.ts
import * as fs from "node:fs/promises";
import * as path from "node:path";
const LEDGER = path.join(process.env.HOUSE_DIR ?? "./house", "rejections.jsonl");
export interface Rejection {
text: string; // 却下された表現(短く)
reason: string; // なぜダメか
category: string; // tone | overclaim | mockery | legal ...
at: string;
}
/** 却下を記録する。レビューで弾いた瞬間に呼ぶ */
export async function recordRejection(r: Omit<Rejection, "at">, now: string) {
const row = JSON.stringify({ ...r, at: now });
await fs.appendFile(LEDGER, row + "\n", "utf-8");
}
/** 直近 N 件を新しい順で返す。system が肥大しないよう件数で上限を切る */
export async function loadRecentRejections(limit: number): Promise<Rejection[]> {
const raw = await fs.readFile(LEDGER, "utf-8").catch(() => "");
const rows = raw.trim().split("\n").filter(Boolean).map((l) => JSON.parse(l) as Rejection);
return rows.slice(-limit).reverse();
}
運用ルールはシンプルです。レビューで何かを弾いたら、その場で recordRejection を呼ぶ。「茶化しすぎてボツ」「盛りすぎてボツ」「トーンがズレてボツ」——理由が積み上がるほど、次に来たモデルが同じ穴に落ちなくなります。
「書くたびに賢くなる」の正体はこれでした。賢くなっているのはモデル本体ではなく、モデルの外側に溜まっていく却下記憶のほうです。だからモデルを乗り換えても、その資産は丸ごと残ります。
注意点: 却下記憶は無限に増えます。
systemに全部入れるとコンテキストを食い潰し、コストも上がる。私たちは「直近N件+カテゴリ別の代表例」だけを注入し、全文は別ストアに退避しています。limitを機械的に課すのが唯一機能しました。「あとで整理する」は来ません。
層3: 機械ゲート+人の目視 — 最後の砦
正直に書くと、層1・層2を積んでも、新しいモデルは想定外の外し方をします。だから公開前のゲートを必ず通します。ゲートは2段構えです。
機械ゲート: 決定的に検査できるものはコードで弾く
禁止語・誇張数字パターン・内部識別子の漏れなど、機械的に判定できるものはモデルに判断させず、決定的なコードで検査します。モデルの良し悪しに依存させないためです。
// src/gate.ts
export interface GateResult {
pass: boolean;
violations: string[];
}
const FORBIDDEN_WORDS = ["業界No.1", "必ず儲かる", "完全無料で永久に"]; // 例
const OVERCLAIM = /\b(\d{2,3})\s*%\s*(改善|増加|削減)/g; // 根拠なき数値主張
const INTERNAL_LEAK = /\/home\/[a-z]+\/|\b[a-z0-9-]+\/internal-[a-z0-9-]+\b/g; // パス/リポ名の漏れ
export function machineGate(text: string): GateResult {
const violations: string[] = [];
for (const w of FORBIDDEN_WORDS) {
if (text.includes(w)) violations.push(`禁止語: ${w}`);
}
if (OVERCLAIM.test(text)) violations.push("根拠の確認できない数値主張の疑い");
if (INTERNAL_LEAK.test(text)) violations.push("内部パス/リポ名の漏れの疑い");
return { pass: violations.length === 0, violations };
}
機械ゲートで弾けたものは、そのまま層2の却下記憶に書き戻すとループが閉じます。検査で見つかった違反が、次回の system 注入材料になる。
const nowIso = new Date().toISOString();
const result = machineGate(draft);
if (!result.pass) {
for (const v of result.violations) {
await recordRejection({ text: draft.slice(0, 80), reason: v, category: "gate" }, nowIso);
}
throw new Error(`machine gate failed: ${result.violations.join(" / ")}`);
}
人の目視: 「見た目」はコードのGREENを信じない
機械ゲートを通っても、見た目を持つ成果物(サムネイル・画像・レイアウト)は人が実際に表示して目で見るまで公開しません。これは譲れない一線です。
理由は実体験です。本文が中立でも、サムネイル単体が文脈を変えてしまうことがある。コードの存在確認や差分ゼロは「描画結果が正しい」ことを保証しません。overflow-hidden なコンテナのスクリーンショットが最初の1画面しか撮れず、pixel-diffがゼロで「問題なし」と誤判定した事故もありました。
// 機械で判定してよいもの / 人の目視が要るもの の線引きを型で表明する
type CheckMode = "machine" | "human-eyeball";
function reviewMode(artifact: "text" | "thumbnail" | "image" | "layout"): CheckMode {
// テキストの規約違反は機械でよい。描画を伴うものは人が見る
return artifact === "text" ? "machine" : "human-eyeball";
}
仕組みで9割を減らし、残りの1割(とくに見た目)を人が見る。全部を人が見るでも全部をモデルに任せるでもない、その真ん中を運用設計で作る、というのが現実解でした。
乗り換えを「事故」でなく「操作」にする
最後に、モデル乗り換えそのものの扱いです。乗り換えは事故的に起きると怖いので、意図的な操作に格下げします。やることは2つだけ。
- モデルIDを
AGENT_MODELの1箇所に集約する(層1で実装済み) - 乗り換え時は、本番投入の前に同じ入力で旧モデルと新モデルの出力を差分比較する
// src/migration-check.ts
import { machineGate } from "./gate.js";
/** 旧/新モデルで同一プロンプトを走らせ、ゲート結果とトーン差を目視に回す */
export async function compareModels(
prompt: string,
run: (model: string, prompt: string) => Promise<string>,
oldModel: string,
newModel: string,
) {
const [oldOut, newOut] = await Promise.all([run(oldModel, prompt), run(newModel, prompt)]);
return {
old: { text: oldOut, gate: machineGate(oldOut) },
neo: { text: newOut, gate: machineGate(newOut) },
// トーンの良し悪しは機械では決めない。差分を人の目視に回すための材料を返すだけ
};
}
これで乗り換えは「ある朝いきなり挙動が変わる」ではなく「旧新を並べて確認してから切り替える」操作になります。system 注入も却下記憶もモデル非依存なので、切り替え後も運用ルールはそのまま効きます。
まとめ
| 層 | 解決する問題 | 核になる設計 |
|---|---|---|
| コンテキスト合成 | 乗り換え時のトーン崩れ | ルールは system に毎回合成・モデルIDは1箇所に集約 |
| 却下記憶 | 見本にない地雷を踏む | 「ボツの理由」を構造化し直近N件を注入 |
| 機械ゲート+人の目視 | モデルの想定外の外し | 決定的検査はコード/描画物は人が実画面で見る |
3層に共通するのは、「賢いモデルを選ぶ」のではなく「どのモデルでも崩れない運用層を持つ」という方向性です。モデルの中身はこれからも勝手に入れ替わります。それでも崩れないようにしたいなら、ルールの置き場所を「会話」から「モデルの外の記憶」へ移す。それだけで、振り回される側が変わります。
コードはそのまま動かせます。HOUSE_DIR に自分たちの tone.md / forbidden.md を置き、AGENT_MODEL を1箇所にまとめるところから始めてみてください。