【POS売上分析Copilotを作る #2】
Tool Calling入門
― 生成AIに「計算させる」のではなく「関数を選ばせる」 ―

【POS売上分析Copilotを作る #2】Tool Calling入門 ― 生成AIに「計算させる」のではなく「関数を選ばせる」 ―

前回の記事では、生成AIにCSVを渡して「分析して」と頼むと数字が毎回変わる理由を、期間・対象・指標・出力形式の4条件から整理しました。そして、4条件を関数の引数に固定すれば、誰が何回計算しても同じ数字になることを確かめました。

ただ、ひとつ宿題が残っています。「先週から売上が落ちた商品は?」という日本語から、cur_start="2026-08-24" のような引数を、毎回人が書くのでしょうか。

ここを生成AIに任せる仕組みが Tool Calling です。生成AIに計算をさせるのではなく、どの関数をどんな引数で呼ぶかを決めさせる。この記事では、その仕組みを最小のコードで動かしながら説明します。

CSVをチャットに渡す方式とTool Callingの流れを比べた図上段はCSV本体と質問を生成AIに丸ごと送り、生成AIが読んで解釈して計算して書くため数字が毎回変わりうる流れ。下段はTool Callingで、生成AIは質問とツール定義からどの関数をどんな引数で呼ぶかを決めるだけで、検証層を通ったあとPythonが計算し、372文字の要点だけを受け取った生成AIが説明を書く流れ。生成AIに「計算させる」のと「関数を選ばせる」のとでは、流れが違う上:CSVをチャットに渡す方式 / 下:Tool CallingA. CSVをチャットに渡す人CSV本体(1,240行)+ 質問丸ごと送る生成AI読む → 「先週」を解釈する → 計算する → 書く(すべて生成AIの中で、毎回その場で)回答数字は毎回変わりうるB. Tool Calling人質問「先週から売上が落ちた商品は?」生成AIどの関数をどんな引数で呼ぶかを決める(計算しない)検証層許可リスト・形式・値の範囲を確認NGなら実行しないPython関数を実行して数字を出すcompare_periods(…)要点だけ(372文字)結果全体 −12,930円減少上位3・増加上位3結果ID: R1生成AI受け取った数字で説明を書く(自分では計算しない)回答数字は毎回同じ根拠の表つき関数名・引数が残る人報告に使える数字を受け取る

この記事を読み終えると、次のことができるようになります。

  1. Tool Calling とは何か——生成AIは計算しない、関数を選ぶだけ
  2. 自分の関数を生成AIに「見せる」ための、ツール定義の書き方
  3. 質問 → ツール要求 → 検証 → 実行 → 説明、という5つのステップを実際のコードで
  4. 生成AIの要求をそのまま実行しないための「検証層」
  5. 生成AIに送っているものは何か(CSV本体は送りません)
Screenshot

【月1 特定テーマ講座(11月)】
POS売上分析Copilotを作る
(POSデータ×PYTHON×生成AI)

【開催日時】 全2回(土)2026/11/7,11/21(13:30〜18:00)
【受講形式】 当日Zoom( or 復習用に後日動画視聴)
【参加費用】 2万2千円(税込み)/人

Tool Calling とは何か

Tool Calling とは、生成AIに「あらかじめ定義した関数の一覧」を渡しておき、質問に対して「どの関数を、どんな引数で呼ぶべきか」をJSON形式で答えさせる仕組みです。関数を実行するのは生成AIではなく、こちら側のプログラムです。

大事なのは後半です。生成AIがやるのは「この質問なら compare_periods を、当期 8/24〜30・前期 8/17〜23 で呼ぶべきだ」と決めるところまで。実際に計算するのはこちらのPython、結果の数字を受け取って説明を書くのはまた生成AI、という分担になります。

CSVをチャットに丸投げする方式と比べると、3つが変わります。

CSVをチャットに渡す Tool Calling
計算するのは 生成AI(毎回その場で) Pythonの関数(仕様が固定)
生成AIに送るもの CSV本体(1,240行すべて) データの概要と、集計結果の要点だけ
あとから確認できるもの 回答の文章だけ どの関数を・どの引数で呼び・何が返ったか

「同じ質問に同じ数字が返る」のは1行目の効果です。2行目はデータを外に出す量の話、3行目は「その数字はどこから来たのか」を追える話で、業務で使うならこの2つも同じくらい効きます。

 

