1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

概要

キャンペーン記事として過去に以下の記事を投稿しましたが3000リクエストに対して数百リクエスト残ったため使い切る方法を考えました。
さくらのAI Engine × Copilot CLI(BYOK)で作業環境を構築してブレストしてみた
3000リクエストでAI×株価分析をやってみた

1日で2000リクエストくらい消費した成果、レート制限エラーが頻発するようになったためコーディングエージェントでがっつり使うような使い方は避ける必要がありました。

そこで、さくらのAI Engine の無償リクエストで残った 約200 回を、Qiita「さくらのAI」キャンペーン記事に対して 1 記事あたり 1 リクエスト を消費する自動化パイプラインを構築しました。

”いちいち記事を読むのが面倒だけどみんなどんなことやってるのかをLLMの使い方の観点で知りたい”というニーズにこたえるタスクです。

記事本文は Qiita API で取得し、LLM リクエストは Copilot CLI の copilot -p を Python 経由で非対話実行。Windows タスクスケジューラで 5分毎に分散させ、レート制限を回避しつつ正確にリクエスト数をコントロールしています。
この記事では計画から実行トラブルシューティングまでのノウハウをまとめます。

やりたかったこと

さくらのAI Engine キャンペーンでは無償で 3,000 リクエスト/月まで利用できます。今回は最終的に残った 200程度のリクエストを計画的に消費し、期日までに残りを無駄にしない運用を目指しました。
対象は Qiita「さくらのAI」キャンペーン記事一覧。各記事の「テーマ」ではなく「LLM・生成AIの使い方」に注目したメモを自動生成します。

当初は「メモ生成+概要生成」の 2 リクエスト構成でした。1 記事あたり 2 リクエスト処理すれば 400 リクエストにちょうど届く計算です。しかし実際に動かしてみると、1 回の copilot -p 実行が内部で複数回の LLM リクエストを発生させるケースがあり、カウントが正確でない問題が浮上しました。
具体的には、プロンプトに @ファイルパス を含めると、Copilot CLI がファイルを読み込むために追加のツール呼び出しを行い、結果として 1 記事あたり 2 回どころか 3〜4 回のリクエストが消費されることがありました。これでは 400 リクエストのコントロールができません。
そこで、途中で設計を見直し、最終的に 1 記事 1 リクエストのメモのみ生成 にシンプル化しました。メモの品質は「概要」がなくても十分役立つものにし、リクエスト数の正確性を最優先しました。

背景:なぜバッチ化が必要だったか

手動で プロンプトを入力するのは現実的ではありません。1 回あたり 20〜60 秒かかるとすると、単純計算で 2.2 時間〜6.7 時間かかります。しかも人間が途中で休憩したり他の作業をしたりすると、さらに時間がかかります。

また、短期間に大量のリクエストを投げると、さくらのAI Engine 側のレート制限に引っかかる可能性があります。無償枠とはいえ、サービスを安定して利用するためには、リクエストをある程度分散させる必要があります。

そこで、Windows タスクスケジューラを使って 5 分おきに自動実行する方式を採用しました。これにより、人間が介在せずに 計画的にリクエストを消費できるようになります。

最終的な構成

1 記事あたり 1 リクエスト

用途 入力 出力
メモ生成 記事タイトル、URL、本文 LLM の使い方に絞った 6 項目の箇条書きメモ

メモの項目は以下の通りです。

  • 使用モデル
  • 用途
  • プロンプト戦略
  • 工夫
  • 結果
  • 再利用のヒント

これらの項目は、読者が「この記事で紹介されている LLM の使い方を、自分のプロジェクトでどう活かせるか」を素早く把握できるように選びました。テーマそのものではなく、実践的なノウハウに焦点を当てています。

スケジュール設計

項目 理由
実行間隔 5 分 レート制限を回避しつつ 6 時間で 400 リクエストに届くペース
1 バッチの記事数 最大 3 件 1 バッチ = 3 リクエスト、72 バッチで 216 件処理可能
実行時間制限 30 分 一部の copilot -p 呼び出しが時間を要するため余裕を持たせる
同時実行 禁止 タスクスケジューラの IgnoreNew で制御

5 分間隔を選んだ理由は、1 分あたり平均 0.6 リクエストという非常に低い頻度に抑えられることです。レート制限の具体的な閾値は公開されていませんが、数分おきに数リクエスト程度なら安全だろうと判断しました。

