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?

Agentic AIを学びたい(Step6:Stateで取得済みを管理する)

0
Posted at

はじめに

前回の記事の続きです。今回は、取得済みの情報を Agent の State に持たせます。

前回は while (true) で、tool_call なら Tool を実行し、final なら本文で終わっていました。
Agentic AIを学びたい(Step5:Agent Loopで終わるまで繰り返す)

本文が返っただけでは終わらせません。予定・天気・移動の三つが State 上で取得済みのときだけ、回答として終了します。取得済みの Tool は、もう一度呼ばれても実行しません。

毎ターン、その時点の State を System Prompt のうしろに足して LLM に渡します。古い State は会話に残しません。

今回の実行では、State が次のように進みました。

予定は未取得 / 天気は未取得 / 移動は未取得
予定は取得済み / 天気は未取得 / 移動は未取得
予定は取得済み / 天気は取得済み / 移動は未取得
予定は取得済み / 天気は取得済み / 移動は取得済み

三つが取得済みになってから、傘と 18:20 出発の本文が返って終了しました。

成果物

npm start

> start
> tsx src/index.ts

モデル: openai/gpt-4o-mini

--- User Prompt ---

今日どう動けばいい?

--- State ---
予定は未取得 / 天気は未取得 / 移動は未取得
{
  "scheduleChecked": false,
  "weatherChecked": false,
  "routeChecked": false,
  "destination": null,
  "eventTime": null,
  "weather": null,
  "duration": null
}

--- 1回目のLLM ---
getSchedule({"date":"今日"})

--- Tool Result ---
19:00 梅田で食事

--- State ---
予定は取得済み / 天気は未取得 / 移動は未取得
{
  "scheduleChecked": true,
  "weatherChecked": false,
  "routeChecked": false,
  "destination": "梅田",
  "eventTime": "19:00",
  "weather": null,
  "duration": null
}

--- 2回目のLLM ---
getWeather({"location":"梅田","time":"19:00"})

--- Tool Result ---
18:00から雨

--- State ---
予定は取得済み / 天気は取得済み / 移動は未取得
{
  "scheduleChecked": true,
  "weatherChecked": true,
  "routeChecked": false,
  "destination": "梅田",
  "eventTime": "19:00",
  "weather": "18:00から雨",
  "duration": null
}

--- 3回目のLLM ---
getRoute({"origin":"自宅","destination":"梅田"})

--- Tool Result ---
30分

--- State ---
予定は取得済み / 天気は取得済み / 移動は取得済み
{
  "scheduleChecked": true,
  "weatherChecked": true,
  "routeChecked": true,
  "destination": "梅田",
  "eventTime": "19:00",
  "weather": "18:00から雨",
  "duration": "30分"
}

--- 回答 ---
終了条件: 予定・天気・移動が揃い、本文が返った

予定は19:00に梅田で食事です。18:00から雨が予想されているので、傘が必要です。家を出る時刻は18:20です。

シーケンス図

1回目の前
  予定は未取得 / 天気は未取得 / 移動は未取得
  getSchedule
  destination: 梅田
  eventTime: 19:00

2回目の前
  予定は取得済み / 天気は未取得 / 移動は未取得
  getWeather
  weather: 18:00から雨

3回目の前
  予定は取得済み / 天気は取得済み / 移動は未取得
  getRoute
  duration: 30分

4回目
  三つとも取得済み
  final
  傘と 18:20 出発

終了条件は次の2つです。

1. 予定・天気・移動が取得済みで、本文が返った
2. 8回を超えた

本文だけが先に返っても、State が揃っていなければ終了しません。

ディレクトリ構成

増えたのは src/agent/state.ts です。

