2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「現場で活用するためのAIエージェント実践入門」でつまずいたことメモ:5章

2
Last updated at Posted at 2025-11-03

はじめに

「現場で活用するためのAIエージェント実践入門」の第5章で私がつまずいたことのメモです。

(このメモのほかの章へ:1章 / 2章 / 3章 / 4章 / 5章 / 6章 / 7章 / 8章 / 9章 / 10章 / まとめ

第5章 データ分析者を支援する

この章では、データ分析を手伝ってくれるAIエージェントを作っていきます。本で掲載されているソースコードはGitHubで公開されています。

5.1 データ分析を伴う業務

私はデータ分析にそれほど明るくないので(自然言語処理をやっていたのでテキストマイニングに少し関わった程度)勉強になりました。特にコラムで紹介されていた「Data Interpreter」が、自然文の指示から動的に有向非巡回グラフを作成して、それに基づきコードを自動生成&実行してくれるというのは興味深いです。

5.2 データ分析エージェントの設計

ここからは、ステップを踏んでAIエージェントを作っていきます。

5.3 実装準備

今回使うコード実行用のサンドボックス「E2B」は、アカウントを作ると無料のHobbyプランで100ドル分のクレジットをもらえます。私がこの章の内容をいろいろ試した時は1ドルくらいしか消費しなかったので、勉強にはHobbyプランで十分です。サイトのデザインが昔のMacintoshのUIを彷彿とさせるものでいい感じです。


テンプレートエンジン「Jinja2」は、テンプレートに分岐やループのロジックを埋められるのが便利です。

なお、プロジェクト名は「Jinja」なのですが、パッケージ名がJinja2なので「Jinja2」とも呼ばれているようです。でも、すでにバージョンは3.xなのでややこしいですね。最新版はJinja v3系ですが、パッケージ名はJinja2のままです。詳細はこちらです。


データ概要を返すdescribe_dataframe()は、本では説明されていませんが、GitHubのscripts/03_describe_dataframe.pyで試すことができます。実行するとこんな感じになりました。

2025-11-02 15:59:38.562 | INFO     | __main__:main:39 - ```python
>>> df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 500 entries, 0 to 499
Data columns (total 19 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   campaign_id      500 non-null    object 
 1   channel_id       500 non-null    object 
 2   campaign_type    500 non-null    object 
 3   start_date       500 non-null    object 
 4   end_date         500 non-null    object 
 5   user_id          500 non-null    object 
 6   login_at         500 non-null    object 
 7   login_interval   500 non-null    int64  
 8   purchase_date    500 non-null    object 
 9   purchase_amount  500 non-null    float64
 10  purchase_items   500 non-null    int64  
 11  click_date       500 non-null    object 
 12  click_count      500 non-null    int64  
 13  conversion_rate  500 non-null    float64
 14  score            500 non-null    float64
 15  is_successful    500 non-null    bool   
 16  weekday          500 non-null    object 
 17  time_of_day      500 non-null    object 
 18  week_of_month    500 non-null    int64  
dtypes: bool(1), float64(3), int64(4), object(11)
memory usage: 70.9+ KB


>>> df.sample(5).to_markdown()
|     | campaign_id   | channel_id    | campaign_type   | start_date   | end_date   | user_id   | login_at   |   login_interval | purchase_date   |   purchase_amount |   purchase_items | click_date   |   click_count |   conversion_rate |   score | is_successful   | weekday   | time_of_day   |   week_of_month |
|----:|:--------------|:--------------|:----------------|:-------------|:-----------|:----------|:-----------|-----------------:|:----------------|------------------:|-----------------:|:-------------|--------------:|------------------:|--------:|:----------------|:----------|:--------------|----------------:|
| 197 | CAMP_4        | Email         | Bundle Offer    | 2024-06-10   | 2024-06-17 | USER_843  | 2024-06-05 |               29 | 2024-06-06      |            143.35 |                5 | 2024-06-05   |             9 |             11.11 |    4.32 | True            | Wednesday | Morning       |               1 |
| 440 | CAMP_1        | Display Ads   | Discount        | 2024-06-19   | 2024-06-25 | USER_497  | 2024-06-10 |               15 | 2024-06-10      |              0    |                0 | 2024-06-08   |             1 |            100    |    9.91 | True            | Monday    | Evening       |               2 |
| 477 | CAMP_10       | Email         | Bundle Offer    | 2024-06-21   | 2024-06-23 | USER_67   | 2024-06-30 |               29 | 2024-07-03      |            377.6  |                9 | 2024-06-30   |             7 |             14.29 |   13.18 | True            | Sunday    | Morning       |               5 |
| 351 | CAMP_5        | Display Ads   | Discount        | 2024-06-07   | 2024-06-09 | USER_231  | 2024-06-26 |               19 | 2024-07-01      |             35.81 |                1 | 2024-06-25   |             7 |              0    |    0.89 | False           | Wednesday | Afternoon     |               4 |
|  21 | CAMP_9        | Search Engine | Discount        | 2024-06-06   | 2024-06-13 | USER_102  | 2024-06-19 |               26 | 2024-06-24      |            363.14 |               10 | 2024-06-18   |             4 |             25    |   27.99 | True            | Wednesday | Morning       |               3 |

>>> df.describe()
|       |   login_interval |   purchase_amount |   purchase_items |   click_count |   conversion_rate |     score |   week_of_month |
|:------|-----------------:|------------------:|-----------------:|--------------:|------------------:|----------:|----------------:|
| count |        500       |           500     |        500       |      500      |          500      | 500       |       500       |
| mean  |         15.264   |           161.215 |          3.566   |        5.422  |           26.7615 |  10.3128  |         2.696   |
| std   |          8.52994 |           167.582 |          3.61937 |        2.7942 |           26.1302 |   7.81839 |         1.28798 |
| min   |          1       |             0     |          0       |        1      |            0      |   0.22    |         1       |
| 25%   |          8       |             0     |          0       |        3      |           12.5    |   4.5025  |         2       |
| 50%   |         15       |           107.955 |          2.5     |        5.5    |           16.67   |   8.71    |         3       |
| 75%   |         22       |           306.957 |          7       |        8      |           33.33   |  13.7825  |         4       |
| max   |         30       |           499.76  |         10       |       10      |          100      |  45.67    |         5       |

「プログラムリスト 5.11」のLLMラッパーは、コストまで計算してくれて便利です。ただ、プロンプトキャッシュによるコスト削減は加味されていないので、実際は安くなることもあります。また、コストを計算するために価格表をメンテするのがちょっと面倒ですね。価格表が更新されたら追従させないといけません。コストもLLMの応答で返してくれると便利なのですが。

5.4 プログラム生成を行うシングルエージェントワークフロー

ここからはプログラマーに相当する部分の実装です。

5.4.1 コード生成(計画)

「プログラムリスト 5.15」のように、プロンプトで「あなたは優秀なデータサイエンティストです。」といったプロフィールを指定することで精度を上げる手法は、次の論文で有名になりました。なお、この論文では、与えるプロフィール情報の自動生成もしています。

その後、複数の専門家を用意して、そこから選抜すると精度が上がるという論文もありました。プロンプトもまだまだ奥が深そうです。

「プログラムリスト 5.16」を試したところ、私の場合はこんなコードを書いてくれました。

def display_dataframe_overview(dataframe):
    """ 
    データフレームの概要を表示します。

    Args:
        dataframe (pd.DataFrame): 概要を表示したいデータフレーム。
    """
    # データフレームのサイズ
    print("データフレームのサイズ:", dataframe.shape)
    # カラム名とデータ型
    print("カラム名とデータ型:")
    print(dataframe.dtypes)
    # NA値の数
    print("各カラムのNA値の数:")
    print(dataframe.isnull().sum())

# データの概要を表示
display_dataframe_overview(df)

このコードでは本のようなdf.describe()は使っていませんが、再度実行するとdf.describe()を使うコードが生成されました。注釈24では、再現性を担保したい場合はseedパラメータを指定するようにとの補足がありますが、このメモの「3.1 OpenAI APIの基本」にも書いたように、ランダム性を消すことはできないので注意が必要です。

5.4.2 コード実行(行動)

「プログラムリスト 5.20」は、GitHubのコードだとインデント付きで結果が表示されるようになっていました。以降も、GitHubのコードの方が洗練されているので、そちらも見ながら読み進めるのがおすすめです。

5.4.3 実行結果のレビュー(知覚)

「プログラムリスト 5.21」でレビュー結果を定義するReviewクラスは、GitHubのコードの方がdescriptionをきっちり指定しています。また、その定義を、プロンプト内の出力形式の指定でもそのまま書いています。

src/prompts/generate_review.jinja より
### 出力形式

以下の Pydantic.BaseModel のデータ型に従って、構造化されたデータを出力してください。

```python
class Review(BaseModel):
    observation: str = Field(
        title="実行結果に対する客観的受け止め",
        description=(
            "まずはコードの実行結果に対する客観的な事実を記述する。例えば「正常に終了し、〇〇という結果を得た。」「エラーが発生した。」などを記述する。"
            "その後、コードの実行結果がユーザーから与えられた要求に対して最低限担保できているかを評価する。"
            "要求を満たさない場合は、その修正方針を追記する。"
        ),
    )
    is_completed: bool = Field(
        title="タスク達成条件",
        description=(
            "実行結果がユーザーから与えられた要求に対して最低限担保できているかを評価する。"
            "タスク要求を満たさない場合はFalse、改善点はあれど最低限要求を満たす場合はTrueとする。"
        ),
    )
```

Structured Outputsで指定すればPydantic.BaseModelから派生したクラスの情報は伝わるはずなので冗長な感じもするのですが、LLMにきっちり理解させるためのテクニックなのかもしれません。

また、「プログラムリスト 5.22」は、会話履歴messagesの途中でsystemロールが何度も出てくるのが特徴的です。このような書き方もあるんですね、なんとなく会話の先頭にしか書かないものなのかと思っていました。

src/modules/generate_review.py より
    messages = [
        {"role": "system", "content": system_instruction},
        {"role": "user", "content": user_request},
        {"role": "assistant", "content": data_thread.code},
        *([{"role": "system", "content": results}] if has_results else []),
        {"role": "system", "content": f"stdout: {data_thread.stdout}"},
        {"role": "system", "content": f"stderr: {data_thread.stderr}"},
        {
            "role": "user",
            "content": "実行結果に対するフィードバックを提供してください。",
        },
    ]

5.4.4 コード生成・コード実行・実行結果のレビューのサイクル実行

「プログラムリスト 5.25」は、GitHubではscripts/08_programmer.pyscripts/programmer.pyとに分かれています。なお、GitHubのコードをそのまま動かすと、以下のような感じで[Errno 2] No such file or directory: 'outputs/process_id/score_distribution.png'が発生してしまいます。

2025-11-03 01:51:51.141 | DEBUG    | src.modules.execute_code:execute_code:16 - execution=Execution(Results: [Result(<PIL.Image.Image image mode=RGBA size=1000x600>), Result(<Figure size 1000x600 with 1 Axes>)], Logs: Logs(stdout: [], stderr: []), Error: ExecutionError(name='FileNotFoundError', value="[Errno 2] No such file or directory: 'outputs/process_id/score_distribution.png'..."

これは、プロンプトでoutputs/process_id以下にファイルを保存するよう指定しているにも関わらず、そのディレクトリが存在しないためです。エラーを回避するため、src/prompts/generate_code.jinjaのプロンプトを修正するのがよさそうです。

src/prompts/generate_code.jinja より(修正前)
<コード生成の制約条件>
(中略)
- グラフや新しいデータは "{{ remote_save_dir }}" のディレクトリ下に適切なファイル名で保存すること。
</コード生成の制約条件>
src/prompts/generate_code.jinja より(修正後)
<コード生成の制約条件>
(中略)
- グラフや新しいデータを保存する際は、必ず最初に "{{ remote_save_dir }}" のディレクトリを作成してから、そのディレクトリ下に適切なファイル名で保存すること。ディレクトリの作成には `import os` と `os.makedirs('{{ remote_save_dir }}', exist_ok=True)` を使用すること。
</コード生成の制約条件>

なお、ここでファイル作成に失敗しているのに画像ファイルが取得できる理由は、E2B Sandboxで動くヘッドレスJupyterサーバーに、Matplotlibでの画像生成処理をフックしてexecution.resultsへ格納する機能があるためです。詳細はこちらです。

5.5 データ分析レポートの作成

プログラマーに相当する部分ができたので、その手前の実行計画立案と、最後のレポート作成を実装します。

5.5.1 分析計画の立案(仮説構築)

「プログラムリスト 5.26」のTaskクラスやPlanクラスも、GitHubのコードではdescriptionexamplesがしっかり定義されています。

「プログラムリスト 5.29」は、GitHubのコードでは結果がインデント付きで表示されるように改善されていました。

5.5.2 プログラムの実行

「プログラムリスト 5.30」を実行すると、同じ画像が、2ファイル生成されることがあります。あまり深くは追っていないのですが、コードによる保存と、前述のE2B SandboxがMatplotlibの画像生成処理をフックしてexecution.resultsへ格納する機能とで重複してしまうことが原因かもしれません。

5.5.3 実行結果を反映したレポートの作成

プログラムで生成したグラフの画像を、そのままLLMに渡すところがおもしろいです。最近のLLMは画像の理解もお手の物なので、こういったことができる訳ですね。

5.5.4 作成された分析レポート

私の場合はこんな感じのレポートができあがりました。

image.png

グラフ付きのレポートができあがるのはインパクトありますね、すごい!

5.6 課題と展望

やってみて分かりましたが、実行するたびに結果は変わりますし、調整も大変そうですし、実務で使うにはいろいろ足りない点も多そうです。本にもあるように、今回作ったものは、AIエージェント実装における第一歩としてのサンプルという位置づけになりそうです。

また、これも本にありますが、あくまでもAIエージェントは手段の一つなので、AIエージェントを導入することが目的になってしまわないよう注意が必要です。

5.7 まとめ

GitHubにはLangGraphによる実装もあるので、そちらも見てみました。

親のグラフはsrc/graph/data_analysis.pybuild_data_analysis_graph()で作成していますが、ここではadd_node()だけで、4章で使ったadd_edge()add_conditional_edge()は出てきません。子のグラフも同様です。

src/graph/data_analysis.py より
def build_data_analysis_graph() -> CompiledStateGraph:
    checkpointer = InMemorySaver()
    graph = StateGraph(DataAnalysisState)
    graph.add_node("generate_plan", generate_plan_node)
    graph.add_node("approve_plan", approve_plan)
    graph.add_node("programmer", build_programmer_graph(_close_programmer))
    graph.add_node("open_programmer", open_programmer)
    graph.add_node("generate_report", generate_report_node)
    graph.set_entry_point("generate_plan")
    return graph.compile(checkpointer=checkpointer)

ノード間のエッジは事前定義されておらず、各ノードに対応する関数がCommandオブジェクトを返すことで、動的に次のノードを指定する形になっています。たとえば、generate_planノードの実装はこんな感じです。

src/graph/nodes/generate_plan.py より
def generate_plan_node(state: DataAnalysisState) -> dict:
    logger.info("|--> generate_plan")
    with open(state["data_file"], "rb") as fi:
        file_object = io.BytesIO(fi.read())
        data_info = describe_dataframe(
            file_object=file_object,
            template_file=TEMPLATE_FILE,
        )
    llm_response = generate_plan(
        data_info=data_info,
        user_request=state["user_goal"],
    )
    plan = llm_response.content
    tasks = plan.tasks
    sub_tasks = [SubTask(state=False, task=task) for task in tasks]
    return Command(
        goto="approve_plan",
        update={
            "data_info": data_info,
            "sub_tasks": sub_tasks,
            "next_node": "approve_plan",
        },
    )

Commandクラスの詳細はこちらです。

計画の生成後に、人間による承認フェーズ、ヒューマンインザループ(Human-in-the-Loop; HITL)が実装されています。workflow.invoke()でワークフローを開始後、制御が返ってきた時に次のノードがapprove_planなのかどうかで判定しています。

src/graph/data_analysis.py より
def invoke_workflow(
    workflow: CompiledStateGraph,
    input_data: dict | Command,
    config: dict,
) -> dict:
    result = workflow.invoke(
        input=input_data,
        config=config,
    )
    logger.debug(result)
    if result["next_node"] == "approve_plan":
        user_input = str(input("User Feedback: Approval? (y/n): "))
        return invoke_workflow(
            workflow=workflow,
            input_data=Command(resume=user_input),
            config=config,
        )
    return result

なお、通常だとワークフローを開始したら途中で制御は戻りませんが、approve_planノードには、途中で制御を返すためにinterrupt()が入っています。

src/graph/nodes/approve_plan.py より
def approve_plan(state: DataAnalysisState) -> Command[Literal["programmer", "generate_plan"]]:
    logger.info("|--> approve_plan")
    is_approval = interrupt(
        {
            "sub_tasks": state["sub_tasks"],
        },
    )
    if is_approval.lower() == "y":
        return Command(
            goto="open_programmer",
            update={
                "user_approval": True,
                "next_node": "open_programmer",
            },
        )
    return Command(
        goto="generate_plan",
        update={
            "user_approval": False,
            "next_node": "generate_plan",
        },
    )

interrupt()で一度制御が戻るので、Pythonのジェネレータで、yieldで呼び出し元に制御を返して、呼び出し元からsend()で送られた情報を受け取り再開する流れと近い感じです。

ただし、interrupt()の内部実装は例外を送出して関数から戻っているので同じ位置からは再開できません(yieldはPython内部でフレームが保存されて再開時に復元されるので実行位置まで戻れるのです)。再開時はそのノードの頭から再実行になるのが注意点です。再開後にinterrupt()まで来たところで、今度は制御を戻すのではなくCommand(resume=hogehoge)で指定した内容がinterrupt()から返ります。yieldとはこの点が違うので、「yieldと同じね!」と理解するのは危険です。

最後に、グラフ構造を図示しておきます(Claude Codeに書いてもらいました)。

この章はなかなか読み応えがありました。それにしても、グラフ付きのレポートができあがるのはインパクトありますね(2回目)。可能性を感じてワクワクする章でした。

次回は「情報収集者の支援」です。

(このメモのほかの章へ:1章 / 2章 / 3章 / 4章 / 5章 / 6章 / 7章 / 8章 / 9章 / 10章 / まとめ

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?