連載: NMS開発者がLLMブートキャンプで学んだこと
← 前回 | [次回 →]
前回は、OpenAI API を使って最初の AI プログラムを作り、障害ログを JSON 形式で整理するところまで試しました。
そこで一番気になったのは、LLM の出力が思ったより揺れることでした。
「JSON だけで返して」と書いても、余計な説明が混ざることがあります。「重要度を分類して」と頼んでも、critical、high、重大、緊急 のように表現がばらけます。人間が読むなら大きな問題ではありません。しかし NMS(Network Management System) やチケットシステムに組み込むなら、これはかなり困ります。
授業で Prompt Engineering を扱ったとき、最初は「AI にうまくお願いする文章術」くらいに考えていました。
でも、実際に bad_prompt と good_prompt を比べたり、SystemMessage と HumanMessage を分けたり、few-shot や Output Parser を試したりするうちに、見え方が変わりました。
Prompt Engineering は、お願いの書き方ではありません。
LLM に渡す入力、役割、制約、例、出力形式を設計する作業でした。
背景
NMS の現場では、SNMP Trap や syslog のような短いメッセージが大量に流れてきます。
たとえば、次のようなメッセージです。
SNMP-TRAP: router-core-01 linkDown ifName=Gi0/1
SNMP-TRAP: router-edge-03 bgpBackwardTransition peer=10.10.0.2
SYSLOG: switch-access-12 CPU utilization exceeded 92%
SYSLOG: nms-poller SNMP timeout router-core-01
これらはひとつひとつ見れば短いですが、障害対応中には同じようなイベントが何十件、何百件も並びます。
担当者が知りたいのは、単に「何が書いてあるか」ではありません。
- 重要度はどれくらいか
- 顧客影響がありそうか
- 一次対応で何を確認すべきか
- 関連するアラートをまとめて見られるか
- チケットにはどう書けばよいか
ここで LLM を使うなら、ただ「この Trap を説明して」と聞くだけでは足りません。出力を業務で使える形にする必要があります。
このとき必要になるのが Prompt Engineering でした。
悪いプロンプトと良いプロンプト
授業では、最初に悪いプロンプトと良いプロンプトの違いを見ました。
たとえば、悪いプロンプトはこうです。
このログを分析して。
これでも LLM は何かしら答えてくれます。
ただし、何をどこまで分析するのか、出力形式は何か、誰向けの回答なのか、重要度の基準は何かが分かりません。そのため、回答は自然文になったり、長すぎたり、逆に短すぎたりします。
NMS の障害分析に寄せるなら、少なくとも次のように書きたくなります。
あなたはネットワーク運用センターの一次対応担当者です。
以下の SNMP Trap または syslog を読み、障害対応の初動判断に使える形で分類してください。
重要度は high, middle, low のいずれかにしてください。
過度に断定せず、ログから推測できる範囲で書いてください。
出力は JSON のみとし、キーは severity, event_type, target, reason, first_actions にしてください。
この違いは、文章が丁寧かどうかではありません。
業務要件が入っているかどうかです。
誰の立場で判断するのか。分類ラベルは何か。断定してよいのか。出力を自然文にするのか JSON にするのか。後続処理で必要なキーは何か。
こうして見ると、プロンプトはかなり仕様書に近いです。
ここでいう仕様設計は、単に長い指示文を書くことではありません。
NMS が収集した SNMP Trap に対して、誰の立場で判断するか、どの値を許可するか、どの例を参考にするか、どの形式で返すかを分けて考えることです。
左側にあるのは、ルータ、スイッチ、サーバから発生した未整理のアラートです。中央では、役割、制約、few-shot、出力スキーマが LLM の処理条件として与えられます。右側では、その結果が検証可能な JSON になり、運用者が確認します。
LLM に障害判断を丸投げするのではなく、自由に生成してよい範囲を仕様として囲む。この図は、私が Prompt Engineering を理解し直したときのイメージです。
System と Human を分けて考える
SystemMessage と HumanMessage を明示的に扱いました。
from langchain_core.messages import SystemMessage, HumanMessage
messages = [
SystemMessage(content="あなたは親切な AI アシスタントです。"),
HumanMessage(content="この文章を要約してください。")
]
前回の記事でも触れましたが、この分離はとても大事です。
system は処理全体の前提や振る舞いを決める場所です。
human、または user は、その時々の入力です。
NMS の例なら、system には「NOC 一次対応担当者として振る舞う」「断定しすぎない」「重要度ラベルは固定する」「出力は JSON にする」といったルールを書きます。
一方、user には実際の Trap やログを入れます。
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
trap = "SNMP-TRAP: router-core-01 linkDown ifName=Gi0/1"
response = llm.invoke([
SystemMessage(content="""
あなたはネットワーク運用センターの一次対応担当者です。
SNMP Trap を high, middle, low のいずれかに分類してください。
出力は JSON のみ。キーは severity, event_type, target, reason, first_actions にしてください。
""".strip()),
HumanMessage(content=trap)
])
print(response.content)
この構造にすると、毎回変わる入力と、毎回変えてはいけないルールを分けられます。
バックエンド開発で言えば、system はサービスの設定やポリシーに近く、user はリクエストデータに近い感覚です。
もちろん LLM は通常の関数とは違います。それでも、入力と制約を分けて考えるだけで、かなり扱いやすくなります。
SNMP Trap 重要度分類器を作ってみる
ここからは、授業の内容を NMS の文脈に寄せて、小さな例を作ります。
目的は、SNMP Trap や syslog を high、middle、low に分類することです。
まず、簡単な分類基準を決めます。
high:
- core router, firewall, gateway など重要装置の Down
- BGP neighbor down
- 複数サービスに影響しそうな到達性低下
middle:
- access switch や一部 interface の障害
- CPU, memory, disk などのしきい値超過
- 冗長構成が残っているが確認が必要なもの
low:
- informational trap
- 一時的な warning
- 直接のサービス影響が低いもの
これをそのままプロンプトに入れてみます。
classification_prompt = """
あなたはネットワーク運用センターの一次対応担当者です。
以下のイベントを high, middle, low のいずれかに分類してください。
分類基準:
- high: core router, firewall, gateway など重要装置の Down、BGP neighbor down、広範囲の到達性低下
- middle: access switch や一部 interface の障害、CPU/memory/disk しきい値超過、確認が必要な warning
- low: informational trap、一時的な warning、直接のサービス影響が低いもの
出力は JSON のみ。
キーは severity, event_type, target, reason, first_actions にしてください。
first_actions は配列で 2 つから 3 つにしてください。
""".strip()
event = "SNMP-TRAP: router-edge-03 bgpBackwardTransition peer=10.10.0.2"
response = llm.invoke([
SystemMessage(content=classification_prompt),
HumanMessage(content=event)
])
print(response.content)
期待する出力は、たとえば次のような形です。
{
"severity": "high",
"event_type": "BGP neighbor down",
"target": "router-edge-03 / peer 10.10.0.2",
"reason": "BGP neighbor の状態変化は経路到達性に影響する可能性があるため。",
"first_actions": [
"router-edge-03 の BGP neighbor 状態を確認する",
"peer 10.10.0.2 への疎通と回線アラートを確認する",
"直近の設定変更またはメンテナンス予定を確認する"
]
}
この時点で、単なる自然文要約よりかなり業務に近くなります。
severity は画面の色分けに使えます。event_type はチケット分類に使えます。target は対象装置や peer の抽出に使えます。first_actions は一次対応チェックリストとして表示できます。
LLM の回答を「読む文章」から「後続処理に渡せるデータ」へ近づける。
これが、私にとって Prompt Engineering の最初の手応えでした。
それでも出力は揺れる
ただし、ここで問題が出ます。
プロンプトに high, middle, low と書いても、LLM が毎回完全にその通り返すとは限りません。
たとえば、次のような揺れが起きます。
{
"severity": "critical",
"event_type": "BGP Down",
"target_device": "router-edge-03",
"actions": [
"BGP 状態を確認する"
]
}
人間が読むなら意味は分かります。
しかし、アプリケーション側で severity は high | middle | low のどれか、target というキーが必須、first_actions は配列、という前提にしていると壊れます。
ここで、授業で扱った Output Parser の重要性が見えてきます。
StrOutputParser は文字列として扱うには便利です。しかし、業務システムで JSON や型付きデータとして扱いたい場合は、JsonOutputParser や PydanticOutputParser のような仕組みを考える必要があります。
前の授業では、次のように Pydantic のモデルを使って出力形式を定義していました。
from pydantic import BaseModel, Field
from langchain_core.output_parsers import JsonOutputParser
class TrapClassification(BaseModel):
severity: str = Field(description="high, middle, low のいずれか")
event_type: str = Field(description="イベント種別")
target: str = Field(description="対象装置、interface、peer など")
reason: str = Field(description="分類理由")
first_actions: list[str] = Field(description="一次対応候補")
parser = JsonOutputParser(pydantic_object=TrapClassification)
そして、parser.get_format_instructions() をプロンプトに埋め込みます。
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", """
あなたはネットワーク運用センターの一次対応担当者です。
SNMP Trap または syslog を分類してください。
分類基準:
- high: core router, firewall, gateway, BGP down, 広範囲の到達性低下
- middle: interface 障害、しきい値超過、確認が必要な warning
- low: informational、一時的な warning、影響が低いもの
{format_instructions}
""".strip()),
("human", "{event}")
])
chain = prompt | llm | parser
result = chain.invoke({
"event": "SNMP-TRAP: router-core-01 linkDown ifName=Gi0/1",
"format_instructions": parser.get_format_instructions()
})
もちろん、Parser を使えばすべて解決するわけではありません。
それでも、「JSON で返して」と自然文でお願いするより、出力形式を明示し、parse できるかをアプリケーション側で確認する方が実務的です。
この感覚は、普通の API 開発で OpenAPI schema や DTO を定義するのに近いです。
LLM の出力にも、型が必要です。
Few-shot は「例で仕様を伝える」方法
特に面白かったのが few-shot でした。
授業では、レビュー文を「肯定的」「否定的」「中立」「混合」のように分類する例がありました。FewShotPromptTemplate や FewShotChatMessagePromptTemplate を使い、入力と期待出力の例をプロンプトに入れます。
最初は、「例をいくつか見せると賢くなる」くらいに思っていました。
でも、NMS の文脈で考えると、few-shot はもっと実務的です。
分類基準を文章で書いても、境界が曖昧なケースがあります。
たとえば、linkDown は常に high でしょうか。core router なら high に近いですが、access switch の未使用 port なら low かもしれません。CPU 90% は high でしょうか。瞬間的な上昇なら middle かもしれません。
こういう境界は、文章のルールだけでは伝えきれません。
そこで例を渡します。
examples = [
{
"input": "SNMP-TRAP: router-core-01 linkDown ifName=Gi0/1",
"output": '{"severity":"high","event_type":"linkDown","target":"router-core-01 Gi0/1","reason":"core router の主要 interface down は広範囲に影響する可能性があるため。"}'
},
{
"input": "SYSLOG: switch-access-12 CPU utilization exceeded 92%",
"output": '{"severity":"middle","event_type":"cpu_threshold","target":"switch-access-12","reason":"access switch の CPU 高騰は確認が必要だが、直ちに広範囲障害とは限らないため。"}'
},
{
"input": "SNMP-TRAP: printer-floor3 coldStart",
"output": '{"severity":"low","event_type":"coldStart","target":"printer-floor3","reason":"業務影響が限定的で、情報通知に近いため。"}'
}
]
これを few-shot として渡すと、LLM は分類ラベルだけでなく、判断の粒度も学びやすくなります。
つまり few-shot は、単なるサンプルではありません。
暗黙知を例で渡す方法です。
NOC の現場には、こういう暗黙知が多いです。「この装置名が core を含むなら慎重に扱う」「BGP は多少の揺れでも確認する」「access switch の単発 warning は様子見にすることがある」。こうした判断基準を全部ルールエンジンにするのは大変ですが、few-shot なら最初のプロトタイプとしては扱いやすいです。
ただし、few-shot にも注意点があります。
例が偏ると、分類も偏ります。古い運用ルールを例に入れ続けると、今の運用ポリシーとずれる可能性があります。例が増えすぎると、トークンも増えます。
結局、few-shot も「なんとなく良い例を入れる」ものではなく、評価しながら管理するデータになります。
5分で試す SNMP Trap Prompt Contract Lab
ここまでの内容を小さくまとめるために、LLM API を呼び出さずに動く確認用スクリプトを作りました。
この実習の目的は、高精度な障害分類器を作ることではありません。API を呼び出す前に、次の三つをローカルで確認することです。
-
systemプロンプトに役割、制約、few-shot が含まれているか - LLM に期待する JSON の形が明確か
- 後続処理で許可値と必須項目を検証できるか
分類部分は、LLM の代わりに単純なルールで期待出力を作ります。実際に LLM API へ置き換えた後も、validate() は残すという構成です。
import json
import sys
from dataclasses import asdict, dataclass
from typing import Literal
Severity = Literal["high", "middle", "low"]
@dataclass
class TrapResult:
severity: Severity
event_type: str
target: str
reason: str
first_actions: list[str]
SYSTEM_PROMPT = """あなたはネットワーク運用センターの一次対応担当者です。
入力された SNMP Trap を分類してください。
制約:
- severity は high, middle, low のいずれか
- ログにない事実を断定しない
- first_actions は 2 件
- 出力は JSON のみ
- キーは severity, event_type, target, reason, first_actions
Few-shot:
入力: router-core-01 BGP neighbor 10.10.0.2 Down
出力: {"severity":"high","event_type":"bgp_down","target":"router-core-01",
"reason":"経路到達性に影響する可能性があるため",
"first_actions":["BGP neighbor の状態を確認する","対向 IP への疎通を確認する"]}
"""
def classify_trap(trap: str) -> TrapResult:
"""LLM 導入前に期待出力を確認するための簡易ルール。"""
text = trap.lower()
target = trap.split()[0] if trap.split() else "unknown"
if "bgp" in text and ("down" in text or "backwardtransition" in text):
return TrapResult(
"high",
"bgp_down",
target,
"経路到達性に影響する可能性があるため",
["BGP neighbor の状態を確認する", "対向 IP への疎通を確認する"],
)
if "linkdown" in text or "cpu" in text or "timeout" in text:
return TrapResult(
"middle",
"device_warning",
target,
"装置またはインターフェースの確認が必要なため",
["対象装置への疎通を確認する", "直近の関連ログを確認する"],
)
return TrapResult(
"low",
"informational",
target,
"直ちに広範囲の障害を示す情報がないため",
["同一イベントの継続有無を確認する", "監視対象と通知条件を確認する"],
)
def validate(result: dict) -> None:
required = {"severity", "event_type", "target", "reason", "first_actions"}
if set(result) != required:
raise ValueError(f"キーが仕様と異なります: {set(result)}")
if result["severity"] not in {"high", "middle", "low"}:
raise ValueError("severity が許可値ではありません")
if not isinstance(result["first_actions"], list) or len(result["first_actions"]) != 2:
raise ValueError("first_actions は 2 件の配列にしてください")
def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
trap = " ".join(sys.argv[1:]) or "router-core-01 BGP neighbor 10.10.0.2 Down"
result = asdict(classify_trap(trap))
validate(result)
print("=== LLM に渡す仕様プロンプト ===")
print(SYSTEM_PROMPT)
print(f"入力: {trap}\n")
print("=== 検証済みの期待出力 ===")
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
上のコードは Python 3.9 以降であれば、追加パッケージなしで実行できます。手元で試す場合は、コードブロックの内容を trap_prompt_lab.py などのファイル名で保存します。
python trap_prompt_lab.py
別のイベントを試す場合は、引数として渡します。
python trap_prompt_lab.py "switch-access-12 CPU utilization exceeded 92%"
デフォルト入力では、次のような結果になります。
{
"severity": "high",
"event_type": "bgp_down",
"target": "router-core-01",
"reason": "経路到達性に影響する可能性があるため",
"first_actions": [
"BGP neighbor の状態を確認する",
"対向 IP への疎通を確認する"
]
}
ここで重要なのは、ルールベース分類そのものではありません。
たとえば severity を critical に変えたり、first_actions を一件に減らしたりすると、validate() が仕様違反としてエラーにします。プロンプトに「JSON のみ」と書くことと、アプリケーション側で JSON を検証することは別の責務だと確認できます。
実際の NMS に接続するときは、classify_trap() を LLM API 呼び出しに置き換えます。一方、validate() は残します。LLM はイベントの意味や一次対応候補を生成し、アプリケーションは許可値、必須キー、配列件数を保証する。この境界が、今回の実習で確認したかったポイントです。
段階的に考えさせる
複雑な問題をいきなり解かせるのではなく、段階に分けてプロンプトを作る練習もありました。
売上データの分析やレビュー分析で、次のように手順を分けます。
1. データから事実を抽出する
2. 変化や異常値を確認する
3. 原因候補を整理する
4. 次のアクションを提案する
この考え方は、障害分析でもかなり使えます。
たとえば、SNMP Trap の分類を一度にやらせるのではなく、次のように分けます。
以下の順番で処理してください。
1. 対象装置、interface、peer、service 名を抽出する
2. イベント種別を分類する
3. 顧客影響がありそうかを推定する
4. severity を high, middle, low から選ぶ
5. 一次対応の確認項目を 2 つから 3 つ出す
出力は JSON のみ。
これは、LLM に長い思考過程を自由に書かせるというより、業務処理のステップを明確にするためのものです。
障害対応では、結論だけが欲しいわけではありません。
なぜ high と判断したのか。どの情報から対象装置を拾ったのか。一次対応はどの順番で確認すべきか。
こうした観点をプロンプトに入れることで、出力が現場で使いやすくなります。
ただし、ここにもバランスがあります。
ステップを細かくしすぎると出力が長くなります。JSON に内部推論を全部入れると、チケットには不要な情報が増えます。逆に短くしすぎると、なぜその判断になったのか分かりません。
実務では、画面表示用、チケット登録用、監査ログ用で出力を分けたくなるかもしれません。
Memory は便利だが、障害対応では慎重に使う
LLM は、前の会話を自動的に覚えたまま次の質問に答えているわけではありません。
API を呼び出すたびに、モデルが参照できるのはそのリクエストで渡された情報だけです。前回までの会話内容を踏まえた回答をさせたい場合は、アプリケーション側で会話履歴を保存し、必要な部分を次の入力と一緒に渡す必要があります。
そこで必要になるのが、Memory です。
授業では ConversationBufferMemory、ConversationBufferWindowMemory、ConversationSummaryMemory、MessagesPlaceholder などを使い、過去の会話をそのまま保持したり、直近の数件だけを残したり、要約して保持したりする方法を扱いました。
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
prompt = ChatPromptTemplate.from_messages([
("system", "あなたはネットワーク運用の支援アシスタントです。"),
MessagesPlaceholder(variable_name="history"),
("human", "{input}")
])
これは、障害対応アシスタントを考えると魅力的です。
たとえば、ある障害チケットについて、最初の Trap、追加の syslog、担当者の確認結果、復旧作業、顧客報告文を会話形式で扱えたら便利です。
しかし、ここでも気をつけるべき点があります。
LLM API は、こちらが渡した履歴をもとに回答します。どの履歴を渡すかはアプリケーション側の責任です。
障害対応では、古い情報が残っていると危険なことがあります。
たとえば、最初は「BGP down が疑わしい」と判断したが、その後の確認で「監視サーバ側の SNMP 到達性問題だった」と分かったとします。このとき、古い仮説が会話履歴に残り続けると、LLM がそれに引っ張られる可能性があります。
そのため、Memory は便利ですが、障害対応で使うなら次のような設計が必要だと感じました。
- チケット単位で履歴を分ける
- 確定情報と仮説を分ける
- 復旧済み、誤報、監視除外などの状態を明示する
- 古い仮説は要約時に弱める
- 最終判断は人間または既存システム側で管理する
授業の Memory 実習は、「AI が会話を覚える」というより、「アプリケーションが何を文脈として渡すかを設計する」練習でした。
ここもまた、Prompt Engineering の一部だと思います。
業務で使うなら
Prompt Engineering を NMS の現場で使うなら、私は最初から大きな Agent を作るより、分類と整形から始めるのがよいと思います。
具体的には、次のような用途です。
- SNMP Trap の重要度を
high | middle | lowに分類する - syslog を障害候補単位にまとめる
- チケット登録用の JSON を生成する
- 一次対応チェックリストを作る
- 顧客向け文面と運用者向け文面を分けて生成する
- few-shot で運用チームの判断基準を反映する
- Output Parser で必須項目を検証する
この中で特に大事なのは、LLM に任せる部分と、アプリケーション側で固定する部分を分けることです。
LLM に任せやすいのは、短いログの意味を読むこと、複数イベントをまとめること、一次対応の候補を出すこと、文章を読みやすく整えることです。
一方で、アプリケーション側で固定すべきものもあります。
severity の許可値、JSON schema、通知先ルール、チケット登録の必須項目、顧客影響の最終判断、自動復旧の実行可否。これらを LLM の自由生成に任せるのは危険です。
つまり、Prompt Engineering は LLM を自由にする技術ではなく、自由にしてよい範囲を決める技術です。
失敗したこと
今回のテーマで一番の失敗は、最初にプロンプトを軽く見ていたことです。
「分かりやすく分類して」
「JSON で返して」
「重要度も付けて」
このくらい書けば十分だと思っていました。
しかし、実際には足りませんでした。
分類ラベルが揺れる。キー名が変わる。理由が長すぎる。断定してほしくないところで断定する。逆に、必要な確認項目が曖昧になる。似た Trap でも装置種別によって重要度を変えたいのに、その違いが伝わらない。
このあたりは、普通のプログラムとは違う難しさです。
if 文なら条件を書けばその通りに分岐します。しかし LLM は、自然言語の指示を解釈して出力します。だからこそ、ルール、例、出力形式、失敗時の扱いまで含めて考える必要があります。
また、few-shot の例を増やせばよいわけでもありませんでした。
例を増やすとプロンプトは長くなります。トークンも増えます。例同士が矛盾していると、むしろ出力が揺れます。現場で使うなら、few-shot の例自体も運用対象になります。
プロンプトは一度書いて終わりではなく、テストしながら育てるものだと感じました。
気づいたこと
Prompt Engineering を学んで、LLM アプリケーションの設計は思った以上にバックエンド開発に近いと感じました。
入力仕様を決める。
処理の前提を決める。
許可する値を決める。
出力形式を決める。
異常系を考える。
テストデータを作る。
評価して改善する。
違うのは、処理本体が通常の関数ではなく、確率的に文章を生成するモデルだという点です。
だから、プロンプトだけで完全に制御しようとすると苦しくなります。
system メッセージで役割と制約を固定する。human メッセージには入力だけを渡す。few-shot で境界例を伝える。Output Parser で後続処理に渡せる形にする。必要ならアプリケーション側で validation と retry を入れる。
この組み合わせが大事です。
NMS の障害対応で言えば、LLM は「判断を確定する機械」ではなく、「ログを読み、仮説と確認項目を整理する部品」として使う方が現実的です。
その部品を安全に使うために、Prompt Engineering が必要になります。
まとめ
Prompt Engineering は「お願いの書き方」ではありませんでした。
少なくとも、業務システムに LLM を組み込む目線では、文章術よりも設計に近いです。
3週目の授業で扱った SystemMessage / HumanMessage、bad prompt / good prompt、few-shot、Output Parser、Memory は、どれも単独のテクニックではなく、LLM に何を任せ、何を固定し、どの形式で受け取り、どの文脈を渡すかを決めるための部品でした。
LLM は API を呼び出すたびに前回までのやり取りを覚えているわけではなく、どの履歴を文脈として渡すかはアプリケーション側が設計する必要がある、という点も含めてです。
SNMP Trap の重要度分類器という小さな例だけでも、それがよく分かります。
ここまで考えると、LLM は単なるチャットではなく、既存の NMS やチケットシステムに接続できる処理部品として見えてきます。
次回は、この処理部品を動かすときに避けて通れない Token と Temperature について整理します。
長い障害ログをそのまま入れてよいのか。temperature=0 にすれば本当に安定するのか。コスト、文脈、出力品質をどう見ればよいのか。
運用システムに近づけるほど、この地味なパラメータが効いてきます。