準備:前回の関数とサンプルCSV

前回と同じサンプルCSVを使います。まだお持ちでない方はこちらからどうぞ。

pos_sales_sample.csv をダウンロード
POS売上サンプルデータ(20商品×62日=1,240行・BOM付きUTF-8・約64KB)

コードを動かすには pandas と openai が必要です。pip install pandas openai で入ります。

 

APIキーはコードに書かず、実行時に入力します。getpass を使うと画面に表示されずに入力できます。

以下、コードです。

import json
import re
from getpass import getpass

import pandas as pd
from openai import OpenAI

pd.set_option("display.unicode.east_asian_width", True)
df = pd.read_csv("pos_sales_sample.csv", encoding="utf-8-sig")

# APIキーはコードに書かない。実行するたびに入力する
client = OpenAI(api_key=getpass("OPENAI_API_KEY: "))
  • OpenAI(api_key=...) が、OpenAIのAPIを呼ぶための窓口(クライアント)です。以降はこの client を通して質問を送ります
  • getpass(...) は、入力した文字を画面に出さずに受け取る関数です。キーがNotebookやファイルに残りません

 

前回作った compare_periods も、そのまま使います(中身は前回の記事のとおりです)。

以下、コードです。

def compare_periods(df, cur_start, cur_end, prev_start, prev_end, category=None):
    """当期と前期の売上金額を商品別に比較する。期間は両端を含む"""
    cur  = df[(df["日付"] >= cur_start)  & (df["日付"] <= cur_end)]
    prev = df[(df["日付"] >= prev_start) & (df["日付"] <= prev_end)]
    if category:
        cur, prev = cur[cur["カテゴリ"] == category], prev[prev["カテゴリ"] == category]
    a = cur.groupby("商品名")["売上金額"].sum()
    b = prev.groupby("商品名")["売上金額"].sum()
    table = (a.to_frame("当期売上").join(b.to_frame("前期売上"), how="outer")
              .fillna(0).astype(int))
    table["増減額"] = table["当期売上"] - table["前期売上"]
    assert table["増減額"].sum() == a.sum() - b.sum()
    return table.sort_values("増減額")

 

APIキーをお持ちでない方へ:記事末尾の付録に、生成AIの応答を模擬する FakeClient を載せています。client = FakeClient() に置き換えると、この記事のコードは最後まで動きます。

 

ステップ1:関数を生成AIに「見せる」——ツール定義

生成AIは、こちらのPythonコードを読めません。「こういう関数があって、こういう引数を取る」ということを、JSON形式の説明書にして渡す必要があります。これがツール定義です。

まず、category に指定できる値をデータから作っておきます。

以下、コードです。

CATEGORIES = sorted(df["カテゴリ"].unique().tolist())
print(CATEGORIES)

 

以下、実行結果です。

['おにぎり', 'デザート', '弁当', '飲料']

 

この一覧をツール定義に埋め込むと、生成AIは「スイーツ」のような存在しないカテゴリを指定できなくなります。

以下、コードです。

TOOLS = [{
    "type": "function",
    "function": {
        "name": "compare_periods",
        "description": "当期と前期の売上金額を商品別に比較し、増減額を返す。「先週から落ちた商品は」のような質問に使う。",
        "strict": True,
        "parameters": {
            "type": "object",
            "additionalProperties": False,
            "properties": {
                "cur_start":  {"type": "string", "description": "当期の開始日(YYYY-MM-DD)"},
                "cur_end":    {"type": "string", "description": "当期の終了日(YYYY-MM-DD)"},
                "prev_start": {"type": "string", "description": "前期の開始日(YYYY-MM-DD)"},
                "prev_end":   {"type": "string", "description": "前期の終了日(YYYY-MM-DD)"},
                "category":   {"anyOf": [{"type": "string", "enum": CATEGORIES},
                                         {"type": "null"}],
                               "description": "カテゴリで絞るときに指定。全商品なら null"},
            },
            "required": ["cur_start", "cur_end", "prev_start", "prev_end", "category"],
        },
    },
}]

 

長く見えますが、Pythonの関数の各部分と1対1で対応しています。

