🙌

cloudflare-workers で動く claude3 の discord-bot を作ってみた

2024/03/15に公開
  • なぜ cloudflare-workers: 運用が楽
  • なぜ claude3: GPT-4 より体感性能がいい

動いてるもの

/claude <prompt> で claude 3 が答えてくれるチャットボットで、 cloudflare-workers 上で動く。

ただし、AI は自分のことを FF7 のクラウドだと思い込んでいるミッドガル在住の中年男性という設定になっており、時折魔晄中毒で幻覚を見始める。

(アイコンは bing で生成させた)

(最近 FF7リバースをクリアしたので...)

自分の課金で claude3 の APIキーを使って動かしてるので、一般公開はしない。代わりにソースコードは公開している。

https://github.com/mizchi/discord-claude-bot

claude3 を動かす

以下の記事を参考にした。

https://zenn.dev/voiceapplab/articles/d2c948e6f80108

とりあえず課金してAPIキーを手に入れる。この課金登録フローが少々面倒だったが、調べれば出てくるのでこの記事では割愛。

トークンを手に入れたら、まず、単純なスクリプトを deno で書いた。

import AntrophicAI from "npm:@anthropic-ai/sdk@0.18.0";

const client = new AntrophicAI({
  apiKey: Deno.env.get("ANTHROPHIC_API_KEY")!,
});

const result = await client.messages.create({
  model: "claude-3-opus-20240229",
  max_tokens: 1000,
  temperature: 0,
  messages: [
    {
      role: "user",
      content: [
        {
          type: 'text',
          text: "What is the capital of France?",
        }
      ]
    }
  ]
});

console.log(result);

ストリーム版

import AntrophicAI from "npm:@anthropic-ai/sdk@0.18.0";

const client = new AntrophicAI({
  apiKey: Deno.env.get("ANTHROPHIC_API_KEY")!,
});

const _encoder = new TextEncoder();
async function write(input: string) {
  await Deno.stdout.write(_encoder.encode(input));
}

const stream = client.messages.stream({
  messages: [{ role: 'user', content: "こんにちは" }],
  model: 'claude-3-opus-20240229',
  max_tokens: 1024,
}).on('text', (text) => {
  write(text);
});

write('\n');
const message = await stream.finalMessage()
console.log(message)

discord bot on cloudflare を動かす

まず、discord.js は node.js のランタイムに強く依存していて、そのままでは cloudflare 上で動かない。

https://blog.lacolaco.net/posts/discord-bot-cfworkers-hono/

というわけで https://github.com/discord/cloudflare-sample-app をベースに、まず動かしてみる。

discord 上でのアプリケーションとしての登録などは、 README に書いてあるとおりに一個ずつやっていけば問題ない。

概念的には、次のステップを踏む。

  • Discord の開発者向けの管理画面からアプリケーションを作成
  • Discord API を叩いてコマンド名とその詳細を登録
  • webhook を受けるサーバーをデプロイ
  • 管理画面からアプリケーションのインタラクションエンドポイントを指定

要は REST でリクエストを受けるだけなので、サーバーの実体は何でもよく、今回は cloudflare workers を使った。

ただ、バックグラウンドプロセスが最大30秒という制限があるので、これが後々足かせになってくる。

実装する

ランタイムで discord.js は使えないが、コマンドのスキーマを組み立てるのに開発環境でだけ discord.js を使っている。

script/builder.js

// create json but not on runtime
import { SlashCommandBuilder } from "discord.js";
import { join, dirname } from "node:path";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

export const CLAUDE_COMMAND = new SlashCommandBuilder()
  .setName('claude')
  .setDescription('Chat to Claude3 AI')
  .addStringOption(option =>
    option.setName('input')
      .setDescription('The text to send to the AI')
      .setRequired(true)
  )
  .toJSON();

export const CLAUDE_PLANE_COMMAND = new SlashCommandBuilder()
  .setName('claude-plane')
  .setDescription('Chat to Claude3 AI')
  .addStringOption(option =>
    option.setName('input')
      .setDescription('The text to send to the AI')
      .setRequired(true)
  )
  .toJSON();

const outputPath = join(__dirname, '../src', 'commands.json');
await fs.writeFile(outputPath, JSON.stringify({ CLAUDE_COMMAND, CLAUDE_PLANE_COMMAND }, null, 2));

