この記事のサンプルに出てくる氏名・電話番号・住所・カード番号・マイナンバーは、すべて架空のテスト用ダミーです。実在の人物やカードとは一切関係ありません。
はじめに
前回の記事で、音声の文字起こしログ(ASR ログ)に含まれる個人情報を、Microsoft Presidio でどこまでマスクできるか試しました。
結果、ルールと NER(固有表現抽出)の組み合わせでは、ASR で崩れた表記にほとんど歯が立ちませんでした。電話番号の 03-1234-5678 が 103、1 になり、会員番号が CSの2026のゼローゼロ60609 になってしまうと、正規表現も Luhn チェックも前提が崩れて拾えません。前回はそこで「今後はローカル LLM などを活用することも考えられますかね?」と締めていました。
今回はその宿題を自分で回収します。崩れた表記でも、前後の文脈から「これは電話番号を読み上げているな」と推論できれば拾えるはずで、それが得意なのは LLM です。ただし大量のデータをマスキングすることを考えると、安易に高性能モデルを使うとトークン利用料で破産してしまう。。。そこで API ではなくローカル LLM を使い、Strands Agents + Ollama という構成で組んでみました。
最終的には AWS の Bedrock AgentCore Runtime に載せたいと考えています。AgentCore Runtime はセッションあたりの最大ハードウェア割り当てが 2vCPU / 8GB で、GPU は使えません。なので「まず 9B モデルで品質の上限を見て、そのあと本番サイズに近い 4B モデルへ落として劣化を測る」という 2 段構えで進めました。この記事はそのローカル検証の記録です。AgentCore への載せ替えは次回やります。
今回の構成
使ったのは次の 3 つです。
Strands Agents
AWS が公開した OSS のエージェント SDK です。少ないコード量でエージェントを作成でき、LLM へのプロンプト投げやツール呼び出しの管理、ループ制御などをやってくれます。
Ollama
ローカルで LLM を動かすフレームワークです。Strands の Ollama プロバイダは Python 版 SDK で提供されています。Qwen3.5 シリーズをこれで動かします。
qwen3.5:9b / 4b
今回モデルとして Qwen3.5 シリーズ(9B / 4B)を選んだのは、日本語性能が手頃なサイズの中で高かったからです。
Nejumi Leaderboard 4(2026-03-06 時点)で 9B が 0.7485、4B が 0.7352 と、9B から 4B に落としてもスコア差が 0.013 しかないです。
もし 4B で行ければコストを大幅に抑えられるかもと期待して選定しました。
組み合わせると構成はこうなります。
アーキテクチャ: LLM には「列挙」だけさせる
設計でいちばん大事にしたのは、LLM にマスク済みの本文を生成させないことです。
「この文章の個人情報を [PERSON] に置き換えて返して」と LLM にお願いするのが素直に見えますが、これをやると原文を勝手に書き換えたり、一部を脱落させたり、ありもしない文を足したりするリスクがあります。また今回はローカル LLM を使う前提なので、出力まで任せると品質がブレそうと判断し、検出だけさせました。
抽出スキーマはこんな形です。今回は PII の種別を 7 つに絞りました。
note に判定根拠を書かせているのは、reasoning の代わりの擬似的な思考メモという狙いです。
from enum import Enum
from pydantic import BaseModel, Field
class PIIType(str, Enum):
PERSON = "PERSON" # 人名(担当者名も含む)
PHONE = "PHONE" # 電話番号(読み上げ・分割表記含む)
EMAIL = "EMAIL" # メール(口頭読み上げ表記含む)
ADDRESS = "ADDRESS" # 住所(建物名・部屋番号含む)
MEMBER_ID = "MEMBER_ID" # 会員番号・お客様番号
MY_NUMBER = "MY_NUMBER" # マイナンバー
CREDIT_CARD = "CREDIT_CARD" # カード番号・有効期限・下4桁
class PIIEntity(BaseModel):
surface: str = Field(description="原文から一字一句そのまま抜き出した文字列")
type: PIIType
note: str = Field(default="", description="判定根拠")
entity_id: int = Field(description="同一実体には同じIDを振る。1始まり")
実装のハマりどころ
素直に組んだつもりが、3 箇所ハマりました。
ハマり①: thinking と構造化出力が両立しない
最初、Strands 経由で qwen3.5:9b に構造化出力(JSON スキーマで形を固定した出力)を投げたら、content が空のまま 200 秒近く返ってこない、という現象に当たりました。
qwen3.5 は thinking 対応モデルで、出力が「thinking チャネル」と「content チャネル」に分かれるようです。
Ollama API の think を有効にしたまま format(JSON スキーマ)を付けると、モデルが推論を全部 thinking 側に書き続けて、肝心の content が埋まらないまま終わってしまいました。
| 組み合わせ | 挙動 |
|---|---|
think=True + format
|
推論が thinking チャネルに吸われ、content が空のまま返る。約 200 秒 |
think=False + format
|
content に有効な JSON が返る(数十秒)。安定 |
なので次のように書いて、Ollama API の think を無効化することで、構造化出力させることができました。
from strands.models.ollama import OllamaModel
model = OllamaModel(
host="http://localhost:11434",
additional_args={"think": False}, # thinking を切って content に JSON を書かせる
model_id="qwen3.5:9b",
temperature=0,
)
ハマり②: 推奨 API が Ollama と非互換だった
もうひとつ、構造化出力の API 選びでもハマりました。Strands が推奨する agent(prompt, structured_output_model=MyModel) という形で Ollama を呼び出すと、StructuredOutputException が出てしまいました。
agent(prompt, structured_output_model=MyModel) では内部で構造化出力を「ツール呼び出しの強制」で実現していて、tool_choice をモデルに強制します。ところが Ollama プロバイダは tool_choice に対応していません。A ToolChoice was provided but is not supported という警告が出たあと、モデルがツールを呼ばずに例外になる、という流れでした。
一方、OllamaModel.structured_output() を直接呼べば、Ollama 固有の format(JSON スキーマ)を使うので tool_choice が要りません。これで 9B / 4B のどちらでも安定して動くようになりました。
# 推奨 API ではなく OllamaModel.structured_output() を直接呼ぶ
async for event in model.structured_output(
_PIIEntitiesLoose,
messages,
system_prompt=EXTRACTION_PROMPT,
):
if "output" in event:
result = event["output"]
(_PIIEntitiesLoose は次のハマり③で出てくる、検証を緩めた受け取り用スキーマです)
ハマり③: 列挙にない type を発明してくる
3 つ目は細かい話です。除外対象(社名・金額・日付)について、モデルが黙って無視するのではなく、DATE_EXCLUDED や AMOUNT_EXCLUDED のような列挙にない type をわざわざ作って「これは除外しました」と返してくる癖がありました。
ここで Pydantic の enum 検証を厳密にかけると、この 1 件のせいで entities 配列全体が弾かれてしまいます。なので一旦 type を str で受ける緩いスキーマで受け取って、その後に列挙外の type を落としました。
なお抽出プロンプトでは、surface を原文のまま返させることを強く念押ししています。ここが崩れると後段の置換が原文を壊すので、いちばん大事な指示です。
# surface の鉄則(最重要)
surface には原文に現れた文字列を【一字一句そのまま】コピーする。
- 数字を半角化したり、読点・空白を除去・挿入したり、整形・正規化することは【禁止】。
- 読み上げの塊は途中で切らず、ひとまとまりで抜き出す。
例:「会員番号はCSの2026のゼローゼロ60609です」→ surface は "CSの2026のゼローゼロ60609"。
- あなたが推測した綺麗な値ではなく、原文のゴミを含む生の文字列だけが正解。
(余談ですが、Homebrew 版の Ollama では llama-server バイナリが同梱されておらず、GGUF モデルが一切起動できないという環境トラブルにも遭いました。公式アプリ版(Homebrew なら cask の ollama-app)に入れ替えたら直りました。同じ症状の人は疑ってみてください。)
9B の結果
まず精度の上限を見るために 9B で流しました。前編と同じ要領で、台本に仕込んだ個人情報で答え合わせをします。Presidio が崩れた表記で取りこぼしていた 6 件に注目してください。
| 項目 | 文字起こし後の姿 | Presidio | 今回(9B) |
|---|---|---|---|
| 電話番号(冒頭) | 103、1 |
❌ 漏れ | ✅ 抽出で検出 |
| 電話番号(末尾) | 10311111の2222 |
❌ 漏れ | ✅ 抽出で検出 |
| 会員番号 | CSの2026のゼローゼロ60609 |
❌ 漏れ | ✅ 抽出で検出 |
| マイナンバー | 123、4、5、678、9012 |
❌ 漏れ | ✅ 抽出で検出 |
| クレジットカード | 411111… |
❌ 漏れ | ✅ 監査で回収 |
| メール | K・ニーナイ…エグザンプルコム |
❌ 漏れ | ✅ 抽出で検出 |
| 人名(山田 / 佐藤健一) | 同左 | ✅ 検出 | ✅ 抽出で検出 |
| 住所(東京都港区高輪…) | 同左 | △ 番地まで | ✅ 抽出で検出(建物名まで丸ごと) |
| 社名(残すべき) | 鴨志賀モバイル… | ー | ✅ 残存 |
| 金額(残すべき) | 1万2800円 | ー | ✅ 残存 |
| 日付(残すべき) | 5月20日 | ー | ✅ 残存 |
合格ライン 9 件(上の 6 件に人名 2 件と住所を加えたもの)すべて、過剰マスク検査(残すべき社名・金額・日付の 3 件)もすべて PASS でした。Presidio が崩れた表記で取りこぼした 6 件を、LLM が文脈推論で全部拾えています。これが今回の主眼だったのでまず一安心です。
おもしろかったのは、クレジットカードの扱いです。temperature を 0 にしても完全には決定的にならず、ランごとにクレカを拾ったり落としたりブレてましたが、監査フェーズで 411111… を追加検出して回収してくれました。
所要時間は、抽出 40.6 秒 + 監査 round1 30.5 秒 + round2 18.9 秒で、合計およそ 90 秒。リアルタイムには遠いですが、ログをまとめてバッチ処理する用途なら十分許容範囲です。残った課題は「佐藤様」という単独の敬称呼びを拾えなかったことくらい(佐藤健一 や SATO は別の箇所でマスク済みなので軽微です)。
4B に落とすとどうなるか
本番想定の AgentCore はセッションあたり最大 2vCPU / 8GB なので、9B より 4B の方が現実的です。そこでアーキテクチャは一切変えず、モデル ID だけ qwen3.5:4b に差し替えて同じパイプラインを流しました。
素の 4B は、合格ライン 8/9・過剰マスク 2/3 と、9B から 2 件リグレッションしました。崩れたのは次の 2 つです。
- 末尾の電話番号
10311111の2222を抽出でも監査でも拾えなかった(取りこぼし、いわゆる recall の劣化) - 残すべき金額
1万2800円を監査が PII と誤判定して過剰マスクした(過剰検出、いわゆる precision の劣化)
どちらも監査フェーズで顕在化しました。そこで監査プロンプトに 2 点だけ追記してみました。
1 つは除外の念押しで「金額は読み上げで桁が崩れても PII ではない。円 / 請求 / お支払いの文脈なら報告しない」。もう 1 つは取りこぼし対策の few-shot で、「末尾の問い合わせ先電話番号も電話番号として報告する」という具体例を 1 件足しました。問題が出たのがどちらも監査側だったので、抽出プロンプトはいじっていません。
結果がこちらです。この監査プロンプトの追記だけで、2 件のリグレッションが同時に解消して 9/9・3/3 に戻りました。
| 項目 | 9B | 4B(素) | 4B(few-shot 後) |
|---|---|---|---|
電話番号(末尾) 10311111の2222
|
PASS | FAIL | PASS |
会員番号 ゼローゼロ60609
|
PASS | PASS | PASS |
マイナンバー 123、4、5、678、9012
|
PASS | PASS | PASS |
クレジットカード 411111…
|
PASS | PASS | PASS |
メール …エグザンプルコム
|
PASS | PASS | PASS |
| 人名 山田 / 佐藤健一 | PASS | PASS | PASS |
| 住所 東京都港区高輪… | PASS | PASS | PASS |
金額 1万2800円(残すべき) |
PASS | FAIL(過剰マスク) | PASS |
| 合計 | 9/9・3/3 | 8/9・2/3 | 9/9・3/3 |
速度も測りました。抽出ワークロード(transcript 全文・JSON 制約・think=False・temperature=0)を同一プロンプトで直接計測した生成速度は、4B が 24.2 tok/s、9B が 14.8 tok/s で、4B が約 1.6 倍速いです。パイプライン全体でも 90 秒から約 50 秒へとほぼ半減しました。
| フェーズ | 9B | 4B(素) | 4B(few-shot 後) |
|---|---|---|---|
| extract | 40.6s | 28.7s | 23.7s |
| audit round1 | 30.5s | 13.1s | 14.8s |
| audit round2 | 18.9s | 7.5s | 8.4s |
| 合計(概算) | 約 90s | 約 50s | 約 47s |
4B は surface に空白や読点を勝手に挿入する癖が 9B より強く出ました。ただ検証フェーズで difflib の fuzzy 照合(類似度 0.85 以上で原文スパンを探す)が全件吸収してくれて、置換は一度も原文を壊しませんでした。「プロンプトで一字一句を強制 → 機械的な照合でフォールバック」の二段構えは、モデルが小さくても有効でした。
まとめと次回
崩れた表記でルール / NER が詰んだ PII を、ローカル LLM の文脈推論 + Python の決定的置換 + 監査ループという組み合わせで全件マスクできました。ポイントを整理すると次の通りです。
- LLM には PII の「列挙」と「消し残しチェック」だけをさせて、置換はコードで決定的にやる
- 小型モデルの取りこぼしやブレは、監査ループと fuzzy 照合という後段の仕組みでかなり吸収できる
- 9B から 4B に落としても、アーキテクチャはそのまま、監査プロンプトの補強 1 箇所で 9B と同等まで戻せた
4B でも実用圏に入りそうな手応えが得られたので、次回はこれを AWS の Bedrock AgentCore Runtime に載せて、ローカルから呼ぶとマスク済みテキストが返ってくる状態を作るところまでやってみます。GPU なし・CPU 推論の本番でどう動くか、楽しみです。
コードはこちらで公開しています。
もし「文字起こし × 個人情報マスキング」でうまくやっている手法や知見があれば、ぜひコメントで教えてください。