🐡

Typechat はどのようにプロンプトを生成してコードを実行するか

2023/10/16に公開

書き終わって気づいたが、この記事とだいたい一緒 https://zenn.dev/ptna/articles/a3882d095fa685

せっかく書いたので別視点の記事として残しておく。コンセプトの理解というより、コードの中身を掘り下げる。

typechat そのものを使うというより、 typechat がどのようにコードを生成するプロンプトを生成しているか、という視点で参考にするためにコードを読んだ。

Typechat とはなにか

MS謹製の自然言語をコード実行ステップに変換するプロンプトジェネレータ。
TypeScript の API スキーマを定義して、自然言語の入力をそのAPIに対するコード実行にステップに変換する。

自分の大雑把な理解

  • プログラマは呼び出し可能なAPIのスキーマと、それを実行するインタープリターを実装する
  • chatgpt は入出力を加工して API 呼び出しのステップとして取り出す
  • インタープリターを実行する

簡単な使い方と、その内部で起きていること

examples/math を参考に、簡単なカリキュレータを実装してみる。
バリデータ部分は省いている。実際には出力をバリデートしてその修正を指示する部分も含まれる。

import { createLanguageModel, createProgramTranslator, evaluateJsonProgram, getData } from "typechat";

const model = createLanguageModel({
  OPENAI_API_KEY: "your api key",
  OPENAI_MODEL: 'gpt-3.5-turbo',
  OPENAI_ENDPOINT: 'https://api.openai.com/v1/chat/completions'
});

const schema = `// 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;
}
`;

const translator = createProgramTranslator(model, schema);
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;
}

async function main(request: string) {
  const response = await translator.translate(request);
  console.log("----------");
  console.log(JSON.stringify(response, null, 2));

  const program = response.data;
  console.log("----------");
  console.log(getData(translator.validator.createModuleTextFromJson(program)));

  const result = await evaluateJsonProgram(program, handleCall);
  console.log("----------");
  console.log(`Result: ${typeof result === "number" ? result : "Error"}`);
}

const request = process.argv[2];
console.log("Translating program...", request);
main(request).catch(console.error);

これを実行して確認

{
  "success": true,
  "data": {
    "@steps": [
      {
        "@func": "add",
        "@args": [
          1,
          2
        ]
      }
    ]
  }
}
----------
import { API } from "./schema";
function program(api: API) {
  return api.add(1, 2);
}
add(1, 2)
----------

これを分解していく。

typechat の実行フロー

  • プロンプト: 関数呼び出しスキーマを定義し、ユーザー入力をそれに変換するように指示
  • ユーザー: AI に公開する API を定義
  • AI: ユーザー入力をAIに対する関数呼び出しステップに変換
  • ユーザー: 関数呼び出しステップをインタプリタとして実行

簡単な図

typechat が入出力を整形し、ChatGPT に投げている。
ユーザーが実装する部分は API スキーマと、その関数呼び出しステップの実行になる。

利用者はそのモデルを念頭にリクエストを与える。

プロンプト

次のように定義されている。

src/program.ts
const programSchemaText = `// 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;
};
`;

TypeScript で関数実行ステップの構造を与えて、 JSON として出力するように指示。

ユーザー: AI に公開 API を定義

// 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;
}

これを呼び出し可能なAPIだと AI は認識する。

リクエストの実行

最終的にこういうリクエストが組み立てられて ChatGPT に渡される。

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:
"""
1+2
"""
The following is the user request translated into a JSON program object with 2 spaces of indentation and no properties with the value undefined:

このとき、次のような出力が得られている。

{
  "success": true,
  "data": {
    "@steps": [
      {
        "@func": "add",
        "@args": [
          1,
          1
        ]
      }
    ]
  }
}

インタプリタ

これは実行ステップが得られているだけなので、実行するインタプリタを自分で実装する必要がある。

math なので次のような四則演算を実装する。

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;
}

というわけでこれを通すと結果が得られる。

Puppeteer Driver を実装してみる

ブラウザを操作するAPI

import { createLanguageModel, createProgramTranslator, evaluateJsonProgram, getData } from ".";

const model = createLanguageModel({
  OPENAI_API_KEY: process.env.OPENAI_API_KEY,
  OPENAI_MODEL: 'gpt-3.5-turbo',
  OPENAI_ENDPOINT: 'https://api.openai.com/v1/chat/completions'
});

import puppeteer from 'puppeteer';

async function createCallHandler() {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setViewport({width: 1080, height: 1024});
  return async (funcName: string, args: any[]) => {
    switch (funcName) {
      case 'goto':  {
        const [input] = args;
        await page.goto(input);
        await page.waitForSelector('body');
      }
      case 'type': {
        const [selector, text] = args;
        await page.waitForSelector(selector);
        page.type(selector, text);
      }
      case 'click': {
        const [selector] = args;
        await page.waitForSelector(selector);
        page.click(selector, text);
      }
    }  
  }
}

const schema = `// This is as schema for browser controll api with puppeteer
export type API = {
  // open url
  goto(url: string): void,
  // type url to string
  type(selector: string, value: string): void,
  // click selector element
  click(selector: string): void
}
`;

const translator = createProgramTranslator(model, schema);

async function main(request: string) {
  const response = await translator.translate(request);
  console.log("----------");
  console.log(JSON.stringify(response, null, 2));
  // console.log(response.)

  const program = response.data;
  console.log("----------");
  console.log(getData(translator.validator.createModuleTextFromJson(program)));

  const handler = await createCallHandler();
  const result = await evaluateJsonProgram(program, handler);
  console.log("----------");
}

const request = process.argv[2];
main(request).catch(console.error);

厳密には API を Promise 化しないといけず、このコードは雰囲気を表現するだけで動かないが、こういう出力が得られる。

$ pnpm tsx src/__browser.ts 'Googleを開いて入力フォーム にpuppeteerと入力し検索ボタンをクリックしてください'
{
  "success": true,
  "data": {
    "@steps": [
      {
        "@func": "goto",
        "@args": [
          "https://www.google.com"
        ]
      },
      {
        "@func": "type",
        "@args": [
          "input[name='q']",
          "puppeteer"
        ]
      },
      {
        "@func": "click",
        "@args": [
          "input[name='btnK']"
        ]
      }
    ]
  }
}
----------
import { API } from "./schema";
function program(api: API) {
  const step1 = api.goto("https://www.google.com");
  const step2 = api.type("input[name='q']", "puppeteer");
  return api.click("input[name='btnK']");
}

selector があってるかどうかは不明だが、実際にはHTMLを取り出して探索させるというフェーズを挟めばよい。そうなると記憶領域としての state みたいなのが欲しくなる。

結局標準の JSON Program だとちょっと物足りなくなってくるので、自作することになりそう。

typechat から学べること

API スキーマを与えて、その呼び出しステップにコードの生成対象を限定することで、高い精度で出力を得ることができる。
今回は言及しないが、ChatGPT Plugin もそういう作りになってるので、ベストプラクティスと言えそう。

validator 部分から得られる知見として、どのように失敗したかの情報を与えることである程度自動リトライさせることが可能。

Discussion