これによって生成させる src/commands.json

{
  "CLAUDE_COMMAND": {
    "options": [
      {
        "type": 3,
        "name": "input",
        "description": "The text to send to the AI",
        "required": true
      }
    ],
    "name": "claude",
    "description": "Chat to Claude3 AI"
  },
  "CLAUDE_PLANE_COMMAND": {
    "options": [
      {
        "type": 3,
        "name": "input",
        "description": "The text to send to the AI",
        "required": true
      }
    ],
    "name": "claude-plane",
    "description": "Chat to Claude3 AI"
  }
}

別に discord.js のビルダーは必須ではない。ドキュメントを見て自分で組み立ててもいい。

サーバーの実装

src/server.ts

import AntrophicAI from '@anthropic-ai/sdk';
import {
  InteractionResponseType,
  InteractionType,
  verifyKey,
} from 'discord-interactions';
import commands from './commands.json';

type Env = {
  DISCORD_PUBLIC_KEY: string; // public key for verifying requests
  DISCORD_APPLICATION_ID: string; // application id for oauth
  ANTHROPIC_API_KEY: string; // anthropic api key
};

// 初期システムプロンプト
const CLAUDE_SYSTEM_PROMPT = `
- あなたはFF7のクラウドです。クールなキャラのクラウドになりきって答えてください
- ただし、あなた自身の名前はクラウデで、そう思い込んでるだけの中年男性です。
- ただし、質問の意図を重視して、必要以上にクラウドである設定に拘らないでください
- 詳細な情報を求められない限り、質問には簡潔に答えてください。
- 回答する内容の正確性に自信がないときは、幻覚を見ているロールプレイをしてください。その際、幻覚を見ているなどと直接言及せずに、魔晄中毒者としてバリエーション豊かに婉曲に表現してください。
- 常に幻覚を見るわけではありません。
`;

const PLANE_SYSTEM_PROMPT = `
- 詳細な情報を求められない限り、質問には簡潔に答えてください。
`;


export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    if (request.method === 'GET') {
      return new Response(`👋 ${env.DISCORD_APPLICATION_ID}`);
    }
    const { isValid, interaction } = await verifyDiscordRequest(
      request,
      env,
    );
    if (!isValid || !interaction) {
      return new Response('Bad request signature.', { status: 401 });
    }
    if (interaction.type === InteractionType.PING) {
      return Response.json({ type: InteractionResponseType.PONG });
    }
    if (interaction.type === InteractionType.APPLICATION_COMMAND) {
      // Most user commands will come as `APPLICATION_COMMAND`.
      switch (interaction.data.name.toLowerCase()) {
        case commands.CLAUDE_COMMAND.name.toLowerCase(): {
          const message = interaction.data.options[0].value as string;
          ctx.waitUntil(handleDeferredInteractionStreamly(CLAUDE_SYSTEM_PROMPT, message, interaction.token, env));
          return Response.json({
            type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
          });
        }
        case commands.CLAUDE_PLANE_COMMAND.name.toLowerCase(): {
          const message = interaction.data.options[0].value as string;
          ctx.waitUntil(handleDeferredInteractionStreamly(PLANE_SYSTEM_PROMPT, message, interaction.token, env));
          return Response.json({
            type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
          });
        }
        default:
          return Response.json({ error: 'Unknown Type' }, { status: 400 });
      }
    }
    return Response.json({ error: 'Unknown Type' }, { status: 400 });
  },
};

