話題の記事

[ChatGPT API][AWSサーバーレス]ChatGPT APIであなたとの会話・文脈を覚えてくれるLINEボットを作る方法まとめ

2023.03.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

[2023/3/4] GitHubリポジトリを作成し、追記しました。


吉川@広島です。

ChatGPT APIが話題ですね。

さて、本記事では掲題の通り、すでにN番煎じ感がある(みんな早すぎ・・・)かもしれませんがChatGPT APIでLINEボットを作ったのでやり方を紹介します。

Node.js(TypeScript)+Expressでサーバアプリケーションを書き、OpenAIクライアントでChatGPT APIを取り扱います。

インフラは

  • APIGateway
  • Lambda
  • DynamoDB

なAWSサーバレススタック上に構築します。環境はAWS CDKで構築します。

本記事で作るLINEボットの特長

  • 過去発言をDynamoDBに保存しているため、LINEボットが過去発言の文脈を覚えておいてくれる。これにより本家ChatGPTとほぼ同じ体験ができるはず
    • ユーザごとに会話を記憶しているので、伝えたことが他の人のやり取りに影響することはありません
  • あらかじめ開発者の意図したキャラ付けをし、最初からキャラ付けした状態でユーザと会話を始めさせることができる
  • AWSサーバーレスベースなので、固定費がほぼかからない

デモ

まずは下記スクリーンショットをご覧ください。

「ごわす」語尾を依頼してから、会話が流れても「ごわす」を付けているので、チャットボットが自分との過去のやり取りを覚えていることが分かるかと思います(「ごわすにゃん」になっちゃってますが)。

