DeepSeek Harness: StreamChunk プロトコルとエラー処理

最終更新:2026-08-31

ストリーミング出力は Agent インタラクション体験の核心——ユーザーは30秒待って完全な返答を得るのではなく、Agent が一語一語考えるのを見たいものです。StreamChunk プロトコルは DSH のストリーミング出力の統一抽象化であり、エラー処理は例外発生時のグレースフルデグラデーションを保証します。

💡 ヒント:ストリーミング出力の難しさは「送る」ことではなく「エラー発生時にどうするか」にあります。StreamChunk プロトコルはエラーを1つのチャンク型として扱い、コンシューマが正常・異常ケースを統一的に処理できるようにします。

📋 前提知識24-llm-adapter.md の完了、LLM アダプタを理解していること

1. 学習内容

StreamChunk シーケンス


2. StreamChunk プロトコル詳細

▶ サンプル 1:

TYPESCRIPT
type StreamChunk =
  | TextChunk
  | ToolCallChunk
  | ToolResultChunk
  | ErrorChunk
  | DoneChunk

interface TextChunk {
  type: 'text'
  content: string
}

interface ToolCallChunk {
  type: 'tool_call'
  id: string
  name: string
  arguments: string
  index: number
}

interface ToolResultChunk {
  type: 'tool_result'
  id: string
  toolCallId: string
  result: any
  isError: boolean
}

interface ErrorChunk {
  type: 'error'
  error: Error
  recoverable: boolean
  retryAfter?: number
}

interface DoneChunk {
  type: 'done'
  reason: 'stop' | 'tool_use' | 'length' | 'cancel' | 'error'
  usage?: TokenUsage
}

interface TokenUsage {
  promptTokens: number
  completionTokens: number
  totalTokens: number
}

▶ サンプル 2:

100%
graph LR
    START[ストリーム開始] --> TEXT[TextChunk × N]
    TEXT --> TC[ToolCallChunk]
    TC --> TR[ToolResultChunk]
    TR --> TEXT2[TextChunk × N]
    TEXT2 --> DONE[DoneChunk]

典型的な Agent 会話フロー:

  1. LLM がテキストを出力 → TextChunk
  2. LLM がツール呼び出しを決定 → ToolCallChunk
  3. ツール実行が完了 → ToolResultChunk
  4. LLM が出力を継続 → TextChunk
  5. ストリーム終了 → DoneChunk

▶ サンプル 3

TYPESCRIPT
const stream = ctx.llm.stream({
  messages: [{ role: 'user', content: 'list project files' }],
  tools: availableTools
})

for await (const chunk of stream) {
  switch (chunk.type) {
    case 'text':
      process.stdout.write(chunk.content)
      break

    case 'tool_call':
      console.log(`\n🔧 Tool call: ${chunk.name}`)
      console.log(`   Arguments: ${chunk.arguments}`)
      break

    case 'tool_result':
      if (chunk.isError) {
        console.log(`   ❌ Tool error: ${chunk.result}`)
      } else {
        console.log(`   ✅ Tool result: ${JSON.stringify(chunk.result)}`)
      }
      break

    case 'error':
      console.error(`\n⚠️ Error: ${chunk.error.message}`)
      if (chunk.recoverable) {
        console.log(`   Will retry in ${chunk.retryAfter}ms`)
      }
      break

    case 'done':
      console.log(`\n✅ Done (${chunk.reason})`)
      if (chunk.usage) {
        console.log(`   Token usage: ${chunk.usage.totalTokens}`)
      }
      break
  }
}

3. チャンク型の詳細

(1) TextChunk

テキストコンテンツ断片、インクリメンタルに出力:

TYPESCRIPT
// LLM が "Hello, world!" を出力
// 複数の TextChunk に分割される可能性:
// chunk 1:{ type:'text', content:'Hello' }
// chunk 2:{ type:'text', content:', ' }
// chunk 3:{ type:'text', content:'world' }
// chunk 4:{ type:'text', content:'!' }

コンシューマはすべての TextChunk を連結すべきで、個別に表示すべきではありません。

(2) ToolCallChunk

LLM がツール呼び出しをリクエスト:

TYPESCRIPT
{
  type: 'tool_call',
  id: 'call_abc123',
  name: 'file_edit',
  arguments: '{"action":"read","path":"src/index.ts"}',
  index: 0
}

注:arguments は JSON 文字列でパースが必要。

