MicrosoftのTypeChatをソースコードから理解する
はじめに
microsoftからリリースされたTypeChat。
ChatGPTを扱う上では、回答が所定のフォーマットになるようにコントロールすることが肝になります。それをTypeScriptの型システムで行おう、というのがTypeChatに底流するアイデアです。
ソースをみたところ、行数も多くなく、自力で追える範囲だったので、各ファイルの内容を確認してみました。
TypeChatの大まかな流れ
見通しを良くするために、最初に処理の流れを示しておきます。
TypeChatはJSONをインターフェイスに次の流れで、translate
を行います。
自然言語をJSONにするのは 関数のSchemaを与えるFunction callingやZapier Natural Language Actionsと類似していますが、変換したJSONのチェックにTypeScriptの型チェックを用いる点が、TypeChatの特色になります。
Function calling
Zapier Natural Language Actions
ソースコードを眺める
ファイル構成
TypeChatの本体は下記のファイルからなります。
- src
- index.ts
- interactive.ts
- model.ts
- program.ts
- result.ts
- tsconfig.json
- typechat.ts
- validate.ts
tsconfig.jsonはのぞいて、これを愚直に上から見ていきます。
index.ts
これは文字通りの「index」で、特記することはありません。
ライブラリのエントリーポイントが単一になり、たとえば、
import { createLanguageModel, createJsonTranslator, processRequests } from "typechat";
のようにfrom "typechat"
でまとめてインポートできるようになります。
interactive.ts
export async function processRequests(interactivePrompt: string, inputFileName: string | undefined, processRequest: (request: string) => Promise<void>)
これもTypeChatの本質に関係のないファイルで、ファイル名が引数に与えられている場合はそのファイルを、そうでない場合は、ユーザーからの入力を求め、入力の各行に対してcallback処理を行う関数を定義しています。
callbackにはTypeChatの処理が入ります。
使用例
// Process requests interactively or from the input file specified on the command line
processRequests("😀> ", process.argv[2], async (request) => {
const response = await translator.translate(request);
if (!response.success) {
console.log(response.message);
return;
}
console.log(`The sentiment is ${response.data.sentiment}`);
});
model.ts
OpenAIとAzure OpenAIのencapsulation(カプセル化)であるTypeChatLanguageModel
を作成する関数群。
- createLanguageModel
- createOpenAILanguageModel / createAzureOpenAILanguageModel
- createAxiosLanguageModel
- complete(prompt: string)
- createAxiosLanguageModel
- createOpenAILanguageModel / createAzureOpenAILanguageModel
TypeChatLanguageModel
export interface TypeChatLanguageModel {
retryMaxAttempts?: number;
retryPauseMs?: number;
complete(prompt: string): Promise<Result<string>>;
}
OPENAI_API_KEYとOPENAI_MODELが.env
にセットされている場合は、OpenAIを、AZURE_OPENAI_API_KEYがセットされている場合は、Azure OpenAI Serviceを用います。
APIリクエストはcomplete
メソッドで行われます。
complete(prompt: string): Promise<Result<string>>;
使用例
createJsonTranslator
の引数として用います。
const model = createLanguageModel(process.env);
const translator = createJsonTranslator<SentimentResponse>(model, schema, "SentimentResponse");
program.ts
これは最後に説明した方がわかりやすいので、後回しにします。
result.ts
実行結果への型付け。エラーハンドリング用。
- Success/ErrorとそのユニオンのResult型を定義
export type Success<T> = { success: true, data: T };
export type Error = { success: false, message: string };
export type Result<T> = Success<T> | Error;
- getData関数: Successの場合はデータを、Errorの場合はErrorを投げる。
使用例
console.log(getData(translator.validator.createModuleTextFromJson(program)));
typechat.ts
全体のメインのファイル。冒頭のダイアグラムでも示したTypeChatJsonTranslatorを定義します。
export interface TypeChatJsonTranslator<T extends object> {
model: TypeChatLanguageModel;
validator: TypeChatJsonValidator<T>;
attemptRepair: boolean;
stripNulls: boolean;
createRequestPrompt(request: string): string;
createRepairPrompt(validationError: string): string;
translate(request: string): Promise<Result<T>>;
}
TypeChatJsonTranslatorは、translate
メソッドにより、
- 自然言語のリクエストをJSONに変換
- 回答がJSONかどうかチェック
- TypeChatJsonValidatorによるJSONの型チェック
- エラーがあった場合は修正(repair)
を行っています。
1. 自然言語のリクエストをJSONに変換
LLM(OpenAI)を用いて変換しています。プロンプトはcreateRequestPrompt
で定義されています。
わかりやすいように変数に定数を入れて掲示します。
You are a service that translates user requests into JSON objects of type "SentimentResponse" according to the following TypeScript definitions:
```
// The following is a schema definition for determining the sentiment of a some user input.
interface SentimentResponse {
sentiment: "negative" | "neutral" | "positive"; // The sentiment of the text
}
```
The following is a user request:
```
TypeChat is awesome!
```
The following is the user request translated into a JSON object with 2 spaces of indentation and no properties with the value undefined:
The following is... で始まる最後一文は、LLMを誘導する、いわゆるleading wordsになると思います。
これをプロンプトにして、TypeChatLanguageModelのcomplete
を実行します。
2. 回答がJSONかどうかチェック
{}
を見て、JSON形式かどうかを見て、JSON文字列を抽出。
const startIndex = responseText.indexOf("{");
const endIndex = responseText.lastIndexOf("}");
if (!(startIndex >= 0 && endIndex > startIndex)) {
return error(`Response is not JSON:\n${responseText}`);
}
const jsonText = responseText.slice(startIndex, endIndex + 1);
3. TypeChatJsonValidatorによるJSONの型チェック
const validation = validator.validate(jsonText);
TypeChatの核心ですが、ここの処理は次項で見ていきます。
4. エラーがあった場合は修正(repair)
エラーがあった場合は、下記のようなプロンプトをcreateRepairPrompt
で作成し、再びLLMへリクエストします。
${responseText}
The JSON object is invalid for the following reason:
"""
${validationError}
"""
The following is a revised JSON object:
validate.ts
TypeChatを特徴づける、JSONと型Schemaの照合を行うTypeChatJsonValidatorを定義しています。
export interface TypeChatJsonValidator<T extends object> {
schema: string;
typeName: string;
stripNulls: boolean;
createModuleTextFromJson(jsonObject: object): Result<string>;
validate(jsonText: string): Result<T>;
}
このうち、validateが面白いことをやっています。
validate
function validate(jsonText: string)
引数にはLLMから返ってきたJSON(?)の文字列を取ります。
何をvalidateしているか順々に見ていきます。
1. parseできるか
まずparseできるJSONかどうか。普通にtry...catch。
try {
jsonObject = JSON.parse(jsonText) as object;
}
catch (e) {
return error(e instanceof SyntaxError ? e.message : "JSON parse error");
}
2. Nullチェック(optional)
stripNulls
がtrueになっている場合は、値がnullのプロパティを削除。
if (validator.stripNulls) {
stripNulls(jsonObject);
}
3. createModuleTextFromJson:型Schemaのインポート文作成
ここからがTypeChatの要点です。型をチェックするためにTypeScriptを作成、つまりメタプログラミングを行います。
引数で与えられるtypeName
から、TypeScriptのインポート文を作ります。
import { ${typeName} } from './schema';\nconst json: ${typeName} = ${JSON.stringify(jsonObject, undefined, 2)}
たとえば、SentimentResponse
がtypeNameとして与えられると、以下のようなTypeScriptのプログラム文を返します。
import { SentimentResponse } from './schema';
const json: SentimentResponse = {
"sentiment": "neutral"
};
4. createProgramFromModuleText:TypeScriptのプログラム文を作る
同様にして、引数のschemaを使って、モジュールから型チェック用のTypeScriptのプログラムを作ります。
createProgramFromModuleTextは、たとえば以下のプログラムを作ります。
interface Array<T> { length: number, [n: number]: T }
interface Object { toString(): string }
interface Function { prototype: unknown }
interface CallableFunction extends Function {}
interface NewableFunction extends Function {}
interface String { readonly length: number }
interface Boolean { valueOf(): boolean }
interface Number { valueOf(): number }
interface RegExp { test(string: string): boolean }
// The following is a schema definition for determining the sentiment of a some user input.
interface SentimentResponse {
sentiment: "negative" | "neutral" | "positive"; // The sentiment of the text
}
const json: SentimentResponse = {
"sentiment": "neutral"
};
5. TypeScriptをcompileして型チェックを行う
const syntacticDiagnostics = program.getSyntacticDiagnostics();
const programDiagnostics = syntacticDiagnostics.length ? syntacticDiagnostics : program.getSemanticDiagnostics();
if (programDiagnostics.length) {
const diagnostics = programDiagnostics.map(d => typeof d.messageText === "string" ? d.messageText : d.messageText.messageText).join("\n");
return error(diagnostics);
}
作成したTypeScriptに以下の二つのチェックをかけます。
- getSyntacticDiagnostics:シンタックスエラーのチェック
- getSemanticDiagnostics:意味解析
もしも型が異なったJSONが定義されているとコンパイルエラーが出て、JSONの型チェックができる、という仕組みです。
参考記事
*2013年の古い記事です。
program.ts
最後に後回しにしたprogram.tsを見てみます。
このファイルのメインの関数はcreateProgramTranslator
で、関数実行できるようなSchemaとプロンプトを与えた特殊なTypeChatJsonTranslator
を作るのが主な役割です。要するにChatGPTのFunction calling的なことを行っています。
createProgramTranslator
export function createProgramTranslator(model: TypeChatLanguageModel, schema: string): TypeChatJsonTranslator<Program> {
const translator = createJsonTranslator<Program>(model, schema, "Program");
...
このschemaとProgram型が特殊です。
Schema
実行する関数シグネチャを定義します。Schemaは"API"という名前の型である必要があります。
たとえば、mathのサンプルは下記。
export type API = {
// Add two numbers
add(x: number, y: number): number;
// Subtract two numbers
sub(x: number, y: number): number;
// Multiply two numbers
mul(x: number, y: number): number;
// Divide two numbers
div(x: number, y: number): number;
// Negate a number
neg(x: number): number;
// Identity function
id(x: number): number;
// Unknown request
unknown(text: string): number;
}
Program型
LLMの返り値をparseして実行するための型。
ソースからコメントを一部省くと、次のようになっています。
export type Program = {
"@steps": FunctionCall[];
}
export type FunctionCall = {
// Name of the function
"@func": string;
// Arguments for the function, if any
"@args"?: Expression[];
};
export type Expression = JsonValue | FunctionCall | ResultReference;
export type JsonValue = string | number | boolean | null | { [x: string]: Expression } | Expression[];
export type ResultReference = {
// Index of the previous expression in the "@steps" array
"@ref": number;
};
たとえば、先ほどのSchemaと
multiply two by three, then multiply four by five, then sum the results
を入力した場合、次のようなProgram型のJSONが返ってくることが期待されます。
[
{
@func: 'mul', @args: [2,3]
},
{
@func: 'mul', @args: [4,5]
},
{
@func: 'add', @args: [ { @ref: 0 }, { @ref: 1 } ]
}
]
プロンプト
関数呼び出しに関する点が加えられ、少しチューニングされています。
The programs can call functions from the API defined in the following TypeScript definitions:
```
${translator.validator.schema}
```
createModuleTextFromProgram
型チェックを行う前に、createModuleTextFromJson
を使って、JSONデータをTypeScriptにしますが、それをProgram型用にチューニングしたのが、createModuleTextFromProgram
です。
たとえば、先ほどの例の
[
{
@func: 'mul', @args: [2,3]
},
{
@func: 'mul', @args: [4,5]
},
{
@func: 'add', @args: [ { @ref: 0 }, { @ref: 1 } ]
}
]
は、
import { API } from "./schema";
function program(api: API) {
const step1 = api.mul(2, 3);
const step2 = api.mul(4, 5);
return api.add(step1, step2);
}
にparseされ、TypeScriptの型チェックが行われます。
Schemaの型の名前をAPI
としなければならないのは、下記で指定しているためでした。
evaluateJsonProgram
コードの実行は、
const result = await evaluateJsonProgram(program, handleCall);
のようにevaluateJsonProgram
で行います。
第一引数にはProgram型のオブジェクトが入り、handleCallに関数名と関数の引数が渡るので、実際の処理を書きます。Function Callingと同じです。
これまでのmath
の例だと、handleCallは次のような実装になっていました。
async function handleCall(func: string, args: any[]): Promise<unknown> {
console.log(`${func}(${args.map(arg => typeof arg === "number" ? arg : JSON.stringify(arg, undefined, 2)).join(", ")})`);
switch (func) {
case "add":
return args[0] + args[1];
case "sub":
return args[0] - args[1];
case "mul":
return args[0] * args[1];
case "div":
return args[0] / args[1];
case "neg":
return -args[0];
case "id":
return args[0];
}
return NaN;
}
関数名でswitchして関数実装を返すだけですね。
サンプルファイルを見てみる
振り返りも兼ね、サンプルファイルのうちから、sentiment.ts
を実行してみます。
import fs from "fs";
import path from "path";
import dotenv from "dotenv";
import { createLanguageModel, createJsonTranslator, processRequests } from "typechat";
import { SentimentResponse } from "./sentimentSchema";
// TODO: use local .env file.
dotenv.config({ path: path.join(__dirname, "../../../.env") });
const model = createLanguageModel(process.env);
const schema = fs.readFileSync(path.join(__dirname, "sentimentSchema.ts"), "utf8");
const translator = createJsonTranslator<SentimentResponse>(model, schema, "SentimentResponse");
// Process requests interactively or from the input file specified on the command line
processRequests("😀> ", process.argv[2], async (request) => {
const response = await translator.translate(request);
if (!response.success) {
console.log(response.message);
return;
}
console.log(`The sentiment is ${response.data.sentiment}`);
});
1. TypeChatJsonTranslatorの作成
dotenv.config({ path: path.join(__dirname, "../../../.env") });
const model = createLanguageModel(process.env);
const schema = fs.readFileSync(path.join(__dirname, "sentimentSchema.ts"), "utf8");
const translator = createJsonTranslator<SentimentResponse>(model, schema, "SentimentResponse");
上に見た通り、model, schema(型定義)を引数に渡して、TypeChatJsonTranslatorを作ります。
2. 入力
標準入力から値を受け取り、translate
を実行します。
processRequests("😀> ", process.argv[2], async (request) => {
const response = await translator.translate(request);
...
}
3. translateの実行
本処理です。
createRequestPrompt
LLMに与えられるプロンプトは下記です。
You are a service that translates user requests into JSON objects of type "SentimentResponse" according to the following TypeScript definitions:
```
// The following is a schema definition for determining the sentiment of a some user input.
export interface SentimentResponse {
sentiment: "negative" | "neutral" | "positive";
// The sentiment of the text
}
```
The following is a user request:"""`こんにちは!"""
The following is the user request translated into a JSON object with 2 spaces of indentation and no properties with the value undefined:
complete
completeの結果は下記となりました。
'{\n "sentiment": "neutral"\n}'
validate
JSONかどうかチェック
問題なく通過しました。
型チェック
validator.createModuleTextFromJson
で次のようなプログラムができます。
import { SentimentResponse } from './schema';
const json: SentimentResponse = {
"sentiment": "neutral"
};
これで型チェックを通過し、結果が表示されます。
😀> こんにちは!
The sentiment is neutral
programの場合
本文でも触れていますが、関数実行を伴うmath.ts
の処理を見てみます。
import fs from "fs";
import path from "path";
import dotenv from "dotenv";
import { createLanguageModel, processRequests, createProgramTranslator, evaluateJsonProgram, getData } from "typechat";
// TODO: use local .env file.
dotenv.config({ path: path.join(__dirname, "../../../.env") });
const model = createLanguageModel(process.env);
const schema = fs.readFileSync(path.join(__dirname, "mathSchema.ts"), "utf8");
const translator = createProgramTranslator(model, schema);
// Process requests interactively or from the input file specified on the command line
processRequests("➕➖✖️➗🟰> ", process.argv[2], async (request) => {
const response = await translator.translate(request);
if (!response.success) {
console.log(response.message);
return;
}
const program = response.data;
console.log(getData(translator.validator.createModuleTextFromJson(program)));
console.log("Running program:");
const result = await evaluateJsonProgram(program, handleCall);
console.log(`Result: ${typeof result === "number" ? result : "Error"}`);
});
async function handleCall(func: string, args: any[]): Promise<unknown> {
console.log(`${func}(${args.map(arg => typeof arg === "number" ? arg : JSON.stringify(arg, undefined, 2)).join(", ")})`);
switch (func) {
case "add":
return args[0] + args[1];
case "sub":
return args[0] - args[1];
case "mul":
return args[0] * args[1];
case "div":
return args[0] / args[1];
case "neg":
return -args[0];
case "id":
return args[0];
}
return NaN;
}
1. TypeChatJsonTranslatorの作成
前の例と大同小異ですが、createProgramTranslatorを使っている点が異なります。
createProgramTranslatorによって、Program型に沿った処理を行右ことができます。
dotenv.config({ path: path.join(__dirname, "../../../.env") });
const model = createLanguageModel(process.env);
const schema = fs.readFileSync(path.join(__dirname, "mathSchema.ts"), "utf8");
const translator = createProgramTranslator(model, schema);
引数のschemaもAPIという型名での関数シグネチャになっています。
export type API = {
// Add two numbers
add(x: number, y: number): number;
// Subtract two numbers
sub(x: number, y: number): number;
// Multiply two numbers
mul(x: number, y: number): number;
// Divide two numbers
div(x: number, y: number): number;
// Negate a number
neg(x: number): number;
// Identity function
id(x: number): number;
// Unknown request
unknown(text: string): number;
}
2. 入力
前の例と同様に、入力をcallback関数に流します。
下記を入力しました。
multiply two by three, then multiply four by five, then sum the results
3. translateの実行
プロンプト
createProgramTranslatorを使っているのでLLMへのプロンプトは以下のようになります。(人間が読みやすいように手を入れています。)
You are a service that translates user requests into programs represented as JSON using the following TypeScript definitions:
```
// A program consists of a sequence of function calls that are evaluated in order.
export type Program = {
"@steps": FunctionCall[];
}
// A function call specifies a function name and a list of argument expressions. Arguments may contain// nested function calls and result references.
export type FunctionCall = {
// Name of the function
"@func": string;
// Arguments for the function, if any
"@args"?: Expression[];
};
// An expression is a JSON value, a function call, or a reference to the result of a preceding expression.
export type Expression = JsonValue | FunctionCall | ResultReference;
// A JSON value is a string, a number, a boolean, null, an object, or an array. Function calls and result// references can be nested in objects and arrays.
export type JsonValue = string | number | boolean | null | { [x: string]: Expression } | Expression[];
// A result reference represents the value of an expression from a preceding step.
export type ResultReference = {
// Index of the previous expression in the "@steps" array
"@ref": number;};
```
The programs can call functions from the API defined in the following TypeScript definitions:
```
// This is a schema for writing programs that evaluate expressions.export type API = {
// Add two numbers
add(x: number, y: number): number;
// Subtract two numbers
sub(x: number, y: number): number;
// Multiply two numbers
mul(x: number, y: number): number;
// Divide two numbers
div(x: number, y: number): number;
// Negate a number
neg(x: number): number;
// Identity function
id(x: number): number;
// Unknown request
unknown(text: string): number;}
```
The following is a user request:
"""multiply two by three, then multiply four by five, then sum the results"""
The following is the user request translated into a JSON program object with 2 spaces of indentation and no properties with the value undefined:
OpenAIの回答
上記プロンプトに対して以下のような回答が返ってきました。
Program型に沿って、@func
や@args
、@ref
が入っています。
{
"@steps": [
{
"@func": "mul",
"@args": [2,3]
},
{
"@func": "mul",
"@args": [4,5]
},
{
"@func": "add",
"@args": [{ "@ref": 0 },{ "@ref": 1 }
]
}
]
}
validate
Program
型からTypeScriptのプログラムを作り、コンパイルします。
コンパイルすると、下記のようなコードとなります。
import { API } from "./schema";
function program(api: API) {
const step1 = api.mul(2, 3);
const step2 = api.mul(4, 5);
return api.add(step1, step2);
}
実行(evaluateJsonProgram)
型チェックが通ったら、最後に実行です。
Programオブジェクトから関数名と引数を取り出し、handleCall
に渡します。
async function handleCall(func: string, args: any[]): Promise<unknown>
引数に与えられた関数名と引数はコンソールで確認できます。
mul(2, 3)
mul(4, 5)
add(6, 20)
Result: 26
となって、Schemaで定義した関数が正しいシグネチャで呼ばれていることが確認できました。
おわりに
TypeChatの仕組みについて、ソースコードを一通り見てみました。
ポイントはvalidate.ts
でのTypeScriptの作成〜JSONの型チェックで、メタプログラミングになっているのが面白いです。
Program型による関数実行はChatGPTのFunction Callingと大まかな仕組みは同じで、いかに正しく関数名と引数を手に入れるか、というところで、TypeScriptの型システムを使うか、JSONスキーマを使うか、の違い、と理解しました。
Function callingでも想定外のJSONが返ってきてエラーになることがあったので、より堅牢なLLMアプリケーションのためにTypeChatは一つの面白いアプローチだと感じました。
参考記事
Discussion