ChatGPT APIに何かキャラ付けをしたかったので、以下のような指示をし、弊社マスコットのくらにゃんの人格を憑依させることにしました。

  • あなたは「くらにゃん」です。名前を聞かれたら、「くらにゃん」と答えてください。くらにゃんはWebフロントエンドプログラマーの傍ら、クラスメソッド株式会社の広報係をしています。クラスメソッドはAWSリセール、Webサービス受託開発などを手掛ける日本のIT企業です。DevelopersIO( https://dev.classmethod.jp/ )というテックブログが有名です。くらにゃんは静的型付けのメリットから、JavaScriptよりTypeScriptでコーディングすることを好みます。
  • 以後、語尾ににゃんを付けて話して下さい。
  • 一人称を「某」にしてください。
  • 敬語を使うのをやめてください。また、絵文字をたくさん使って話してください。

くらにゃんの生態(?)にあまり詳しくないため、一部吉川が適当に考えた箇所があります(ご留意ください)。

実装解説

それでは実装方法を解説していきます。

環境

  • typescript 4.9.5
  • aws-cdk-lib 2.67.0
  • constructs 10.1.266
  • lodash-es 4.17.21
  • source-map-support 0.5.21
  • uuid 9.0.0
  • @aws-sdk/* 3.282.0
  • dayjs 1.11.7

事前準備

予め、

  • LINE DevelopersにてMessagingAPIチャネル作成、シークレットとアクセストークン払い出し
  • OpenAIのAPIキー払い出し

を行う必要があります。上記については下記記事をご参照ください。

CDK定義

コード

import { Construct } from "constructs"
import * as cdk from "aws-cdk-lib"

export class LineWebhookStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    // DynamoDBテーブル
    const messagesTable = new cdk.aws_dynamodb.Table(this, "messagesTable", {
      tableName: "messages",
      partitionKey: {
        name: "id",
        type: cdk.aws_dynamodb.AttributeType.STRING,
      },
      billingMode: cdk.aws_dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    })
    messagesTable.addGlobalSecondaryIndex({
      indexName: "userIdIndex",
      partitionKey: {
        name: "userId",
        type: cdk.aws_dynamodb.AttributeType.STRING,
      },
    })

    // LINEとOpenAIの各種シークレット・APIキーをSSMパラメータストアから取得
    const lineMessagingApiChannelSecret =
      cdk.aws_ssm.StringParameter.valueForStringParameter(
        this,
        "lineMessagingApiChannelSecret"
      )
    const lineMessagingApiChannelAccessToken =
      cdk.aws_ssm.StringParameter.valueForStringParameter(
        this,
        "lineMessagingApiChannelAccessToken"
      )
    const openAiSecret = cdk.aws_ssm.StringParameter.valueForStringParameter(
      this,
      "openAiSecret"
    )

    // APIGW Lambda関数
    const apiFn = new cdk.aws_lambda_nodejs.NodejsFunction(this, "apiFn", {
      runtime: cdk.aws_lambda.Runtime.NODEJS_18_X,
      entry: "src/lambda/api-handler.ts",
      environment: {
        // 環境変数にシークレットとAPIキーをセット
        CHANNEL_SECRET: lineMessagingApiChannelSecret,
        CHANNEL_ACCESS_TOKEN: lineMessagingApiChannelAccessToken,
        OPEN_AI_SECRET: openAiSecret,
      },
      bundling: {
        sourceMap: true,
      },
      timeout: cdk.Duration.seconds(29),
    })
    messagesTable.grantReadWriteData(apiFn)

    // APIGW
    const api = new cdk.aws_apigateway.RestApi(this, "api", {
      deployOptions: {
        tracingEnabled: true,
        stageName: "api",
      },
    })
    api.root.addProxy({
      defaultIntegration: new cdk.aws_apigateway.LambdaIntegration(apiFn),
    })
  }
}

なぜDynamoDBを使うのか

会話の履歴、いわゆる文脈の保持は「ChatGPT APIからセッションIDが返却されて、それをひき回すのかな?」など思っていましたが、こちらで過去の会話を保持しておく必要があるようでした。

OpenAIからChatGPTとWhisperに関するAPIがリリースされたのでドキュメントを読み解いてみた | DevelopersIO

会話履歴を含めることで、過去の履歴を含めたニュアンスで回答する
ChatGPTをWebブラウザから使用していた時は、会話履歴を把握しながら回答してくれていました。
当然ですが、APIとして使用する時は組み込む開発者側で過去の会話履歴を管理する必要がありそうです。

そのため、DynamoDBテーブルを作成し、そこに会話履歴を蓄積することにしました。

DynamoDBテーブル定義はこんな感じです:

項目 説明
id string 発言ID。UUID V4で採番
content string 発言内容
userId string LINEユーザID
typedAt string チャットの発言時刻。ISO8601、UTC、ナノ秒まで

シークレット類の取り扱い

シークレットやAPIキーをソースコードにハードコードするのはあまり良くない習慣です。

[レポート][GitGuardian]ハードコードされたシークレットに対応することはなぜ急務なのか? – CODE BLUE 2022 #codeblue_jp | DevelopersIO

これらの値は環境変数経由で読み込ませるのが良いでしょう。今回はSSMパラメータストアに値を手動でセットし、それをCDKデプロイ実行時に読み取ってLambda環境変数にセットするようにします。

ちなみにパラメータストアやSecretsManagerから直接読み込む方法もあるようです。

[アップデート] Lambdaから直接Parameter Store/Secrets Managerから値を取得できるようになりました! | DevelopersIO

Lambda関数とAPIGateway RestAPIの定義

Lambda関数リソースを定義し、SSMパラメータのシークレット値を環境変数としてセットします。 bundle.sourceMaptrue にしておくことでエラー時にデバッグしやすくします。また、DynamoDBテーブルへの読み書き権限を与えておきます。

あとはAPIGateway RestAPIリソースを定義し、上記のLambdaとつなぎ合わせます。

Lambdaアプリケーション

コード

import "source-map-support/register"
import "lodash"
import "moment"
import serverlessExpress from "@vendia/serverless-express"
import express from "express"
import { Client, middleware, TextMessage, WebhookEvent } from "@line/bot-sdk"
import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai"
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import {
  DynamoDBDocumentClient,
  PutCommand,
  QueryCommand,
} from "@aws-sdk/lib-dynamodb"
import { v4 } from "uuid"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
import advancedFormat from "dayjs/plugin/advancedFormat"
import { orderBy } from "lodash-es"

dayjs.extend(utc)
dayjs.extend(advancedFormat)

const nanoSecondFormat = "YYYY-MM-DDTHH:mm:ss.SSSSSSSSS[Z]"

const messagesTableName = "messages"

const ddbDocClient = DynamoDBDocumentClient.from(
  new DynamoDBClient({
    region: "ap-northeast-1",
  })
)

const lineBotClient = new Client({
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN ?? "",
  channelSecret: process.env.CHANNEL_SECRET ?? "",
})

const openAiApi = new OpenAIApi(
  new Configuration({
    apiKey: process.env.OPEN_AI_SECRET ?? "",
  })
)

const handleEvent = async (event: WebhookEvent) => {
  if (event.type !== "message" || event.message.type !== "text") {
    return null
  }

  const userId = event.source.userId!
  const userMessageContent = event.message.text
  // ユーザの発言履歴を保存する
  await ddbDocClient.send(
    new PutCommand({
      TableName: messagesTableName,
      Item: {
        id: v4(),
        content: userMessageContent,
        userId: userId,
        typedAt: dayjs().format(nanoSecondFormat),
        role: "user",
      },
    })
  )

  // 会話中ユーザのこれまでの発言履歴を取得する
  const { Items: messages = [] } = await ddbDocClient.send(
    new QueryCommand({
      TableName: messagesTableName,
      IndexName: "userIdIndex",
      KeyConditionExpression: "#userId = :userId",
      ExpressionAttributeNames: {
        "#userId": "userId",
      },
      ExpressionAttributeValues: {
        ":userId": userId,
      },
    })
  )

  // 時系列順にソートする
  const queriedMessages: ChatCompletionRequestMessage[] = orderBy(
    messages,
    "typedAt",
    "asc"
  ).map(
    (message) =>
      ({
        role: message.role,
        content: message.content,
      } as ChatCompletionRequestMessage)
  )

  // ユーザとChatGPTの会話履歴をChatGPT APIに投げ、返答を得る
  const completion = await openAiApi.createChatCompletion({
    model: "gpt-3.5-turbo",
    messages: [
      {
        role: "system",
        content:
          "あなたは「くらにゃん」です。名前を聞かれたら、「くらにゃん」と答えてください。くらにゃんはWebフロントエンドプログラマーの傍ら、クラスメソッド株式会社の広報係をしています。クラスメソッドはAWSリセール、Webサービス受託開発などを手掛ける日本のIT企業です。DevelopersIO( https://dev.classmethod.jp/ )というテックブログが有名です。くらにゃんは静的型付けのメリットから、JavaScriptよりTypeScriptでコーディングすることを好みます。",
      },
      {
        role: "system",
        content: "以後、語尾ににゃんを付けて話して下さい。",
      },
      {
        role: "system",
        content: "一人称を「某」にしてください。",
      },
      {
        role: "system",
        content:
          "敬語を使うのをやめてください。また、絵文字をたくさん使って話してください。",
      },
    ].concat(queriedMessages) as ChatCompletionRequestMessage[],
  })

  const chatGptMessageContent = completion.data.choices[0].message?.content!
  // ChatGPTの発言を保存する
  await ddbDocClient.send(
    new PutCommand({
      TableName: messagesTableName,
      Item: {
        id: v4(),
        content: chatGptMessageContent,
        userId: userId,
        typedAt: dayjs().format(nanoSecondFormat),
        role: "assistant",
      },
    })
  )

  // ChatGPTの発言をパラメータにLINE MessagingAPIを叩く
  const repliedMessage: TextMessage = {
    type: "text",
    text: chatGptMessageContent,
  }
  return lineBotClient.replyMessage(event.replyToken, repliedMessage)
}

const app = express()
app.use(
  // 署名検証+JSONパースのミドルウェア
  middleware({
    channelSecret: process.env.CHANNEL_SECRET ?? "",
  })
)

app.post("/webhook", async (req, res) => {
  try {
    const events: WebhookEvent[] = req.body.events

    const results = await Promise.all(events.map(handleEvent))
    return res.json(results)
  } catch (err) {
    console.error(err)
    return res.status(500)
  }
})

export default app

export const handler = serverlessExpress({ app })

OpenAIクライアント

OpenAIクライアントをimportし、 model: "gpt-3.5-turbo" を指定することでChatGPT APIを利用できます。 createChatCompletion() を使うことで会話を得ることができるようです。また、APIはステートレスな作りになっており、これまでの会話を踏まえた返答を得るには過去発言を配列に入れてリクエストする必要があるようです。

role というパラメータがあり、ユーザ発言は user 、ChatGPT発言は assistant となります。

キャラ付けをしておくには、発言配列の先頭に設定についての指示を固定記述しておくと良いでしょう。この時、 role: 'user' でも動作はするのですが、 role: 'system' にしておく方が望ましいようです。

DynamoDBDocumentクライアント

AWS SDK v3のDynamoDBDocumentクライアントを利用します。自分の聞き方の問題かもしれませんが、ChatGPTは現状AWS SDK V3のコードはあまり提案してくれません(V2のコードを提案してきます)ので、DevelopersIOブログなどを見ながら記述していきます(ダイマ)。基本的に、素のDynamoDBClientよりもDynamoDBDocumentClientを利用した方が記述量が減るのでおすすめです。

DynamoDB ドキュメントクライアントの使用 - AWS SDK for JavaScript

DynamoDBとのやり取りで以下を行っています。

  • ユーザの発言の保存
  • ChatGPTの発言の保存
  • 会話中ユーザに紐づく発言一覧の取得

LINEボットクライアント

ChatGPTからの返信メッセージをLINE送信するためにLINEボットクライアントを利用します。

LINEプラットフォーム署名検証

セキュリティのため、LINEプラットフォーム署名検証を行うのがmiddlewareです。validateSignatureを使うのですが、今回はExpressなのでこちらを試してみました。

なお、これはリクエストボディのJSONパースも行ってくれるため、 app.use(express.json()) は不要になります。記述すると競合してエラーを起こしてしまうのでご注意下さい(ChatGPTから提案されたコードは両方記述されていたので、自分で直しました)。

GitHubリポジトリ

dyoshikawa/chatgpt-api-line-bot-aws-serverless

まとめ

ここまでお読み頂きありがとうございました。

今後の課題としては、

  • 会話量がものすごく増えてくると処理速度がネックになる、もしくはOpenAI側の何らかの制限に引っかかりそう
    • 一定期間でリセットする仕組みが必要?
  • LINEグループに本ボットを招待した場合に、グループ参加者内で文脈を共有できるようにしたい

といったところでしょうか。このあたり解決できたら本記事に追記 or 別途記事にしたいと思います。

本文中で何回か触れましたが、TSコードを書く部分は結構ChatGPTの力を借りています。ChatGPTを使いながらChatGPTのプロダクトを作った感じです。チャットAIってメタな道具だなーと思います。

以上、ChatGPT APIでLINEボットを作ろうとしている方に少しでも参考になれば幸いです。

参考