(3) ToolResultChunk

ツール実行後の結果:

TYPESCRIPT
{
  type: 'tool_result',
  id: 'result_xyz789',
  toolCallId: 'call_abc123',
  result: { content: 'export const name = ...' },
  isError: false
}

isError: true はツール実行失敗を示す;result にはエラー情報が含まれます。

(4) 複数ツール呼び出し

1回の会話で複数のツールが呼び出される場合:

TEXT 📖 参照専用
TextChunk:"2つのファイルを確認します"
ToolCallChunk:{ name:"file_edit", id:"call_1", index:0 }
ToolCallChunk:{ name:"file_edit", id:"call_2", index:1 }
ToolResultChunk:{ toolCallId:"call_1", result:... }
ToolResultChunk:{ toolCallId:"call_2", result:... }
TextChunk:"上記2ファイルの内容..."
DoneChunk:{ reason:"stop" }

4. エラーリカバリ機構

(1) エラー分類

TYPESCRIPT
// リカバリ不可エラー(例:無効な API キー)
{
  type: 'error',
  error: new Error('Invalid API key'),
  recoverable: false
}

// リカバリ可能エラー(例:レート制限)
{
  type: 'error',
  error: new Error('Rate limit exceeded'),
  recoverable: true,
  retryAfter: 5000
}

// リカバリ可能エラー(例:ネットワークジッタ)
{
  type: 'error',
  error: new Error('Connection timeout'),
  recoverable: true,
  retryAfter: 2000
}

(2) 自動リカバリフロー

100%
graph TD
    ERR[ErrorChunk] --> CHECK{recoverable?}
    CHECK -->|No| FAIL[ストリーム終了 + DoneChunk<br/>reason:error]
    CHECK -->|Yes| WAIT[retryAfter 待機]
    WAIT --> RETRY[リクエスト再試行]
    RETRY --> SUCCESS{成功?}
    SUCCESS -->|Yes| CONTINUE[ストリーム継続]
    SUCCESS -->|No| ERR2[別の ErrorChunk]
    ERR2 --> CHECK2{リトライ回数枯渇?}
    CHECK2 -->|No| WAIT
    CHECK2 -->|Yes| FAIL

(3) 手動リカバリ

TYPESCRIPT
for await (const chunk of stream) {
  if (chunk.type === 'error') {
    if (chunk.recoverable) {
      ctx.logger.warn(`Stream error, recoverable: ${chunk.error.message}`)
      // フレームワークが自動リトライ
    } else {
      ctx.logger.error(`Stream error, non-recoverable: ${chunk.error.message}`)
      break
    }
  }
}

(4) ツールエラー処理

ツール実行が失敗した場合、フレームワークはエラーを ToolResultChunk として LLM に返します:

TYPESCRIPT
{
  type: 'tool_result',
  id: 'result_1',
  toolCallId: 'call_1',
  result: { error: true, message: 'Permission denied: /etc/passwd' },
  isError: true
}

LLM はその後、以下を選択できます:


5. 中断とキャンセル

(1) ユーザーキャンセル

ユーザーが Web UI の「停止」ボタンをクリックすると、ストリームがキャンセル:

TYPESCRIPT
const controller = new AbortController()

const stream = ctx.llm.stream(request, { signal: controller.signal })

// ユーザーがキャンセル
controller.abort()

// ストリームが DoneChunk を生成
// { type:'done', reason:'cancel' }

(2) タイムアウトキャンセル

TYPESCRIPT
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 60000)

try {
  for await (const chunk of ctx.llm.stream(request, { signal: controller.signal })) {
    // チャンクを処理
  }
} finally {
  clearTimeout(timer)
}

(3) 条件付きキャンセル

TYPESCRIPT
let tokenCount = 0

for await (const chunk of stream) {
  if (chunk.type === 'text') {
    tokenCount += chunk.content.length
    if (tokenCount > 10000) {
      controller.abort()
      break
    }
  }
}

(4) キャンセル時のクリーンアップ

キャンセル後、フレームワークは以下を保証:


6. リトライ戦略

(1) 組み込みリトライ戦略

エラータイプ リトライ回数 バックオフ戦略
レート制限 (429) 3 指数バックオフ
ネットワークタイムアウト 2 固定間隔
サーバーエラー (5xx) 2 指数バックオフ
認証エラー (401) 0 リトライなし
リクエストエラー (400) 0 リトライなし

(2) リトライの設定

