OpenAIのFunction Callingが出たときに、GPTを使って自然言語でツールを操作するというのをやったんだけど、この程度にGPT使う必要なくない?という感じもしたので、GPTなどLLM使わずに実装してみました。
LLM使わずに実現できることはLLM使わないほうがよさげ。
前回のブログ、これです。
こんな感じで動くようになっています。
ツールのテキスト操作にGPTなんかいらんかったんや!
— きしだൠ(K1S) (@kis) 2023年7月1日
サクサク動くわ。 pic.twitter.com/JAD3grWJGx
GPT4使ったときはこんな感じ
OpenAIのFunction Callingでツール操作を試すやつ、GPT-4だとかなり文脈を理解してくれるし、位置関係も結構ただしく扱ってくれる。しかし遅い。 pic.twitter.com/nkijZpcnP6
— きしだൠ(K1S) (@kis) 2023年6月19日
今回は格フレームという考え方をベースに実装してます。
格フレームは、アプリケーションから利用する観点だと、「動詞が決まったら単語の役割きまるよね」というものです。
例えば「ぼく」「うどん」「博多駅」「食べる」という単語があれば、文法構造なくても「ぼくは博多駅でうどんを食べる」のような意味が浮かぶわけで、主語になりそうな単語だとか場所になりそうな単語とかは決まるよね、みたいな感じ。
ちゃんとした説明は、放送大学の自然言語処理の教科書などを見てください。
今回は位置サイズを変えるか色を変えるかという動作があるので、対応するインタフェースを用意します。
interface GeometoryCommand { void execute(FunctionApiSample.GraphObject obj, Degree degree, Dimension fieldSize); } interface ColorCommand { void execute(FunctionApiSample.GraphObject obj, String color); }
あとはそれらの操作と操作対象オブジェクトを覚えておくフィールドを用意。
GeometoryCommand geometoryCommand = null; ColorCommand colorCommand = null; FunctionApiSample.GraphObject obj = null;
それぞれの動作はこんな感じでメソッドを書いておきます。
void toLeft(FunctionApiSample.GraphObject obj, Degree degree, Dimension fieldSize) { obj.left -= fieldSize.width / (degree == Degree.LITTLE ? 10 : 5); obj.left = Math.max(obj.left, 0); } void toRight(FunctionApiSample.GraphObject obj, Degree degree, Dimension fieldSize) { obj.left += fieldSize.width / (degree == Degree.LITTLE ? 10 : 5); obj.left = Math.min(obj.left, fieldSize.width - obj.width); }
色を変える操作は、指定した色を保持したラムダを返すように。
ColorCommand colorCommand(String color) {
return (obj, c) -> obj.color = color;
}
意味に該当する単語を並べておきます。
static Map<String, List<String>> normalizeData = Map.ofEntries( Map.entry("left", List.of("左")), Map.entry("right", List.of("右")), Map.entry("up", List.of("上")), Map.entry("down", List.of("下")), Map.entry("center", List.of("中央", "真ん中")),
実際には単語から意味をひくので転置。
static Map<String, String> normalize = normalizeData.entrySet().stream()
.flatMap(e -> e.getValue().stream().map(v -> Map.entry(v, e.getKey())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
これ、entrySet
まで書くとCopilotが続きを全部書いてくれています。こういう、定型だけど実際に書くとめんどいコードを自動で書いてくれるのがとてもよい。
あとは、Kuromojiで形態素解析してそれぞれの単語に該当するフレームを埋めていきます。
var tokens = tokenizer.tokenize(input); Degree degree = Degree.NONE; for (var token : tokens) { System.out.println(token.getAllFeatures()); var baseForm = token.getBaseForm(); var command = normalize.get(baseForm); if (command == null) { continue; } System.out.println("command:" + command); switch (command) { case "rect", "triangle", "image": obj = objectMap.get(command); break; case "red", "blue", "yellow", "green", "black", "white": colorCommand = colorCommand(command); geometoryCommand = null; break; case "left": geometoryCommand = this::toLeft; colorCommand = null; break;
Kuromojiはここで使ってる形態素解析器です。
最後に、対象と操作が埋まってたら実行。
if (obj != null) { if (geometoryCommand != null) { geometoryCommand.execute(obj, degree, fieldSize); } else if (colorCommand != null) { colorCommand.execute(obj, "red"); } }
ここで、埋まったフレームをそのまま保持することで、次回のコマンドで対象などが省略されたときにそれを使うようにして、コンテキストを持っているように動作させてます。
そして、前回サンプルでボタンが押されたときの処理を書き替え。
static CaseFrameGrammer caseFrameGrammer = new CaseFrameGrammer(); static void goPrompt() { String prompt = textField.getText(); caseFrameGrammer.parse(prompt, objectMap, new Dimension(800, 600)); // 画面を再描画 Graphics2D g = image.createGraphics(); draw(g); g.dispose(); imageLabel.repaint(); textField.setText(""); }
やることが増えると、それぞれの単語や操作が増えて、あともう少し丁寧に構文解析してあげると、割と実用になる気がします。 実際にアプリケーションでLLMを利用するときにも、既存の自然言語処理で可能な部分はなるべくロジカルに書いておいて、ユーザー入力の解析や結果出力時の要約などLLMじゃないとできないことだけLLMを呼び出すのがいいんではないかと思いました。
確かにGPT使うほうが柔軟な対応ができるけど、遅いし高いのと、動きの安定感がないので、自力でやったほうがいい場合が多い気がします。
ソースはこちら。
https://gist.github.com/kishida/c0a7ae4e7a5db7e7e440fbde1886a5f6