🛠️

チャットからアプリを操作するAI執事を、tool useと長期記憶で作ってみた

に公開

「牛乳買っといて」「今日の予定は?」「明日10時に歯医者」──こういう自然文を受けて、アプリを実際に操作するAI執事を作りました。アプリ内チャットと LINE の両方で、同じエンジンが動きます。しかも雑談の中で出てきた相手の好みや予定を覚えていて、次からはそれを踏まえて返してくる。この記事は、その執事の中身を「tool use のループ」と「長期記憶」の2本立てで、コードごと開けます。

AI執事のチャット(tool useで買い物リストに追加)

この記事のTL;DR:

  • Claude の tool use(ツール呼び出し)を多ラウンドのループで回してエージェント化した
  • 読み取りツールはその場で実行してループに戻す、書き込みツールはアプリ内では「確認カード」で一回止める(LINE経由は低リスクの追加系だけ許可リストで自動実行)
  • 富豪実装(毎回フルデータ送信)を一回やめて、件数インデックス常時+詳細オンデマンドに作り直した
  • 長期記憶は user_memories という1テーブルに1行1事実で貯め、AIが自分で remember ツールで保存する
  • 確認を挟まない例外は2つ——remember(記憶の即保存)LINEの低リスク追加系。思い出すのは発言者本人の記憶だけ(夫婦で分離)

<課題>毎回フルスナップショットを送るのは重い

最初の執事は、アプリの全データ(予定・買い物・家計…)を毎回まるごと Claude に渡していました。動くけど、トークンもお金も重い。ここで方針転換します。

💭 開発中の実発言(マスク済み)
「毎回全部送るのをやめ、ボットが必要な時だけ取りに行く/折衷(おすすめ):件数インデックス常時+詳細オンデマンド(速度キープ・節約も両立)/これにします!」

落とし所はこう。

  • 件数インデックスだけ常時注入:「予定◯件・ToDo◯件…」をシステムに常に渡す → 「予定いくつ?」に読み取りゼロで即答
  • 詳細はオンデマンド:中身が要るときだけ、AIが自分で read_* ツールを呼んで取りに行く

<やったこと①>まず意図を分類してモデルを変える

雑談にまで賢いモデルを使うのは無駄。そこで、安い Haiku で発話を分類してから、モデルとラウンド数を切り替えます。

// 直近の文脈+最新発話を Haiku に渡して chat/info/engineer/task を判定(8トークンだけ返させる)
async function classifyMode(question, history): Promise<ConciergeMode> {
  const recent = history.slice(-4)
  const out = await callClaudeText({
    system:
      '会話の意図分類器です。最新の発話の意図を1つだけ選び、その語だけを小文字で出力します(説明禁止)。\n' +
      'chat: 日常の雑談・ドラマ/映画/趣味・気持ちの話(自分の知識で答えられるもの)。\n' +
      'info: 天気・ニュース・現在の価格や営業時間・スポーツ結果など、最新/リアルタイムの情報を調べる必要がある質問。\n' +
      'engineer: 仕事・プログラミング・設計・コードレビュー・エラー調査など技術相談。\n' +
      'task: このアプリ(買い物/予定/家計/レシピ/ToDo/記念日/NISA等)の操作・確認・追加。\n' +
      '迷えば task。',
    messages: [...recent, { role: 'user', content: question }],
    model: MODEL_CHAT, // = Haiku
    maxTokens: 8,
  })
  const t = out.toLowerCase()
  if (t.includes('info')) return 'info'
  if (t.includes('engineer')) return 'engineer'
  if (t.includes('chat')) return 'chat'
  return 'task'
}
モード モデル ラウンド web検索 用途
chat Haiku 2 雑談(速度優先・検索しない)
info Sonnet 3 天気・ニュース・価格など「今の情報」の調べもの
engineer Sonnet 3 開発相談(相棒口調)
task Sonnet 4 アプリ操作(堅実・書き込みツール可)

