🩺

GraphAIでTypescript簡単非同期処理 実践例:RCT論文の要約ツール作成

に公開
1

はじめに

医学生として日々たくさんの学術論文を読む必要があり、特に医者なら誰でも知ってるような有名なRCT(ランダム化比較試験)論文を効率よく理解するのが常に課題でした。大量の情報から重要なポイントをサクッと抽出して、その意義を適切に評価するのはかなり大変な作業です。

そこで、ちょっと前にGraphAIとClaudeのAPIを使ってRCT論文を自動要約するWebアプリを作ってみることにしました。開発していく中で、GraphAIの使い勝手の良さと拡張性に「おっ、これいいじゃん!」と素直に感動しました。

最近LLMの性能がものすごい勢いで向上していて、複雑なワークフローを組まなくても簡単なプロンプトでサクッと論文を要約できるようになっています。さらに、NotebookLM[1]といったナレッジベースを簡単に構築できるアプリケーションも登場しているので、今となってはこのWebアプリは提供価値としては微妙ですが、複雑なDAGで表されるワークフローが拡張性高く簡単に実装できる事実は有意義ですし、実際の業務においてはより下位のモデルを効率的に組み合わせてコストをカットする場面も多々あると思われるので、GraphAIのユースケースについて簡単に記事として残すことにしました。

GraphAIとは

https://github.com/receptron/graphai

簡単にいえば非同期処理エンジンです。複雑な依存関係のあるタスク群について、タスクの一覧とその依存関係をyamlファイルに書くだけで勝手に効率的に実行してくれます。

似たようなワークフローエンジンとしてはApache Airflow, Digdag, Luigi, Prefect, Argo Workflows, Temporalなど様々存在しますが、これらは導入の敷居が高かったり、コンテナベースの実行環境が必要だったり、設定が複雑だったりと、ちょっとした用途には過剰なオーバーヘッドがかかることが多いです。一方でGraphAIは簡単で、軽量で、Typescriptなのでフロントエンドでも動いたりとちょっとした用途にぴったりです。

基本的な使い方

  1. エージェント(後述)を定義する
  2. yamlで使うエージェントを書き並べて、ついでに処理の依存関係も書く
  3. 適当なコードベースで実行したらasyncとかawaitとか頑張らなくても勝手に非同期処理してくれる

GraphAIのエージェントは関数みたいなもんです。関数みたいなもんなので、複数箇所で呼び出して使いまわせます。例えば、Claudeを呼び出すタスクがいっぱいワークフローの中にある場合はClaudeを呼び出すエージェントを一個作っておけば使いまわせます。

デフォルトプリセットのエージェントもいっぱいあるみたいです。私は今回自分で作りました。

作ったシステムのワークフローの概要

精度の高い要約を短時間で得るために、RCT論文の流れにしたがって下図のようなワークフローを組みました。

このワークフローを同期的に処理した場合、単純計算で、非同期処理のおよそ5倍の時間がかかります。
そこで、graphaiの出番です。

エージェントの定義

まずはそれぞれのタスクを実行するエージェントを定義していきます。

上の画像を見ると、合計12個のタスクがあり、エージェントも12個定義しなければいけないように見えますが、「文字列結合」以外の11タスクはClaudeに問い合わせを行うだけであり、またエージェントは再利用可能なので、作るべきエージェントは

・Claudeに問い合わせを行うエージェント
・文字列結合をするエージェント

の2つだけで良いです。

実際のコードはこんな感じ。サンプルも併記して書くのが作法らしいですが、個人用なので雑になってます。

custom-agent.ts
import { AgentFunction, AgentFunctionInfo } from "graphai";
import Anthropic from '@anthropic-ai/sdk';

