yoi/crates/llm-worker/docs/spec/timeline.md
2026-04-04 04:27:46 +09:00

15 KiB
Raw Blame History

Timeline層設計

目的

  • OpenAI / Anthropic / Gemini のストリーミングイベントを単一の抽象レイヤーに正規化し、LLMクライアントの状態遷移を制御する。
  • イベント単位処理Meta系などとブロック単位処理テキスト/Thinking/ToolCallを同一のパイプラインで扱えるようにする。

要件

イベントストリームに対して直接的にループ処理をしようとすると発生する煩雑な状態管理を避ける。

イベントをloop+matchで処理をするような手段を取ると、テキストのデルタの更新先や、完了タイミングなどの状態管理が必要になる。 また、コンテンツブロックに対する単純なイテレータではping/usageなどの単発イベントを同期的に処理することができない。

  • Meta系イベントの即時処理ブロック内部イベントと順序が前後しないようにする
  • ブロック開始/差分/終了でスコープを保持する
  • 型安全なハンドラー
    • blockでキャッチするdeltaについて、Text/InputJson/Thinking等、ブロックに即したイベントの型が必要。
  • エラーの適切な制御

Memo

  • ブロックを処理するHandlerで保持するコンテキストについて、LLMで用いられるコンテキストと混同を避けるために「スコープ」と呼称する。
  • ブロックは常に一つである前提。複数のブロックが同時に存在することは無いため。Timelinecurrent_block: Option<BlockType>で現在のブロックを追跡する。

モジュール構成

crates/llm-worker/src/
├── handler.rs           # Kind / Handler トレイト定義、Meta系・Block系Kind定義
├── timeline/
│   ├── mod.rs           # 公開API re-export
│   ├── event.rs         # Timeline層のイベント型llm_client::eventからの変換含む
│   ├── timeline.rs      # Timeline本体、ErasedHandler、BlockHandlerWrapper群
│   ├── text_block_collector.rs   # TextBlockCollector組み込みHandler
│   └── tool_call_collector.rs    # ToolCallCollector組み込みHandler
└── event.rs             # Worker層の公開イベント型timeline::eventからの変換含む

イベント型は3層に存在する:

  1. llm_client::event - プロバイダ正規化済みイベント
  2. timeline::event - Timeline内部イベントllm_client::eventからのFrom変換)
  3. crate::eventWorker層公開 - 外部公開イベント(timeline::eventからのFrom変換)

イベントモデル

前提:llm_client層は各プロバイダのストリーミングレスポンスを正規化し、フラットなEvent列挙として出力する。Timeline層はそれを受け取り同一構造のtimeline::event::Eventに変換する。

// timeline::event::Event
pub enum Event {
    // Meta events
    Ping(PingEvent),
    Usage(UsageEvent),
    Status(StatusEvent),
    Error(ErrorEvent),

    // Block lifecycle events
    BlockStart(BlockStart),
    BlockDelta(BlockDelta),
    BlockStop(BlockStop),
    BlockAbort(BlockAbort),
}

メタイベント型

pub struct PingEvent {
    pub timestamp: Option<u64>,
}

pub struct UsageEvent {
    pub input_tokens: Option<u64>,
    pub output_tokens: Option<u64>,
    pub total_tokens: Option<u64>,
    pub cache_read_input_tokens: Option<u64>,
    pub cache_creation_input_tokens: Option<u64>,
}

pub struct StatusEvent {
    pub status: ResponseStatus,
}

pub enum ResponseStatus {
    Started,
    Completed,
    Cancelled,
    Failed,
}

pub struct ErrorEvent {
    pub code: Option<String>,
    pub message: String,
}

ブロックイベント型

pub enum BlockType {
    Text,
    Thinking,
    ToolUse,
    ToolResult,
}

pub struct BlockStart {
    pub index: usize,
    pub block_type: BlockType,
    pub metadata: BlockMetadata,
}

pub enum BlockMetadata {
    Text,
    Thinking,
    ToolUse { id: String, name: String },
    ToolResult { tool_use_id: String },
}

pub struct BlockDelta {
    pub index: usize,
    pub delta: DeltaContent,
}

pub enum DeltaContent {
    Text(String),
    Thinking(String),
    InputJson(String),
}

pub struct BlockStop {
    pub index: usize,
    pub block_type: BlockType,
    pub stop_reason: Option<StopReason>,
}

pub enum StopReason {
    EndTurn,
    MaxTokens,
    StopSequence,
    ToolUse,
}

pub struct BlockAbort {
    pub index: usize,
    pub block_type: BlockType,
    pub reason: String,
}

TimelineとKind/Handler

TimelineはEventストリームを受け取り、登録されたHandler<K: Kind>にディスパッチする。

Kindhandler.rs

Kindはイベント型のみを定義する。スコープはHandler側で定義するため、同じKindに対して異なるスコープを持つHandlerを登録できる。

pub trait Kind {
    type Event;
}

Handlerhandler.rs

