0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自社運用の LLM をセキュアに保つ — PII マスキング·監査ログ·権限分離·専有構成

0
Posted at

「監査ログ、どこまで残せばいいんですか?」

先週、都内のある食品メーカーの会議室で、情シス部長にそう聞かれた。前回の設計レビューから二週間、彼らは社内 RAG のプロトタイプを 5 部署に展開したところだった。動くものはできた。だが、法務と内部監査から「これ、本番に上げていいの?」という差し戻しを受けたという。

会議室のホワイトボードには、赤マーカーで「PII」「監査」「権限」と三つだけ書かれていた。その三つがクリアにならないと、経営会議に上げられない。担当者の顔には焦りより諦めが半分あった。「動くもの」を作るのと「運用に耐えるもの」の間には、思ったより深い谷がある。筆者もこの現場に入るまで、正直そこを甘く見ていた。

社内 LLM 運用のセキュリティ設計を象徴するサーバールームの通路

前提 — どこまでを「セキュア」と呼ぶか

想定する読者は、社内向け 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 の構築·運用 を担当している。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?