export const anthropicAccessAgent: AgentFunction = async ({ params, namedInputs }) => {
  try {
    // 環境変数からAPIキーを取得
    const apiKey = process.env.ANTHROPIC_API_KEY;
    if (!apiKey) {
      throw new Error("ANTHROPIC_API_KEY環境変数が設定されていません");
    }

    // ユーザー入力とシステムプロンプト、会話履歴を取得
    const userInput = namedInputs?.userInput || params?.userInput || "Hello, Claude";
    const systemPrompt = namedInputs?.systemPrompt || params?.systemPrompt || "";
    const history = namedInputs?.history || params?.history || [];

    const anthropic = new Anthropic({
      apiKey: apiKey,
    });

    // 会話履歴からメッセージ配列を作成
    const messages: Anthropic.MessageParam[] = [...history];
    
    // 新しいユーザーメッセージを追加
    messages.push({ role: "user" as const, content: [{type: "text", text: userInput,"cache_control": {"type": "ephemeral"}}] });
    
    // APIリクエストオプション
    const requestOptions: Anthropic.MessageCreateParams = {
      model: "claude-3-5-sonnet-20240620",
      max_tokens: 8192,
      messages: messages,
    };
    
    // システムプロンプトが指定されている場合は追加
    if (systemPrompt) {
      requestOptions.system = systemPrompt;
    }

    // リトライロジックを実装
    const maxRetries = 10;
    let retryCount = 0;
    let msg;

    while (retryCount < maxRetries) {
      try {
        msg = await anthropic.messages.create(requestOptions);
        break; // 成功したらループを抜ける
      } catch (error: any) {
        retryCount++;
        
        // 最大リトライ回数に達した場合はエラーをスロー
        if (retryCount >= maxRetries) {
          throw error;
        }
        
        console.log(`Anthropic APIリクエスト失敗 (${retryCount}/${maxRetries}): ${error.message}`);
        
        // ランダムな待機時間(2〜15分)を設定
        const waitTimeMs = (Math.random() * 2 + 1) * 60 * 1000;
        const waitTimeMin = Math.round(waitTimeMs / 60000 * 10) / 10;
        console.log(`${waitTimeMin}分待機してリトライします...`);
        
        // 指定時間待機
        await new Promise(resolve => setTimeout(resolve, waitTimeMs));
      }
    }

    // レスポンスから結果を取得
    if (!msg || !msg.content[0]) {
      throw new Error("APIレスポンスが不正です");
    }
    
    const result = msg.content[0].type === 'text' ? msg.content[0].text : '';
    console.log('Anthropic APIレスポンス:', result);
    const usage = msg.usage;
    console.log('Anthropic API使用量:', usage);
    
    // 新しいアシスタントメッセージを履歴に追加
    const updatedHistory = [
      ...messages,
      { role: "assistant" as const, content: result }
    ];
    
    // 結果と更新された会話履歴を返す
    return { result, history: updatedHistory };
  } catch (error: any) {
    console.error("Anthropic API呼び出しエラー:", error);
    return { error: error.message || "不明なエラーが発生しました" };
  }
};

const anthropicAccessAgentInfo: AgentFunctionInfo = {
  name: "anthropicAccessAgent",
  agent: anthropicAccessAgent,
  mock: anthropicAccessAgent,
  samples: [
    {
      inputs: { 
        userInput: "こんにちは、クロード", 
        systemPrompt: "あなたは親切なAIアシスタントです。",
        history: []
      },
      params: {},
      result: { 
        result: "こんにちは!お手伝いできることがあれば、お気軽にお尋ねください。",
        history: [
          { role: "user", content: "こんにちは、クロード" },
          { role: "assistant", content: "こんにちは!お手伝いできることがあれば、お気軽にお尋ねください。" }
        ]
      },
    },
  ],
  description: "Anthropic Claude APIにアクセスし、ユーザー入力に対する応答と会話履歴を返すエージェント",
  author: "jhondoe",
  repository: "null",
  license: "WTFPL",
  category: ["utility", "llm"]
};

export default anthropicAccessAgentInfo;

/**
 * コードボックスで囲まれたテキストを抽出して結合するエージェント
 * 
 * @param results - コードボックスを含む文字列の配列
 * @returns 抽出されたコードボックスの内容を結合した文字列
 */