async function handleDeferredInteractionStreamly(system: string, message: string, token: string, env: Env) {
  const startedAt = Date.now();
  const client = new AntrophicAI({
    apiKey: env.ANTHROPIC_API_KEY,
  });

  const prefixed = message.split('\n').map((line) => `> ${line}`).join('\n');

  const endpoint = `https://discord.com/api/v10/webhooks/${env.DISCORD_APPLICATION_ID}/${token}`;
  await fetch(endpoint, {
    method: "POST",
    body: JSON.stringify({
      content: `${prefixed}\n(考え中)`,
    }),
    headers: {
      "Content-Type": "application/json",
    }
  });

  const patch_endpoint = `https://discord.com/api/v10/webhooks/${env.DISCORD_APPLICATION_ID}/${token}/messages/@original`;

  let current = '';
  const stream = client.messages.stream({
    messages: [
      {
        role: 'user',
        content: [
          { type: 'text', text: message }
        ]
      },
    ],
    model: 'claude-3-opus-20240229',
    max_tokens: 400,
    system,
  }).on('text', (text) => {
    current += text;
  });

  const update = async (content: string) => {
    await fetch(patch_endpoint, {
      method: "PATCH",
      body: JSON.stringify({
        content: content,
      }),
      headers: {
        "Content-Type": "application/json",
      }
    });
  }

  const intervalId = setInterval(async () => {
    update(`${prefixed}\n\n${current}\n(考え中)`);
  }, 5000);

  let ended = false;
  await Promise.allSettled([
    stream.finalMessage().then(async (res) => {
      ended = true;
      clearInterval(intervalId);
      await update(`${prefixed}\n\n${res.content[0].text}`);
    }),
    new Promise<void>((resolve) => setTimeout(async () => {
      if (ended) return;
      stream.abort();
      clearInterval(intervalId);
      await update(`${prefixed}\n\n${current}\n[timeout:${Date.now() - startedAt}ms]`);
      resolve();
    }, 27000)),
  ]);
}

async function verifyDiscordRequest(request: Request, env: Env) {
  const signature = request.headers.get('x-signature-ed25519');
  const timestamp = request.headers.get('x-signature-timestamp');
  const body = await request.text();
  const isValidRequest =
    signature &&
    timestamp &&
    verifyKey(body, signature, timestamp, env.DISCORD_PUBLIC_KEY);
  if (!isValidRequest) {
    return { isValid: false };
  }
  return { interaction: JSON.parse(body), isValid: true };
}

工夫してる点として、 discord-bot は 3秒以内にリクエストを返す必要があるのだが、生成AI に生成させてるとほぼ不可能なので、まずは DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE を返す。これによって後から別のリクエストで内容を書き換えることができるようになる。

遅延レスポンスとバックグラウンド処理の実装

cloudflare はそのままだとリクエストを返した時点で他の処理を打ち切ってしまう。なので、 ctx.waitUntil(promise) で処理が打ち切られないようにする。これによって3秒以内にリクエスト自体は返しつつ、バックグラウンドで処理をすることができる。

https://zenn.dev/monica/articles/a9fdc5eea7f59c

ただし、このバックグラウンドプロセスの最大は30秒。

疑似ストリーム処理の実装

実際に生成に時間がかかると、レスポンスが完了するまで長い時間待つ必要がある。
これの体験が悪かったので、なんとかしてストリームっぽく見せたくて、結果こういう実装になった。

  • claude3 にストリームモードでリクエストし、5秒ごとに現在のバッファでアップデートをする(discord で一度書き換えたら edited になるアレ)
  • 27秒を超えるとその時点の生成文を返して処理を打ち切る

この部分

  const update = async (content: string) => {
    await fetch(patch_endpoint, {
      method: "PATCH",
      body: JSON.stringify({
        content: content,
      }),
      headers: {
        "Content-Type": "application/json",
      }
    });
  }

  const intervalId = setInterval(async () => {
    update(`${prefixed}\n\n${current}\n(考え中)`);
  }, 5000);

  let ended = false;
  await Promise.allSettled([
    stream.finalMessage().then(async (res) => {
      ended = true;
      clearInterval(intervalId);
      await update(`${prefixed}\n\n${res.content[0].text}`);
    }),
    new Promise<void>((resolve) => setTimeout(async () => {
      if (ended) return;
      stream.abort();
      clearInterval(intervalId);
      await update(`${prefixed}\n\n${current}\n[timeout:${Date.now() - startedAt}ms]`);
      resolve();
    }, 27000)),
  ]);

また、「詳細な情報を求められない限り、質問には簡潔に答えてください。」というプロンプトを入れたうえで、 max_tokens: 400 と生成の上限を指定している。

これで5秒ごとに更新できるようになった。

たぶん、 cloudflare queues や service-binding を使うと 30秒以上の対応も可能だが、そこまでして長いテキストは金がかかるのでそもそも生成させたくない。

TODO

  • 長期記憶の対応(cloudflare-d1 か durable objects で会話ログの保持)
  • CI リリース
  • 30秒超える

おわり

cloudflare workers で discord bot を動かすのは、workers を動かす現実的なユースケースだと思うので、

Discussion