Agents SDKを使った判例検索 (9)

Agents SDKを使った判例検索コード( database_query.py )解析の8回目。今回は「エージェント定義」の働きを見ていく。

FORCE_REFERENCE_RULES = """【参照指示の強制ルール】
「概要」「その事件」「その判決」等が含まれる場合は、
resolve_case_doc_id → get_case_text を必ず実行すること。
"""

エージェントに守らせたい追加ルールを文字列で定義している。具体的には「概要」「その事件」「その判決」など曖昧参照が来たら、必ず resolve_case_doc_id → get_case_text を実行せよ、という運用ルールを定めている。単にツールを用意するだけでなく、システムプロンプトとしてこのルールを組み込むことで、ハルシネーションを減らすことができる。

このルールの狙い

ユーザーが「その事件の概要を教えて」と言ったとき、AIは「どの事件か」と「概要の中身」の両方を知る必要がある。

  • 「その事件」「その判決」: これらは会話の文脈に依存する言葉。これを解決するために、まず resolve_case_doc_id を使って、過去のやり取りから正しい doc_id を特定させる。
  • 「概要」: 判例の概要をまとめるには、主文や理由の全文を確認しなければならない。そのため、特定した doc_id を使って get_case_text を実行し、生のデータを読み込ませる。

なぜ2段階の実行を強制させるのか

AIは時として、検索結果のリスト(タイトルや日付のみ)だけを見て、「おそらくこういう事件だろう」と推測で答えてしまうことがある。そこで、次の2段階の処理を強制して、中身を読み込む(get_case_text)までは、概要を答えてはならないという拘束をAIに与え、回答の信頼性を担保する。

  • ステップ1:resolve_case_doc_id (特定):メタデータ(裁判所、日付、事件番号など)を照合し、対象を1件に絞り込む。
  • ステップ2:get_case_text (取得):絞り込んだIDを用いて、実際の判決本文や理由を取得する。
case_agent = Agent(
    name="CaseDBHelper",
    instructions=(
        "あなたは判例DB検索アシスタント。\n"
        "必ずツールで確認できた事実のみを用いて回答せよ。\n\n"
        "検索依頼には search_cases、\n"
        "参照指示には resolve_case_doc_id と get_case_text を用いる。\n\n"
        + FORCE_REFERENCE_RULES
    ),
    tools=[search_cases, resolve_case_doc_id, get_case_text],
)

これまで個別に作成してきた「検索」「特定」「取得」という3つのツールを、1人のAIエージェント(CaseDBHelper)として統合するための定義。OpenAI Agents SDKにおいて、ツール(関数)はあくまで部品であり、この Agent クラスの定義により、それらをいつ、どう使うべきかの知性と役割が与えられる。

エージェントのアイデンティティ付与

  • name="CaseDBHelper":エージェントの名前。ログを確認する際や、複数のエージェントが連携(Handoff)する際に、「誰が発言しているか」を識別するための識別子となる。
  • instructions (システムプロンプト):エージェントの性格と行動指針を決定する重要部分。
    • 事実に基づく回答の徹底:「必ずツールで確認できた事実のみを用いて回答せよ」と命じることで、AIが自分の知識で勝手に法律解釈を捏造(ハルシネーション)するのを抑制する。
    • ツールの使い分け:どのリクエストに対して、どのツールを使うべきかを明文化し、AIが迷わないように導いている。
    • ルールの注入:先ほど定義した FORCE_REFERENCE_RULES を末尾に加えることで、「曖昧な指示のときは必ず特定から取得までのステップを踏む」というルールをAIに刻み込む。
  • tools:エージェントが利用できる道具のリスト。ここに登録された関数(search_casesresolve_case_doc_idget_case_text)だけが、AIによって実行される。

「部品」から「エージェント」へ

これまで解説してきた個別の関数は、単体ではただのプログラムに過ぎない。しかし、この Agent 定義により、AIは以下のような自律的な思考ができるようになる。

  1. 状況判断:ユーザーの質問が「新しい判例を探して」なのか「さっきの事件を詳しく教えて」なのかを分析する。
  2. ツールの選択:分析結果に基づき、search_cases でリストを作るべきか、resolve_case_doc_id でIDを特定すべきかを自分で決める。
  3. 結果の解釈: ツールから返ってきた「候補が見つかりませんでした」や「整形されたテキスト」を読み取り、人間が理解できる自然な日本語に再構築して回答する。
HistoryItem = Tuple[str, str]

会話履歴1件を (ユーザー発話, アシスタント発話) の2要素タプルとして扱う型エイリアス(読みやすさのため)。AIが「過去にどんな会話をしたか」という記憶を記録するための、データの形を定義したもの

  • HistoryItem:このデータの形式に付けた名前(型エイリアス)。
  • Tuple[str, str]:「2つの文字列がセットになった固定の箱」という意味。
    • 1つ目の str:ユーザー(人間)の発言
    • 2つ目の str:アシスタント(AI)の回答