const summarizeAgent: AgentFunction = async ({ params, namedInputs }) => {
  try {
    const results = namedInputs?.results || [];
    
    if (!Array.isArray(results)) {
      throw new Error("入力は文字列の配列である必要があります");
    }
    
    // 各文字列からコードボックスの内容を抽出
    const extractedContents = results.map(text => {
      // コードボックス(```で囲まれた部分)を検索
      const regex = /(?:```|''')([\s\S]*?)(?:```|''')/g;
      const matches: string[] = [];
      let match;
      
      while ((match = regex.exec(text)) !== null) {
        // マッチした内容の1番目のキャプチャグループ(コードボックス内のコンテンツ)を追加
        matches.push(match[1].trim());
      }
      
      return matches.join("\n\n");
    });
    
    // 抽出されたコンテンツを結合
    const combinedResult = extractedContents.filter(content => content.length > 0).join("\n\n");
    
    return combinedResult || "コードボックスが見つかりませんでした";
  } catch (error: any) {
    console.error("summarizeAgentエラー:", error);
    return { error: error.message || "不明なエラーが発生しました" };
  }
};

const summarizeAgentInfo: AgentFunctionInfo = {
  name: "summarizeAgent",
  agent: summarizeAgent,
  mock: summarizeAgent,
  samples: [
    {
      inputs: { 
        results: [
          "これは```コードボックス1```を含むテキストです",
          "これは```コードボックス2```を含むテキストです"
        ]
      },
      params: {},
      result: "コードボックス1\n\nコードボックス2",
    },
  ],
  description: "文字列配列から```で囲まれたコードボックスの内容を抽出して結合するエージェント",
  author: "johndoe",
  repository: "null",
  license: "WTFPL",
  category: ["utility", "text-processing"]
};

export { summarizeAgentInfo };

同じ会話履歴を何度も使い回すのでコンテキストキャッシングを行なっています。

ワークフローの定義

エージェントができたら次はワークフローを定義していきます。ワークフローはyamlで定義するみたいです。今回はさっきの図で示したDAGの非同期処理を行なって欲しかったので、次のようなyamlにしました。

defineDAG.yaml
      version: 0.5,
      nodes: {
        task1: {
          agent: "anthropicAccessAgent",
          params: {
            userInput: `
            <paper>${extractedText}</paper>
            この論文の内容を3000字程度で要約してください。ただし、以下の規則に従ってください。
    ・常体の日本語で記す。
    ・要約の章立てはAbstract, Introduction, Methods, Results, Discussionとする
    ・web browsing skillは使用せず論文の内容のみに準拠する
    ・医学生に解釈できる難易度で記す。
    
    まず、タスク1のみを実行してください:章ごとの字数の決定`,
            systemPrompt: "",
            history: []
          }
        },
        task2: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task1.history",
            userInput: "タスク2のみを実行してください:Abstractの章を書くのに必要な情報を論文から収集する"
          }
        },
        task3: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task2.history",
            userInput: "タスク3のみを実行してください:Abstractの章を書く。最終的な出力はコードボックス'''で囲む。コードボックスの中身は必ず【Abstract】\nという文字列で始める。"
          }
        },
        task4: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task1.history",
            userInput: "タスク4のみを実行してください:Introductionの章を書くのに必要な情報を論文から収集する"
          }
        },
        task5: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task4.history",
            userInput: "タスク5のみを実行してください:Introductionの章を書く。最終的な出力はコードボックス'''で囲む。コードボックスの中身は必ず【Introduction】\nという文字列で始める。"
          }
        },
        task6: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task1.history",
            userInput: "タスク6のみを実行してください:Methodsの章を書くのに必要な情報を論文から収集する"
          }
        },
        task7: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task6.history",
            userInput: "タスク7のみを実行してください:Methodsの章を書く。最終的な出力はコードボックス'''で囲む。コードボックスの中身は必ず【Methods】\nという文字列で始める。"
          }
        },
        task8: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task1.history",
            userInput: "タスク8のみを実行してください:Resultsの章を書くのに必要な情報を論文から収集する"
          }
        },
        task9: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task8.history",
            userInput: "タスク9のみを実行してください:Resultsの章を書く。最終的な出力はコードボックス'''で囲む。コードボックスの中身は必ず【Results】\nという文字列で始める。"
          }
        },
        task10: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task1.history",
            userInput: "タスク10のみを実行してください:Discussionの章を書くのに必要な情報を論文から収集する"
          }
        },
        task11: {
          agent: "anthropicAccessAgent",
          params: { systemPrompt: "" },
          inputs: {
            history: ":task10.history",
            userInput: "タスク11のみを実行してください:Discussionの章を書く。最終的な出力はコードボックス'''で囲む。コードボックスの中身は必ず【Discussion】\nという文字列で始める。"
          }
        },
        finalResult: {
          agent: "summarizeAgent",
          params: {},
          inputs: {
            results: [":task3.result", ":task5.result", ":task7.result", ":task9.result", ":task11.result"]
          },
          isResult: true
        }
      }
    }

依存関係はinputsに他のタスクの結果を挿入することで自動的に検出されます。Claudeにアクセスするエージェントに関してはシステムプロンプトを受け付けられるように実装したのですが、元々ClaudeのWeb GUIでユーザープロンプトを入力して直列で処理していたワークフローを改善したワークフローなので、今回は使っていません。