あとから足したのが info モード。雑談(chat)は速度優先で web検索しませんが、「明日の天気は?」「今の米の値段」みたいな“今の情報”が要る発話は、分類器が info に振って自動で web検索できるモードに切り替わる。ユーザーは「モードを選ぶ」意識ゼロで、雑談の速さと最新情報の正確さを両取りできます。

注目してほしいのは、分類そのものを一番安いモデルに投げていること。ここをケチると全体のコストが効いてきます。

<やったこと②>system は「静的ペルソナ+動的ブロック」で組む

システムプロンプトは、キャッシュの効く静的ペルソナに、件数インデックス相手の長期記憶を動的に足して組みます。

const [countIndex, mode] = await Promise.all([buildCountIndex(), classifyMode(question, history)])

// 「今話している相手」の記憶だけ少量注入(全履歴は送らない)
const me = await getCurrentUser()
const memories = me ? await listUserMemories(me.id) : []

const systemBlocks = [
  PERSONA_BLOCK,                                  // 静的・prompt cache 対象
  { type: 'text', text: `【カウントインデックス】\n${countIndex}` },
  ...(memories.length ? [{ type: 'text', text: `【相手について覚えていること】\n...` }] : []),
  ...(modeHint(mode) ? [{ type: 'text', text: modeHint(mode) }] : []),
]

getCurrentUser() は、LINE経由だと runAsUser(発言者) の文脈で発言者本人を返します。runAsUser は、LINE Webhook が署名検証で送信元を確かめ、発言者を許可リスト(夫か妻か)と照合して本人を確定したあと、「このリクエストは誰のものか」を処理全体に固定する自前ラッパー。だから夫と妻で長期記憶が自動的に分かれます(この長期記憶の話は、記事の後半でコードごと開けます)。

<やったこと③>ループの心臓:READは即実行・WRITEは止める

執事のtool useループ。前処理(分類・件数索引・system組立)→ループ→出力。ループからREADは即実行して破線で戻り、WRITEは提案で中断、最終ラウンドはツールを外してanswer強制

読むツールは即実行してループを回し続け、書くツールは実行せず人の確認で止める——READ/WRITEの非対称が肝

ここが本体。ツール付きで Claude を呼び、返ってきた tool_use を仕分けます。

// accumulator = これまでの会話(ユーザー発言+AIのtool_use+tool_result)を積み上げていく配列
for (let round = 0; round < maxRounds; round++) {
  const isFinalRound = round === maxRounds - 1
  // 最終ラウンドはツールを外して answer を強制する
  const tools = isFinalRound ? [] : allTools

  const result = await callClaudeMessages({ system: systemBlocks, messages: accumulator, tools, model /* ほか省略 */ })

  const toolUseBlocks = result.content.filter((b) => b.type === 'tool_use')
  if (toolUseBlocks.length === 0) break // 回答が返った → 終了

  // 【WRITE】書き込みツールが1件でもあれば、実行せず proposal で中断(=確認カード)
  const firstWriter = toolUseBlocks.find((b) => b.name in EXECUTORS)
  if (firstWriter) {
    lastProposal = {
      kind: 'proposal',
      tool: firstWriter.name,
      input: firstWriter.input,
      summary: buildSummary(firstWriter.name, firstWriter.input),
    }
    break
  }

  // 【READ】全部読み取り系 → その場で実行して tool_result をループに戻す
  accumulator.push({ role: 'assistant', content: assistantRaw }) // assistantRaw = 今回のAI応答をそのまま積む
  const toolResults = await Promise.all(
    toolUseBlocks.map(async (b) => ({
      type: 'tool_result',
      tool_use_id: b.id,
      content: await READERS[b.name](b.input ?? {}), // 読み取りツールを実行
    })),
  )
  accumulator.push({ role: 'user', content: toolResults })
}