使用したファイル

work/
├── data/campaign_articles/
│   └── article_queue.json          # 処理キュー & リクエスト数カウンタ
├── output/campaign_summaries/
│   ├── memo/{id}_memo.md           # 生成した LLM 使い方メモ
│   └── batch.log                   # 実行ログ
└── scripts/
    ├── fetch_campaign_articles.py  # 記事一覧取得 & キュー更新
    ├── generate_memo.py            # 1 記事 1 リクエストでメモ生成
    ├── run_memo_batch.ps1          # バッチ実行スクリプト
    └── register_memo_task.ps1      # タスクスケジューラ登録

キューの構造

article_queue.json は以下のような構造です。

{
  "target_req": 400,
  "target_articles": 400,
  "total_req": 15,
  "updated_at": "2026-08-25T11:49:44+09:00",
  "articles": [
    {
      "id": "e8985c8c5a040213ab72",
      "title": "さくらのAI Engineのモデルに人狼をやらせて比較してみた",
      "url": "https://qiita.com/.../e8985c8c5a040213ab72",
      "created_at": "2026-08-...",
      "status": "done",
      "req_count": 1,
      "memo_file": "C:\\data\\quiita\\work\\output\\campaign_summaries\\memo\\e8985c8c5a040213ab72_memo.md"
    }
  ]
}

statuspending / done / error の 3 値を取ります。total_reqtarget_req に達すると、それ以上の処理を行いません。

なぜ Python で copilot -p をラップしたか

最初は PowerShell パイプラインでそのままファイル出力していました。具体的には、次のようなコマンドを想定していました。

$memo = copilot -p "..." 2>&1 | Out-String
$memo | Out-File -FilePath "memo.md" -Encoding UTF8

しかし、これで生成されたメモの日本語が文字化けしていました。調べてみると、原因は PowerShell 5.1 のパイプライン処理にありました。

文字化けの原因

PowerShell 5.1 では、Out-StringOut-File がテキストを処理する際に、コンソールのコードページ(日本語 Windows では CP932 / Shift_JIS)を経由します。copilot -p 自体は正しい UTF-8 バイト列を出力しているのですが、PowerShell のパイプラインを通ると CP932 に変換され、その後 UTF-8 としてファイル保存しようとして文字化けが発生していました。

Python ラッパーへの切り替え

対策として、Python の subprocess.run(capture_output=True) で生の UTF-8 バイト列を取得し、Path.write_text(encoding="utf-8") で直接保存する方式に切り替えました。

result = subprocess.run(
    [str(NODE_EXE), str(COPILOT_LOADER), "-p", prompt, "--allow-all", "--silent", "--no-color", "--stream", "off", "--disable-builtin-mcps"],
    capture_output=True,
    env=env,
    timeout=180,
)
stdout = result.stdout.decode("utf-8", errors="replace").strip()
Path(memo_file).write_text(stdout, encoding="utf-8")

この方式では、PowerShell のパイプラインを経由しないため、UTF-8 のままファイルに保存できます。結果として、日本語の文字化けは完全に解消しました。

また、Copilot CLI は nvm for Windows でグローバルインストールしていたため、通常の PATH では見つからず、Python 側で直接ノード実行ファイルとローダーを指定する必要がありました。

NODE_EXE = Path(r"C:\nvm4w\nodejs\node.exe")
COPILOT_LOADER = Path(r"C:\nvm4w\nodejs\node_modules\@github\copilot\npm-loader.js")

リクエスト数を正確に 1 に抑える工夫

copilot -p はプロンプトにファイルパスを含めると、そのファイルを読み込むための追加ツール呼び出しが発生し、結果として複数回の LLM リクエストを消費することがあります。これを避けるため、以下の対策を入れました。

ファイル参照を使わず、本文を直接埋め込む

当初はプロンプトに @work/data/campaign_articles/raw/{id}.md のようにファイルパスを指定していました。これにより Copilot CLI がファイル読み取りツールを呼び出し、追加のリクエストが発生していました。

修正後は、Python 側で Qiita API から取得した本文を直接プロンプト文字列に埋め込みます。

