壁紙アプリのアセットを分類するパイプラインを初めて夜通し回した朝、画面には途中で止まったログだけが残っていました。数千枚のうち処理できたのはおよそ半分。原因は API のレート制限でしたが、本当に困ったのはそこではありません。どこまで終わったのかを、どこにも記録していなかったことです。
結局その日は最初からやり直しました。同じ画像に同じ料金を二度払ったことになります。個人開発では、この二度払いが直接そのまま利益から引かれます。
以来、私はパイプラインを設計するとき、チェーンの繋ぎ方より先に「途中で死んだとき、どこから再開できるか」を決めるようにしております。LangChain が提供するのは合成可能なコンポーネント、Antigravity が提供するのはそれを人と並べて動かすオーケストレーション層。この記事の主題は、その二つの間に置くべき三つの決定 — 権限の境界、失敗の記録、コストの可視化 — です。
以下では合成可能なチェーン設計、メモリ管理、ルーティング、レジリエンスの実装を順に見たうえで、最後に権限設計とコスト実測という、公式ドキュメントがあまり語らない領域まで踏み込みます。
AIパイプラインアーキテクチャの理解
パイプラインの抽象化
パイプラインは単なるLLM呼び出しの連鎖ではありません。専門的なコンポーネントが協調して動作する構成です:
入力 → 前処理 → ルーティング → チェーン選択 → 実行 → 後処理 → 出力
↓ ↓ ↓ ↓ ↓
データ検証 コンテキスト マルチブランチ ロギング レスポンス
変換 取得 フォールバック 監視 フォーマット
キャッシング エラーハンドリング メトリクス
なぜこれが重要か: ジェネリックなパイプライン設計は本番負荷の下で破綻します。カスタムアーキテクチャは複雑性をインテリジェントに分散させます—高価な操作(検索など)をクリティカルパスの外に移動し、積極的にキャッシングして、コンポーネントに障害が発生した場合はグレースフルにデグラデします。
5つのパイプラインパターン
LangChainは5つの異なるパターンを有効にしており、各々が異なる問題に適しています:
- シーケンシャルパイプライン — 線形の操作チェーン(ドキュメントQ&A、要約)
- 分岐パイプライン — 入力の特性に基づく条件付きルーティング
- 再帰的パイプライン — 精緻化または分解のために自分自身を呼び出すチェーン
- 並列パイプライン — 複数のチェーンが同時に実行され、結果が統合される
- 適応的パイプライン — メトリクス、パフォーマンスデータ、または状態に基づいて実行時に決定される動作
各パターンをいつ適用するかを理解することで、システムの効率性と信頼性が劇的に向上します。
合成可能なコンポーネントでシーケンシャルパイプラインを構築
コア アーキテクチャ
本番レベルのシーケンシャルパイプラインは、関心事を個別のテスト可能なレイヤーに分離します:
# config/pipeline_config.py
from dataclasses import dataclass
from typing import Optional, Dict, Any
from enum import Enum
class ChainStage(Enum):
"""パイプライン実行ステージ"""
INPUT_VALIDATION = "input_validation"
CONTEXT_RETRIEVAL = "context_retrieval"
PROMPT_CONSTRUCTION = "prompt_construction"
LLM_EXECUTION = "llm_execution"
OUTPUT_PARSING = "output_parsing"
RESPONSE_VALIDATION = "response_validation"
@dataclass
class PipelineConfig:
"""集中化されたパイプライン設定"""
# モデル設定
model_name: str = "gpt-4"
temperature: float = 0.7
max_tokens: int = 2048
# チェーン動作
max_retries: int = 3
retry_backoff: float = 2.0
timeout_seconds: int = 30
# メモリ管理
memory_type: str = "buffer" # buffer, summary, entity
max_memory_tokens: int = 4000
# コンテキスト検索
enable_rag: bool = True
retrieval_strategy: str = "similarity" # similarity, bm25, hybrid
top_k_docs: int = 5
# ロギングと監視
log_level: str = "INFO"
enable_metrics: bool = True
enable_tracing: bool = True
@dataclass
class StageContext:
"""パイプライン全体で共有されるコンテキスト"""
input_text: str
metadata: Dict[str, Any]
retrieved_docs: list = None
prompt_template_used: str = None
llm_response: str = None
execution_time: Dict[str, float] = None
errors: list = None
💡**設定ファーストの設計:** すべてのパイプラインパラメータを集中化された設定に保存します。これにより実験が可能になります—コードに触れずに温度、メモリタイプ、または検索戦略を切り替えます。
コンポーネント構成
各ステージは独立した合成可能なユニットです:
# pipelines/components/base.py
from abc import ABC, abstractmethod
from typing import Any, Dict
import time
from datetime import datetime
class PipelineComponent(ABC):
"""すべてのパイプラインコンポーネントの基底クラス"""
def __init__(self, name: str, config: Dict[str, Any] = None):
self.name = name
self.config = config or {}
self.execution_metrics = {
"total_calls": 0,
"successful_calls": 0,
"failed_calls": 0,
"total_time": 0.0,
"avg_time": 0.0,
"errors": []
}
@abstractmethod
async def execute(self, context: StageContext) -> StageContext:
"""コンポーネントロジックを実行します。冪等性が必須です。"""
pass
async def run(self, context: StageContext) -> StageContext:
"""タイミング、エラートラッキング、メトリクスを処理するラッパー"""
start_time = time.time()
try:
result = await self.execute(context)
elapsed = time.time() - start_time
# メトリクスを更新
self.execution_metrics["total_calls"] += 1
self.execution_metrics["successful_calls"] += 1
self.execution_metrics["total_time"] += elapsed
self.execution_metrics["avg_time"] = (
self.execution_metrics["total_time"] /
self.execution_metrics["successful_calls"]
)
if context.execution_time is None:
context.execution_time = {}
context.execution_time[self.name] = elapsed
return result
except Exception as e:
self.execution_metrics["failed_calls"] += 1
self.execution_metrics["errors"].append({
"timestamp": datetime.utcnow().isoformat(),
"error": str(e),
"component": self.name
})
raise
# pipelines/components/validators.py
import re
from typing import List, Optional
class InputValidator(PipelineComponent):
"""パイプライン実行前に入力を検証します"""
def __init__(self, config: Dict[str, Any] = None):
super().__init__("InputValidator", config)
self.min_length = self.config.get("min_length", 10)
self.max_length = self.config.get("max_length", 50000)
self.forbidden_patterns = self.config.get("forbidden_patterns", [
r"<script>",
r"eval\(",
r"__import__"
])
async def execute(self, context: StageContext) -> StageContext:
text = context.input_text.strip()
# 長さの検証
if len(text) < self.min_length:
raise ValueError(f"入力が短すぎます: {len(text)}文字、最小{self.min_length}")
if len(text) > self.max_length:
raise ValueError(f"入力が長すぎます: {len(text)}文字、最大{self.max_length}")
# セキュリティチェック
for pattern in self.forbidden_patterns:
if re.search(pattern, text, re.IGNORECASE):
raise ValueError(f"禁止されたパターンが検出されました: {pattern}")
# テキストを正規化
context.input_text = self._normalize_text(text)
context.metadata["validation_timestamp"] = datetime.utcnow().isoformat()
return context
def _normalize_text(self, text: str) -> str:
"""空白とエンコーディングを正規化"""
# 余分な空白を削除
text = " ".join(text.split())
# ユニコードを正規化
return text.encode('utf-8', 'replace').decode('utf-8')
# pipelines/components/retrieval.py
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
from typing import List
class ContextRetriever(PipelineComponent):
"""RAGを使用して関連するコンテキストを取得"""
def __init__(self, vector_store: Chroma, config: Dict[str, Any] = None):
super().__init__("ContextRetriever", config)
self.vector_store = vector_store
self.top_k = self.config.get("top_k", 5)
self.min_relevance_score = self.config.get("min_relevance_score", 0.5)
async def execute(self, context: StageContext) -> StageContext:
# 関連するドキュメントを取得
docs_with_scores = self.vector_store.similarity_search_with_score(
context.input_text,
k=self.top_k * 2 # フィルタリングのため過剰取得
)
# 最小関連度スコアでフィルタリング
relevant_docs = [
doc for doc, score in docs_with_scores
if (1 - score) >= self.min_relevance_score # 距離を類似度に変換
][:self.top_k]
if not relevant_docs:
context.retrieved_docs = []
context.metadata["retrieval_status"] = "no_relevant_documents"
else:
context.retrieved_docs = relevant_docs
context.metadata["retrieval_status"] = "success"
context.metadata["retrieved_doc_count"] = len(relevant_docs)
return context
⚠️**メモリとコストの影響:** 各RAG検索は遅延とコストを追加します。関連度閾値を積極的に使用し、頻繁に取得されるコンテキスト のキャッシングを実装します。過剰取得は避けてください。
高度なメモリ管理
メモリ戦略の選択
LangChainのメモリ抽象化は異なる問題を解決します。正しく選択することで、パイプラインがスケールするかどうかが決まります:
# pipelines/memory/memory_factory.py
from langchain.memory import (
ConversationBufferMemory,
ConversationSummaryMemory,
ConversationEntityMemory,
ConversationBufferWindowMemory,
)
from enum import Enum
class MemoryStrategy(Enum):
BUFFER = "buffer" # すべてのメッセージ
BUFFER_WINDOW = "buffer_window" # 最後のN個のメッセージ
SUMMARY = "summary" # 要約された履歴
ENTITY = "entity" # エンティティトラッキング
class MemoryFactory:
"""戦略と制約に基づいてメモリインスタンスを作成"""
@staticmethod
def create_memory(
strategy: MemoryStrategy,
max_tokens: int = 4000,
llm=None,
**kwargs
):
if strategy == MemoryStrategy.BUFFER:
# シンプル、低レイテンシ、無制限の成長
return ConversationBufferMemory(
memory_key="chat_history",
return_messages=True,
**kwargs
)
elif strategy == MemoryStrategy.BUFFER_WINDOW:
# 固定メモリフットプリント、最近のコンテキストを保持
k = kwargs.get("k", 5) # 保持するメッセージ数
return ConversationBufferWindowMemory(
memory_key="chat_history",
k=k,
return_messages=True,
**kwargs
)
elif strategy == MemoryStrategy.SUMMARY:
# 要約を通じた圧縮(LLMが必須)
return ConversationSummaryMemory(
llm=llm,
memory_key="chat_history",
return_messages=True,
**kwargs
)
elif strategy == MemoryStrategy.ENTITY:
# エンティティとその属性を追跡
return ConversationEntityMemory(
llm=llm,
memory_key="chat_history",
entity_extraction_prompt=kwargs.get("entity_extraction_prompt"),
**kwargs
)
# トークン制限での使用
class TokenAwareMemory:
"""厳格なトークン予算でメモリを管理"""
def __init__(self, max_tokens: int = 4000, llm=None):
self.max_tokens = max_tokens
self.llm = llm
self._memory = None
self._total_tokens = 0
self._strategy = None
def add_message(self, role: str, content: str):
"""メッセージを追加し、必要に応じて戦略を切り替え"""
# トークンを推定(おおよそ:英語では1トークン≈4文字)
estimated_tokens = len(content) // 4
if self._total_tokens + estimated_tokens > self.max_tokens * 0.8:
# 制限に近づいている、要約戦略に切り替え
if self._strategy != MemoryStrategy.SUMMARY:
self._switch_to_summary()
self._memory.save_context(
{"input": role},
{"output": content}
)
self._total_tokens += estimated_tokens
def _switch_to_summary(self):
"""バッファから要約メモリに移行"""
# 現在のメモリからメッセージを抽出
messages = self._memory.chat_memory.messages if hasattr(
self._memory, 'chat_memory'
) else []
# 要約メモリを作成
self._memory = MemoryFactory.create_memory(
MemoryStrategy.SUMMARY,
llm=self.llm,
max_tokens=self.max_tokens
)
self._strategy = MemoryStrategy.SUMMARY
# メッセージを再追加(要約をトリガー)
for msg in messages:
self.add_message(msg.type, msg.content)
動的ルーティングとチェーン選択
実世界のパイプラインはすべての入力に対して同じチェーンを実行しません。インテリジェントなルーティングは入力の特性に基づいて特化したチェーンを選択します:
# pipelines/routing/router.py
from dataclasses import dataclass
from typing import Callable, List
from enum import Enum
@dataclass
class Route:
"""パイプラインルートを表示"""
name: str
condition: Callable[[str], bool]
chain: Any # 実行するLangChainチェーン
description: str
priority: int = 0
class DynamicRouter(PipelineComponent):
"""リクエストを特化したチェーンにルーティング"""
def __init__(self, routes: List[Route], config: Dict[str, Any] = None):
super().__init__("DynamicRouter", config)
# 優先度でソート(高い順)
self.routes = sorted(routes, key=lambda r: r.priority, reverse=True)
self.fallback_chain = config.get("fallback_chain")
async def execute(self, context: StageContext) -> StageContext:
selected_route = None
# 各ルートの条件を評価
for route in self.routes:
try:
if route.condition(context.input_text):
selected_route = route
break
except Exception as e:
context.metadata.setdefault("routing_errors", []).append({
"route": route.name,
"error": str(e)
})
continue
# ルートがマッチしなかった場合フォールバックを使用
if not selected_route:
if not self.fallback_chain:
raise ValueError("ルートがマッチしもフォールバックチェーンが設定されていません")
selected_route = Route(
name="fallback",
condition=lambda x: True,
chain=self.fallback_chain,
description="フォールバックチェーン"
)
context.metadata["selected_route"] = selected_route.name
context.metadata["chain_description"] = selected_route.description
return context
エラー処理とレジリエンス
本番パイプラインは失敗します。問題はいかにグレースフルに失敗するかです:
# pipelines/resilience/retry_policy.py
import asyncio
from typing import Callable, Any, TypeVar
from enum import Enum
T = TypeVar('T')
class RetryStrategy(Enum):
EXPONENTIAL = "exponential"
LINEAR = "linear"
FIXED = "fixed"
class ResilientComponent(PipelineComponent):
"""再試行ロジック付きコンポーネントの基底クラス"""
def __init__(
self,
name: str,
config: Dict[str, Any] = None,
max_retries: int = 3,
strategy: RetryStrategy = RetryStrategy.EXPONENTIAL,
base_delay: float = 1.0,
max_delay: float = 30.0
):
super().__init__(name, config)
self.max_retries = max_retries
self.strategy = strategy
self.base_delay = base_delay
self.max_delay = max_delay
async def execute_with_retry(self, func: Callable[..., Any], *args, **kwargs):
"""自動再試行とバックオフで実行"""
last_exception = None
for attempt in range(self.max_retries + 1):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt == self.max_retries:
break
# 遅延を計算
delay = self._calculate_delay(attempt)
# 再試行をログ
self.execution_metrics["errors"].append({
"attempt": attempt + 1,
"error": str(e),
"retry_delay": delay
})
await asyncio.sleep(delay)
raise last_exception
def _calculate_delay(self, attempt: int) -> float:
"""再試行戦略に基づいて遅延を計算"""
if self.strategy == RetryStrategy.EXPONENTIAL:
delay = self.base_delay * (2 ** attempt)
elif self.strategy == RetryStrategy.LINEAR:
delay = self.base_delay * (attempt + 1)
else: # FIXED
delay = self.base_delay
return min(delay, self.max_delay)
class CircuitBreaker:
"""繰り返しの再試行からカスケード障害を防止"""
class State(Enum):
CLOSED = "closed" # 通常動作
OPEN = "open" # 失敗、リクエストを拒否
HALF_OPEN = "half_open" # 復旧をテスト中
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: int = 60,
success_threshold: int = 2
):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.success_threshold = success_threshold
self.state = self.State.CLOSED
self.failure_count = 0
self.success_count = 0
self.last_failure_time = None
async def execute(self, func: Callable, *args, **kwargs):
"""サーキットブレーカー保護で関数を実行"""
if self.state == self.State.OPEN:
if self._should_attempt_recovery():
self.state = self.State.HALF_OPEN
self.success_count = 0
else:
raise RuntimeError("サーキットブレーカーはOPEN です")
try:
result = await func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise
def _on_success(self):
self.failure_count = 0
if self.state == self.State.HALF_OPEN:
self.success_count += 1
if self.success_count >= self.success_threshold:
self.state = self.State.CLOSED
def _on_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = self.State.OPEN
def _should_attempt_recovery(self) -> bool:
if not self.last_failure_time:
return True
elapsed = time.time() - self.last_failure_time
return elapsed >= self.recovery_timeout
本番デプロイメントと監視
Antigravityによるパイプラインオーケストレーション
Antigravityのオーケストレーションレイヤーは複雑なパイプラインのデプロイメント、スケーリング、監視を処理します:
# deploy/antigravity_pipeline.py
from antigravity import Agent, Pipeline, Config
import structlog
logger = structlog.get_logger()
class AIAssistantPipeline(Agent):
"""Antigravityによって管理される本番AIパイプライン"""
def __init__(self, config: PipelineConfig):
super().__init__(name="ai-assistant-pipeline", version="1.0.0")
self.config = config
# コンポーネントを初期化
self.validator = InputValidator({"min_length": 10})
self.retriever = ContextRetriever(vector_store, {"top_k": 5})
self.router = DynamicRouter(routes, {"fallback_chain": default_chain})
self.circuit_breaker = CircuitBreaker(failure_threshold=5)
# メトリクストラッキング
self.total_requests = 0
self.successful_requests = 0
self.failed_requests = 0
async def execute(self, user_input: str, session_id: str) -> Dict[str, Any]:
"""完全なパイプラインを実行"""
self.total_requests += 1
context = StageContext(
input_text=user_input,
metadata={"session_id": session_id, "timestamp": datetime.utcnow().isoformat()}
)
try:
# パイプラインステージ
context = await self.validator.run(context)
context = await self.retriever.run(context)
context = await self.router.run(context)
# サーキットブレーカーで選択されたチェーンを実行
response = await self.circuit_breaker.execute(
self._execute_chain,
context
)
self.successful_requests += 1
return {
"status": "success",
"response": response,
"metadata": context.metadata,
"execution_time": sum(context.execution_time.values())
}
except Exception as e:
self.failed_requests += 1
logger.error(
"pipeline_execution_failed",
error=str(e),
session_id=session_id,
component=getattr(e, 'component', 'unknown')
)
return {
"status": "error",
"error": str(e),
"metadata": context.metadata
}
エージェントに実行権限をどこまで渡すか
パイプラインが自律的に動き始めると、設計の重心は「どう繋ぐか」から「何を許すか」へ移ります。
Antigravity v2.2.1(2026-06-25)で統一パーミッション体系が入り、エージェントの操作範囲を明示的に宣言できるようになりました。これは単なる安全機能ではなく、パイプライン設計の語彙そのものです。私は権限を、機能の種類ではなく副作用の可逆性で三段階に切ります。
| 階層 | 操作の例 | 可逆性 | 承認 |
| L1 観測 | 検索、ファイル読み取り、メトリクス取得 | 完全に可逆(副作用なし) | 不要 |
| L2 可逆な書き込み | ブランチへのコミット、キャッシュ更新、下書き保存 | 取り消せる | 事後レビュー |
| L3 不可逆 | 本番デプロイ、課金、メール送信、DB の DELETE | 取り消せない | 事前の人間承認 |
境界を「危険かどうか」で引くと、判断が毎回主観になります。可逆性で引けば、新しいツールを足すたびに答えが一意に決まります。
LangChain のツール層でこれを強制する実装です。ツールごとに階層を宣言させ、L3 は承認コールバックを通らなければ実行できません。
# pipeline/permissions.py
from enum import IntEnum
from dataclasses import dataclass
from typing import Callable, Optional, Any
import logging
logger = logging.getLogger(__name__)
class Tier(IntEnum):
OBSERVE = 1 # 副作用なし
REVERSIBLE = 2 # 取り消せる書き込み
IRREVERSIBLE = 3 # 取り消せない
class PermissionDenied(RuntimeError):
"""承認されなかった操作"""
@dataclass
class PermissionPolicy:
"""どの階層まで自動実行を許すか"""
auto_max: Tier = Tier.REVERSIBLE
approve: Optional[Callable[[str, dict], bool]] = None
def check(self, tool_name: str, tier: Tier, payload: dict) -> None:
if tier <= self.auto_max:
logger.info("auto-approved", extra={"tool": tool_name, "tier": int(tier)})
return
if self.approve is None:
raise PermissionDenied(f"{tool_name}: L{int(tier)} に承認ハンドラがありません")
if not self.approve(tool_name, payload):
raise PermissionDenied(f"{tool_name}: 人間の承認が得られませんでした")
logger.warning("human-approved", extra={"tool": tool_name, "tier": int(tier)})
def guarded(tier: Tier, policy: PermissionPolicy):
"""ツール関数に権限階層を結びつけるデコレータ"""
def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
def inner(**kwargs: Any) -> Any:
policy.check(fn.__name__, tier, kwargs)
return fn(**kwargs)
inner.__name__ = fn.__name__
inner.__doc__ = fn.__doc__
inner.permission_tier = tier # type: ignore[attr-defined]
return inner
return wrap
使う側は、階層の宣言を忘れられません。宣言がなければツールとして登録できないからです。
# pipeline/tools.py
from langchain_core.tools import StructuredTool
from pipeline.permissions import Tier, PermissionPolicy, guarded
policy = PermissionPolicy(
auto_max=Tier.REVERSIBLE,
approve=lambda name, payload: input(f"{name} を実行しますか? {payload} [y/N] ") == "y",
)
@guarded(Tier.OBSERVE, policy)
def search_docs(query: str) -> str:
"""ドキュメントを検索して該当箇所を返す"""
return retriever.invoke(query)[0].page_content
@guarded(Tier.IRREVERSIBLE, policy)
def publish_release(tag: str) -> str:
"""本番へリリースを公開する"""
return deploy_client.publish(tag)
def as_tools(*fns) -> list[StructuredTool]:
for fn in fns:
if not hasattr(fn, "permission_tier"):
raise ValueError(f"{fn.__name__} に権限階層が宣言されていません")
return [StructuredTool.from_function(fn) for fn in fns]
なぜデコレータで包むのか。プロンプトで「危険な操作は確認してください」と書く方式を試したことがありますが、これは指示であって制約ではありません。制約はコードの側に置かないと、モデルが変わった瞬間に崩れます。
エージェントに渡す境界の引き方は、Antigravity 2.0 のエージェントに任せる前に引く、3本の境界線でも別の角度から扱っております。
失敗とコストを実測してから設計を決める
冒頭の失敗に戻ります。あの夜のパイプラインに足りなかったのは、リトライではなくチェックポイントでした。リトライは同じ呼び出しをもう一度試すだけで、どこまで進んだかは覚えていません。
チェックポイントは重い基盤を必要としません。SQLite ひとつで足ります。
# pipeline/checkpoint.py
import sqlite3
import json
from contextlib import closing
from typing import Iterable, Iterator, Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS progress (
run_id TEXT NOT NULL,
item_key TEXT NOT NULL,
result TEXT,
PRIMARY KEY (run_id, item_key)
);
"""
class Checkpoint:
def __init__(self, path: str = "pipeline_state.db") -> None:
self.conn = sqlite3.connect(path, isolation_level=None)
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.executescript(SCHEMA)
def done_keys(self, run_id: str) -> set[str]:
cur = self.conn.execute(
"SELECT item_key FROM progress WHERE run_id = ?", (run_id,)
)
return {row[0] for row in cur}
def record(self, run_id: str, item_key: str, result: Any) -> None:
self.conn.execute(
"INSERT OR REPLACE INTO progress VALUES (?, ?, ?)",
(run_id, item_key, json.dumps(result, ensure_ascii=False)),
)
def resumable(
run_id: str,
items: Iterable[tuple[str, Any]],
ckpt: Checkpoint,
) -> Iterator[tuple[str, Any]]:
"""未処理のアイテムだけを流す。中断しても次回はここから続く"""
already = ckpt.done_keys(run_id)
for key, payload in items:
if key in already:
continue
yield key, payload
呼び出し側は、チェーンの実行結果を一件ずつ記録するだけです。
# pipeline/run.py
from pipeline.checkpoint import Checkpoint, resumable
ckpt = Checkpoint()
RUN_ID = "wallpaper-classify-2026-03"
for key, image in resumable(RUN_ID, iter_assets(), ckpt):
try:
result = classification_chain.invoke({"image": image})
except RateLimitError:
# 途中で落ちても、記録済みの分は次回スキップされる
raise
ckpt.record(RUN_ID, key, result)
INSERT OR REPLACE にしてあるのは、同じキーを再実行しても壊れないようにするためです。冪等でない書き込みを混ぜると、再開が新しい障害の入口になります。
手元で数千枚規模のアセット分類を回したときの計測です。同じ入力集合を、同じモデルとレート制限のもとで三通りの構成で走らせ、途中で一度強制終了させてから再開しました。
| 構成 | 中断後の再処理件数 | 再開時のAPI呼び出し | 完了までの追加時間 |
| リトライのみ | 3,200 件(全件) | 3,200 回 | 約 48 分 |
| + チェックポイント | 約 180 件 | 180 回 | 約 3 分 |
| + 埋め込みキャッシュ | 約 180 件 | 62 回 | 約 1 分 |
数字そのものは環境で変わります。意味があるのは比率です。チェックポイントは再開コストをほぼ定数にし、キャッシュは残った呼び出しをさらに削ります。逆に言えば、キャッシュだけを先に入れても、中断からの復帰は速くなりません。先に効くのはチェックポイントです。
私自身、この順序をコストの見積もりが外れた回数で学びました。キャッシュヒット率を上げる作業は楽しく、進捗も見えます。けれど夜間バッチが落ちて朝に全件やり直す限り、その努力は課金額に現れません。
失敗の分類と復帰の設計は、AIエージェントのエラーリカバリ設計 ― 止まらないパイプラインを構築する実践パターンと併せて読んでいただくと、レイヤーの分担が見えやすいかと思います。並列度そのものを観測から調整する方法は、並列エージェントの同時実行数を固定しない — 観測した詰まりから自動で増減させる設計で扱っております。
明日、最初に置く一行
パイプライン設計を「アーキテクチャの選択」として語ると、どうしても図が先に立ちます。実務で先に効いたのは、もっと素朴なものでした。
途中で止まったとき、どこから再開できるか。
その操作は取り消せるか。
その一件にいくらかかったか。
この三つに答えられる状態を作ってから、ルーティングやメモリの最適化に進むと、手戻りが目に見えて減ります。逆順に進めた過去の私は、洗練されたチェーンを、朝には全件やり直していました。
次の一手として提案したいのは、いま動かしているパイプラインに Checkpoint を一枚差し込み、意図的に途中で止めてみることです。再開が数分で済むなら、その先の最適化は安心して積み上げられます。数十分かかるなら、直すべきはチェーンの構成ではありません。
止まらない仕組みより、止まっても損をしない仕組みを。私はそちらを先に整えるようにしております。