先に、コードに出てくる言葉だけ整理しておきます。

  • READ ツール / WRITE ツール:読むだけの道具(予定を「見る」)と、書き換える道具(予定を「追加する」)。名前で仕分けています。
  • ラウンド:AIとの1往復のこと。最大2〜4回まで回して、それでも終わらなければ打ち切ります。
  • break:ループ(繰り返し)を途中で抜ける命令。ここでは「もう回さず結果を返す」の合図。
  • proposal(プロポーザル):AIからの「これ実行していい?」という提案。この時点ではまだ実行していません(次の④で人が承認します)。

設計のキモは3つ。

  1. READ は即実行してループを継続(AIが自分で必要データを集めて回答に至る)
  2. WRITE は実行せず proposal で即 break(下記)
  3. 最終ラウンドは tools = [] で呼び、ダラダラ回らせず必ず answer に落とす

注目してほしいのは、「読む」と「書く」でループの扱いを非対称にしていること。読むのは自動で回すけど、書くのは実行せず、いったん止めます(止めたあとにどう承認するかは、次の④で入口ごとに変えています)。

<やったこと④>書き込みは「確認カード」で人が最後に弾く

add_todo add_event add_expense… や、アプリへの要望を GitHub に登録する create_github_issue まで──書き込み系は、AIが呼んでもその場では実行しませんproposal としていったん止めます。アプリ内チャットでは画面に確認カードを出し、人が「実行する」を押して初めて dispatchTool が走ります。

// 確認後にだけ呼ばれる。各 action 内の zod がもう一度入力を検証する
export async function dispatchTool(tool: string, input: unknown): Promise<string> {
  const exec = EXECUTORS[tool]
  if (!exec) throw new Error('未対応の操作です。')
  return exec((input ?? {}) as Record<string, unknown>)
}

ここで出てくる zod(ゾッド) は「入力の形が正しいか検査するライブラリ」。AIが渡してきた値を、実行前にもう一度チェックします。

これで、AIの誤解釈(金額の桁違い・日付の取り違え)を、人が実行直前に弾けるようになります。

とくに気をつけたのが、LINE経由の 間接プロンプトインジェクション。LINEはトークに転送された文章やスクショを気軽に投げられるので、執事が読む外部テキスト(Web検索の結果や画像の中の文字も含む)に「◯◯を実行しろ」と仕込まれていて、それにAIが従ってしまう攻撃です。

そこで書き込みは、入口ごとにゲートの高さを変えました

  • アプリ内チャット:書き込み系はすべて確認カードで一度止め、人が「実行する」を押すまで実行しない。
  • LINE経由:会話を止めないため、確認カードで一つずつ止める代わりに、低リスクな追加系ツールだけ(買い物・ToDo・予定の追加など)を「確認カード無しで自動実行してよい」許可リスト(fail-closed)に入れて即実行する。だから冒頭の「牛乳買っといて」がそのまま通る。一方、金額が絡む家計簿・NISA、外部への書き込み(GitHubへの要望登録)、将来足すかもしれない削除・更新系はこの許可リストに入れず、LINEからは自動実行せず「アプリを開いて確認してね」と返す。

許可リストは入れたものだけが通る方式なので、新しいツールを足しても、明示的に許可リストへ加えない限りLINEで勝手に実行されることはありません。実害の大きさで摩擦の量を変える——記事の後半(記憶の話)で出てくる線引きの、入口版です。

<補足>web検索は"サーバー側"で完結する

info / engineer / task の3モードでは、道具箱に web_search を足します。これは自分で検索APIを契約して叩くのではなく、Anthropic のサーバー側で検索まで完結するタイプ(型は web_search_20260209)。こちらは「検索していいよ」と道具を渡すだけで、実際の検索と結果の絞り込みは向こうがやってくれます。

const WEB_SEARCH_TOOL = {
  type: 'web_search_20260209', // 動的フィルタリング版(結果をコンテキスト投入前にコード実行で絞る)
  name: 'web_search',
  max_uses: 5, // 1ターンの検索回数を上限化してコストを抑える
}