TYPESCRIPT
export const Config = Schema.object({
  maxRetries: Schema.number().default(3).description('最大リトライ回数'),
  retryDelay: Schema.number().default(1000).description('初期リトライ遅延(ms)'),
  retryMultiplier: Schema.number().default(2).description('指数バックオフの遅延乗数')
})

(3) カスタムリトライロジック

TYPESCRIPT
async function streamWithRetry(
  ctx: Context,
  request: LLMRequest,
  maxRetries = 3
): Promise<void> {
  let attempt = 0
  
  while (attempt <= maxRetries) {
    try {
      for await (const chunk of ctx.llm.stream(request)) {
        if (chunk.type === 'error' && chunk.recoverable) {
          attempt++
          const delay = Math.min(1000 * Math.pow(2, attempt), 30000)
          ctx.logger.warn(`retry ${attempt}/${maxRetries} in ${delay}ms`)
          await sleep(delay)
          break
        }
      }
      return
    } catch (error) {
      attempt++
      if (attempt > maxRetries) throw error
    }
  }
}

7. ロギングとオブザーバビリティ

(1) ストリームロギング

TYPESCRIPT
ctx.on('llm/stream/start', (request) => {
  ctx.logger.info(`stream started:model=${request.model}`)
})

ctx.on('llm/stream/chunk', (chunk) => {
  ctx.logger.debug(`chunk:type=${chunk.type}`)
})

ctx.on('llm/stream/end', (done) => {
  ctx.logger.info(`stream ended:reason=${done.reason}, tokens=${done.usage?.totalTokens}`)
})

(2) パフォーマンスメトリクス

TYPESCRIPT
interface StreamMetrics {
  ttfb: number
  totalDuration: number
  chunkCount: number
  toolCallCount: number
  retryCount: number
  tokenUsage: TokenUsage
}

(3) 構造化ロギング

TYPESCRIPT
ctx.logger.info('llm_request', {
  model: request.model,
  messageCount: request.messages.length,
  toolCount: request.tools?.length || 0,
  stream: true
})

ctx.logger.info('llm_response', {
  model: response.model,
  duration: Date.now() - startTime,
  tokens: response.usage?.totalTokens
})

❓ よくある質問

Q ストリームキャンセル後、既に呼び出されたツールはロールバックされますか?
A いいえ。ツール実行は即座に有効(例:ファイルが既に作成されている)。キャンセルは後続の LLM 出力のみを停止します。
Q ErrorChunk の後、ストリームは常に終了しますか?
A 必ずしもそうではありません。エラーがリカバリ可能な場合、フレームワークはリトライし、ストリームは継続する可能性があります。リカバリ不可エラーやリトライ枯渇のみが DoneChunk を生成してストリームを終了します。
Q ストリーミング出力の TTFB の計算方法は?
A 最初のチャンクが到着した時刻を記録:typescript const start = Date.now() for await (const chunk of stream) { if (chunk.type === 'text') { const ttfb = Date.now() - start ctx.logger.info(`TTFB:${ttfb}ms`) break } }
Q 複数の ToolCallChunk の index の用途は?
A index は並列ツール呼び出しの順序を識別します。1つの LLM レスポンスに複数の tool_calls が含まれる場合、index は0から増分します。
Q ストリーミングと完全出力を同時にサポートできますか?
A はい。アダプタは complete()stream() の両方を実装します。呼び出し側が必要に応じて選択します。
Q テスト用にストリーミング出力をモックするには?
A typescript async function* mockStream():AsyncIterable<StreamChunk> { yield { type:'text', content:'Hello' } yield { type:'text', content:', world!' } yield { type:'done', reason:'stop' } }

📖 まとめ


📝 練習問題

1. ⭐ 基礎:StreamChunk ストリームを反復するコンシューマを書き、text/tool_call/tool_result/error/done チャンクをそれぞれカウントしてください。モックストリームでテスト。

2. ⭐⭐ 応用:タイムアウト付きストリームコンシューマを実装してください——30秒以内にチャンクが到着しない場合、自動的にストリームをキャンセル。AbortController を使用してキャンセル。

3. ⭐⭐⭐ チャレンジctx.llm.stream() のカスタムリトライラッパーを実装し、リカバリ可能エラーで自動リトライ(指数バックオフ、最大3回)してください。各リトライの遅延と結果をログ出力。失敗と成功を交互に返すモック LLM アダプタでテスト。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%