タム
2025.05.09
25
こんにちは、タムです。
今回は、最近趣味で開発しているHaskell製のLLMライブラリ「lgchain-hs」について紹介させていただきます。
最近、ChatGPTやGeminiなどのLLMを使ったAIエージェントの開発が活発になってきていますよね。
PythonのLangChainが有名ですが、「Haskellでも同じようなことができないかな?」と思って
開発を始めたのが「lgchain-hs」です。
※LangChainを名乗るのは流石におこがましいので、"lgchain"としています
このブログは特に以下の方に読んでいただきたいです:
- 壊れないAIエージェントの開発に興味がある方
- HaskellでLLMを扱いたい方
いきなりですが、まずは本ライブラリの具体的な使用例を見ていきましょう。
### 1. シンプルな文字列出力
一番基本的な使い方です。OpenAIのモデルに質問を投げて、回答を文字列で受け取ります。
```haskell
{-# LANGUAGE OverloadedStrings #-}
import Lgchain.Core.Clients (Chain(StrChain), invoke, runOrFail, strOutput)
import Lgchain.Core.Requests (ReqMessage(ReqMessage), Role(System, User))
import Control.Monad.Trans.Except (ExceptT(ExceptT))
import Lgchain.OpenAI.Clients (ChatOpenAI(ChatOpenAI), OpenAIModelName(GPT4O))
main :: IO ()
main = runOrFail $ do
let prompt = [
ReqMessage System "You are a helpful assistant.",
ReqMessage User "Haskellについて教えてください。"
]
let model = ChatOpenAI GPT4O
let chain = StrChain model prompt
result <- invoke chain Nothing
response <- ExceptT $ return $ strOutput result
liftIO $ putStrLn response
```
### 2. 構造化データの取得
これが「lgchain-hs」の真骨頂です。
例えば、レシピを構造化データとして取得してみましょう。
```haskell
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
import Lgchain.Core.Clients (Chain(Chain), invoke, runOrFail, structedOutput)
import Lgchain.Core.Requests (ReqMessage(ReqMessage), Role(System, User), ViewableText, deriveJsonSchema)
import Data.Map qualified as M
import GHC.Generics (Generic)
import Lgchain.Gemini.Clients (ChatGemini(ChatGemini), GeminiModelName(GEMINI_1_5_FLASH))
data Recipe = Recipe
{ ingredients :: [ViewableText],
steps :: [ViewableText]
}
deriving (Eq, Show, Generic)
deriveJsonSchema ''Recipe -- ここがポイント!
main :: IO ()
main = runOrFail $ do
let prompt = [
ReqMessage System "ユーザが入力した料理のレシピを考えてください。",
ReqMessage User "{dish}"
]
let model = ChatGemini GEMINI_1_5_FLASH
let chain = Chain model prompt (undefined :: Recipe)
let formatMap = M.fromList [("{dish}", "カレー")]
result <- invoke chain (Just formatMap)
recipe <- ExceptT $ return $ structedOutput result
liftIO $ print recipe
```
`deriveJsonSchema`を使うことで、Haskellのデータ型定義から自動的にJSONスキーマを生成できます。
これにより、型安全な構造化データの取得が可能になります。
### 3. チャット履歴の管理
チャットボットを作る場合、会話の履歴管理は必須ですよね。
SQLiteを使った履歴管理の例を見てみましょう。
```haskell
{-# LANGUAGE OverloadedStrings #-}
import Lgchain.Core.Histories.ChatMessageHistories (ChatMessageHistory(addMessage, deleteMessages), getMessages)
import Lgchain.Core.Histories.ChatMessageHistories.RDB (SqliteChatMessageHistory(SqliteChatMessageHistory), migrate)
import Lgchain.Core.Requests (ReqMessage(ReqMessage), Role(Assistant, System, User))
main :: IO ()
main = runOrFail $ do
let history = SqliteChatMessageHistory "database.db" "session1"
liftIO $ migrate history
-- 履歴の追加と取得が簡単!
liftIO $ addMessage history (ReqMessage User "こんにちは")
messages <- liftIO $ getMessages history
```
### 4. エージェントワークフロー
複数のステップを持つAIエージェントも実装できます。
例えば、「質問を分析して適切なロールを選択→回答を生成」というワークフローを考えてみましょう。
```haskell
data ExampleState = ExampleState
{ query :: ViewableText,
currentRole :: Maybe ViewableText,
messages :: [ViewableText]
}
deriving (Eq, Show, Generic)
-- ロール選択ノード
data SelectionNode = SelectionNode
instance AgentNode SelectionNode ExampleState where
run _ state = do
-- ロール選択ロジック
return $ state { currentRole = Just "選択されたロール" }
-- 回答ノード
data AnsweringNode = AnsweringNode
instance AgentNode AnsweringNode ExampleState where
run _ state = do
-- 回答生成ロジック
return $ state { messages = messages state ++ ["生成された回答"] }
```
さて、なぜHaskellでLLMライブラリを作ろうと思ったのか。
PythonのLangChainは本当に素晴らしいライブラリです。
特に外部ツールとの連携や拡張性は群を抜いています。
ただ、最近のLLM開発では**Model-Completion-Prompt (MCP)** パターンが主流になってきています。
つまり、複雑なツール連携よりも、プロンプト設計と構造化出力の取り扱いが重要になってきているんです。
「だったらHaskellでも十分戦えるんじゃない?」
そう考えて、型安全なLLMインターフェースの構築を始めました。
最大の課題は「構造化出力の型安全な取得」でした。
AIエージェントを開発していると、よく遭遇する問題があります:
- LLMから返ってくるJSONが期待した構造と違う
- スキーマの定義が面倒で、しかも手動だとミスが起きやすい
- モデルプロバイダごとに構造化出力の定義フォーマットが異なる
これらの問題を解決しないと、実用的なAIエージェントの実装は困難です。
## 構造化出力の設計と解決法
最初は、HaskellのGenericを使ってスキーマを動的に生成しようとしました。
でも、Haskellの型システムと`LLMModel`型クラスの関係で、これは断念することに...
そこで考えた解決策が:
1. ChatGPTの構造化出力定義形式を中間表現として採用
2. `JsonSchemaConvertable`型クラスを`TemplateHaskell`で実装
3. ユーザ定義型からChatGPT定義形式への自動変換を実現
4. Geminiなどの他のプロバイダはChatGPT形式からの変換を定義して吸収
この方法で、型安全性を保ちながら、異なるモデル間での構造化出力の取り扱いを統一できました。
プロジェクトは現在以下の3つのライブラリを提供しています:
1. `lgchain-hs-core`: 基本的な抽象化とユーティリティ
2. `lgchain-hs-openai`: OpenAIモデルとの統合
3. `lgchain-hs-gemini`: Geminiモデルとの統合
中心的な概念は:
- `Chain`, `StrChain`: LLM操作の組み合わせを表現
- `ChatMessageHistory`: 履歴管理(RDB対応)
- `AgentNode`: AIエージェントのワークフローを型安全に記述
特に「統一インターフェースと型レベルの安全性」にこだわって設計しています。
### クリーンなモジュール設計
設計上のこだわりポイントとして、クライアント実装時の依存が極力coreに集中するようにしています。
具体的には:
```haskell
-- coreモジュールから必要な型や関数をimport
import Lgchain.Core.Clients (Chain(Chain), invoke, runOrFail)
import Lgchain.Core.Requests (ReqMessage(ReqMessage), Role(System, User))
-- プロバイダモジュールからはLLMModel型インスタンスのみをimport
import Lgchain.OpenAI.Clients (ChatOpenAI(ChatOpenAI))
-- または
import Lgchain.Gemini.Clients (ChatGemini(ChatGemini))
```
これを実現しているのが`LLMModel`型クラスです:
```haskell
class LLMModel a where
invokeWithSchema :: (JsonSchemaConvertable b) => a -> Prompt -> b -> Maybe FormatMap -> ExceptIO (Output b)
invokeStr :: a -> Prompt -> Maybe FormatMap -> ExceptIO (Output b)
```
各プロバイダ固有の呼び出し方法は、そのプロバイダの`LLMModel`インスタンスが知っているため、
クライアント側は統一されたインターフェースだけを使えば良いのです。
これにより:
- クリーンなコード(余計なimportが不要)
- 高い保守性(プロバイダ固有の実装を隠蔽)
- 容易な切り替え(異なるプロバイダ間でのスイッチが簡単)
を実現しています。
一番苦労したのは、モデルごとの構造化出力定義の違いを吸収する部分です。
現在はChatGPT形式をベースにしていますが、これには潜在的な問題があります:
**もしChatGPT形式で表現できない構造化出力を求めるプロバイダが出てきたら?**
より柔軟なスキーマ変換の仕組みについては、実現可能性が見えてきたら対応したいです。
その他、今後の改善点として考えているのは:
- MCPクライアントの実装
- DSLベースのワークフロー定義
- LangGraph風のステートマシン連携機能
他にもやるべきことはたくさんありますが、一歩一歩進めていきたいと思います。
また進展があれば記事にしようと思うので、ご期待ください!
MCP時代のAIエージェント開発において、構造化出力と型の扱いは非常に重要です。
Haskellの型システムを活かすことで、より安全で保守性の高いAIエージェントが作れると考えています。
コードやライブラリはすべて[GitHub](https://github.com/jtamu/lgchain-hs)で公開しています。
フィードバックやコントリビューションをお待ちしています!