Pythonの関数とツール定義(JSON)の対応を示した図左にcompare_periods関数のコード、右にそのツール定義を置き、関数名とname、docstringとdescription、仮引数とparameters.properties、categoryの候補とenumを線で結んだ図。生成AIが読むのは右側だけで、コードは渡さない。ツール定義は、Pythonの関数を「生成AIが読める説明書」に書き直したもの左:こちらの関数(生成AIは読めない) 右:ツール定義(生成AIに渡す)Python の関数ツール定義(JSON)def compare_periods(df, cur_start, cur_end, prev_start, prev_end, category=None): “””当期と前期の売上金額を 商品別に比較する””” cur = df[(df[“日付”] >= cur_start) & (df[“日付”] <= cur_end)] … return table.sort_values(“増減額”)関数名は def の行、引数は仮引数、説明は docstring“name”: “compare_periods”,“description”: “当期と前期の売上金額を 商品別に比較し、増減額を返す。…”,“strict”: true,“parameters”: { “properties”: { “cur_start”: {“type”: “string”, …}, “cur_end”: {“type”: “string”, …}, “prev_start”: {“type”: “string”, …}, “prev_end”: {“type”: “string”, …}, “category”: {“enum”: [4カテゴリ] か null} }, “required”: [全部の引数], “additionalProperties”: false}関数名説明引数候補生成AIが読むのは右側だけ。左のコードそのものは渡さない。「description」は生成AIがこの関数を使うかどうかを判断する材料。「enum」はデータから作った候補で、存在しない値を防ぐ。
  • name:関数名。生成AIはこの名前で「呼びたい関数」を指定してきます
  • description:生成AIが「いつこの関数を使うか」を判断する材料です。何をする関数かだけでなく、どんな質問に向くかを書くと選ばれやすくなります
  • parameters:引数の説明書。JSON Schema という書式で、引数の名前・型・説明を並べます
  • enum:取りうる値を列挙します。category は「この4つのどれか、または null」に制限しました
  • required:必須の引数。strict: True のときは全部の引数を必須にし、additionalProperties: False を付ける決まりです。こうすると生成AIは定義にない引数を勝手に足せなくなります

 

ステップ2:質問を送り、ツール要求を受け取る

ツール定義ができたら、質問と一緒に送ります。ここで、生成AIに「基準日」と「先週の定義」をこちらから教えておくのがポイントです。前回の記事で見たとおり、これを教えないと「先週」の解釈が毎回変わります。

以下、コードです。

SYSTEM = """あなたはD店の売上分析を手伝います。
数値は必ずツールの結果だけを使い、自分で計算しないでください。
基準日は 2026-08-31 です。「先週」は基準日の直前の月曜〜日曜、「前週」はその1週間前です。
データの期間は 2026-07-01〜2026-08-31、カテゴリは おにぎり/デザート/弁当/飲料 です。"""

question = "先週から売上が落ちた商品は?"

first = client.chat.completions.create(
    model="gpt-5.6-luna",
    messages=[{"role": "system", "content": SYSTEM},
              {"role": "user", "content": question}],
    tools=TOOLS,
    tool_choice="auto",
    reasoning_effort="none",   # gpt-5系で tools を使うときに必要
)
msg = first.choices[0].message
call = msg.tool_calls[0]
print("関数名:", call.function.name)
print("引数  :", call.function.arguments)
print("本文  :", msg.content)
  • messages は会話の履歴です。system はこちらからの前提条件、user が質問
  • tools=TOOLS で、ステップ1のツール定義を渡します
  • tool_choice="auto" は「関数を使うかどうかは生成AIが判断する」という指定です。"required" にすると必ず関数を選ばせます
  • reasoning_effort="none":gpt-5系のモデルで tools を使うとき、これを付けないと Function tools with reasoning_effort are not supported ... というエラーで止まります。付け忘れやすいので注意してください
  • 応答は first.choices[0].message に入っています。関数を呼びたいときは tool_calls にその要求が、そうでないときは content に文章が入ります

 

以下、実行結果です。

関数名: compare_periods
引数  : {"cur_start":"2026-08-24","cur_end":"2026-08-30","prev_start":"2026-08-17","prev_end":"2026-08-23","category":null}
本文  : None

 

生成AIは「先週から売上が落ちた商品は?」という日本語を、関数名と引数のJSONに翻訳して返してきました。基準日 8/31 から「先週=8/24〜30、前週=8/17〜23」を正しく解決しています。content が None なのは、まだ文章を書く段階ではないからです。

ここで気づいてほしいのは、まだ何も計算されていないということです。返ってきたのは「こう呼んでほしい」という要求だけで、compare_periods は1回も動いていません。

