Zenn
🤖

TypeScriptで作って動かして理解するAIエージェント

2025/03/18に公開

Cline、Cursor、Windsurf、Claude Codeなど、AIを活用したコードエージェントの選択肢がますます広がっています。またBoltやv0など、特定用途や環境への最適化を狙った制約の強いエージェントも登場し、それらを組み合わせることで新たな可能性も生まれています。

実際の開発現場では「どのようなシチュエーションで、どのエージェントを活用すべきか?」という具体的かつ実践的なノウハウについて、まだ試行錯誤の段階だと思います。

その中で、自分用のより優れたエージェントを作りたいといったニーズを持つ人や、そのようなニーズがない場合でも、「選定や理解のために、簡単な自律実行プログラムを自ら書いて色々実験してみたい」と考える人は多いのではないでしょうか。

そこで今回、しくみを整理し理解を深めるために、小さくて理解しやすいAIエージェントのサンプルを作成しましたので、そのコードと学びをシェアしたいと思います。

動作の仕組み

AIエージェントの作りは、いわゆる「Re-Actパターン」の実装であり、概ね次のようになっています。

  1. ユーザの課題を解く道筋を立てる
  2. LLMは独白形式で課題を解決する方法を推論し、必要があれば実行するアクション(Toolコール)を宣言する。
  3. LLMの独白を監視しているプログラム(システム)はToolコールを見つけると、LLMの指示どおりにツールの実行を行い、結果をLLMの独白の後に書き込む。
  4. LLMは実行結果を見て、当初のタスクが完了していれば報告を出す。そうでない場合は、独白→アクションを繰り返す。

OpenAIやAnthropicのAIにはFunction CallingやTool Callingに近い機能がありますが、より柔軟な記述や呼び出し方を実現するため、独自タグ(例:<tool>など)を用いて、出力内にツール呼び出しを埋め込み、それをシステムが監視・実行する方式を取っているエージェント実装が多い様に思います。

作るものの企画

  1. 後からツールを追加したり、プロンプトをさまざまに書き直すことを想定

    • ツールのモジュラリティを高くして、使い回しやすくしたい。
  2. 動作を理解し、挙動を実験することを目的に

    • シンプルな構成にすることで、プロンプトにどう影響が出るのかを追いやすい。

以上をコンセプトに、まずはサンプルとして「鶴亀算」などの算数の問題を解く程度の簡単なエージェントを想定し、以下のツールを用意しました。

  • Calcツール:四則演算を行う
  • Compareツール:数値の大小比較を行う
  • GCDツール:最大公約数を求める
  • LCMツール:最小公倍数を求める
  • fileReadツール:ファイルを読む
  • fileWriteツール:ファイルを書き換える
  • Responseツール:ユーザに質問したり、結果を報告する

なお、当初はユーザへの質問や回答を<response>という独自タグで行っていましたが、仕組みがシンプルになるよう「Responseツール」として実装するほうが分かりやすいと気づいたため、そのような構成にしています。

動作サンプルとリポジトリ

miniエージェントが動いている様子の動画です。算数の問題をファイルに記述させ、その後にそれぞれの問題を解かせるという内容です(2分ぐらい)。

https://youtu.be/cu_9jFxkGr0

こちらがリポジトリです

https://github.com/hidenoriohnishi/mini-agent

コード詳細

MainループとToolコール

ユーザへの応答も含め、すべてのアクションをツールコールで行うようにしているので、メイン関数は非常にシンプルです。大まかな手順は以下のとおりです。

  1. Systemプロンプトを設定
  2. 無限ループで下記を繰り返す
    • 独白による推論(Reasoning)
    • 推論結果にツールコールがあればそれを実行(Action)

簡単のため、「1度に1つしかツールを呼び出さない」「ツールを使ったら結果を必ず待つ」という指定をプロンプト側で行い、実装を単純化しています。

以下のコードでは、独白が <tool> タグで終わっているかを正規表現でチェックし、該当すればツールを実行し、その結果を <tool-result> タグとして会話コンテキストに書き込みます。

const toolCallRegex = /<tool[^>]*type="(?<name>[^"]+)"[^>]*>(?<parameters>[\s\S]*?)<\/tool>(?:\s*)$/

async function processToolCalls (text: string): Promise<string | undefined> {
  const match = text.match(toolCallRegex)
  const name = match?.groups?.name
  const parameters = match?.groups?.parameters?.trim()
  const tool = tools.find((t: { name: string }) => t.name === name)
  if (tool && parameters) {
    const toolResult = await tool.execute(parameters)
    return `<tool-result>${toolResult}</tool-result>`
  }
  return undefined
}

async function main () {
  const messages: CoreMessage[] = []
  messages.push({ role: 'system', content: SYSTEM_PROMPT })
  while (true) {
    try {
      const { text } = await generateText({
        model,
        messages,
        temperature: 0.3
      })
      messages.push({ role: 'assistant', content: text })
      console.log(pc.yellow(text))

      const result = await processToolCalls(text)
      if (result) {
        messages.push({ role: 'system', content: result })
        console.log(pc.cyan(result))
      }
    } catch (error) {
      console.error(pc.red('エラー:'), error)
      break
    }
  }
}

プロンプト

システムプロンプトは次のとおりです。ポイントとしては2点あります。

先に紹介した通り、挙動をシンプルにするためにツールコールを1個ずつしか書けない様に規定しており、「ツールの実行結果の待ち受け」というセクションを分けてその挙動について特別に指定しています。

LLMのレスポンス(ユーザへの出力)も「Responseツール」で行うようにしているため、「ユーザとのコミュニケーション方法」というセクションで、そのルールをOne-Shotプロンプトで明確に指定しています。

import { tools } from './tools/tool'

