LangChain.jsでwithStructuredOutputする時の、生成結果を安定させる2つの方法

LangChain.jsでは、生成AIによるテキスト生成結果を構造化したオブジェクトで取得する方法があります。withStructuredOutputメソッドを使用して構造データを定義し、データを取得できます。しかし、JSONのパースエラーが発生する可能性もあるため、適切な対策が必要です。詳細はドキュメントを参照してください。LangChain.jsを活用する際は、データ処理を行いやすいユーティリティ関数を作成すると便利です。

広告ここから
広告ここまで

目次

    LangChain.jsには、生成AIによるテキスト生成結果を構造化したオブジェクトで取得する仕組みがあります。その中の1つがwithStructuredOutputメソッドを利用することです。このメソッドはChatAnthropicなどのLangChainでいうModelに該当するクラスの一部がサポートしています。(Cloudflare Workers AIはサポートしていませんでした)

    withStructuredOutputには、zodで定義した構造データを渡す

    どのような構造でデータを取得したいかを定義するには、zodを使うのが一般的な様子です。ネストした構造やオブジェクトの配列なども作ることができます。また、これは個人的な印象ですが、生成AIが利用することを想定すると、1つ1つの値にdescribeを利用して説明文を添えた方が効果的な気がしています。

    // Define the schema for changelog entries
    const ChangelogEntrySchema = z.object({
        id: z.string().describe('Unique ID for the changelog entry'),
        version: z.string().describe('Version name of the entry (e.g., "acacia")'),
        date: z.string().describe('Date of the entry in YYYY-MM-DD format'),
        title: z.string().describe('Title of the changelog entry'),
        description: z.string().describe('Detailed description of the changes'),
        category: z.string().describe('Category of the entry (e.g., "uncategorized")'),
        isBreakingChange: z.boolean().describe('Flag indicating whether this is a breaking change'),
        url: z.string().url().describe('URL to the detailed documentation for this changelog entry'),
    });
    
    // Schema for entries grouped by date
    const DateEntriesSchema = z
        .record(
            z.string().describe('Date key in YYYY-MM-DD format'),
            z.array(ChangelogEntrySchema).describe('Array of changelog entries for a specific date')
        )
        .describe('Object mapping dates to their respective changelog entries');
    
    // Schema for entries grouped by version
    const VersionSchema = z
        .record(z.string().describe('Version key (e.g., "acacia" or "legacy")'), DateEntriesSchema)
        .describe('Object mapping versions to their respective date entries');

    あとはzodで作成したスキーマをwithStructuredOutputの引数に渡せばOKです。

    const llm = new ChatAnthropic({
        model: 'claude-3-7-sonnet-20250219',
        anthropicApiKey: this.env.CLAUDE_API_KEY,
        streaming: true,
    });
    const structuredLlm = llm.withStructuredOutput(ChangelogSchema);
    

    モデルの実行は、withStructuredOutputの戻り値を利用することに注意しましょう。

    const result = await structuredLlm.invoke([
        [
            'system',
            'You are a helpful assistant that parses Stripe API changelog markdown and returns a structured object.',
        ],
        ['user', filteredChangelogContent],
    ]);
    console.log(result);

    指定しても構造化に失敗することはある

    withStructuredOutputを使えば安定してオブジェクトでデータが取得できるかというと、そうでもない様子です。今回試したケースでも、次のようなエラーログが頻発しました。JSONのparseに失敗しているようすですので、生成AIが出力した文字列状態でのJSONデータが破損している可能性が高いです。

    OutputParserException [Error]: Failed to parse. Text: ""{\"20....}]"". Error: "Expected ',' or '}' after property value in JSON at position 3032 (line 1 column 3033)"

    対策1: プロンプトで調整する

    もっとも簡単な方法は、ドキュメントにあるようにプロンプトで「JSONをちゃんと出せ」と指示することです。ドキュメントでは「You must always return valid JSON fenced by a markdown code block. Do not return any additional text.」と書くことが推奨されています。なのでsystemプロンプトを定義する部分にこれを追加するようにしましょう。

    
    const result = await structuredLlm.invoke([
        [
            'system',
            'You are a helpful assistant that parses Stripe API changelog markdown and returns a structured object. You must always return valid JSON fenced by a markdown code block. Do not return any additional text.',
        ],
        ['user', filteredChangelogContent],
    ])

    対策2: OutputFixingParseを使って、生成AIに直させる

    プロンプト指示だけでは、まだ生成結果が安定しないこともあります。そんな時は、OutputFixingParserを使います。これは生成AIによる出力結果をもう一度生成AIに渡し、構造を修正させるステップを入れる方法です。

    実装の際は、try – catchで作ることになります。これはLangChain.jsのwithStructuredOutputでは、JSONのパースに失敗するとエラーがThrowされるためです。ただしそれ以外のエラー、例えばAPI制限や障害などでfailすることもあり得ますので、ある程度JSONに関するエラーであることがわかったモノだけを通す方がよさそうです。また、throwされたエラーオブジェクトには、llmOutputとして生成結果が含まれることがあります。この値がないと生成結果の修正ができないので、この辺りもハンドリングしてやりましょう。

    
    const llm = new ChatAnthropic({
        model: 'claude-3-7-sonnet-20250219',
        anthropicApiKey: this.env.CLAUDE_API_KEY,
        streaming: true,
    });
    
    try {
        const structuredLlm = llm.withStructuredOutput(ChangelogSchema);
        const result = await structuredLlm.invoke([
            [
                'system',
                'You are a helpful assistant that parses Stripe API changelog markdown and returns a structured object.You must always return valid JSON fenced by a markdown code block. Do not return any additional text.',
            ],
            ['user', filteredChangelogContent],
        ]);
        return result;
    } catch (e) {
        if (e instanceof Error) {
            if (
                e.message.includes('Unexpected end of JSON input') ||
                e.message.includes('Failed to parse.')
            ) {
                console.log('JSONエラー!');
                const misformattedOutput = e.llmOutput;
                if (misformattedOutput) {
                    const parser = StructuredOutputParser.fromZodSchema(ChangelogSchema);
                    const parserWithFix = OutputFixingParser.fromLLM(llm, parser);
                    const fixedOutput = await parserWithFix.parse(misformattedOutput);
                    return fixedOutput;
                }
                console.log('JSONエラーが発生しましたが、llmOutputが見つかりませんでした');
            }
        }
        throw e;
    }

    まとめ

    少し手間にはなりますが、LangChain.jsではさまざまな仕組みでデータの処理を行うことができます。今回のケースは、比較的構造化データ取得時によく使うコードになるとは思いますので、utilify関数としてラップしておくとよいかもしれません。

    import { StructuredOutputParser } from "langchain/output_parsers";
    import { OutputFixingParser } from "langchain/output_parsers/fix";
    import { z } from "zod";
    import { BaseLanguageModelInterface } from "langchain/base_language";
    import { BaseChatModel } from "langchain/chat_models/base";
    
    
    // Interface for LLMs with structured output capability
    interface StructuredOutputCapableLLM extends BaseChatModel {
      withStructuredOutput: <T extends z.ZodTypeAny>(schema: T) => {
        invoke: (messages: Array<[string, string]>) => Promise<z.infer<T>>;
      };
    }
    
    /**
     * Parses Stripe API changelog markdown and returns a structured object
     * @param filteredChangelogContent The changelog content to parse
     * @param llm Any LLM instance that supports structured output
     * @returns Structured changelog data according to ChangelogSchema
     */
    export async function parseChangelog<T extends z.ZodTypeAny>(
      filteredChangelogContent: string,
      llm: StructuredOutputCapableLLM,
      schema: T
    ) {
      try {
        const structuredLlm = llm.withStructuredOutput(ChangelogSchema);
        const result = await structuredLlm.invoke([
          [
            'system',
            'You must always return valid JSON fenced by a markdown code block. Do not return any additional text.',
          ],
          ['user', prompt],
        ]);
        return result as z.infer<T>;
      } catch (e) {
        if (e instanceof Error) {
          if (
            e.message.includes('Unexpected end of JSON input') ||
            e.message.includes('Failed to parse.')
          ) {
            console.log('JSONエラー');
            const misformattedOutput = (e as any).llmOutput;
            if (misformattedOutput) {
              const parser = StructuredOutputParser.fromZodSchema(ChangelogSchema);
              const parserWithFix = OutputFixingParser.fromLLM(llm, parser);
              const fixedOutput = await parserWithFix.parse(misformattedOutput);
              return fixedOutput as z.infer<T>;
            }
            console.log('JSONエラーが発生しましたが、llmOutputが見つかりませんでした');
          }
        }
        throw e;
      }
    }

    Document

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark