「監査ログ、どこまで残せばいいんですか?」
先週、都内のある食品メーカーの会議室で、情シス部長にそう聞かれた。前回の設計レビューから二週間、彼らは社内 RAG のプロトタイプを 5 部署に展開したところだった。動くものはできた。だが、法務と内部監査から「これ、本番に上げていいの?」という差し戻しを受けたという。
会議室のホワイトボードには、赤マーカーで「PII」「監査」「権限」と三つだけ書かれていた。その三つがクリアにならないと、経営会議に上げられない。担当者の顔には焦りより諦めが半分あった。「動くもの」を作るのと「運用に耐えるもの」の間には、思ったより深い谷がある。筆者もこの現場に入るまで、正直そこを甘く見ていた。

前提 — どこまでを「セキュア」と呼ぶか
想定する読者は、社内向け LLM システムを PoC から本番に移そうとしている情シス·CTO 補佐·エンジニア。会社規模は従業員 300〜3000 人、SOC 2 や ISO 27001 の準拠が営業要件になり始めた層である。スタックは Python 3.11、LangChain 系、ベクトル DB は pgvector か Qdrant、LLM は OpenAI と自前ホスティングの gpt-oss を併用、というのが今回のクライアントの構成だった。
「セキュア」の定義は会社ごとに違う。今回は四点に絞る。個人情報が意図せず外部 API に漏れないこと。誰がいつ何を聞いたかが後から追えること。ある部署の資料が別部署から見えないこと。そして LLM の出力が下流システムを壊さないこと。順に見ていく。
ステップ 1 — PII マスキング: ルールと NER のハイブリッド
最初は全部ルールベースで済ませようと思った。電話番号もメールもマイナンバーも正規表現で拾える。実際、初期実装は 200 行ほどの正規表現で 8 割方カバーできていた。
ただ日本語の氏名と住所で詰まった。「田中」は名字か地名か。「東京都渋谷区」は住所の一部だが、社内文書では単に所在の記載に過ぎないことも多い。文脈を見ないと過剰マスキングになって、出力が「■■さんが■■で■■を担当」だらけになる。ここで方針を切り替えた。形式が固定された PII (メール·電話·マイナンバー·クレカ) はルールで高速に、氏名·住所·組織名は Microsoft Presidio + spaCy の日本語 NER でスコア付きに判定する、というハイブリッドである。
Presidio に日本語カスタム recognizer を差し込む最小例はこうなる。
from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
from presidio_analyzer.nlp_engine import NlpEngineProvider
my_number = PatternRecognizer(
supported_entity="JP_MY_NUMBER",
supported_language="ja",
patterns=[Pattern("mynumber", r"\b\d{4}[- ]?\d{4}[- ]?\d{4}\b", 0.85)],
)
config = {
"nlp_engine_name": "spacy",
"models": [{"lang_code": "ja", "model_name": "ja_core_news_lg"}],
}
nlp_engine = NlpEngineProvider(nlp_configuration=config).create_engine()
analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["ja"])
analyzer.registry.add_recognizer(my_number)
def mask(text: str) -> str:
results = analyzer.analyze(text=text, language="ja")
# 信頼度 0.6 以上のみマスク (過剰マスキング防止)
out, cursor = [], 0
for r in sorted([x for x in results if x.score >= 0.6], key=lambda x: x.start):
out.append(text[cursor:r.start] + f"<{r.entity_type}>")
cursor = r.end
out.append(text[cursor:])
return "".join(out)
閾値 0.6 は魔法の数字ではない。クライアント側で誤マスクと漏れマスクを 200 サンプル手動評価して、業務で受容できる線を探った結果である。初期値 0.5 だと過剰、0.75 だと氏名の取りこぼしがあった。ここは業種と文書の質でチューニングするしかない。
もう一つ、テキスト以外を忘れない。実運用では PowerPoint と Excel が半分近くを占める。Presidio 単体ではファイル形式を扱えないので、社内文書 ingestion パイプラインの上流で unstructured 経由でテキスト化してからマスキングをかける流れになる。画像の中に写った名刺やホワイトボードは、OCR を挟むかそもそも取り込まない、を明示的に選ぶ。
ステップ 2 — 監査ログ: 五つの問いに答える
担当者の「どこまで残せばいいんですか」に対する筆者の答えは、いつも同じ五要素になる。誰が (user_id)、何を (プロンプトと参照ドキュメントの ID)、何時に (UTC のミリ秒)、どのモデルで (プロバイダ·モデル名·バージョン)、どのコストで (トークン数·概算料金)。GDPR や SOC 2、そして 2026 年 8 月から段階施行される EU AI Act のいずれもが、この五つのどれかを求めてくる。
スキーマは JSON Lines で immutable 追記のみ、ストレージは S3 (Object Lock で改ざん防止) + Datadog へ tail、というのがこの半年で定着したパターンだった。最小の一行はこうなる。
{
"ts": "2026-07-08T02:14:33.482Z",
"trace_id": "01J...",
"tenant_id": "acme-sales",
"user_id": "u_8821",
"role": "sales_manager",
"prompt_hash": "sha256:...",
"pii_entities": ["PERSON", "PHONE_NUMBER"],
"retrieved_doc_ids": ["doc_311", "doc_902"],
"model": "gpt-oss-120b@2026-05",
"provider": "self-hosted",
"tokens": {"in": 1820, "out": 342},
"cost_jpy": 0.0,
"policy_decision": "allow",
"latency_ms": 2413
}
プロンプト本文を丸ごと残すべきか、というのは毎回議論になる。筆者の立場は「ハッシュだけを長期保存、本文は 30 日 で自動削除、法務が明示要求した場合のみ長期化」である。全文保存は監査には便利だが、事故時のブラストラディウスが跳ね上がる。前回書いた RAG+オントロジー の設計 (→ 社内データを LLM に繋ぐ設計 — RAG とオントロジーで「業務構造」を扱う) で登場した doc_id 体系がここで効いてくる。プロンプトを全文残さなくても、retrieved_doc_ids と prompt_hash があれば、後から「あの回答は何を根拠にしていたか」は再現できる。
ステップ 3 — 権限分離: ACL をベクトルの隣に置く
共通 LLM プールを持ちつつ、テナントと部署ごとにドキュメントの見え方を変える。教科書的には ABAC を IAM に載せる、と一言で済むが、RAG では検索インデックス側で切らないと意味がない。LLM に流し込んだ時点でモデルは全部読んでしまうからだ。
実装としては、各チャンクのメタデータに acl フィールドを持たせる。値は簡単なリスト、例えば ["role:sales", "tenant:acme", "clearance:general"]。検索時、リクエスト元のトークンから得た属性でフィルタする。pgvector なら WHERE metadata @> '{"acl": ["role:sales"]}'::jsonb のような書き方で済む。Qdrant なら Filter オブジェクト。
ここでハマったのは「継承」だった。ある文書が「営業部全体」向けだが、添付の別紙は「営業部長以上」だけ見せたい。最初は文書単位でしか acl を持っていなかったので、細かい分割ができず、結果としてチャンク単位で acl を持ち直す設計に作り直した。工数で言えば二日、コードで言えば ingest パイプラインの十数行の変更だが、これに気づかず本番前日まで進めていたら痛かった。
ステップ 4 — 出力ガードレール: Pydantic + reask で壊れない
LLM の出力が下流の kintone や Salesforce にそのまま流れる箇所では、schema 検証が必須になる。JSON で返してと頼んでも 100 回に 2〜3 回は壊れて返ってくる。Pydantic で受け、失敗したら失敗理由を添えて LLM に自己修正させる、いわゆる reask パターンで大半は片付く。
from pydantic import BaseModel, ValidationError, Field
from typing import Literal
class TicketDraft(BaseModel):
title: str = Field(max_length=60)
priority: Literal["low", "mid", "high"]
due_date: str # ISO 8601
assignee_email: str
def structured_call(llm, prompt: str, max_retry: int = 2) -> TicketDraft:
last_err = None
for _ in range(max_retry + 1):
raw = llm.invoke(prompt if last_err is None
else f"{prompt}\n\n前回の出力は次の理由で無効だった: {last_err}\n"
f"上記スキーマに沿って JSON のみ返せ。")
try:
return TicketDraft.model_validate_json(raw)
except ValidationError as e:
last_err = str(e)
raise RuntimeError(f"schema validation failed: {last_err}")
実測では 2 回の retry で成功率が 99% を超えた。残りの 1% はモデルを一段上に切り替えるか、人手レビューに落とすかの判断になる。ここで「retry 前提の設計」を最初から入れておくと、後で慌てなくて済む。
ステップ 5 — 専有ホスティング: 全か無かではない
「全部オンプレにしたい」と経営会議で言われることは多い。だが要件を分解すると、実はそこまで必要ないケースがほとんどだった。判断軸は三つある。
機密度: 財務·人事·未公開の M&A 情報は自前。営業日報や公開資料寄りの照会は API でよい。
コスト: 月間トークン数がある閾値 (現状の相場だとおおよそ 数億トークン) を超えると、自前 GPU の TCO が API を下回る。それ未満なら API のほうが安い。
レイテンシ: 対話 UI で 2 秒を切りたいなら、地理的に近い自前推論のほうが安定する。バッチなら API で十分。
今回のクライアントは、機密度の高い RAG (人事·法務) を gpt-oss-120b + vLLM で自社データセンターに置き、一般照会は OpenAI の API に流す、というハイブリッドで落ち着いた。二つのモデルを一つのゲートウェイの後ろに置き、ポリシーエンジンがリクエストのメタデータからルーティングする。監査ログには provider フィールドを必ず入れる。どちらに流れたかを後から追えないと、事故時の説明ができない。
動作確認とハマりポイント
本番切り替え前日、負荷試験で PII マスキングのレイテンシが跳ねた。spaCy の日本語モデル ja_core_news_lg は初回ロードが遅く、ワーカープロセスごとに読み直していたのが原因だった。共有メモリに載せて解決したが、30ms 追加、と言えば小さく聞こえるが、100 並列で見ると別の顔をする。小さな数字の裏に運用の顔がある、というのはこの仕事を続けていて何度も体感する。
もう一つ、監査ログの retention。SOC 2 は「7 年」と一律で言われがちだが、実際は文書分類ごとに違って良い。全部 7 年で持つと S3 コストが 3 倍になるケースもある。法務·情シス·財務で分類の合意を取ってから retention を設計する。技術者だけで決めきれない領域だ、と割り切って会議室に法務を呼ぶ。
応用
PII マスキング閾値をユーザー役割ごとに変える (法務ロールだけ 0.9 まで下げる、等)
監査ログを OpenTelemetry の span として送信し、既存の APM 基盤に統合
ACL の acl フィールドを Active Directory / SAML の属性から自動同期
出力ガードレールに tool 呼び出しの前段 dry-run を挟み、副作用のあるアクションだけ人手承認
ゲートウェイに rate limit と cost budget をロール別で入れ、月次の予算超過を予防
まとめ
セキュリティは「一枚の壁」ではなく、入口·処理中·出口·記録の四層にそれぞれ小さな仕掛けを置く仕事である。派手な機能はないが、この層が薄いと本番投入の会議が通らない。次回は評価·再学習·追加開発、つまり「作った後どう回すか」の話に進む。プロトタイプを本番に上げるまでが今回、上げた後に劣化させないのが次回である。
筆者は 5years+ で韓国·日本の中堅企業向けに LLM の構築·運用 を担当している。