LayerX AI Agent ブログリレー 38日目の記事です。
こんにちは、LayerX バクラク事業部 Platform Engineering 部 Enabling グループに新卒入社した shibutani と申します。
弊社では早くからLLMを組み込んだ機能開発を行っており、多くのAIエージェント機能を提供しています。一部のプロダクトでは、そういったAI機能の開発においてVercel AI SDKを利用しています。そのような背景もあり、これまではGo言語を中心としたバックエンド開発が主流であった弊社でもTypeScriptを用いたバックエンド開発を行うケースが登場してきました。
TypeScriptにおけるリクエストスコープなメタデータの管理
バックエンド開発を行う際には、ログやテレメトリの送信など様々な場所でユーザーIDなどのリクエストスコープなメタデータが必要になります。特にLLMを用いたプロダクト開発を行う際には、精度改善のためにユーザーの行動をトラッキングできるTraceIDなどのメタデータが重要となります。
Go言語においてリクエストスコープなメタデータを扱う際には、多くの場合でcontext.Contextを利用します。その際には、関数呼び出しの引数に明示的にcontext.Contextオブジェクトを渡しますが、このような引数によるメタデータ管理が一般化している理由としては3rd Partyライブラリを含めて多くのライブラリのインターフェイスがcontext.Contextを引き受ける形になっていることが挙げられます。それによって、開発者はとりあえずcontext.Contextに情報を入れておけばよいと考えることができます。
しかし、TypeScript(ECMAScript/Node.js)においてはそのような引数によるメタデータ管理がGo言語ほど一般的ではありません。
では、TypeScriptにおいて、リクエストスコープなメタデータを管理するにはどうすればよいのでしょうか。その方法が次の章で紹介するAsyncLocalStorageです。
AsyncLocalStorage
Node.jsの公式サイトによるとAsyncLocalStorageとは次の通りです。
These classes are used to associate state and propagate it throughout callbacks and promise chains. They allow storing data throughout the lifetime of a web request or any other asynchronous duration. It is similar to thread-local storage in other languages.
AsyncLocalStorageはNode.jsが提供するデータストアであり、同期関数におけるコールバックや、非同期関数におけるPromiseチェーンの中でデータが保持されるため、リクエストスコープなメタデータを保存するのに活用できるというわけです。
それでは、AsyncLocalStorageの具体的な使用方法について順を追って説明していきます。AsyncLocalStorageの基本的な要素はstoreオブジェクト、run関数、enterWith関数です。
storeオブジェクトはAsyncLocalStorageにセットされた値そのものを指します。storeの形式に特に決まりはなく、プリミティブ型の値を直接入力することも可能ですが、一般的なユースケースではキーバリュー型のオブジェクトを入力することがほとんどかと思います。
run関数はstoreに新しい値をセットし、その値の有効範囲を決定する関数です。run関数の第二引数で指定されるコールバックもしくは非同期関数の中では新しいstoreの値が取得可能になります。また、兄弟関係にあるrun関数の間ではstoreの値は共有されません。
import { AsyncLocalStorage } from 'node:async_hooks'; const storage = new AsyncLocalStorage<{ key: string }>(); storage.run({ key: 'value1' }, () => { console.log(storage.getStore()!.key); }); console.log(storage.getStore()!.key) storage.run({ key: 'value2' }, () => { console.log(storage.getStore()!.key); });
enterWith関数も同様にstoreに新しい値をセットします。run関数との大きな違いとしては、enterWith関数ではコールバックを必要としません。その点は大きなメリットのように思えますが、値のスコープがわかりづらいというデメリットもあります。例えば下記のように、ある時点では値が含まれていたにもかかわらず、他の処理によって意図せずundefinedになってしまうということが起こりえます。
import { AsyncLocalStorage } from 'node:async_hooks'; const storage = new AsyncLocalStorage<{ key: string }>(); storage.enterWith({ key: 'value1' }); console.log(storage.getStore()!.key); storage.enterWith({ key: undefined }); console.log(storage.getStore()!.key);
AsyncLocalStorage自体はシングルトンオブジェクトとして定義し、全ての箇所からそのオブジェクト経由でstoreにアクセスします。その際のスコープに応じて適切な値がstore経由で取り出せるという仕組みになっています。この仕組みによってリクエストスコープなメタデータを管理することが可能になります。
使いやすいインターフェイス設計を目指して
ここからはAsyncLocalStorageを用いたリクエストスコープなメタデータ管理を行うクラスを設計していきます。その際の大きな論点として、storeに対して新しい値を追加する際に、run関数を使うか、それともenterWith関数を使うかという論点があります。
run関数を使う場合、run関数の中で引数として指定されるコールバック関数の中でのみ新しいstoreが有効になります。そのため、storeのスコープがわかりやすく、暗黙的なstoreの汚染が起こりづらいというメリットがあります。その一方で、値が追加されるたびにコールバック関数の中で後続の処理を行う必要があり、コードの可読性が低下します。
enterWith関数を使う場合には、コールバック関数が不要となる一方で、新しい値が追加されたstoreのスコープがわかりづらく、リクエストハンドラの上流で使用された場合、他のテナントのリクエストとスコープが共有されるリスクがあります。
そこで、enterWithはテストなど限られた目的でのみ利用するべきものと定め、run関数を中心としたインターフェイス設計を行うこととしました。
さらに、弊社のバックエンド開発ではこれまでGo言語が中心的に利用されてきたため、下記のようにGo言語のcontext.Contextに近いインターフェイス設計としました。
import { AsyncLocalStorage } from 'node:async_hooks'; export type ContextKey<T> = { id: unique symbol; readonly $_type?: T; }; export class ContextCalledOutsideOfContextError extends Error { constructor() { super('Context was accessed outside of an active AsyncLocalStorage scope.'); } } class Context { private readonly storage = new AsyncLocalStorage<Map<symbol, unknown>>(); public withValue<T, R, Args extends unknown[]>( key: ContextKey<T>, value: T, fn: (...args: Args) => R, ...args: Args ): R { const parent = this.storage.getStore(); const newStore = parent ? new Map(parent) : new Map<symbol, unknown>(); newStore.set(key.id, value); return this.storage.run(newStore, fn, ...args); } public getValue<T>(key: ContextKey<T>): T | undefined { const store = this.storage.getStore(); if (!store) { throw new ContextCalledOutsideOfContextError(); } if (!store.has(key.id)) { return undefined; } return store.get(key.id) as T; } } export const context = new Context();
特にこだわっているのが、getValue関数の部分です。Go言語のcontext.Contextにおいて値を取得する際は、同時に値の型チェックをランタイムで行うことができる、型アサーション (例: foo.(string)) を実行するのが一般的です。しかし、TypeScriptにおいてはプリミティブ型を除き、ランタイムにおける厳密な型アサーションを行うことはできません。
そこで、事後的に型チェックを行うのではなく、あらかじめ定義するkeyの型に、対応する値の型を含めて、参照・挿入ともに型安全になるようにしました。これによって、型ガード関数などの実装者に依存する形での型チェックを行わずに、型安全に値を取り出すことが可能になります。
export const tenantIdKey: ContextKey<string> = { id: Symbol('tenantId') }; context.withValue(tenantIdKey, 't_123', async () => { });
弊社での活用例
弊社ではWorkflow EngineのTemporalとVercelのAI SDKを組み合わせたAgent機能開発を行っています。その際に、Temporal ActivityのInterceptorにおいてTraceIdなどのメタデータをContextに追加し、同一のリクエストスコープで実行されるOpenTelemetryのSpanProcessorにおいてContextから取り出したTraceIDを付与しています。
class Interceptor implements ActivityInboundCallsInterceptor { public async execute( input: ActivityExecuteInput, next: Next<ActivityInboundCallsInterceptor, "execute"> ): Promise<unknown> { const sessionId = randomId(); const traceId = randomId(); return await context.withValue( sessionIdKey, sessionId, async (input: ActivityExecuteInput) => { return await context.withValue( traceIdKey, traceId, async (input: ActivityExecuteInput) => { return await next(input); }, input ); }, input ); } } export class OriginalSpanProcessor extends SpanProcessor { public override onStart(span: Span, parentContext: Context): void { super.onStart(span, parentContext); const sessionId = requestContext.sessionId; const traceId = requestContext.traceId; if (traceId) { const spanAny = span as any; if (spanAny._spanContext) { spanAny._spanContext.traceId = traceId; } } if (sessionId) { span.setAttribute("session.id", sessionId); } } }
こういった、同一のリクエストスコープで実行されるが、明示的に引数として値の受け渡しが難しいようなケースにおいては、同一のリクエストスコープ内のどこからでもアクセス可能で、フレームワークの実装に依存しないAsyncLocalStorageが非常に有用です。
おわりに
今回はTypeScriptにおいてリクエストスコープな情報を扱うための方法としてAsyncLocalStorageを紹介しました。
リクエストスコープだけでなく、任意の関数呼び出しに対してスコープを設定したデータ共有が可能となる強力な方法のため、ぜひ皆さんも試してみてください!
コメント