Loading post
Jul 21, 2026

僕はベイエリアで働く日本人ソフトウェアエンジニアで、PayPalで決済を数年やったあと、いまはフィンテックスタートアップのAtlasで、相変わらずお金の移動と台帳と、それに付きものの障害パターンを相手にしています。で、ご多分に漏れず、頭の中には「いつかちゃんと理解したいことリスト」が延々とあります。コンシステントハッシング、トランザクション分離レベル、L4とL7のロードバランシング、決済リトライにおける冪等性——そしてこれもご多分に漏れず、リストは伸びる一方でした。
足りていなかったのはモチベーションではなくて、継続性です。もっと言うと、始めるまでの摩擦です。「今日は何を勉強するか」を決めること自体がひと仕事で、テーマを選んで良い資料を探し当てた頃には通勤時間が終わっている。そこで、この2つの意思決定を丸ごと取り除くシステムを作りました。毎朝、Cloud Functionが積んであるバックログからトピックを1つ選び、LLMに構造化された学習記事を書かせ、Firestoreに保存して、ポートフォリオサイトの学習セクションに公開します。電車でスマホを開くと、新しい記事が待っている。さらに下流では、クイズが自動生成され、記事のポッドキャスト版まで作られます(これは別の記事で書きます)。
この記事では、パイプラインの仕組み、予想外に難しかった部分(トピックの繰り返し問題、構造化出力のバリデーション)、コスト、そして今ならどうするかを書きます。
システム全体はいつものスタックで動いています。計算はFirebase Functions(TypeScript)、保存はFirestore、閲覧はNext.jsフロントエンドです。全体像はこうなります。
(毎時cron, UTC)
Cloud Scheduler ──▶ dailyArticleGeneration()
│
▼
study_schedules (Firestore)
「このスケジュールは今の時刻に実行すべき?」
│ yes
▼
┌── トピック選択 ─────────┐
│ sequential / random │
│ ai_suggested │
│ linear ◀── Linear API │ (label: Study, state: Todo)
└───────────┬─────────────┘
▼
LLM → 構造化JSON記事
(タイトル / 要約 / セクション /
要点 / クイズ問題)
│
▼
study_articles (Firestore)
│
┌─────────────────┼───────────────────┐
▼ ▼ ▼
Next.js閲覧UI クイズdoc Firestoreトリガー
(閲覧数, ノート, → ポッドキャスト
記事チャット) 音声生成
各ステージに入る前に、設計判断をいくつか挙げておきます。
スケジューラは意図的に単純にしてあります。 スケジュールごとにCloud Schedulerジョブを作るのではなく、1つの関数が毎時起動してstudy_schedulesコレクションをスキャンし、その時刻に実行すべきスケジュールを探します。スケジュールはただのFirestoreドキュメントです——頻度、実行時刻(UTCで保存)、AIプロバイダーとモデル、出力言語、トピック選択モード。スケジュールの追加や一時停止はドキュメントの書き込みであって、インフラ変更ではありません。
下流のすべては記事ドキュメントを起点にします。 記事がstudy_articlesに着地すると、生成側は何も知らないまま、他の機能がそこにぶら下がります。閲覧UIはビューカウントを増やし、クイズドキュメントが記事にリンクし、記事を文脈にした質問チャットができ、FirestoreのonDocumentCreatedトリガーがテキスト読み上げによるポッドキャスト生成を起動します。パイプラインはパイプラインのまま、機能は端にくっつく構造です。
スケジュール実行のエントリポイントは概ねこんな形です(この記事のコードはすべて実際のコードを簡略化したものです)。
export const dailyArticleGeneration = onSchedule(
{ schedule: "0 * * * *", timeZone: "UTC", timeoutSeconds: 540, memory: "1GiB" },
async () => {
const currentHour = new Date().getUTCHours().toString().padStart(2, "0");
const schedules = await db
.collection("study_schedules")
.where("status", "==", "active")
.get();
for (const doc of schedules.docs) {
if (shouldRunNow(doc.data() as Schedule, currentHour)) {
await executeSchedule(doc.id);
}
}
}
);
function shouldRunNow(schedule: Schedule, currentHour: string): boolean {
// スケジュール時刻のどれかが今の時間帯に一致するか?
const matches = schedule.scheduledTimes.some(
(t) => t.split(":")[0] === currentHour
);
if (!matches) return false;
// 冪等性ガード:今日この時間帯にすでに実行済みならスキップ (UTC)
if (schedule.lastRunAt) {
const last = schedule.lastRunAt.toDate();
const sameUtcDay =
last.toISOString().slice(0, 10) === new Date().toISOString().slice(0, 10);
if (sameUtcDay && last.getUTCHours() === Number(currentHour)) return false;
}
return true;
}
lastRunAtのガードは見た目以上に重要です。スケジュール実行される関数は稀に同じ枠で複数回起動されることがあり、ガードがないと同じ枠で記事が2本(LLM請求も2回分)できてしまいます。また各実行はschedule_runsレコード——ステータス、選ばれたトピックID、生成された記事ID、エラーメッセージ——を書き込みます。何かが静かに壊れたとき、これがほぼタダで手に入る可観測性になります。
ここがこのシステムで一番気に入っている部分です。スケジュールドキュメントにはtopicSelectionModeがあり、4つの戦略を持ちます。
nextTopicIndex)を永続化して、実行をまたいでローテーションが継続する。実際に日常使いしているのはlinearモードです。僕は「Xを理解したい」と思いついたら、その場でLinearのissueにしています——スマホから5秒で済みます。パイプラインはLinearのGraphQL APIに問い合わせ、Studyラベル付きでTodo状態のissueを古い順に取得し、バックログを先頭から食べていきます。
const issues = await listOpenStudyIssues(); // team YUD, label "Study", state Todo
const issue = issues[0]; // 古い順 — バックログをFIFOで消化する
const topicName = topicNameFromIssue(issue);
// issue本文が記事のカリキュラム概要になる
const customPrompt = issue.description
? `この記事は、以下のissue本文に列挙されたトピックを必ずカバーすること。
本文をカリキュラムの骨子として使い、各項目を現役シニアエンジニアの
深さまで肉付けする。\n---\n${issue.description}\n---`
: undefined;
// ...記事を生成して保存...
// ループを閉じる:issueをIn Progressに移動し、
// 公開された記事URLをコメントとして残す
await markIssueArticleGenerated(issue, articleUrl);
ここで効いているポイントが2つあります。まず、issueの本文は無視されません。タイトルの下に箇条書きでメモしておけば(「リーダー選出、フェンシングトークン、リース失効をカバー」)、それが記事のアウトラインになります。記録時のぼんやりした思いつきが、生成時には構造化されたシラバスに変わるわけです。次に、ループが閉じていること。issueはIn Progressに移動され、生成された記事へのリンクがコメントとして付くので、Linearのボードがそのまま「何を学んだかのログ」を兼ねます。さらに各Linear issueはlinear:{identifier}タグ経由でFirestoreのトピックドキュメントに対応付けられるので、同じissueから記事を再生成しても重複トピックは作られません。
LLMに毎日「システムデザインのトピックを提案して」と頼むと、キャッシュについて大量に学ぶことになります。そして翌週もまたキャッシュです。うちの生成器にも露骨に好みがあって、キャッシュ、Kubernetesのデプロイ、あとなぜかOutboxパターンとDebeziumばかり出してくる。ちゃんと多様性を出すには、防御を何層も重ねる必要がありました。
1. 直近タイトルのプロンプト投入。 生成のたびにFirestoreから直近の記事タイトルを最大500件取得し、新しい50件を「重複・類似の言い回しを避けよ」としてプロンプトに含めます。
2. テーマ頻度カウント。 タイトルを正規表現定義のテーマと照合し、直近ウィンドウで3回以上出現したテーマは、プロンプトに「最近使いすぎたテーマ——今は選ぶな」セクションとして明示します。
const THEMES = [
{ id: "cache", label: "キャッシュ / Redis / CDN",
patterns: [/\bcache\b/i, /redis/i, /\bcdn\b/i, /キャッシュ/] },
{ id: "kubernetes", label: "Kubernetes / デプロイ",
patterns: [/kubernetes/i, /\bk8s\b/i] },
];
const counts = countThemes(recentTitles.slice(0, 200), THEMES);
const overused = THEMES.filter((t) => counts[t.id] >= 3);
// → プロンプトに追記: 「最近使いすぎたテーマ (避けること):
// - キャッシュ / Redis / CDN (直近タイトルに5件)」
3. 禁止トピックのハードフィルタ。 あまりに量産されたトピックは、トピック選択時とタイトル検証時の両方でチェックされる正規表現パターンで完全に禁止しました。僕のコードベースには実質「Debezium記事はもう書くな」という定数が存在するわけですが、後悔はしていません。
4. 実行内の多様性バケット。 1回の実行で複数記事を生成する場合、各候補トピックをバケット(キャッシュ、データベース、メッセージング、セキュリティ、ケーススタディ…)に分類し、同じバケットは同一実行内で2回使えないようにしています。
5. 生成後のタイトル検証とリトライ。 上記すべてをプロンプトに入れても、モデルはときどき、ほぼ重複のタイトルを平気で出してきます。そこで生成されたタイトルを事後に検証し、却下された場合は却下理由をフィードバックしてリトライします。
for (let attempt = 1; attempt <= 3; attempt++) {
const raw = await callLLM(buildPrompt({ topic, recentTitles, retryNote }));
const article = parseArticleJson(raw);
const check = validateTitle(article.title, recentTitles);
if (check.ok) return article;
retryNote =
`前回のタイトル "${article.title}" は却下された ` +
`(${check.reasons.join(", ")})。明確に異なる切り口を選ぶこと。`;
}
throw new Error("Title failed validation after 3 attempts");
一般化できる教訓:プロンプトレベルの「Xを避けてください」は提案であり、コードレベルの検証+リトライは保証です。両方必要です。検証だけだとAPI呼び出しが無駄になり、プロンプトだけだと重複がすり抜けるからです。
難易度の段階設計について。各トピックと記事は難易度レベル(beginner→expert)を持ち、プロンプトは各レベルを具体的な読者像に対応付けます——intermediateは「システムデザインの判断力を深めるシニアエンジニア:トレードオフ、判断基準、障害パターンを重視」、advancedはスケーリングの崖と二次的影響が加わる、という具合です。実運用では日次スケジュールをintermediateに固定し、初回の記事で物足りなかったトピックだけ手動でレベルを上げています。「同じテーマの記事を3本読んだら自動で難易度を上げる」ような完全自動の階段は、将来のアイデアリストに入っています。
記事はMarkdownの塊ではありません。閲覧UIがセクション、コードブロック、クイズを別々のコンポーネントとして描画するため、LLMは型付きの契約に一致するJSONオブジェクトを返さなければなりません。
interface GeneratedArticle {
title: string;
summary: string;
introduction: string;
sections: {
title: string;
content: string; // markdown
codeExamples?: { language: string; code: string; explanation: string }[];
externalLinks?: { title: string; url: string; description: string }[];
}[];
conclusion: string;
keyTakeaways: string[];
suggestedQuestions?: QuizQuestion[]; // クイズドキュメントになる
}
レスポンスのパースは、必要に迫られて防御的です。
function parseArticleJson(responseText: string): GeneratedArticle {
// モデルはJSONを```jsonフェンスで包みがち。しかも記事自体が
// コード例の中に```フェンスを含むので、「最も外側の」ブロックを探す。
const jsonStr = extractOutermostJsonBlock(responseText);
// 長い記事は出力トークン上限にぶつかってオブジェクトの途中で切れることがある
if (!jsonStr.trimEnd().endsWith("}")) {
throw new Error("AI response was truncated - JSON incomplete");
}
const parsed = JSON.parse(jsonStr);
if (!parsed.title || !parsed.summary || !parsed.sections) {
throw new Error("Parsed JSON missing required fields");
}
return parsed as GeneratedArticle;
}
この関数の障害パターンは全部、実際に踏んだものです。フェンス付き出力、長文記事での途中切れ、必須フィールドが欠けた正当なJSON。Firestoreに書く前にはundefined値をフィールド単位で除去し(Firestoreはundefinedを拒否します——定番のハマりどころです)、読了時間を毎分100語で計算し(技術文書は散文より読むのが遅い)、タイトルからURLスラッグを導出します。
dry-runモードもあります。生成エンドポイントにdryRun: trueを渡すと、トピックを解決し、実際のプロンプトを組み立てて、デバッグ情報(既存タイトルを何件含めたか、テーマカウント、プロンプト長)と一緒に返します——LLMは呼ばず、何も書き込みません。生成品質が変になったとき、モデルに何を渡していたかをタダで正確に確認できることが、このシステムで一番役に立つデバッグ手段です。
このシステムの本体はプロンプトであり、何度も改訂を重ねています。現行版には、失敗して学んだルールがいろいろ入っています。現役シニアエンジニア向けに書くこと、「キャッシュとは何か」を説明しないこと。トレードオフの前に仕組みをきれいに説明すること。トレードオフ比較表を最低1つ入れること。実際の本番で起きる障害パターン(ホットキー、サンダリングハード、リトライ増幅)を名指しし、奇抜なエッジケースを発明しないこと。個人的に気に入っているのは、日常生活のアナロジー全面禁止ルールです。レストランの例え話が何度目かに出てきたときに追加しました。概念に足場が必要なら、リクエスト・レスポンスのサイクル、データベース書き込み、決済フローで説明する。
プロンプトが進化し続けるので、バージョニングが重要になります。設定はFirestoreドキュメント(プロバイダー、モデル名、temperature、デフォルト値)にあり、モデル切り替えはドキュメント編集で済みます。各記事はどのプロバイダー・モデルで生成されたかを記録し、プロンプト自体もバージョン番号を持ちます——これは下流で本領を発揮します。ポッドキャストパイプラインがプロンプトバージョンをキャッシュ破棄の入力として使うのです(詳細は姉妹記事で)。言語も第一級のパラメータで、僕は主に日本語で生成しています。プロンプトには明示的な日本語スタイル指示(です・ます調で統一、カタカナ語と英語表記の使い分け、コードとコメントは英語のまま)が入っています。
想像より安いです。日次の記事は大きめのLLM呼び出し1回(入力数千トークン、長い構造化出力)に加え、ai_suggestedモードでの安いトピック提案呼び出しと、検証失敗時の最大2回のリトライ——実際にはリトライが発火するのは5日に1回程度です。中間グレードのモデルで記事1本あたり$0.05〜0.15程度に収まるので、毎日の習慣として月あたりおよそ$2〜5。Firebase側のコストは誤差です。Firestoreの読み書きと毎時のスケジューラ起動は無料枠に余裕で収まり、関数は1GiBインスタンスが1日に数十秒動くだけ。月に技術書1冊と比べたら、比較にすらなりません。
うまくいったこと:
うまくいかなかったこと(最初は、あるいは今も):
将来のアイデア: 埋め込みによる意味的重複排除。自動の難易度階段(クイズに合格したら同じトピックを1段上で再生成)。クイズの間違いを次の生成にフィードバック(「読者はフェンシングトークンでつまずいた——別の角度から再訪せよ」)。週次ダイジェスト記事(その週のトピックを1本の復習記事に合成)。
lastRunAtの時間帯チェック)と実行ログコレクションだけ忘れずに。undefined除去。振り返ってみると、自動化して一番レバレッジが効いたのは「書くこと」ではなく「決めること」の方でした。学びたいことのバックログを抱えているエンジニアなら、トピックの供給源はすでに持っているはずです。issueトラッカーの中に。