実行

最後に、GraphAIのエンジンを走らせれば非同期処理が最適化された形で実行できます。

agent.ts
/*諸々のコード*/
    // GraphAIの実行
    const agentFilters = [{
      name: "recordLlmFilter",
      agent: async (context: any, agent: any) => {
        const result = await agent(context);
        await recordLlmResponse(context.debugInfo.nodeId, result);
        return result;
      }
    }];

    const graph = new GraphAI(graphConfig, customAgents, { agentFilters });
    const result = await graph.run();
/*諸々のコード*/

あとはNextとHonoで適当にラッパー作って、デプロイして終わり(画像に5分と書かれていますが実際は40秒くらいです、ClaudeのAPIリミットで5分くらいかかってたのでTierを上げて解決しました)!

終わりに

今回はGraphAIを使ってRCT論文要約ツールを作ってみましたが、正直なところ「こんなに簡単でいいの?」という驚きがありました。特に非同期処理の実装の簡潔さには目を見張るものがありました。

実は今回使った機能は、GraphAIの機能のほんの一部に過ぎないようです。公式ドキュメントを見ると他にも面白そうな機能が山ほどあるので、これからもキャッチアップしていきたいと思っています。ドキュメントも非常に充実していて、学習曲線も緩やかなので、週末プロジェクトでサクッと使ってみるのにぴったりだと感じました。

特にLLMを使ったエージェントを作る機会がある方は、ぜひ一度試してみることをお勧めします。非同期処理の実装で頭を悩ませる時間が大幅に削減できるはずです。

最後に、この記事を読んでGraphAIに興味を持っていただけた方がいれば幸いです。まだまだ発展途上のライブラリですが、その分野での可能性は無限大だと感じています。

おまけ

要約の例です。

https://www.nejm.org/doi/full/10.1056/NEJMoa1804980
DOI: 10.1056/NEJMoa1804980

この論文が

要約

【Abstract】
びまん性大細胞型B細胞リンパ腫(DLBCL)は初期治療や二次治療に抵抗性を示したり、幹細胞移植後に再発したりする場合があり、そのような患者の予後は不良である。本研究では、CD19を標的とするキメラ抗原受容体(CAR) T細胞療法であるtisagenlecleucelの有効性と安全性を評価するため、国際的な第2相ピボタル試験を実施した。対象は自家造血幹細胞移植に不適格または移植後に進行したDLBCL成人患者で、主要評価項目は独立審査委員会による最良総合奏効率とした。93例が投与を受け、最良総合奏効率は52%(完全奏効40%、部分奏効12%)であった。12ヶ月無再発生存率は65%(完全奏効例では79%)と高く、持続的な奏効が得られた。安全性については、Grade 3-4のサイトカイン放出症候群(22%)や神経学的事象(12%)などが認められたが、治療関連死亡は報告されなかった。以上より、tisagenlecleucelは再発・難治性DLBCLに対して高い持続的奏効率を示す有望な治療法であることが示唆された。

【Introduction】
びまん性大細胞型B細胞リンパ腫(DLBCL)は最も一般的な非ホジキンリンパ腫であるが、初回治療に対して10-15%が原発性難治性で、20-35%が再発する。二次治療に対する反応率は40-60%程度だが、自家造血幹細胞移植を受けられるのは50%程度で、その3年無増悪生存率は30-40%にとどまる。二次治療として高用量化学療法と造血幹細胞移植を受けられない患者の予後は特に不良で、生存期間中央値はわずか4.4ヶ月、1年生存率23%、2年生存率16%と報告されている。自家移植後再発患者の一部は同種移植の対象となるが、治療関連合併症のリスクが高く、1年非再発死亡率は23%に及ぶ。

最近の後ろ向き研究では、原発性難治性DLBCLまたは自家移植後12ヶ月以内再発の患者636例を分析し、次治療への反応率26%、完全奏効率7%、生存期間中央値6.2ヶ月という結果が示された。これらの不良な転帰は、再発・難治性DLBCL患者に対する新たな治療選択肢の必要性を明確に示している。

そのような中、抗CD19 CAR T細胞療法であるtisagenlecleucelが注目されている。この治療法はB細胞を標的として除去し、単施設第2a相試験でB細胞リンパ腫に対する有効性が示されている。本研究は、このtisagenlecleucelの再発・難治性DLBCL患者に対する有効性と安全性を評価することを目的とした国際的な第2相試験である。