この記事の実行結果のうち、生成AIの応答(このステップと、ステップ5)は付録の FakeClient で得たものです。実際のAPIでも、返ってくるものの形(tool_calls の中の name と arguments)は同じです。

 

ステップ3:ツール要求を検証する

生成AIの要求を、そのまま実行してはいけません。存在しない関数を呼ぼうとしたり、データにない期間を指定したりすることがあるからです。実行の前に検証層を挟みます。

以下、コードです。

ALLOWED = {"compare_periods"}
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
DATA_MIN, DATA_MAX = df["日付"].min(), df["日付"].max()

def validate(name, arguments):
    """ツール要求を検証する。問題があれば理由を、なければ None を返す"""
    if name not in ALLOWED:
        return f"許可されていない関数: {name}"
    args = json.loads(arguments)
    for k in ["cur_start", "cur_end", "prev_start", "prev_end"]:
        v = args.get(k)
        if not isinstance(v, str) or not DATE.match(v):
            return f"{k} が日付の形式ではありません: {v!r}"
        if not (DATA_MIN <= v <= DATA_MAX):
            return f"{k} がデータの期間外です: {v}(データは {DATA_MIN}〜{DATA_MAX})"
    if args.get("category") is not None and args["category"] not in CATEGORIES:
        return f"存在しないカテゴリ: {args['category']}(候補: {CATEGORIES})"
    return None

print("検証:", validate(call.function.name, call.function.arguments) or "OK")
  • ALLOWED:呼んでよい関数の許可リスト。定義した関数以外は、名前が合っていても実行しません
  • DATE:日付が YYYY-MM-DD の形かどうかを確かめる正規表現です
  • json.loads(arguments):arguments は文字列で届くので、辞書に戻します
  • 期間がデータの範囲内か、カテゴリが一覧にあるかを順に確かめ、最初に見つかった問題を返します

 

以下、実行結果です。

検証: OK

 

わざと壊れた要求を渡すと、どうなるか見てみます。

以下、コードです。

print(validate("compare_periods",
               '{"cur_start":"2026-09-01","cur_end":"2026-09-07","prev_start":"2026-08-25","prev_end":"2026-08-31","category":null}'))
print(validate("compare_periods",
               '{"cur_start":"2026-08-24","cur_end":"2026-08-30","prev_start":"2026-08-17","prev_end":"2026-08-23","category":"スイーツ"}'))
print(validate("run_sql", '{"query":"SELECT * FROM sales"}'))

 

以下、実行結果です。

cur_start がデータの期間外です: 2026-09-01(データは 2026-07-01〜2026-08-31)
存在しないカテゴリ: スイーツ(候補: ['おにぎり', 'デザート', '弁当', '飲料'])
許可されていない関数: run_sql

 

3つ目が重要です。run_sql という関数はこちらに存在しませんが、もし「生成AIが指定した名前の関数を探して実行する」ような書き方をしていたら、意図しないものが動く可能性があります。許可リストにある関数しか動かないようにしておくのが、Tool Calling を安全に使う前提です。

検証に失敗したときは、その理由を生成AIに返して、条件を直してもらうか、利用者に聞き直してもらいます。数字を作って埋めることはしません。

 

ステップ4:関数を実行し、生成AIに返す結果を作る

検証を通ったので、こちらのPythonで計算します。ここで、結果の表をそのまま返さないのがコツです。生成AIに渡すのは、説明を書くのに必要な要点だけにします。

以下、コードです。

args = json.loads(call.function.arguments)
table = compare_periods(df, **args)

def pct(cur, prev):
    return round((cur - prev) / prev * 100, 1) if prev > 0 else None

result_for_ai = {
    "result_id": "R1",
    "期間": {"当期": f"{args['cur_start']}〜{args['cur_end']}",
           "前期": f"{args['prev_start']}〜{args['prev_end']}"},
    "全体": {"当期売上": int(table["当期売上"].sum()),
           "前期売上": int(table["前期売上"].sum()),
           "増減額":  int(table["増減額"].sum()),
           "増減率":  pct(int(table["当期売上"].sum()), int(table["前期売上"].sum()))},
    "減少上位": table.head(3)["増減額"].reset_index().to_dict(orient="records"),
    "増加上位": table.tail(3)["増減額"].sort_values(ascending=False)
                    .reset_index().to_dict(orient="records"),
}
print(json.dumps(result_for_ai, ensure_ascii=False, indent=2))
print("文字数:", len(json.dumps(result_for_ai, ensure_ascii=False)))
  • compare_periods(df, **args):**args は「辞書の中身を、引数の名前つきで展開して渡す」書き方です。cur_start="2026-08-24", ... と書いたのと同じになります
  • result_id:この結果に付ける通し番号です。あとで説明文の中の数字が「どの結果から来たか」を追うために使います
  • table.head(3)["増減額"]:減少額の大きい3商品の「増減額」列だけを取り出します。説明に必要なのはここまでなので、当期・前期の売上は送りません
  • to_dict(orient="records"):表を「1行=1つの辞書」のリストに変えます。JSONにしやすい形です

 