HandlerはKindに対する処理を定義し、自身のスコープ型も決定する。

pub trait Handler<K: Kind> {
    type Scope: Default;

    fn on_event(&mut self, scope: &mut Self::Scope, event: &K::Event);
}
  • Kindによって受け取るイベント型が決定される
  • Handler::ScopeによってHandler固有のスコープ型が決定される
  • Meta系とBlock系を統一的に扱える

Meta系Kind

スコープ不要の単発イベント。登録時にスコープが即座に開始される:

pub struct UsageKind;
impl Kind for UsageKind {
    type Event = UsageEvent;
}

pub struct PingKind;
impl Kind for PingKind {
    type Event = PingEvent;
}

pub struct StatusKind;
impl Kind for StatusKind {
    type Event = StatusEvent;
}

pub struct ErrorKind;
impl Kind for ErrorKind {
    type Event = ErrorEvent;
}

Block系Kind

ライフサイクルStart/Delta/Stopを持つ。スコープはHandler側で定義。Block系は3種定義されている:

TextBlockKind

pub struct TextBlockKind;
impl Kind for TextBlockKind {
    type Event = TextBlockEvent;
}

pub enum TextBlockEvent {
    Start(TextBlockStart),
    Delta(String),
    Stop(TextBlockStop),
}

pub struct TextBlockStart {
    pub index: usize,
}

pub struct TextBlockStop {
    pub index: usize,
    pub stop_reason: Option<StopReason>,
}

ThinkingBlockKind

pub struct ThinkingBlockKind;
impl Kind for ThinkingBlockKind {
    type Event = ThinkingBlockEvent;
}

pub enum ThinkingBlockEvent {
    Start(ThinkingBlockStart),
    Delta(String),
    Stop(ThinkingBlockStop),
}

pub struct ThinkingBlockStart {
    pub index: usize,
}

pub struct ThinkingBlockStop {
    pub index: usize,
}

ToolUseBlockKind

pub struct ToolUseBlockKind;
impl Kind for ToolUseBlockKind {
    type Event = ToolUseBlockEvent;
}

pub enum ToolUseBlockEvent {
    Start(ToolUseBlockStart),
    InputJsonDelta(String),
    Stop(ToolUseBlockStop),
}

pub struct ToolUseBlockStart {
    pub index: usize,
    pub id: String,
    pub name: String,
}

pub struct ToolUseBlockStop {
    pub index: usize,
    pub id: String,
    pub name: String,
}

使用例

// 使用例1: デルタを即時出力(スコープ不要)
struct PrintHandler;
impl Handler<TextBlockKind> for PrintHandler {
    type Scope = ();

    fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
        if let TextBlockEvent::Delta(s) = event {
            print!("{}", s);
        }
    }
}

// 使用例2: テキストを蓄積して収集Stringをスコープとして利用
struct TextCollector { results: Vec<String> }
impl Handler<TextBlockKind> for TextCollector {
    type Scope = String;

    fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
        match event {
            TextBlockEvent::Start(_) => {}
            TextBlockEvent::Delta(s) => buffer.push_str(s),
            TextBlockEvent::Stop(_) => {
                self.results.push(std::mem::take(buffer));
            }
        }
    }
}

Timelineの責務

  1. Eventストリームを受信
  2. Meta系イベントを即座に該当Handlerへディスパッチ
  3. Block系イベントBlockStart/Delta/Stop/AbortをBlockKindごとのライフサイクルイベントに変換
  4. 各Handlerごとのスコープの生成・管理BlockStart時に生成、BlockStop/Abort時に破棄
  5. 登録されたHandlerへの登録順ディスパッチ
  6. 暗黙的なスコープ開始BlockStartなしにDeltaが到達した場合の対応

Handlerの型消去timeline.rs

Meta系: ErasedHandler<K>

各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要。Send + Sync境界付き:

pub trait ErasedHandler<K: Kind>: Send + Sync {
    fn dispatch(&mut self, event: &K::Event);
    fn start_scope(&mut self);
    fn end_scope(&mut self);
}

pub struct HandlerWrapper<H, K>
where
    H: Handler<K>,
    K: Kind,
{
    handler: H,
    scope: Option<H::Scope>,
    _kind: PhantomData<fn() -> K>,  // Send+Sync安全なPhantomData
}

Meta系Handlerは登録時にstart_scope()が呼ばれ、スコープが常にアクティブな状態で保持される。

Block系: ErasedBlockHandler

Block系はBlockStart/BlockDelta/BlockStop/BlockAbortの4メソッドに分離されたtrait:

trait ErasedBlockHandler: Send + Sync {
    fn dispatch_start(&mut self, start: &BlockStart);
    fn dispatch_delta(&mut self, delta: &BlockDelta);
    fn dispatch_stop(&mut self, stop: &BlockStop);
    fn dispatch_abort(&mut self, abort: &BlockAbort);
    fn start_scope(&mut self);
    fn end_scope(&mut self);
    fn has_scope(&self) -> bool;
}