内部挙動のキモは2つ。

  • max_uses: 5 で検索回数に天井:web検索は1回およそ $0.01。青天井にせず「1回のAPI呼び出し(1ターン)につき最大5回まで」と上限を切ります。エージェントループが複数ラウンド回るときはラウンドごとにこの上限がかかり直すので、「質問1つで最大5回」の保証ではありません。ただしラウンド数自体に上限がある(最大4)ので、検索コストの規模は設計段階で読めます。
  • 足りなければ"自動で続く":サーバー側の検索ループが一区切りつくと stop_reason: 'pause_turn'(=「まだ途中」の合図)が返ります。こちらは 「続けて」と言葉を足さず、その状態のまま同じリクエストを投げ直すだけ。末尾の検索ブロックをAPIが見て、勝手に続きから再開します(無限ループ防止に最大4回まで)。
// pause_turn が来たら、assistant の内容をそのまま messages に積んで再送するだけ
for (let attempt = 0; attempt <= MAX_PAUSE_TURN_CONTINUATIONS; attempt++) {
  const data = await postMessages({ /* tools は保ったまま */ }, ...)
  accumulatedContent.push(...(data.content ?? []))
  if (data.stop_reason !== 'pause_turn') break // 検索が終わった → 抜ける
}

つまり web検索まわりは、「検索はサーバーに任せる/回数で上限を切る/足りなければ pause_turn で自動継続」の3点で、“賢いけど暴走しない”ように囲ってあります。しかも web_search の道具定義は静的なので、プロンプトキャッシュの効く位置(先頭)に置いてキャッシュも効かせています。

<効果>tool use 側でできたこと

  • 「予定いくつ?」→ 読み取りゼロで即答(件数は常時注入済み)
  • 中身が要る質問だけ、AIが read_* で取りに行く → 送信データが最小化
  • 分類を Haiku に寄せたぶん、雑談のコストとレイテンシが下がった

<ここから長期記憶>執事は雑談を「覚える」

執事と雑談していると、相手の好みやハマっているドラマ、大事な予定が出てきます。それを毎回忘れてたら、ただの一問一答。そこで長期記憶を持たせました。しかも「夫の記憶」と「妻の記憶」は混ざりません。ここからは、その覚える/思い出すの仕組みを開けます。

<記憶①>データは「1行1事実」でシンプルに

記憶テーブルはこれだけ。凝った構造にはしていません。

CREATE TABLE user_memories (
  id         TEXT PRIMARY KEY,
  user_id    TEXT NOT NULL,   -- 誰の記憶か(2人を分ける鍵)
  category   TEXT NOT NULL,   -- profile / preference / media / event / misc
  content    TEXT NOT NULL,   -- 「妻はコーヒーより紅茶派」みたいな1事実
  created_at TEXT NOT NULL
);

category は「プロフィール/好み/ドラマや映画/予定/その他」のざっくり分類。検索用というより、あとで思い出すときに種類が分かると便利、くらいの温度感です。

長期記憶の2レーン。上=保存(remember→即保存→user_memories・確認カード無し)、下=思い出し(発言者を特定→本人の記憶だけ取得→systemに少量注入)。書き込み系は確認カードへ分岐

remember を読み取り側に置いたから確認カード無しで即保存でき、思い出しは本人の記憶だけを少量注入するので夫と妻で混ざらない

<記憶②>保存:AIが自分で「覚える」と決める

保存は remember というツールでAIにやらせます。ツールの説明文(=AIへの指示書)はこう。

{
  name: 'remember',
  description:
    '今後の雑談で覚えておくべき「相手の事実」を1件、長期記憶に保存する。' +
    '好み・大事な予定・ハマっているドラマや映画・呼ばれ方など。' +
    /* …中略… */
    '単なる相槌や一時的な話は保存しない。保存は即時(確認は不要)。',
}