以下、実行結果です。

{
  "result_id": "R1",
  "期間": {
    "当期": "2026-08-24〜2026-08-30",
    "前期": "2026-08-17〜2026-08-23"
  },
  "全体": {
    "当期売上": 635010,
    "前期売上": 647940,
    "増減額": -12930,
    "増減率": -2.0
  },
  "減少上位": [
    {
      "商品名": "ハンバーグ弁当",
      "増減額": -31900
    },
    {
      "商品名": "明太子おにぎり",
      "増減額": -30600
    },
    {
      "商品名": "麦茶",
      "増減額": -8540
    }
  ],
  "増加上位": [
    {
      "商品名": "幕の内弁当",
      "増減額": 19140
    },
    {
      "商品名": "鮭おにぎり",
      "増減額": 12960
    },
    {
      "商品名": "アイスバー",
      "増減額": 12360
    }
  ]
}
文字数: 372

 

生成AIに送るのは、この 372文字 だけです。元のCSVは約43,000文字(1,240行)ありますが、それは一度も外に出ていません。

生成AIに送るものと手元に残るものを分けた図左は手元に残るもの:CSV本体(1,240行・43,391文字)、関数のコード、検証層、計算結果の表。右は生成AIへ送るもの:前提条件158文字、質問文14文字、ツール定義724文字、集計結果の要点372文字。CSV本体に対して集計結果の要点は約117分の1。1回目の送信は896文字、2回目は659文字。生成AIに送るのは「前提・質問・ツール定義・結果の要点」だけこの記事のコードで、1つの質問に対して実際に外へ出た文字数(2026-09-16 実測)手元に残る(送らない)CSV本体1,240行・43,391文字compare_periods のコード計算のしかた検証層(validate)許可リスト・形式・範囲計算結果の表(20商品)根拠として手元に保存生成AIへ送る前提条件(system)基準日・期間・カテゴリ一覧158文字質問文「先週から売上が落ちた商品は?」14文字ツール定義(TOOLS)関数名・説明・引数の仕様724文字集計結果の要点(result_for_ai)全体・減少上位3・増加上位3372文字CSV本体 43,391文字 に対して、外に出た集計結果は 372文字(約 1/117)。■ 手元のCSV(43,391文字)■ 送った要点(372文字)1回目の呼び出しで送るのは 896文字、2回目は 659文字。合計しても約1,600文字で、CSV本体の4%に満たない。

 

ステップ5:結果を渡して、説明を書かせる

最後に、この結果を生成AIに返して、日本語の説明を書いてもらいます。

以下、コードです。

second = client.chat.completions.create(
    model="gpt-5.6-luna",
    messages=[
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": question},
        msg,                                          # 生成AIのツール要求をそのまま履歴に入れる
        {"role": "tool", "tool_call_id": call.id,
         "content": json.dumps(result_for_ai, ensure_ascii=False)},
    ],
    reasoning_effort="none",
)
print(second.choices[0].message.content)
  • messages には、最初の質問からの会話の履歴をすべて入れます。生成AIは前回の呼び出しを覚えていないので、毎回こちらから渡します
  • 3つ目の msg は、ステップ2で生成AIが返してきたツール要求です。そのまま履歴に入れます
  • 4つ目がツールの結果です。role を "tool" にし、tool_call_id でステップ2の要求と対応づけ、content に結果のJSONを文字列で入れます
  • 今回は tools を渡していないので、生成AIは関数を呼ばず、文章を返します

 

以下、実行結果です。