各BlockKindに対し専用のラッパー構造体が実装されている:

  • TextBlockHandlerWrapper<H> - DeltaContent::Textのみをフィルタしてディスパッチ
  • ThinkingBlockHandlerWrapper<H> - DeltaContent::Thinkingのみをフィルタしてディスパッチ
  • ToolUseBlockHandlerWrapper<H> - DeltaContent::InputJsonをフィルタし、BlockMetadata::ToolUseからid/nameを追跡。Stopイベントにもid/nameを含めてディスパッチ

Timeline構造体

pub struct Timeline {
    // Meta系ハンドラー
    usage_handlers: Vec<Box<dyn ErasedHandler<UsageKind>>>,
    ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
    status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
    error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,

    // Block系ハンドラーBlockTypeごとにグループ化
    text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
    thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
    tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,

    // 現在アクティブなブロック
    current_block: Option<BlockType>,
}

ハンドラ登録メソッド

メソッド Kind 備考
on_usage() UsageKind Meta系登録時にスコープ開始
on_ping() PingKind Meta系
on_status() StatusKind Meta系
on_error() ErrorKind Meta系
on_text_block() TextBlockKind Block系
on_thinking_block() ThinkingBlockKind Block系
on_tool_use_block() ToolUseBlockKind Block系

ディスパッチ処理

impl Timeline {
    pub fn dispatch(&mut self, event: &Event) {
        match event {
            // Meta系: 即時ディスパッチ(登録順)
            Event::Usage(u) => self.dispatch_usage(u),
            Event::Ping(p) => self.dispatch_ping(p),
            Event::Status(s) => self.dispatch_status(s),
            Event::Error(e) => self.dispatch_error(e),

            // Block系: スコープ管理しながらディスパッチ
            Event::BlockStart(s) => self.handle_block_start(s),
            Event::BlockDelta(d) => self.handle_block_delta(d),
            Event::BlockStop(s) => self.handle_block_stop(s),
            Event::BlockAbort(a) => self.handle_block_abort(a),
        }
    }
}

Block系のディスパッチフロー:

  • BlockStart: current_blockを設定 → 該当BlockTypeのハンドラに対しstart_scope()dispatch_start()
  • BlockDelta: current_block未設定時は暗黙的に設定。スコープ未開始のハンドラには暗黙的にstart_scope()を呼ぶ → dispatch_delta()
  • BlockStop: dispatch_stop()end_scope()current_blockをクリア
  • BlockAbort: dispatch_abort()end_scope()current_blockをクリア

BlockType::ToolResulttext_block_handlersにルーティングされる(テキストとして扱う)。

ブロック中断

pub fn abort_current_block(&mut self)

キャンセルやエラー時に呼び出し、進行中のブロックに対してBlockAbortイベントを発火してスコープをクリーンアップする。

組み込みHandler

TextBlockCollector

テキストブロックを収集する組み込みHandler。Arc<Mutex<Vec<String>>>を内部に持ち、Clone可能。

pub struct TextBlockCollector {
    collected: Arc<Mutex<Vec<String>>>,
}

impl Handler<TextBlockKind> for TextBlockCollector {
    type Scope = TextCollectorState;  // buffer: String を保持
    // Start: バッファクリア
    // Delta: バッファに追記
    // Stop: バッファの内容をcollectedに確定
}

主なメソッド: take_collected(), collected(), has_content(), clear()

ToolCallCollector

ToolUseブロックを収集する組み込みHandler。完了したツール呼び出しをToolCallとして収集する。

pub struct ToolCallCollector {
    collected: Arc<Mutex<Vec<ToolCall>>>,
}

impl Handler<ToolUseBlockKind> for ToolCallCollector {
    type Scope = CollectorState;  // current_id, current_name, input_json_buffer を保持
    // Start: id/nameを記録、バッファクリア
    // InputJsonDelta: JSONバッファに追記
    // Stop: バッファをパースしToolCallを確定
}

主なメソッド: take_collected(), collected(), has_pending_calls(), clear()

Workerは内部でTextBlockCollectorToolCallCollectorを自動的にTimelineに登録して使用する。

期待効果

  • 統一インターフェース: Meta系もBlock系もHandler<K: Kind>で統一
  • 型安全: Kindによってイベント型がコンパイル時に決定、Handler::ScopeによってHandler固有のスコープ型が決定
  • スコープの柔軟性: 同一Kindに対して異なるScopeを持つ複数Handlerを登録可能
  • 責務分離: llm_client層はフラットなEvent出力、Timeline層がブロック構造化、Worker層が公開イベントへの変換
  • スコープ管理の自動化: Handlerは自前でスコープ保持を意識せずに済む
  • 暗黙的スコープ開始: BlockStartを送らないプロバイダOpenAI等にも対応
  • Send + Sync: 全ハンドラがSend + Sync境界を持ち、非同期コンテキストで安全に使用可能