def build_prompt(title: str, url: str, body: str) -> str:
    truncated = truncate_body(body)
    return (
        "以下の Qiita 記事から、「テーマ」ではなく「LLM・生成AIの使い方」にだけ注目して、"
        "以下の 6 項目について日本語でメモを作成してください。\n\n"
        "- 使用モデル\n"
        "- 用途\n"
        "- プロンプト戦略\n"
        "- 工夫\n"
        "- 結果\n"
        "- 再利用のヒント\n\n"
        "出力ルール:\n"
        "1. 先頭に ## 記事情報 として、記事タイトルと記事URLを必ず含めてください。\n"
        "2. 各項目を ## 見出しで始め、その下に - で箇条書きを記述してください。\n"
        "3. 各箇条書きは短く具体的にしてください。\n"
        "4. URL はプレーンテキストで出力し、Markdown リンク構文や端末用ハイパーリンクを使わないでください。\n"
        "5. ツール呼び出しは不要です。テキストのみ出力してください。\n"
        "6. 情報がわからない項目は「不明」と記載してください。\n\n"
        f"【記事情報】\n"
        f"タイトル: {title}\n"
        f"URL: {url}\n\n"
        f"【記事本文】\n"
        f"{truncated}"
    )

MCP サーバーの無効化

Copilot CLI はデフォルトで GitHub MCP サーバーなどを有効にしており、これらが追加のツール呼び出しを引き起こす可能性があります。--disable-builtin-mcps オプションを追加することで、これらを無効化しました。

その他のオプション

--allow-all    : ユーザー確認なしでツールを実行(今回はツール呼び出し自体を無効化)
--silent       : 余計な進行状況出力を抑制

出力のクリーニング

--no-color を指定しても、Copilot CLI は端末用ハイパーリンク(OSC 8 シーケンス)を挿入することがありました。また、出力に Markdown リンク構文 [text](url) が含まれることもありました。これらを正規表現でクリーニングしています。

def clean_output(text: str) -> str:
    # OSC 8 ハイパーリンク
    text = re.sub(
        r"\x1b\]8;[^;\x07]*;([^\x07]+)\x07[^\x1b]*\x1b\]8;;\x07",
        r"\1",
        text,
        flags=re.DOTALL,
    )
    text = re.sub(r"\x1b\]8;[^\x07]*\x07", "", text)
    # Markdown リンク -> URL のみ
    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\2", text)
    return text

実行中に遭遇した問題と対応

1. 特定の記事で copilot -p が応答を返さない

Hermes Agent × さくらのAI Engineで、AIエージェントを作ってみた という記事で、copilot -p が応答を返さず 10 分の実行制限で強制終了していました。同じ記事を繰り返し処理しようとして、タスクが起動してすぐ終了する状態になっていました。

ログを見ると、11:18 にこの記事の処理を開始した後、11:23・11:28・11:33 と後続のスケジュールが起動するも、すべて同じ記事から始まってすぐに終了していました。タスクスケジューラの「新しいインスタンスを開始しない」設定が有効になっていたため、前回の実行が終わっていない間は新規実行もブロックされ、結果として全く進まなくなっていました。

原因を調べると、copilot -p は内部的に推論モデルを呼び出しており、特定のプロンプトでは思考に時間がかかり、長時間応答を返さないケースがあるようです。エラーハンドリングがないため、タイムアウトするまでキューが更新されず、次回も同じ記事を処理しに行くループに陥っていました。

対応:

  • タスクの実行時間制限を 10 分から 30 分に延長
  • generate_memo.pysubprocess.run(..., timeout=180) を追加
  • タイムアウトした場合はキューを error 状態にしてスキップ
try:
    memo = clean_output(call_copilot(build_prompt(article["title"], article["url"], body)))
except Exception as exc:
    article["status"] = "error"
    article["error"] = str(exc)
    queue["updated_at"] = datetime.now(JST).isoformat()
    queue_path.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"[memo] ERROR skipped {args.article_id}: {exc}")
    return

2. Qiita API から 403 Forbidden

バッチごとに記事リストを再取得していたところ、しばらくすると Qiita API から 403 Forbidden が返るようになりました。これはレート制限やアクセス制限によるものと考えられます。

対応:

  • fetch_campaign_articles.py に指数バックオフリトライを追加
  • リフレッシュ失敗時は既存キューを維持して処理を継続
  • run_memo_batch.ps1 で fetch 失敗でも処理を止めないように変更