先週(2026-08-24〜2026-08-30)の売上は635,010円で、前週(2026-08-17〜2026-08-23)の647,940円から12,930円(-2.0%)減少しました。
減少額が大きい商品は、ハンバーグ弁当(-31,900円)、明太子おにぎり(-30,600円)、麦茶(-8,540円)です。
一方で幕の内弁当(+19,140円)など増えた商品もあり、全体の減少幅は減った商品の合計より小さくなっています。
(根拠:結果ID R1)

 

この文章に出てくる数字は、すべてステップ4の result_for_ai に入っていたものです。生成AIは計算していません。数字はPython、説明は生成AIという分担が、コードのうえで実現できました。

 

引数を制限する——設計のコツ

ステップ1のツール定義とステップ3の検証は、生成AIに任せる範囲を決めるところです。ここを緩くすると、せっかくの再現性が崩れます。いくつかの原則を挙げます。

引数の種類 こうする 理由
日付 YYYY-MM-DD の文字列に固定し、データの範囲内かを検証する 「先週」のような相対表現を引数に入れると、解釈が生成AI任せになる
カテゴリ・商品名 データから作った一覧を enum にする 存在しない名前や、似た名前への勝手な置き換えを防ぐ
件数(上位N件など) 整数にして上限を検証する(1〜10 など) 「上位10000件」で結果が巨大になるのを防ぐ
ファイルパス・SQL・Pythonコード 引数にしない 生成AIが自由な文字列を渡せる箇所を作らない。読むファイルも実行する処理も、こちらで決める

最後の行は「生成AIにコードを書かせて実行する」方式との違いでもあります。その方式は柔軟ですが、何が実行されるかを事前に決められません。Tool Calling は、実行できることをあらかじめ関数の一覧に限定することで、再現性と安全性を確保しています。

 

用語ミニ辞典

Pythonを書いている方でも、この分野で初めて見るかもしれない用語だけを挙げます。

Tool Calling(ツール呼び出し、Function Calling とも)
生成AIに、あらかじめ定義した関数の中から「呼ぶべきもの」と「その引数」をJSON形式で答えさせる仕組み。実行するのはアプリケーション側で、生成AIは計算しない。

ツール定義
関数の名前・説明・引数の仕様を、生成AIに渡すためにJSON形式で書いたもの。この記事の TOOLS。

JSON Schema
「このJSONはこういう形であるべき」を記述する書式。ツール定義の parameters はこれで書く。type・enum・required などの語彙を持つ。

strict モード
ツール定義に "strict": true を付けると、生成AIの返す引数がスキーマに厳密に従うようになる。引数はすべて required に入れ、additionalProperties: false を付けるのが決まり。

tool_calls
生成AIの応答に含まれる「関数を呼んでほしい」という要求のリスト。各要素に id・関数名・引数(JSON文字列)が入る。

tool ロール
会話の履歴で、関数の実行結果を生成AIに返すときのメッセージ種別。tool_call_id で「どの要求への結果か」を対応づける。

検証層
生成AIのツール要求を実行する前に、許可リスト・引数の形式・値の範囲を確かめる処理。この記事の validate。講座では ToolGuard と呼んでいる。

結果ID
関数の実行結果に付ける通し番号(R1 など)。説明文の数字がどの結果に基づくかを追跡するために使う。

 

まとめ

Tool Calling では、生成AIは計算しません。ツール定義を見て「どの関数を、どんな引数で呼ぶか」を決め、こちらのPythonが計算し、返ってきた要点だけを使って説明を書きます。

同じ質問に同じ数字が返るのは、計算しているのが仕様の固定された関数だからです。CSV本体を外に送らずに済むのは、送るのが372文字の集計結果だけだからです。あとから「その数字はどこから来たのか」を追えるのは、関数名・引数・結果IDが残るからです。

ただし、生成AIの要求をそのまま実行してはいけません。許可リスト・引数の形式・値の範囲を確かめる検証層を必ず挟み、ファイルパスやコードのような自由な文字列を引数にしない。生成AIに任せる範囲を、関数の一覧と引数の仕様で決める——それが Tool Calling の設計です。

次の記事では、ステップ5で返ってくる「説明」の側を扱います。文章の中の数字が本当に結果と一致しているかを、どう確かめるか。構造化出力と数値の検証がテーマです。

 

よくある質問

Q. Claude や Gemini でも同じことができますか?

できます。ツール定義の書き方と応答の取り出し方(接続層)だけが各社で少し違い、検証層・関数・結果の作り方はそのまま使えます。当社の講座では OpenAI 版をリファレンスにし、Claude 版・Gemini 版のNotebookも配布しています。