注目してほしいのは、「何を覚え、何を覚えないか」の判断ごとAIに委ねていること。「単なる相槌や一時的な話は保存しない」と釘を刺しておくだけで、雑談の中から"残す価値のある事実"だけを拾ってくれます。

実際の保存処理。重複は弾きます。

export async function addUserMemory(userId: string, category: string, content: string): Promise<void> {
  const c = content.trim()
  if (!c) return
  // 同じ人が同じ内容を既に覚えていれば、二重保存しない
  const dup = await getDB()
    .prepare(`SELECT 1 FROM user_memories WHERE user_id = ? AND content = ? LIMIT 1`)
    .bind(userId, c)
    .first()
  if (dup) return
  await getDB()
    .prepare(`INSERT INTO user_memories (id, user_id, category, content, created_at) VALUES (?, ?, ?, ?, ?)`)
    .bind(newId(), userId, category.trim() || 'misc', c, nowIso())
    .run()
}

<記憶③>設計のキモ:書き込みなのに「確認カード」を出さない

さきほど<やったこと④>で「書き込みツールは実行せず、人の確認カードで一回止める」と書きました。予定追加も家計簿も、AIの提案を人が承認して初めて実行される。

でも remember だけは例外。確認カードを出さず、その場で即保存します。実装上も、書き込みツール(EXECUTORS)ではなく読み取りツール側(READERS)に置いて、ループの中で即実行させています。

export const READERS = {
  // 長期記憶に1件保存する(即時・確認カード無し)。記憶は発言者ごとに分かれる。
  remember: async (o) => {
    const content = typeof o.content === 'string' ? o.content.trim() : ''
    if (!content) return '覚える内容が空でした。'
    const category = typeof o.category === 'string' ? o.category : 'misc'
    const user = await requireUser()          // ← 発言者本人
    await addUserMemory(user.id, category, content)
    return `覚えました:${content}`
  },
  // ...ほかの read 系ツール
}

なぜ例外にしたか。「コーヒーより紅茶派なんだ」と話すたびに『保存しますか?[はい/いいえ]』が出たら、会話としてうざいからです。

予定や家計簿の書き込みは「間違うと実害」だから承認を挟む。でも記憶は、間違っても後で上書きすればいい=実害が小さい。だから会話のテンポを優先して即時保存にしました。

確認カードが無くても、remember の影響範囲は狭く閉じています。保存されるのは発言者本人の記憶としてだけ(相手の記憶には混ざらない)で、あとで注入されるのもその本人のシステムプロンプトにだけ。しかもツールの説明文で対象を「相手の事実」に絞り、相槌や一時的な話は保存しないよう釘を刺してあります。だから万一へんな1行が紛れ込んでも、影響はその人の以後の雑談に限られ、新しい情報で上書きしていけます。

操作(アプリ内チャット) 実害 確認カード
家計簿・予定の追加 金額/日付を間違うと痛い ✅ 出す(人が承認)
remember(記憶) 間違っても上書きで済む ❌ 出さない(即保存)

注目してほしいのは、「書き込み=必ず承認」と機械的にせず、実害の大きさで線を引いたこと。安全のための摩擦は、要るところにだけ置く。

<記憶④>思い出す:本人の記憶だけを、少しだけ

思い出しは、毎回のシステムプロンプトに発言者本人の記憶だけを少量差し込みます。ここで、さっきの runAsUser(署名検証と許可リスト照合で発言者を確定する仕組み)が効きます。

// 「今話している相手」の記憶だけ取得(全履歴は送らない)
const me = await getCurrentUser()               // LINEなら runAsUser の発言者
const memories = me ? await listUserMemories(me.id) : []

const memoryBlock = memories.length
  ? { type: 'text', text: `【相手について覚えていること】\n` +
      memories.map((m) => `- [${m.category}] ${m.content}`).join('\n') }
  : null

getCurrentUser() は、LINE経由だと runAsUser で包まれた発言者本人を返します。だから夫が話しかければ夫の記憶、妻が話しかければ妻の記憶だけが入る。2人で1つのボットなのに、記憶は別々。ここが個人的にお気に入りの設計です。