for attempt in range(3):
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        if exc.code in (403, 429) and attempt < 2:
            wait = 2 ** attempt
            print(f"[fetch] HTTP {exc.code} on {url}, retrying in {wait}s...")
            time.sleep(wait)
            continue
        raise

fetch に失敗しても、既存のキューが残っていれば未処理の記事を継続して処理できます。これにより、一時的な API 制限でも全体の進行が止まらなくなりました。

3. 日本語メモの文字化け

前述の通り、PowerShell 5.1 のパイプラインを通すと日本語が文字化けしました。Python ラッパーに切り替えることで解決しました。同様の問題に遭遇する方は、PowerShell 側でファイル保存せず、必ず Python や他の UTF-8 対応ツールで直接保存することをおすすめします。

タスクスケジューラの登録

タスクは register_memo_task.ps1 で登録しました。ポイントは以下の通りです。

$Action = New-ScheduledTaskAction -Execute "powershell.exe" `
    -Argument "-ExecutionPolicy Bypass -NoProfile -File `"$BatchScript`""

$Trigger = New-ScheduledTaskTrigger -Once -At $StartTime `
    -RepetitionInterval (New-TimeSpan -Minutes 5) `
    -RepetitionDuration (New-TimeSpan -Hours 6)

$Settings = New-ScheduledTaskSettingsSet `
    -AllowStartIfOnBatteries `
    -DontStopIfGoingOnBatteries `
    -StartWhenAvailable `
    -MultipleInstances IgnoreNew `
    -ExecutionTimeLimit (New-TimeSpan -Minutes 30)

MultipleInstances IgnoreNew は重要です。前回の実行が終わっていない場合、新しいインスタンスを開始せず、前回の完了を待ちます。これにより、同じ記事を並列処理したり、リクエスト数が意図せず増えたりするのを防ぎます。

最初は LogonTypeS4U にしようとしましたが、非管理者権限では拒否されたため、Interactive に戻しました。ユーザーがログオンしている状態であれば問題なく動作します。

バッチ処理動作中
image.png

監視方法

実行中は以下の方法で進捗を確認しました。

1. タスクスケジューラの状態

Get-ScheduledTask -TaskName "SakuraCampaignMemoBatch" | Get-ScheduledTaskInfo

LastTaskResult0 なら正常終了、1 なら何らかのエラー、267009 は実行中を表します。

2. バッチログの確認

Get-Content -Tail 40 -Encoding UTF8 "C:\data\quiita\work\output\campaign_summaries\batch.log"

各バッチで処理した記事 ID、成功/失敗、total_req の値が記録されています。

3. キューファイルの確認

$q = Get-Content "C:\data\quiita\work\data\campaign_articles\article_queue.json" -Encoding UTF8 | ConvertFrom-Json
$q.total_req
($q.articles | Where-Object { $_.status -eq "done" }).Count

4. さくらのAI Engine コントロールパネル

実際のリクエスト消費量は、さくらのAI Engine のコントロールパネルで確認できます。キューの total_req と照合することで、意図しない余分なリクエストが発生していないかチェックできます。

得られた知見

  • PowerShell 5.1 は UTF-8 出力を扱うのが難しい。日本語を扱うバッチ処理では Python ラッパーが安定。
  • copilot -p のファイル参照は追加リクエストを誘発する。本文を直接埋め込むことで 1 プロンプト 1 リクエストを実現できる。
  • 長い応答を待つ記事は必ず存在する。タイムアウトとスキップの仕組みがないと、全体の進行が止まる。
  • 外部 API はレート制限・ブロックを受ける。毎回リストを取らず、失敗時のフォールバックを持つことが重要。
  • キュー管理でリクエスト数を可視化すると、安心して自動運用できる。
  • タスクの実行時間制限は余裕を持たせる。1 件の処理が長引いても、次回の実行に影響を与えないよう設計する。
  • 同じ記事の繰り返し処理を防ぐstatus 管理と MultipleInstances IgnoreNew で重複を排除する。

生成されたメモの具体例

以下は、実際に生成されたメモの一例です。

# サーバスクリプトからAIを呼ぶ小ネタ3本
https://qiita.com/pmc-ko/items/0fa6e6c147aa2d1d999b

記事情報

 - タイトル: サーバスクリプトからAIを呼ぶ小ネタ3本
 - URL: https://qiita.com/pmc-ko/items/0fa6e6c147aa2d1d999b

使用モデル

 - gpt-oss-120b(さくらのAI Engine API)
 - 推論モデルであり、content と reasoning にレスポンスが分かれる

用途

 - 問い合わせ内容から種別・緊急度を自動分類
 - コメントのやりとりを3行で引き継ぎ要約
 - 状況変化時の通知文を自然な日本語で生成してSlack通知

プロンプト戦略

 - システムプロンプトで役割を明確に指定(ヘルプデスク一次受付、引き継ぎ担当)
 - 選択肢や出力形式をプロンプト内で厳密に指定
 - JSON出力を指示し、コードフェンスや前後の説明文を禁止
 - 各行60文字以内、行頭番号禁止など形式制約を明示

工夫

 - AIの答えをそのまま使わず、ホワイトリストで照合してから代入
 - 人が既に入力している値はAIで上書きしない
 - content が空の場合を明示的にハンドリング
 - BeforeUpdate で更新前の値を context.UserData に保存し、AfterUpdate で差分検知
 - AI生成に失敗しても通知そのものは止めないフォールバック文面を用意

結果

 - 分類は「月末の請求書処理があるので今日中に」など、キーワードマッチでは出せない判断が可能
 - コメント4件から「何が起きた/何をした/次に何をすべきか」の3行要約が成功
 - 通知文は項目の羅列ではなく、読める日本語の文章になった

再利用のヒント

 - 分類項目には必ずホワイトリスト照合を入れ、無効な選択肢値が入るのを防ぐ
 - 推論モデルでは max_tokens を余裕をもって取り、content 空をチェックする
 - 更新前後の差分処理には context.UserData を使う
 - プリザンターの HttpClient 型通知はサーバスクリプトから使えないため、Slack型やTeams型を使う

このように、テーマそのものではなく「どう使ったか」「何に気をつけたか」が整理されます。

この手法を自分で試す際に行った作業

  1. Copilot CLI をインストールする

    • npm install -g @github/copilot
  2. スクリプトを配置する

    • scripts/fetch_campaign_articles.py
    • scripts/generate_memo.py
    • scripts/run_memo_batch.ps1
    • scripts/register_memo_task.ps1
  3. 初回キューを作成する

    • python scripts/fetch_campaign_articles.py
  4. タスクスケジューラに登録する

    • powershell scripts/register_memo_task.ps1
  5. 監視する

    • output/campaign_summaries/batch.log
    • data/campaign_articles/article_queue.json
    • さくらのAI Engine コントロールパネル

実行結果サマリー

160件くらい記事があったので全部やりたかったものの動作チューニング等でそこそこリクエストを消費した都合で残リクエストで160件すべてやりきることができず結局110件でストップさせました。
この110件に対してAIつかって分析させたところ、、、再度レート制限が発生。。。
3000リクエストで上限はきまっているのだからレート制限はやめてほしいところですが・・・無料なので強くは言えません。
結局さくらのAIがまともに使用できなくなり最後のサマリ作成だけ github copilot(gpt5.6-luna)の無料枠で作りました。

概略

蓄積された105件のQuiita記事メモを対象に、LLMの用途、プロンプト戦略、実装上の工夫、結果・課題、モデルファミリーを集計した。 最も目立つ傾向は、コード開発との組み合わせ(35.2%)、コンテンツ生成(30.5%)、自動化・ワークフロー(21.9%)であり、LLM単体ではなく既存ソフトウェアの一部として組み込む利用が中心だった。

やったこと

105件のMarkdownメモを読み込み、定型セクション(使用モデル、用途、プロンプト戦略、工夫、結果、再利用のヒント)を抽出した。
キーワード辞書による記事単位のカテゴリ判定を行い、同一記事内の重複キーワードは1件として数えた。
モデル名をファミリー単位に正規化した。gpt-oss-120b、Kimi、Qwen、Gemma、LLM-jp、Phiを集計した。
集計結果をCSVに保存し、カテゴリ別の比率とモデルファミリーの件数をグラフ化した。
主な集計結果

image.png

image.png

image.png

ということで最後の30リクエスト分は残ったものの3000リクエスト使い切りました。

image.png

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?