Q. API の料金はどれくらいかかりますか?

この記事の例では、1つの質問で生成AIを2回呼びます。送る文字数は合計でも2,000文字程度、返ってくる文章も数百文字なので、gpt-5.6-luna の公開料金(入力 100万トークンあたり 0.10ドル、出力 0.60ドル。2026年9月16日に公式ページで確認)に照らすと、1質問あたり1円に届きません。料金は変わることがあるので、実施前に公式ページでご確認ください。

Q. CSVのデータは外部に送られないのですか?

この記事の設計では、生成AIに送るのは「基準日やカテゴリ一覧などの前提条件」「質問文」「集計結果の要点(372文字)」の3つだけです。CSV本体は pd.read_csv で手元に読み込まれたきり、外に出ていません。ただし、result_for_ai に何を入れるかはこちらの設計次第なので、商品名や金額を外部サービスに送ってよいかは、社内のルールに照らして判断してください。

Q. 生成AIが間違った関数や引数を選んだらどうなりますか?<

検証層で止まります。検証に失敗した理由を生成AIに返して条件を直させるか、利用者に聞き直してもらいます。数字を作って埋めることはしません。関数が1つしかないこの記事の例では選び間違いは起きませんが、関数が増えると description の書き方が選択の精度を左右します。

Q. 関数を複数定義したら、生成AIは使い分けてくれますか?

TOOLS のリストに複数の関数を入れれば、質問に応じて選びます。「日別の推移を見たい」なら集計関数、「先週と比べたい」なら比較関数、というように、description に「どんな質問に向くか」を書き分けておくのがコツです。

 

付録:APIキーがないときの模擬クライアント

この記事のコードを、APIキーなしで最後まで動かすためのものです。本物と同じ形で応答を返すだけで、生成AIは使っていません。準備のところで client = OpenAI(...) の代わりに client = FakeClient() としてください。

以下、コードです。

from types import SimpleNamespace as NS

class FakeClient:
    """本物の client と同じ形(choices[0].message.tool_calls / .content)で応答を返す"""
    def __init__(self):
        self.chat = NS(completions=NS(create=self._create))

    def _create(self, model, messages, tools=None, tool_choice=None, **kw):
        if tools:   # 1回目:ツール要求を返す
            args = {"cur_start": "2026-08-24", "cur_end": "2026-08-30",
                    "prev_start": "2026-08-17", "prev_end": "2026-08-23", "category": None}
            call = NS(id="call_demo001", type="function",
                      function=NS(name="compare_periods",
                                  arguments=json.dumps(args, separators=(",", ":"))))
            msg = NS(role="assistant", content=None, tool_calls=[call])
        else:       # 2回目:ツール結果から説明文を組み立てる
            r = json.loads(messages[-1]["content"])
            z, d, u = r["全体"], r["減少上位"], r["増加上位"]
            msg = NS(role="assistant", tool_calls=None, content=(
                f"先週({r['期間']['当期']})の売上は{z['当期売上']:,}円で、"
                f"前週({r['期間']['前期']})の{z['前期売上']:,}円から"
                f"{abs(z['増減額']):,}円({z['増減率']}%)減少しました。\n"
                f"減少額が大きい商品は、{d[0]['商品名']}({d[0]['増減額']:,}円)、"
                f"{d[1]['商品名']}({d[1]['増減額']:,}円)、{d[2]['商品名']}({d[2]['増減額']:,}円)です。\n"
                f"一方で{u[0]['商品名']}(+{u[0]['増減額']:,}円)など増えた商品もあり、"
                f"全体の減少幅は減った商品の合計より小さくなっています。\n"
                f"(根拠:結果ID {r['result_id']})"))
        return NS(choices=[NS(message=msg)])

 

当社の講座では、これをもう少し本格的にした「模擬モード」(質問文を解釈して、本物と同じ形のツール要求を組み立てるルールベースの代替)を教材に同梱しています。APIキーがなくても全演習を最後まで進められるようにするためです。

Screenshot

【月1 特定テーマ講座(11月)】
POS売上分析Copilotを作る
(POSデータ×PYTHON×生成AI)

【開催日時】 全2回(土)2026/11/7,11/21(13:30〜18:00)
【受講形式】 当日Zoom( or 復習用に後日動画視聴)
【参加費用】 2万2千円(税込み)/人