【Methods】
本研究は、再発または難治性びまん性大細胞型B細胞リンパ腫(DLBCL)患者を対象とした国際的な第2相ピボタル試験である。単群、非盲検、多施設共同で実施された。対象は18歳以上で、少なくとも2つの前治療歴があり、自家造血幹細胞移植に不適格または移植後再発した患者とした。

試験薬はtisagenlecleucel (抗CD19 CAR T細胞療法)で、主要評価項目は最良総合奏効率(完全奏効+部分奏効)とした。副次評価項目には奏効期間、全生存期間、安全性、細胞動態が含まれた。

治療プロトコルは以下の通りである:患者から白血球を採取し、必要に応じてブリッジング療法を行う。その後、リンパ球除去化学療法(フルダラビン+シクロホスファミドまたはベンダムスチン)を実施し、tisagenlecleucelを単回投与した。

効果判定は独立評価委員会によるLugano分類に基づいて行われた。有害事象はCTCAE v4.03で評価し、サイトカイン放出症候群についてはペンシルバニア大学のグレーディングスケールを使用した。

統計解析では、最初の50例と80例で中間解析と主要解析を計画し、Lan-DeMetsグループ逐次デザインを使用した。生存解析にはKaplan-Meier法を用いた。

また、バイオマーカー解析として、CD19発現および免疫チェックポイント関連タンパク質の発現を評価した。これらの方法により、tisagenlecleucelの有効性と安全性を総合的に評価することを目指した。

【Results】
本研究では165人が登録され、うち111人がtisagenlecleucel輸注を受けた。輸注を受けた患者の年齢中央値は56歳(範囲22-76歳)で、55%が難治性DLBCLであった。

有効性の主要評価項目である最良総奏効率は52%(95%信頼区間41-62%)であり、完全奏効率40%、部分奏効率12%であった。3ヶ月時点での完全奏効率は32%、部分奏効率は5%で、この奏効率は6ヶ月時点でも維持された。奏効持続期間中央値は未到達(95%信頼区間10ヶ月-未到達)であった。完全奏効患者の79%、全奏効患者の65%が12ヶ月時点で無再発と推定された。

安全性に関しては、サイトカイン放出症候群が58%に発生し、そのうちグレード3以上は22%であった。神経学的有害事象は21%に発生し、グレード3以上は12%であった。その他のグレード3以上の主な有害事象として、血球減少症(32%)、感染症(20%)、発熱性好中球減少症(14%)が報告された。治療関連死亡は認められなかった。

バイオマーカー解析では、腫瘍のCD19発現レベルと奏効率に明確な相関は見られなかった。しかし、PD-1/PD-L1相互作用スコアが最も高い5例とLAG3陽性T細胞比率が最も高い11例では、無効または早期再発の傾向が観察された。

CAR-T細胞の体内動態解析では、奏効患者でより長期のCAR遺伝子持続が確認されたが、用量反応関係や曝露反応関係は認められなかった。これらの結果は、tisagenlecleucelが難治性・再発性DLBCLに対して有望な治療選択肢となる可能性を示唆している。

【Discussion】
本研究は、強力な前治療を受けた再発または難治性のDLBCL成人患者において、tisagenlecleucelが高い奏効率と持続的な奏効期間を示したことを報告した。SCHOLAR-1研究で報告された標準治療での完全奏効率7%、全生存期間中央値6.2ヶ月と比較すると、本研究の結果は大幅な改善を示唆している。

サイトカイン放出症候群は58%の患者で発生したが、グレード3または4は22%にとどまり、ほとんどの場合tocilizumabで管理可能だった。これは、本療法の安全性プロファイルが許容範囲内であることを示している。

tisagenlecleucelとKTE-C19(ZUMA-1試験)の直接比較は困難だが、両者ともCD19指向CAR-T細胞療法が高い持続的奏効率を示すことを示唆している。また、低いまたは検出不能なCD19発現でも治療効果が得られる可能性が示された。

本療法は、高用量療法や造血細胞移植の適応がない、または失敗した再発/難治性DLBCL患者にとって有望な選択肢となる可能性がある。しかし、長期的な副作用の可能性についてはさらなる分析が必要である。より大規模な研究とより長期のフォローアップを通じて、本療法の有効性と安全性をさらに確認していく必要がある。

こうなります

脚注
  1. 一個下の後輩なんかは論文を全部NotebookLMにぶちこむのが流行ってるようです ↩︎

Discussion

Hidden comment