export const SYSTEM_PROMPT = `
# 指示
あなたはユーザの課題を解決するためのアシスタントです。この指示をよく読み、メタな視点であなた自身の挙動を常に観察します。そして、以下に従ってユーザの課題解決を手伝ってください。

* ユーザが課題を出したら、それを実行するためのタスクを<task>タグで囲んで全て書き出します。
* 次に、<think>タグを使って、課題を解くための筋道を考えます。
* 課題の解決に必要なツールを実行し、結果を得ます。
* <think>タグで一度に長く考えることは避けて、できるだけ短い思考&実行を繰り返します。
* どうしても必要な場合にのみ、ユーザに質問してください。
* 最初に設定した全てのタスクが終わったらユーザに報告をしてください。

## ツールの使い方
ツールを使う際は、メッセージの最後に以下の様に記述してください。
\`\`\`
<tool type="{ツール名}">{そのツールのパラメータ}</tool>
\`\`\`

## ツールの実行結果の待ち受け
* <tool>タグを記述したら、systemによって<tool-result>が記述されるのを待ちます。
* <tool-result>が記述されるまでは、いかなる記述も許されません。
* <tool-result>が記述されたら、<think>タグを使って結果を精査し、その後の筋道を考えます。

## 各ツールの説明

各ツールのパラメータはツール毎にzodスキーマで規定されています。

${tools.map(tool => `### ${tool.name}
${tool.description}
#### パラメータ
${tool.parameter}`).join('\n\n')}

## ユーザとのコミュニケーション方法
* ユーザへのコミュニケーションは全てresponseツールを使って行います。
* 質問を含め、responseツールを使わないとユーザには一切表示されませんので注意してください。

\`\`\`
例:<tool type="response">どの様な課題を手伝いましょうか?</tool>
\`\`\`

# アウトプット
--- 以下にアウトプットが続きます ---
`

ツールモジュール

ツールは、以下のように「名前」「説明」「パラメータ定義」「実行関数」を1セットとして定義しています。

export const ToolSchema = z.object({
  name: z.string().describe('ツール名'),
  description: z.string().describe('このツールの説明'),
  parameter: z.string().describe('ツールのパラメータ(zodスキーマによる表現)'),
  execute: z.function()
    .args(z.string())
    .returns(z.promise(z.string()))
    .describe('ツールの実行関数')
})
export type Tool = z.infer<typeof ToolSchema>

以下は四則演算を行うCalcツールの例です。システムプロンプトでは、こうしたツールから説明文等を動的に読み込んで、ツールの使い方を一括で示す形を採用しています。

実装をシンプルにするため、ツールのzod定義(calcSchemaなど)をプロンプト内のパラメータ表記として「コピペ」している点はやや煩雑ですが、サンプル段階では割り切っています。

import { z } from 'zod'
import type { Tool } from './tool'

export const calcSchema = z.object({
  a: z.number().describe('左オペランド'),
  b: z.number().describe('右オペランド'),
  ope: z.enum(['add', 'sub', 'mul', 'div', 'mod']).describe('オペレータ')
})

export const calcTool: Tool = {
  name: 'calc',
  description: '四則演算を実行します。',
  parameter: `
z.object({
  a: z.number().describe('左オペランド'),
  b: z.number().describe('右オペランド'),
  ope: z.enum(['add', 'sub', 'mul', 'div', 'mod']).describe('オペレータ')
})`,
  execute: async (args: string) => {
    try {
      const validatedData = calcSchema.parse(JSON.parse(args))
      const result = (() => {
        switch (validatedData.ope) {
          case 'add':
            return validatedData.a + validatedData.b
          case 'sub':
            return validatedData.a - validatedData.b
          case 'mul':
            return validatedData.a * validatedData.b
          case 'div':
            if (validatedData.b === 0) {
              return '計算エラー: 0での除算はできません'
            }
            return validatedData.a / validatedData.b
          case 'mod':
            return validatedData.a % validatedData.b
        }
      })()
      return result.toString()
    } catch (error) {
      if (error instanceof z.ZodError) {
        return `計算エラー: ${error.errors.map(e => e.message).join(', ')}`
      }
      return `計算エラー: ${error}`
    }
  }
}

まとめ

最後に、今回作って動かしてみて得られた学びを簡単に共有します。なお、現在私はコードエージェントのUI/UXデザインと改善に興味があるため、少しそちら寄りの話が多めです。

  • 仕組み自体は単純
    コードエージェントの性能は、用意するツールの設計とプロンプトでの指示内容でかなり左右されると感じました。
  • チューニングとテストセット
    性能を上げるためには頻繁なチューニングが必要ですが、その際「改善/改悪」を判断しやすいテストセットや評価基準が重要だと思います。エージェントが解決したい課題をはっきりと反映したテストセットを用意することが肝心です。
  • 自律性とエスカレーションのせめぎ合い
    自動で問題を解決してほしい一方で、解決できないときは早く報告してほしいというニーズがあるので、どのタイミングでユーザに確認やエスカレーションを行うかというUI/UXのデザインが重要だと感じました。

このような点から、「ユーザーへのうなづき要求(Nod-Request)」のようなツールを追加して、ある程度の時間が経過してユーザからの応答がなければ自動承認する、などの仕組みを試すのも面白そうだと考えています。すると、LLMの思考粒度(<think>での推論回数など)も変わり、結果としてエージェントの問題解決力にも影響が出るはずです。

LLMを組み込んだシステム全体の設計では、非決定的で多様な応答を考慮しつつ最適化していく必要があり、その過程には多くの面白さと発見があります。こうしたノウハウはまだ十分体系化されていないので、私自身もリサーチを続けながら、また機会があれば記事として共有していきたいと思います。

Sparkle AIブログ

Discussion

ログインするとコメントできます