はじめに
前回の記事では、生成AI API初心者の自分が、さくらのAI Engineの無償プランを契約してPowerShellから初めてのAPIコールをするところまでを記録しました。
今回はその続きとして、Laravelで**「殴り書きの作業メモを、技術記事向けのMarkdownに変換するツール」**を作ります。名前は memo2md。ローカル専用・SQLite・変換履歴つきの、完全に自分用のツールです。
そして先に結論を言うと、このツールの出力を検品していく過程で、AIがメモに書いていない架空のコマンドを創作して下書きに混ぜてくる現場を押さえました。本記事の後半はその検出と対策の記録です。
なお本記事の制作過程を正直に書いておくと、当日の作業メモをmemo2mdに変換させた下書きを骨格にして、構成の相談と仕上げはAIアシスタント(Claude)と一緒に行い、最終チェックと加筆を人力でやっています。前回に続き「AIに聞きながら」の体制です。そして皮肉なことに、そのmemo2mdの下書きの中に捏造が見つかりました。
本記事は2026年7月時点の画面・仕様をもとにしています。
作ったもの
やることはシンプルで、こういう殴り書きを貼ると——
powershellでさくらのAPI叩いたら日本語が文字化けした
最初PS5.1だったのでwinget install --id Microsoft.PowerShell で7入れた
7.6.3になったけどまだ化ける
結局UTF8GetStringで28591経由で戻したら直った
教訓:エラーっぽく見えても中身確認する
——こういうMarkdownが返ってきます。
---
title: "PowerShellからさくらのAI Engine APIを呼び出す際の文字化け対策"
date: 2026-07-20
tags:
- さくらのAI
- PowerShell
---
## はじめに
本稿では、PowerShell からさくらのAI Engine を呼び出した際に発生した
日本語文字化けの原因と対策についてまとめます。
(以下略)
フロントマター(title / date / tags)の生成、見出し構造への再編成、コマンド断片のコードブロック化、です・ます調への整形までAIがやってくれます。
構成
入力フォーム(Blade)
↓
ConvertController(入力検証)
↓
SakuraAiClient(Service:プロンプト組み立てとAPI呼び出し)
↓
さくらのAI Engine(OpenAI互換 Chat Completions / gpt-oss-120b)
↓
結果画面(プレビュー・コピー・.mdダウンロード)+SQLiteに履歴保存
ポイントは、Controllerに直接HTTPリクエストを書かず、SakuraAiClientというServiceクラスに切り出したことです。プロンプトの試行錯誤(後述)はこのクラスだけを書き換えれば済みますし、前回PowerShellで書いたリクエストJSONの構造が、ほぼそのままこのクラスに移植できました。
実装のポイント
SakuraAiClient(API呼び出し部分)
app/Services/SakuraAiClient.php を新規作成し、API呼び出しをここに集約しています。
// app/Services/SakuraAiClient.php
$response = Http::withToken(config('services.sakura.token'))
->timeout(120)
->post(config('services.sakura.base_url').'/chat/completions', [
'model' => config('services.sakura.model'),
'messages' => [
['role' => 'system', 'content' => $this->systemPrompt()],
['role' => 'user', 'content' => $memo],
],
'temperature' => 0.2,
'max_tokens' => 2000,
'stream' => false,
])
->throw()
->json();
model・messages・temperatureをJSONで送る、という骨格は前回PowerShellでやったことと完全に同じです。言語が変わっても学びが持ち越せるのは、OpenAI互換APIのいいところだと感じました。
systemプロンプトはどこに書くのか
変換ルールのプロンプトは、同じSakuraAiClient.phpの中のprivateメソッドに閉じ込めています。上のコードの$this->systemPrompt()の中身がこれです。
// app/Services/SakuraAiClient.php(同じファイルの下のほう)
private function systemPrompt(): string
{
$today = now()->toDateString();
return <<<PROMPT
あなたは技術ブログの編集者です。ユーザーが送る作業メモを、技術ブログ記事のMarkdownに変換してください。
ルール:
- 冒頭にYAMLフロントマター(title, date, tags)を付ける。dateは {$today} とする
- 「はじめに」「発生した問題」「試したこと」「まとめ」のような見出し構造に再編成する
- コマンドやコードの断片は、言語指定付きのコードブロックで囲む
- メモの口調を、記事向けの丁寧な文体(です・ます調)に整える
- メモに書かれていない事実・手順・数値・コマンドを追加しない(捏造禁止)
- 出力はMarkdown本文のみとし、前置きや説明文を付けない
PROMPT;
}
日付だけはAIに任せずPHPのnow()で埋め込んでいます(AIは今日の日付を知らないため)。これが初期版で、後半の検品を経てルールが3行増えることになります。
履歴の保存
conversionsテーブル(入力メモ・出力Markdown・使用モデル・APIのusage情報)に毎回保存しています。usageカラムにはレスポンスに含まれるトークン消費量をJSONのまま入れてあり、将来「無償枠3,000リクエストをどれだけ使ったか」を可視化するための布石です。
セットアップでハマったこと
WSL2のUbuntuにPHPが入っていなかった
composer -Vを実行したら、Windows側にインストールしてあったComposer(/mnt/c/ProgramData/...)がPATH越しに見えて、php: not found。Ubuntu側にはPHPもComposerも入っていませんでした。
sudo apt install php8.5-cli php8.5-xml php8.5-curl php8.5-sqlite3 \
php8.5-mbstring php8.5-zip unzip composer
php8.5-sqlite3を忘れるとphp artisan migrateで「could not find driver」になるので注意です。
また、プロジェクトはWSL側のホームディレクトリ配下(~/laravel/memo2md)に置きました。/mnt/c/配下に置くとファイルI/Oが遅くなるためです。
動かしてからの3連戦
初回の変換ボタンを押してから成功するまでに、エラーを2段階くぐりました。
第1戦:URI must include a scheme and host
変換ボタンを押した瞬間に500エラー。
「POST先のURLにスキームとホストがない」=base_urlの設定が空、という意味でした。原因は自分のミスで、config/services.phpに書くはずの設定をSakuraAiClientのファイルに書いてしまっていたこと。設定を正しい場所に移動して解決です。
第2戦:401 Invalid token
次は認証エラー。ここで切り分けに使ったのが「Laravelの外から同じトークンでcurlを叩く」でした。
curl https://api.ai.sakura.ad.jp/v1/chat/completions \
-H "Authorization: Bearer 発行したトークン" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-oss-120b","messages":[{"role":"user","content":"hi"}]}'
curlは成功。Laravelだけ401。 つまりトークン自体は有効で、Laravelが読んでいる値がどこかで壊れている、と絞り込めます。
第3戦:hexダンプで犯人逮捕
見えない文字(改行コードや空白)の混入を疑って、Laravelが実際に読んでいるトークンをhexダンプしました。
php artisan tinker --execute='$t=(string) config("services.sakura.token"); dump(strlen($t), bin2hex(substr($t,0,4)), bin2hex(substr($t,-4)));'
結果、先頭4文字のhexが 55 55 49 44。文字に戻すと——「U」「U」「I」「D」。
.envに書式例として書いた SAKURA_AI_TOKEN=UUID:シークレット の「UUID」の部分を、実際の値に置き換えずそのまま貼っていました。お恥ずかしい限りですが、サーバーは何も間違っていなくて、「UUIDというトークンは知らない」と正しく401を返していただけでした。
なお、このデバッグの過程で、見えない空白や改行コード(CRLFの\rなど)が混入しても事故らないよう、トークンの読み込みにtrim()を挟む変更も入れました。今回の直接の原因ではありませんでしたが、.envをWindows側のエディタで編集することもあるので、再発防止として残しています。
// 変更前
$response = Http::withToken(config('services.sakura.token'))
// 変更後:前後の空白・改行が混ざっても除去される
$response = Http::withToken(trim((string) config('services.sakura.token')))
前回の記事で得た教訓は「文字化け=API失敗とは限らない。まず生のレスポンスの中身を見る」でした。今回もまったく同じで、見えないもの(トークンの中身)をhexで可視化したら一発。教訓が1記事またいで回収された形です。
出力の検品:AIは3種類の「余計なこと」をした
初変換に成功したあと、出力Markdownを検品して、systemプロンプトを段階的に調整しました。見つかった問題は3種類です。
その1:タグの表記ゆれ(軽症)
tagsに「さくらのAI-Engine」という存在しないハイフン結合の造語が生成されていました。プロンプトに次のルールを追加したら、次の出力から直りました。
- tagsは3〜5個。技術要素の一般名詞を使い、独自の造語やハイフン結合をしない。さくらのAI Engineに言及する場合のタグ表記は「さくらのAI」で固定する
その2:推測の断定化(中症)
メモには「たぶんUTF-8のレスポンスをLatin-1として読んでた」と推測で書いたのに、出力では「誤認識していたことが原因でした」と断定に格上げされていました。そこで次のルールを追加。
- メモ内で推測として書かれている内容(「たぶん」「〜かも」等)は、断定せず「〜の可能性が高い」等の表現を保つ
2回目の出力では「原因であることを示しています」まで軟化しました。ただし「たぶん」のニュアンスが完全に保たれたわけではなく、プロンプトで矯正はできるが完璧にはならないというのが現時点の結論です。ここは沼なので、深追いせず「出力は必ず人間が確認する」運用でカバーすることにしました。
その3:架空のコマンドの創作(重症)
そして当日の作業ログをmemo2mdで下書き化したとき、決定的なものが出ました。下書きにこう書かれていたのです。
# tinker の代替コマンド
php artisan tinker --execute="config('services.sakura_ai')"
このコマンド、実行していません。 元のメモには「tinkerの一発コマンドに切り替えた」と行為だけ書いて、コマンド本体を書いていませんでした。するとAIは空白を埋めるために、それらしいコマンドを創作したのです。しかも設定キーがservices.sakura_ai(実物はservices.sakura)で、コピペしても動かない架空のコードでした。
怖いのは、見た目が完全にもっともらしいことです。自分で実行した本人だから気づけましたが、他人のメモを変換していたら素通りしていたと思います。
ここから得た知見はこうです:
メモに「行為」だけ書いてあって「実物(コマンドや値)」が無いとき、AIは実物を発明して埋めようとする。
対策として、プロンプトに次のルールを追加しました。あわせて運用側でも「メモにはコマンドを省略せず貼る」を心がけることにしています。
- メモにコマンドや値が書かれていない場合、コードブロックを創作せず本文で言及するに留める
このルールを追加して同じメモを再変換したところ、架空のコマンドは生成されなくなり、「tinker --execute の一発コマンドに切り替えて設定値の確認を行いました」という本文での言及に変わりました。問題発見→ルール追加→再検証、でひとまず一区切りです。とはいえ生成AIの出力は同じ入力でも毎回揺れるので、最終的な検品は人間の仕事として残しておきます。
調整後のsystemプロンプト全文(現時点の最終版)
あなたは技術ブログの編集者です。ユーザーが送る作業メモを、技術ブログ記事のMarkdownに変換してください。
ルール:
- 冒頭にYAMLフロントマター(title, date, tags)を付ける。dateは {今日の日付} とする
- 「はじめに」「発生した問題」「試したこと」「まとめ」のような見出し構造に再編成する
- コマンドやコードの断片は、言語指定付きのコードブロックで囲む
- メモの口調を、記事向けの丁寧な文体(です・ます調)に整える
- メモに書かれていない事実・手順・数値・コマンドを追加しない(捏造禁止)
- 出力はMarkdown本文のみとし、前置きや説明文を付けない
- tagsは3〜5個。技術要素の一般名詞を使い、独自の造語やハイフン結合をしない。さくらのAI Engineに言及する場合のタグ表記は「さくらのAI」で固定する
- メモ内で推測として書かれている内容(「たぶん」「〜かも」等)は、断定せず「〜の可能性が高い」等の表現を保つ
- メモにコマンドや値が書かれていない場合、コードブロックを創作せず本文で言及するに留める
まとめ
- 前回PowerShellで書いたAPIリクエストの構造は、そのままLaravelのServiceクラスに移植できました。OpenAI互換のありがたみです
- エラーは一段ずつ深くなる(URI無し→401→成功)。各段階で原因が違うので、curlでの切り分けとhexダンプによる可視化が有効でした
- AIの出力検品では、①表記ゆれ、②推測の断定化、③架空コマンドの創作、の3段階の「余計なこと」が見つかりました。①はプロンプトで完治、②は軽減止まり、③はルール追加後の再変換で改善を確認しましたが、プロンプト+人間の検品の両方が必須です
- 「見えないものは可視化して中身を見る」という前回の教訓は、文字化けにもトークンにもAIの出力にも、全部効きました
無償枠の3,000リクエストはまだほとんど残っているので、引き続きこのツールを育てながら使い倒していきます。


