ぽくつなです

わんぱくな JSON ストリームパーサーを見る日

この記事は はてなエンジニアアドベントカレンダー 2024 5 日目の記事です。 昨日は id:susisu さんの Data types à la carte in TypeScript でした。

本人が「アクセス増えたと思ったら別の記事で、全然読まれてない...」と言っていたので「いきなりフランス語で難しそうやからね」と伝えました。本文は日本語なので、みなさんも読んで下さい。


今日は最近見て面白かったコードの紹介です。

ChatGPT が流行って以来、アプリでストリームのレスポンスをよく見るようになりました。

LLM によるテキスト生成はわりと時間がかかる処理で、もしすべて生成し終えてからレスポンスするとユーザーを待たせてしまうからでしょう。テキストがちょっとずつ表示される UI は昔からあるものですが、LLM を使ったアプリケーションが出てきて以来、演出としてではなく実用としてよく見られるようになったと思います。

各社が提供している LLM の API を利用する場合も、大抵ストリームでレスポンスを受け取る方法も提供されています。また自然文の生成だけでなく、指定したスキーマを埋めて JSON で構造化されたデータを返してくれる機能があります。アプリケーションに組み込みやすくて重宝しますね。

では LangChain で JSON のレスポンスストリームを読んでいる様子を見てください これは Gemini API のレスポンスを JsonOutputParser に渡していて、チャンクを受信するたびにパース結果を出力しています。

JsonOutputParser

え!?!? 今みた!?!?

もっとわかりやすく1文字ずつバッファに書き込んでいってパースさせてみましょう。
上の行が stream をシミュレートして書き込まれた内容、下がその時点でパースした内容です。

1文字ずつ

おわかりいただけだろうか...

まだリテラルが終わっていない段階でパースされた値が得られているのを...

stream: {"name": "p
parsed: {"name": "p"}

↑ この段階で name の値が p としてパースされてる!!

stream: {"name": "pokutuna", "age": 1
parsed: {"name": "pokutuna", "age": 1}

age: 1 の瞬間がある!!

stream: { ... "food": ["tonka
parsed: { ... "food": ["tonka"]}

↑ まだ Array 閉じてないのに!!

JSON ストリームの読み込みは、いろいろなライブラリで実装されています。 例えば、NDJSON に対して行ごとにオブジェクトを受け取れるものや、JSON Path で値をひっかけるもの、SAX-like な特定のトークンが来たらコールバックを受け取るもの (もう SAX という響きが懐かしいぞ)など。

でも LangChain のこのパターンを見るのは始めてで、なにそれ!? と思ってコードを見に行きました。

この動作はこの parse_partial_json で実装されています。

github.com

文字列の開始のダブルクオートや、Object や Array の開きカッコなど、開始トークンが来るたびに、対応する閉じトークンを積んでいって、最後に reverse してくっつけて補完して json.loads しています。なかなか勢いのある実装。

内容を正確にパースをするという観点からは許されるか怪しい、レスポンスを途中までしか受け取ってないからといって、お小遣い3円の瞬間があっていいのか?

しかし { "message": "長いテキスト長いテキスト長いテキスト長いテキスト... のような文字列の終わりを待ってずっと値を使えないなら、レスポンスをストリームすることで本来得たいユーザを待たせない体験が得られません。

実装も富豪的で、全体のパースを試して失敗したら1文字ずつ読んでカッコ等を積んでいく、ダメなら末尾を捨てていって試す、と何回 json.loads するつもりなのか。

これは JsonOutputParser 全体で、自然文中に JSON が含まれるレスポンス

はい、指示に従って JSON で回答します。

```json
{"hoge": "fuga",  ...

みたいな出力もパースできるようにするためですね。

大抵「ストリームで JSON を処理したい」というと、超巨大なログを扱うとか、一度にメモリに読み込みたくないとか、実行時のリソースに意識があります。しかし LangChain のこの実装はユーザを待たせないため、途中でもいいから値を返す、Object や Array だけでなく、文字列や数値すら途中で返してしまう、AI との会話文ストリームからも取り出す、というのが面白いですね。そんなちょっとした観光名所でした。

途中の stream.py はこれ stream.py · GitHub

この記事は はてなエンジニアアドベントカレンダー 2024 5 日目の記事です。
明日は 6日目 id:miki_bene さんです!!