agentic_ai_study/
├─ src/
│  ├─ index.ts
│  ├─ env.ts
│  ├─ agent/
│  │  ├─ prompts.ts         未取得の Tool だけ呼ぶ指示
│  │  ├─ state.ts           取得済みと、場所・時刻・天気・移動時間
│  │  └─ runtime.ts         State を渡す、再実行防止、終了判定
│  ├─ llm/
│  │  ├─ openrouter.ts
│  │  └─ types.ts
│  └─ tools/
│     ├─ schedule.ts
│     ├─ weather.ts
│     ├─ route.ts
│     └─ execute.ts
├─ .env
├─ package.json
└─ tsconfig.json
npm start
  └─ tsx src/index.ts
       └─ agent/runtime.ts
            ├─ agent/state.ts
            └─ while (true)

コード

変わったファイルを載せます。

src/agent/state.ts

import type { RouteResult } from "../tools/route.js";
import type { ScheduleResult } from "../tools/schedule.js";
import type { WeatherResult } from "../tools/weather.js";

export type AgentState = {
  scheduleChecked: boolean;
  weatherChecked: boolean;
  routeChecked: boolean;
  destination: string | null;
  eventTime: string | null;
  weather: string | null;
  duration: string | null;
};

export function createState(): AgentState {
  return {
    scheduleChecked: false,
    weatherChecked: false,
    routeChecked: false,
    destination: null,
    eventTime: null,
    weather: null,
    duration: null,
  };
}

export function isReadyToAnswer(state: AgentState): boolean {
  return state.scheduleChecked && state.weatherChecked && state.routeChecked;
}

export function alreadyChecked(state: AgentState, name: string): boolean {
  switch (name) {
    case "getSchedule":
      return state.scheduleChecked;
    case "getWeather":
      return state.weatherChecked;
    case "getRoute":
      return state.routeChecked;
    default:
      return false;
  }
}

export function describeState(state: AgentState): string {
  const mark = (checked: boolean) => (checked ? "取得済み" : "未取得");
  return [
    `予定は${mark(state.scheduleChecked)}`,
    `天気は${mark(state.weatherChecked)}`,
    `移動は${mark(state.routeChecked)}`,
  ].join(" / ");
}

export function applyToolResult(state: AgentState, name: string, result: unknown): void {
  if (name === "getSchedule") {
    const schedule = result as ScheduleResult;
    const event = schedule.events[0];
    state.scheduleChecked = true;
    state.eventTime = event?.time ?? null;
    state.destination = event?.location ?? null;
    return;
  }

  if (name === "getWeather") {
    const weather = result as WeatherResult;
    state.weatherChecked = true;
    state.weather = weather.forecast;
    return;
  }

  if (name === "getRoute") {
    const route = result as RouteResult;
    state.routeChecked = true;
    state.duration = route.duration;
  }
}

export function skippedResult(state: AgentState, name: string): unknown {
  return {
    skipped: true,
    reason: "取得済みのため再実行しない",
    tool: name,
    state,
  };
}

src/agent/prompts.ts

export const USER_PROMPT = "今日どう動けばいい?";

export const SYSTEM_PROMPT = `あなたは外出前の準備を手伝うアシスタントです。
まず予定を getSchedule で確認してください。
予定に外出先と時刻がある場合は、次に getWeather でその場所と時刻の天気を確認してください。
出発地は自宅です。目的地が分かったら getRoute で移動時間を確認してください。
一度の応答では Tool を1つだけ呼んでください。
各ターンで渡す State を見て、未取得の Tool だけ呼んでください。取得済みの Tool は呼ばないでください。
予定も天気も移動時間も、Tool の結果が出るまで推測してはいけません。
結果が揃ったら、予定、傘の要否、家を出る時刻を日本語で短く答えてください。
出発時刻は、予定の時刻から移動時間を引き、さらに10分早く出てください。`;

src/agent/runtime.ts