つまり、HistoryItem は、ユーザーの問いとAIの答えの1セット(1往復)を表している。

なぜこの型定義が必要になるか

AIエージェントが文脈(コンテキスト)を理解するためには、過去の会話をリスト形式(List[HistoryItem])で保持し続ける必要がある。

  1. 指示の具体化FORCE_REFERENCE_RULES(「その事件」という言葉があれば特定ツールを使え、という命令)を実現するために不可欠。AIは履歴を遡り、「『その事件』とは、3つ前の発言で出てきた東京地裁の件だな」と特定できるようになる。
  2. 型安全性の確保:プログラムの中で「履歴データは必ず(人間, AI)のペアである」と厳格に決めておくことで、データの入れ間違いによるエラーを防ぐ。
def _build_input(question: str, history: List[HistoryItem], max_turns: int = 8) -> str:
    lines: List[str] = []
    for u, a in history[-max_turns:]:
        lines.append(f"ユーザー: {u}")
        lines.append(f"アシスタント: {a}")
    lines.append(f"ユーザー: {question}")
    return "\n".join(lines)

AIエージェントに、これまでの会話の流れを教え込み、最新の質問に答えさせるための下準備を行う処理。AIは1回1回のやり取りで、記憶をリセットしてしまう性質があるため、人間のように自然に対話を続けるには、この関数のように過去の履歴を毎回「物語」として作り直して渡す必要がある。

コードの仕組み

  • def _build_input(question: str, history: List[HistoryItem], max_turns: int = 8) -> str:
    エージェントに渡す入力テキスト(プロンプト)を、履歴込みで組み立てる関数。直近 max_turns 件だけ使う。
  • lines: List[str] = []
    プロンプトを行単位で組み立てるための配列を用意。
  • for u, a in history[-max_turns:]:
    履歴の末尾(最新側)から最大 max_turns 件を取り出して回す。
  • lines.append(f"ユーザー: {u}")
    履歴中のユーザー発話を「ユーザー: …」形式で1行追加。
  • lines.append(f"アシスタント: {a}")
    履歴中のアシスタント発話を「アシスタント: …」形式で1行追加。
  • lines.append(f"ユーザー: {question}")
    最後に今回の質問(最新のユーザー入力)を同じ形式で追加。
  • return "\n".join(lines)
    行配列を改行でつないで、最終的な入力文字列として返す。
    ※ここで作った文字列が、Runner.run_sync にそのまま渡される。

エージェント設計における記憶の重要性

この関数によって、AIは以下のような高度な振る舞いが可能になる。

  • 指示の継続性:「東京地裁の判例を探して」→「じゃあ、その内容を要約して」という会話において、「その内容」が「さっき見つけた東京地裁の判例」であることを、履歴(History)を読み直すことで理解できる。
  • 文脈に応じた回答FORCE_REFERENCE_RULES(参照指示の強制ルール)が効力を発揮するのも、この _build_input が過去の発言をAIの目の前に差し出しているおかげ。
def run_agent(question: str, history: Optional[List[HistoryItem]] = None) -> str:
    prompt = _build_input(question, history or [])
    result = Runner.run_sync(case_agent, prompt)
    return result.final_output

これまで準備してきたエージェント、ツール、記憶(履歴)のすべてを連結し、回答を生成する関数(run_agent)を定義している。プログラムの他の部分からこの関数を呼び出すだけで、複雑なAIの思考プロセスが起動する。

  • def run_agent(question: str, history: Optional[List[HistoryItem]] = None) -> str:
    外から呼び出す実行用の関数。質問と(あれば)履歴を受け取って、エージェントを動かして結果を返す。
  • prompt = _build_input(question, history or [])
    履歴が None なら空配列にして、さっきの _build_input でプロンプト文字列を作る。
  • result = Runner.run_sync(case_agent, prompt)
    case_agent を作ったプロンプトで同期実行する。実行中に必要ならエージェントが tools=[...] からツールを選んで呼ぶ。
  • return result.final_output
    実行結果オブジェクトから、最終的にユーザーへ返す文章(最終回答)だけ取り出して返す。

Runner がもたらす自律性

このコードは、Runner.run_sync を呼び出す際、AIに「どうやって検索しろ」とは指示していない。AIはプロンプトから、「これは判例を特定(Resolve)してから本文を取得(Get)すべきだ」と自分で判断し、resolve_case_doc_id を呼び出し、get_case_text を実行する。そして、そこから得られた情報を統合し、適切なトーンで回答を作成する。このような自律的なループを1行で実行できるのが、Agents SDKの最大の強みである。

以上で、database_query.py の解析はすべて終了した。