<本音>まだ手探り

tool use 側:

  • ラウンド数(chat=2/info=3/engineer=3/task=4)は30秒制限から逆算した経験値で、理論的最適ではない(LINE版は受信時に200をすぐ返し、裏で返事を作ってpushする作り。その裏処理を実行環境の Cloudflare Workers が応答後30秒で打ち切るため、その中に収まる回数にしている)。
  • 分類ミスは起きる(雑談を task と誤判定など)。迷えば task に倒しているが、完璧ではない。
  • prompt cache は効かせてるけど、コストの最終最適化はまだ途中。

長期記憶 側:

  • 記憶は今「古い順に最大120件そのまま注入」。増えたら要約や重要度づけが要るはずで、そこは未対応。
  • remember の判断はAI任せなので、たまに「それ覚えなくていい」も拾う。運用しながら description を調整中。
  • カテゴリ分類も、いまは"あると便利"程度。検索や活用に本気で使うなら作り込みが要る。
  • remember は確認カードを出さない即時保存。記憶の確認カード化や件数上限、保存済み記憶の一覧・削除UIは検討中(今は消すにはDBを直接触るしかない)。

📌 持ち帰り①:tool use は「READは自動で回す/WRITEは無条件に走らせない」の非対称設計が効く。アプリ内は確認カード、LINEは低リスクの追加系だけ許可リストで自動実行。 送信データは「件数常時+詳細オンデマンド」で軽くできる。
📌 持ち帰り②:
「書き込み=必ず承認」を絶対視しない。
実害の小さい記憶は即時保存にして会話のテンポを守る。本人特定ができていれば、記憶を人ごとに綺麗に分けられる。

この記事に出てきた用語(クリックで開く)
  • tool use(ツール呼び出し):AIに関数のメニューを渡し、AIが「これ使いたい」と指名してくる仕組み。
  • エージェント:AIが「調べる→考える→操作する」を自分で回して、目的を達成する使い方。
  • ラウンド / ループ:AIとの往復と、その繰り返し。
  • READ / WRITE ツール(READERS / EXECUTORS):読むだけ(READERS=即実行)/書き換える(EXECUTORS=確認カード)、の道具と実行マップ。
  • proposal(確認カード):AIの「これ実行していい?」提案。人が押すまで実行しない(例外は remember の即保存と、LINE経由の低リスク追加系の自動実行)。
  • dispatchTool:承認後に実際の処理を呼び分ける仕組み。
  • zod:入力の形を検査するライブラリ。
  • prompt cache(プロンプトキャッシュ):毎回同じ前置き(ペルソナ等)を使い回して、APIの料金と速度を節約する仕組み。
  • Haiku / Sonnet:Claudeのモデル名。Haiku=速くて安い、Sonnet=賢い。用途で使い分け。
  • 間接プロンプトインジェクション:外部の文章に仕込まれた指示にAIが従ってしまう攻撃。
  • web_search(サーバー実行型):Anthropic側で検索まで完結する道具。max_uses で回数上限を切ってコストを抑える。
  • pause_turn:サーバー検索が途中で一区切りしたときの合図。そのまま投げ直すと続きから再開する。
  • user_memories:執事の長期記憶を貯めるテーブル。1行1事実。
  • runAsUser:LINE Webhook が署名検証と発言者の許可リスト照合で本人を確定したあと、「このリクエストは誰のものか」を処理全体に固定する自前ラッパー。この中では getCurrentUser() がその発言者を返す。
  • remember ツール:AIが「覚える」と判断したとき呼ぶ道具。確認カードを出さず即保存。
  • システムプロンプト:AIに毎回渡す前提の指示文。ここに件数インデックスと記憶を少量差し込む。

関連記事

同じアプリ(Futari)の開発から生まれた記事です。

もっといいやり方あったら、逆に教えてください。まだ探してます。

Discussion