import { requireEnv } from "../env.js";
import { callOpenRouter } from "../llm/openrouter.js";
import type { AssistantReply, ChatMessage, ChatResponse, ToolCall } from "../llm/types.js";
import { executeTool, tools } from "../tools/execute.js";
import { SYSTEM_PROMPT, USER_PROMPT } from "./prompts.js";
import {
  alreadyChecked,
  applyToolResult,
  createState,
  describeState,
  isReadyToAnswer,
  skippedResult,
} from "./state.js";

const MAX_TURNS = 8;

type AgentResponse =
  | { type: "final"; content: string }
  | { type: "tool_call"; calls: ToolCall[] };

function readMessage(response: ChatResponse): AssistantReply {
  const message = response.choices?.[0]?.message;
  if (!message) {
    throw new Error("応答に choices[0].message がありません");
  }
  return message;
}

function toAgentResponse(message: AssistantReply): AgentResponse {
  const calls = message.tool_calls ?? [];
  if (calls.length > 0) {
    return { type: "tool_call", calls };
  }
  if (!message.content) {
    throw new Error("応答に tool_calls も content もありません");
  }
  return { type: "final", content: message.content };
}

export async function run(): Promise<void> {
  console.log("モデル:", requireEnv("MODEL_NAME"));
  console.log("\n--- User Prompt ---\n");
  console.log(USER_PROMPT);

  const messages: ChatMessage[] = [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: USER_PROMPT },
  ];
  const state = createState();

  let turn = 0;

  while (true) {
    turn += 1;
    if (turn > MAX_TURNS) {
      throw new Error(`終了条件: ${MAX_TURNS}回を超えたため終了しました`);
    }

    console.log("\n--- State ---");
    console.log(describeState(state));
    console.log(JSON.stringify(state, null, 2));

    const [system, ...history] = messages;
    const message = readMessage(
      await callOpenRouter(
        [
          {
            role: "system",
            content: `${system?.content ?? ""}\n\n現在のState:\n${JSON.stringify(state, null, 2)}\ntrue の項目は取得済みです。未取得のものだけ呼び、三つとも true なら回答してください。`,
          },
          ...history,
        ],
        tools,
      ),
    );
    console.log("\n--- LLM Response ---");
    console.log(JSON.stringify(message, null, 2));
    console.log("\n--- Tool Calls ---");
    console.log(JSON.stringify(message.tool_calls ?? [], null, 2));

    const response = toAgentResponse(message);

    if (response.type === "final") {
      if (!isReadyToAnswer(state)) {
        console.log("\n終了しない: State がまだ揃っていない");
        messages.push({ role: "assistant", content: response.content });
        messages.push({
          role: "user",
          content: "State を見て、未取得の情報を Tool で取得してください。",
        });
        continue;
      }

      console.log("\n--- 回答 ---");
      console.log("終了条件: 予定・天気・移動が揃い、本文が返った");
      console.log("取り出し元: choices[0].message.content\n");
      console.log(response.content);
      return;
    }

    console.log(`\n--- ${turn}回目のLLM ---`);
    console.log("取り出し元: choices[0].message.tool_calls\n");

    messages.push({
      role: "assistant",
      content: message.content ?? null,
      tool_calls: response.calls,
    });

    for (const call of response.calls) {
      const args = JSON.parse(call.function.arguments) as unknown;
      const skipped = alreadyChecked(state, call.function.name);
      const result = skipped ? skippedResult(state, call.function.name) : executeTool(call);

      if (skipped) {
        console.log(`再実行をスキップ: ${call.function.name}`);
      } else {
        applyToolResult(state, call.function.name, result);
        console.log(`${call.function.name}(${JSON.stringify(args)})`);
      }

      console.log("\n--- Tool Result ---");
      console.log(JSON.stringify(result, null, 2));
      console.log("\n--- State ---");
      console.log(describeState(state));
      console.log(JSON.stringify(state, null, 2));

      messages.push({
        role: "tool",
        tool_call_id: call.id,
        name: call.function.name,
        content: JSON.stringify(result),
      });
    }
  }
}
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?