DeepSeek Harness: StreamChunk プロトコルとエラー処理
最終更新:2026-08-31
ストリーミング出力は Agent インタラクション体験の核心——ユーザーは30秒待って完全な返答を得るのではなく、Agent が一語一語考えるのを見たいものです。StreamChunk プロトコルは DSH のストリーミング出力の統一抽象化であり、エラー処理は例外発生時のグレースフルデグラデーションを保証します。
📋 前提知識:24-llm-adapter.md の完了、LLM アダプタを理解していること
1. 学習内容
- ストリーミング出力プロトコル:StreamChunk
- チャンク型:text/tool_call/tool_result
- エラーリカバリ機構
- 中断とキャンセル
- リトライ戦略
- ロギングとオブザーバビリティ
2. StreamChunk プロトコル詳細
▶ サンプル 1:
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:
graph LR
START[ストリーム開始] --> TEXT[TextChunk × N]
TEXT --> TC[ToolCallChunk]
TC --> TR[ToolResultChunk]
TR --> TEXT2[TextChunk × N]
TEXT2 --> DONE[DoneChunk]
典型的な Agent 会話フロー:
- LLM がテキストを出力 → TextChunk
- LLM がツール呼び出しを決定 → ToolCallChunk
- ツール実行が完了 → ToolResultChunk
- LLM が出力を継続 → TextChunk
- ストリーム終了 → DoneChunk
▶ サンプル 3
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
テキストコンテンツ断片、インクリメンタルに出力:
// 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 がツール呼び出しをリクエスト:
{
type: 'tool_call',
id: 'call_abc123',
name: 'file_edit',
arguments: '{"action":"read","path":"src/index.ts"}',
index: 0
}
注:arguments は JSON 文字列でパースが必要。
(3) ToolResultChunk
ツール実行後の結果:
{
type: 'tool_result',
id: 'result_xyz789',
toolCallId: 'call_abc123',
result: { content: 'export const name = ...' },
isError: false
}
isError: true はツール実行失敗を示す;result にはエラー情報が含まれます。
(4) 複数ツール呼び出し
1回の会話で複数のツールが呼び出される場合:
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) エラー分類
// リカバリ不可エラー(例:無効な 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) 自動リカバリフロー
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) 手動リカバリ
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 に返します:
{
type: 'tool_result',
id: 'result_1',
toolCallId: 'call_1',
result: { error: true, message: 'Permission denied: /etc/passwd' },
isError: true
}
LLM はその後、以下を選択できます:
- 別のパスでリトライ
- ユーザーに権限不足を通知
- 別の方法でタスクを完了
5. 中断とキャンセル
(1) ユーザーキャンセル
ユーザーが Web UI の「停止」ボタンをクリックすると、ストリームがキャンセル:
const controller = new AbortController()
const stream = ctx.llm.stream(request, { signal: controller.signal })
// ユーザーがキャンセル
controller.abort()
// ストリームが DoneChunk を生成
// { type:'done', reason:'cancel' }
(2) タイムアウトキャンセル
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) 条件付きキャンセル
let tokenCount = 0
for await (const chunk of stream) {
if (chunk.type === 'text') {
tokenCount += chunk.content.length
if (tokenCount > 10000) {
controller.abort()
break
}
}
}
(4) キャンセル時のクリーンアップ
キャンセル後、フレームワークは以下を保証:
- 新しいチャンクの受信を停止
- 既に受信したチャンクは正常に処理
DoneChunk { reason: 'cancel' }を生成- ネットワーク接続を解放
6. リトライ戦略
(1) 組み込みリトライ戦略
| エラータイプ | リトライ回数 | バックオフ戦略 |
|---|---|---|
| レート制限 (429) | 3 | 指数バックオフ |
| ネットワークタイムアウト | 2 | 固定間隔 |
| サーバーエラー (5xx) | 2 | 指数バックオフ |
| 認証エラー (401) | 0 | リトライなし |
| リクエストエラー (400) | 0 | リトライなし |
(2) リトライの設定
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) カスタムリトライロジック
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) ストリームロギング
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) パフォーマンスメトリクス
interface StreamMetrics {
ttfb: number
totalDuration: number
chunkCount: number
toolCallCount: number
retryCount: number
tokenUsage: TokenUsage
}
(3) 構造化ロギング
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
})
❓ よくある質問
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 } } complete() と stream() の両方を実装します。呼び出し側が必要に応じて選択します。typescript async function* mockStream():AsyncIterable<StreamChunk> { yield { type:'text', content:'Hello' } yield { type:'text', content:', world!' } yield { type:'done', reason:'stop' } } 📖 まとめ
- StreamChunk 5型:text、tool_call、tool_result、error、done
- ストリームライフサイクル:テキスト出力 → ツール呼び出し → ツール結果 → 出力継続 → 完了
- エラーはリカバリ可能とリカバリ不可に分類;リカバリ可能エラーは自動リトライ
- AbortController によるキャンセルは
DoneChunk { reason: 'cancel' }を生成 - リトライ戦略はエラータイプにより異なる;指数バックオフがデフォルト
- ライフサイクルイベントと構造化ロギングによるオブザーバビリティ
📝 練習問題
1. ⭐ 基礎:StreamChunk ストリームを反復するコンシューマを書き、text/tool_call/tool_result/error/done チャンクをそれぞれカウントしてください。モックストリームでテスト。
2. ⭐⭐ 応用:タイムアウト付きストリームコンシューマを実装してください——30秒以内にチャンクが到着しない場合、自動的にストリームをキャンセル。AbortController を使用してキャンセル。
3. ⭐⭐⭐ チャレンジ:ctx.llm.stream() のカスタムリトライラッパーを実装し、リカバリ可能エラーで自動リトライ(指数バックオフ、最大3回)してください。各リトライの遅延と結果をログ出力。失敗と成功を交互に返すモック LLM アダプタでテスト。