LLM（大規模言語モデル）のためのHaskellのフレームワーク

この記事はHaskell Advent Calendar 2024の6日目の記事です。

# 概要

Haskell向けのLLMの[フレームワーク (Intelli Monad)](https://hackage.haskell.org/package/intelli-monad)を作成し、自立エージェントであるアプリケーションに組み込みました。

このフレームワークは型安全なFunction CallingやStructured Outputを提供し、チャットの履歴をモナドで管理し、履歴の永続化を行います。LLMにはOpenAIを利用し、履歴の永続化にはPersistentライブラリを利用しています。LLMの入出力が型安全であるため、Hspecなどの既存のユニットテストでテストが可能です。

# 目的

Haskellを使ってLLM（大規模言語モデル）を自立エージェントであるアプリケーションに組み込むことが目的です。

組み込み対象のアプリケーションには、次の二つのパターンが存在します。一つはヒューマン・イン・ザ・ループのアプリケーションで、Copilotのようにユーザーからの入力を自然言語として受け取り、LLMが自然言語で応答し、ユーザーがその良否を判断するケースです。もう一つは自立エージェントで、ユーザーの入力を介さず、環境の状態から判断してアクションを実行するケースです。今回は自立エージェントのシステムに組み込むことを念頭に開発しました。

自立エージェントに対してフレームワークに必要だと思った機能は次のとおりです。特に重要なのは型安全な入出力をサポートすることです。LLMとプログラムの連携を自然言語ではなく、安全に行うためです。また、Function CallingやStructured OutputとのやりとりはJSON Schemaで型を定義することを想定しています。

- 型安全なFunction CallingやStructured Output
- チャット履歴の管理（特にモナドを使った状態管理やデータの永続化）
- デバッグのためのCLIツール
- マルチメディアのサポート（主に画像）
- シングルバイナリ化（デプロイを簡単にするため）

# 既存のフレームワーク

LLMの既存のフレームワークとして次のようなものがあります。調査不足かもしれませんが、LangChainが要件に最も近いです。上記の要件をすべて満たすものはありませんでした（LangChainではシングルバイナリ化が難しいため）。

| フレームワーク名                                                         | 開発言語   | コメント                                                                                                                                                                                                                                         |
| ---------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [LangChain](https://github.com/langchain-ai/langchain)           | Python | 一般的なLLMのフレームワーク。LLMとのデータの入出力を型をつけて安全に行うようなフレームワークではない。[pydantic](https://docs.pydantic.dev/latest/)という別のフレームワークを使って、[タイプアノテーションをもとにJSON Schemaを生成することができる。](https://python.langchain.com/v0.1/docs/modules/model_io/chat/structured_output/) |
| [outlines](https://github.com/dottxt-ai/outlines)                | Python | Structured Outputに特化したフレームワーク。データのタイプアノテーションからJSON Schemaを生成して、LLMとのデータの入出力に型をつけられる。                                                                                                                                                         |
| [Rig](https://rig.rs/)                                           | Rust   | 一般的なLLMのフレームワーク。LLMとのデータの入出力を型をつけて安全に行うフレームワークではない。例えば[Function Calling](https://github.com/0xPlaygrounds/rig/blob/main/rig-core/examples/agent_with_tools.rs#L29-L47)ではJSON Schemaをユーザーが明示的に書く必要があるため手間がかかる。                                |
| [instructor-rs](https://github.com/instructor-ai/instructor-rs/) | Rust   | structの型からJSON Schemaを自動で生成できるが、それ以外の機能がない。                                                                                                                                                                                                  |
| [instructor-go](https://github.com/instructor-ai/instructor-go/) | Go     | structの型からJSON Schemaを自動で生成できるが、それ以外の機能がない。                                                                                                                                                                                                  |

# 作成したフレームワーク (Intelli Monad)

LLMのフレームワークを作るため、次の課題がありました。

1. OpenAIへの包括的なAPI
2. JSON SchemaのフィールドにLLM向けの説明をつける

まず、OpenAIとのやりとりをするための包括的なライブラリを作成しました。OpenAIはAPIのスキーマをOpenAPIというフォーマットで提供しています。そこから[HaskellのAPI](https://hackage.haskell.org/package/openai-servant-gen)を生成しました。しかし、使用した[生成ツール (openapi-generator-cli)](https://github.com/OpenAPITools/openapi-generator-cli)の品質が良くなく、コンパイルエラーの修正、定義がない型の追加、型の変更が必要でした。マルチメディアのサポート（主に画像）は自動生成したAPIではうまくいかず、こちらも手作業での修正を行いました。生成したAPIはHaskell Servant形式ですので、OpenAIへのアクセス用Haskellライブラリだけでなく、[OpenAIのAPIサーバーのモック](https://github.com/junjihashimoto/intelli-monad/blob/main/openai-servant-gen-examples/app/chat-completion-server.hs)も作成可能です。

次に、型安全なFunction CallingやStructured Outputを行うため、[Haskellの型をJSON Schemaに変換する型クラスを用意しました。](https://hackage.haskell.org/package/intelli-monad-0.1.0.2/docs/src/IntelliMonad.Types.html#JSONSchema) GHC Genericを使って作成しています。現時点ではEitherのようなSum Typeには対応していません。各フィールドへのLLM向けの注釈はHasFunctionObject型クラスで設定します。式を入力し、数字を出力する例は以下のようになります。

```Haskell
-- LLMへの出力の型の定義
data Number = Number
  { number :: Double
  }
  deriving (Eq, Show, Generic, JSONSchema, FromJSON, ToJSON)

instance HasFunctionObject Number where
  getFunctionName = "number"
  getFunctionDescription = "validate input"
  getFieldDescription "number" = "A number"

instance Tool Number where
  data Output Number = NumberOutput
    { code :: Int,
      stdout :: String,
      stderr :: String
    }
    deriving (Eq, Show, Generic, FromJSON, ToJSON)
  toolExec _ = return $ NumberOutput 0 "" ""

-- LLMへの入力の型の定義
data Input = Input
  { formula :: Text
  }
  deriving (Eq, Show, Generic, JSONSchema, FromJSON, ToJSON)

instance HasFunctionObject Input where
  getFunctionName = "formula"
  getFunctionDescription = "Describe a formula"
  getFieldDescription "formula" = "A formula"

main :: IO ()
main = do
  -- LLMの呼び出し
  v <- generate [user "Calculate a formula"] (Input "1+3")
  print (v :: Maybe Number)  
```

チャット履歴の管理にはStateモナドとPersistentライブラリを使用したPromptモナドを用意しました。また、デバッグのためのCLIはhaskelineを使っています。

# 評価と課題

チャットのやり取りからシステムの更新の必要性を判断し、アップデートするべきソフトとそのバージョンを取り出し、自動でアップデートするアプリケーションを作成しました。Hspecを使ってLLMのプロンプトをテストし、本番環境で動作させていますが、現在のところ、アプリケーションは正常に動作しています。

フレームワークに対する課題は次のとおりです。

1. Haskellの型からJSON Schemaを生成するが、Sum Typeをサポートしていないため使える型が制限されている
2. OpenAPIのスペックをもとにOpenAIのライブラリを生成しているが、コード生成に問題があり、生成後のコードを手修正しないと使えない。そのため、最新のOpenAIの機能を利用するのに支障がある
3. デバッグのためにチャットのやりとりの詳細をダンプするデバッグモードがない
4. チャットの履歴に対してLLMのトークン数の上限に対応する機能がない

# まとめ

Haskell向けのLLMの[フレームワーク (Intelli Monad)](https://hackage.haskell.org/package/intelli-monad)を作成しました。Haskellの型からJSON Schemaを生成し、LLMの入出力に対して型を定義することで、型安全なFunction CallingやStructured Outputを実現できます。このフレームワークを使い、プログラムをアップデートするためのエージェントを作成し、本番環境で利用しています（具体的なシステムについてはここでは述べませんが）。

とはいえ、このフレームワークはまだ発展途上であり、現在も改善を進めておりますので、ご意見をいただけると助かります。