跳轉到

SSE 串流

Server-Sent Events (SSE) 能夠即時串流 AI 回應,透過在文字生成時即時顯示,而非等待完整回應,提供更好的使用者體驗。

概述

當你以 stream: true 發送訊息時,API 會回傳一連串事件而非單一 JSON 回應。每個事件包含 Agent 處理和回應的增量資料。

啟用串流

要啟用串流,在訊息請求中設定 stream: true

curl -N -X POST "https://api.codeer.ai/api/v1/chats/12345/messages" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{
    "message": "你好!",
    "stream": true,
    "agent_id": "550e8400-e29b-41d4-a716-446655440000"
  }'

Tool Tag 可見性

  • include_tool_tags 的預設值為 false
  • 當省略或為 false 時,串流文字事件中的 <tool ...>...</tool> 區塊會被移除。
  • 需要原始 tool 區塊的內部用戶端必須明確傳 include_tool_tags=true

事件格式

事件遵循 SSE 規格:

event: <event_type>
data: <json_payload>

每個事件包含共通欄位:

欄位 說明
type 事件類型
response_id 此回應的唯一識別碼
chat_id 聊天會話 ID

事件類型

response.created

當 LLM 初始化完成並準備處理時發出。

{
  "type": "response.created",
  "response_id": "abc123",
  "chat_id": 12345,
  "agent_id": "550e8400-e29b-41d4-a716-446655440000",
  "model": "gpt-4",
  "conversation_group_id": "group_abc",
  "user_conversation_id": 1001
}

response.chat.title.updated

當聊天標題自動產生時發出(通常在第一則訊息後)。

{
  "type": "response.chat.title.updated",
  "response_id": "abc123",
  "chat_id": 12345,
  "name": "關於營業時間的問題"
}

response.reasoning_step.start

當 Agent 開始推理步驟時發出(例如:搜尋知識庫、呼叫工具)。

{
  "type": "response.reasoning_step.start",
  "response_id": "abc123",
  "chat_id": 12345,
  "step": {
    "id": "step_abc123",
    "content": "正在搜尋營業時間的知識庫",
    "tool_name": "retrieve_context_objs",
    "args": {
      "query": "營業時間"
    },
    "timestamp": "2024-01-15T10:30:00Z"
  }
}
tool_name 說明
search_web 搜尋網頁
fetch_web_content 從 URL 取得內容
retrieve_context_objs 選出相關知識物件
get_context_obj_lines 讀取知識物件中的相關內容
call_agent 呼叫其他 Agent
request_form 請求表單輸入
memory 寫入使用者記憶
http_request 呼叫已設定的 HTTP 端點
lookup_history / lookup_attachments 查詢歷史記錄或附件
generate_image 產生圖片

response.reasoning_step.end

當推理步驟完成時發出。

{
  "type": "response.reasoning_step.end",
  "response_id": "abc123",
  "chat_id": 12345,
  "step": {
    "id": "step_abc123",
    "tool_name": "retrieve_context_objs",
    "result": {
      "success": true,
      "data": "找到 3 個相關文件..."
    },
    "timestamp": "2024-01-15T10:30:01Z",
    "token_usage": {
      "total_prompt_tokens": 150,
      "total_completion_tokens": 45,
      "total_tokens": 195,
      "total_calls": 1
    }
  }
}

response.interaction_request

當 Agent 需要用戶端先完成互動時發出。最常見的情況是開啟表單請求。

{
  "type": "response.interaction_request",
  "response_id": "abc123",
  "chat_id": 12345,
  "history_id": 12345,
  "conversation_group_id": "group_abc",
  "form_request_id": "form-uuid-here",
  "form_schema": {
    "title": "聯絡表單",
    "fields": [
      {
        "name": "email",
        "label": "Email",
        "type": "text"
      }
    ]
  }
}

收到此事件後,用戶端應先顯示對應互動畫面,提交或拒絕該請求,再視需求繼續對話。

response.output_text.delta

每個生成的文字片段都會發出。將這些串接起來以建立完整回應。

{
  "type": "response.output_text.delta",
  "response_id": "abc123",
  "chat_id": 12345,
  "delta": "我們的營業時間是"
}

include_tool_tags=false 時,只包含 <tool ...>...</tool> 的片段會被移除,且不會送出空的 delta 事件。

response.output_text.completed

當回應完全生成時發出。包含完整文字和 token 使用量。

{
  "type": "response.output_text.completed",
  "response_id": "abc123",
  "chat_id": 12345,
  "final_text": "我們的營業時間是週一至週五,上午 9 點到下午 6 點。",
  "usage": {
    "total_prompt_tokens": 250,
    "total_completion_tokens": 85,
    "total_tokens": 335,
    "total_calls": 1
  }
}

include_tool_tags=false 時,final_text 會先移除 <tool ...>...</tool> 區塊後再回傳。

response.error

當處理過程中發生錯誤時發出。

{
  "type": "response.error",
  "response_id": "abc123",
  "chat_id": 12345,
  "message": "處理請求失敗",
  "code": 10005
}

錯誤代碼值

code 欄位包含錯誤代碼(不是 HTTP 狀態碼)。常見的值:

  • 10005 (SYS_SERVER_ERROR) - 內部伺服器錯誤
  • 10006 (SYS_BAD_REQUEST) - 無效的請求

完整清單請參閱錯誤代碼

response.interaction_request

當 assistant run 被刻意中斷且需要使用者互動時發出。

此事件使用 interaction_type 的可辨識聯集(discriminated union):

  • form:收集結構化輸入,並透過 resume_form_request_id 繼續。
  • payment:啟動外部結帳並追蹤非同步交易狀態。

表單互動範例

{
  "type": "response.interaction_request",
  "response_id": "abc123",
  "chat_id": 12345,
  "interaction_type": "form",
  "conversation_group_id": "cvg-xxx",
  "form_request_id": "3c8e...",
  "form_schema": {
    "id": "contact_form",
    "title": "Contact Info",
    "fields": [
      {
        "name": "email",
        "label": "Email",
        "type": "shortText",
        "required": true
      }
    ]
  }
}

付款互動範例

{
  "type": "response.interaction_request",
  "response_id": "abc123",
  "chat_id": 12345,
  "interaction_type": "payment",
  "conversation_group_id": "cvg-xxx",
  "payment": {
    "payment_request_id": "23db...",
    "checkout_url": "https://api.example.com/api/v1/payments/checkout/<token>",
    "merchant_order_no": "CDR202603...",
    "amount_twd": 1200,
    "currency": "TWD",
    "status": "pending",
    "item_desc": "Consultation fee"
  }
}

付款狀態生命週期

付款互動狀態通常會經過以下階段:

  • pending:已建立請求、尚未開始結帳(可取消)。
  • processing:已開始結帳(例如已開啟結帳頁),不可再取消。
  • succeeded / failed:金流最終結果。
  • cancelled:使用者在 pending 階段取消。
  • expired:逾時未完成。

其他行為:

  • 只有目前狀態為 pending 才能執行取消。
  • 後端會透過週期性作業,將長時間停留在 pending/processing 的請求標記為 expired

為什麼 payment 是獨立 interaction type

payment 不是一般 form 的變體,主要原因:

  • 生命週期不同:涉及外部結帳 + webhook/polling,不是一次送出就完成。
  • payload 契約不同:需要 checkout_url、交易識別資訊與狀態欄位。
  • UI 行為不同:需要前往付款頁、刷新狀態、顯示交易終態。

我們仍維持同一個事件入口(interaction_request),並用 interaction_type 進行型別分流,保持編排一致性。

付款資料持久化行為

對於 interaction_type="payment",後端會把付款摘要持久化為對話記錄,至少包含:

  • 訂單編號(merchant_order_no
  • 標題(item_desc
  • 金額(amount_twd, currency
  • 狀態(pending/processing/succeeded/failed/cancelled/expired/...

後續 callback(例如金流 notify webhook)會更新同一筆摘要,讓重新整理歷史或繼續對話時,都能保留付款上下文。

串流結束

串流以特殊訊息結束:

data: [DONE]

務必處理此標記以正確關閉連線。

逾時處理

預設串流逾時為 180 秒。如果在此期間內沒有收到任何事件,伺服器會先送出 response.error,再關閉串流。對於長時間執行的操作,請確保你的用戶端能妥善處理逾時與重新連線。

用戶端實作

JavaScript(瀏覽器)

使用原生 fetch API 搭配串流:

const url = 'https://api.codeer.ai/api/v1/chats/12345/messages';

// EventSource 不支援 POST,使用 fetch 搭配串流
const response = await fetch(url, {
  method: 'POST',
  headers: {
    'x-api-key': 'YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    message: '你好!',
    stream: true,
    agent_id: 'your-agent-id'
  })
});

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split('\n');
  buffer = lines.pop() || '';

  for (const line of lines) {
    if (line.startsWith('data: ')) {
      const data = line.slice(6);
      if (data === '[DONE]') {
        console.log('串流完成');
        return;
      }
      try {
        const event = JSON.parse(data);
        handleEvent(event);
      } catch (e) {
        // 不是 JSON,可能是事件名稱行
      }
    }
  }
}

function handleEvent(event) {
  switch (event.type) {
    case 'response.output_text.delta':
      // 將文字附加到 UI
      document.getElementById('output').textContent += event.delta;
      break;
    case 'response.interaction_request':
      // 顯示表單或其他互動畫面
      openInteractionUi(event);
      break;
    case 'response.output_text.completed':
      console.log('最終回應:', event.final_text);
      break;
    case 'response.error':
      console.error('錯誤:', event.message);
      break;
  }
}

Python

使用 requests 函式庫:

import requests
import json

def stream_chat(chat_id: int, message: str, agent_id: str, api_key: str):
    url = f"https://api.codeer.ai/api/v1/chats/{chat_id}/messages"
    headers = {
        "x-api-key": api_key,
        "Content-Type": "application/json",
        "Accept": "text/event-stream"
    }
    payload = {
        "message": message,
        "stream": True,
        "agent_id": agent_id
    }

    with requests.post(url, headers=headers, json=payload, stream=True) as response:
        response.raise_for_status()

        for line in response.iter_lines():
            if not line:
                continue

            line = line.decode('utf-8')

            if line.startswith('data: '):
                data = line[6:]
                if data == '[DONE]':
                    print("\n串流完成")
                    break

                try:
                    event = json.loads(data)
                    handle_event(event)
                except json.JSONDecodeError:
                    pass

def handle_event(event: dict):
    event_type = event.get('type')

    if event_type == 'response.output_text.delta':
        print(event.get('delta', ''), end='', flush=True)
    elif event_type == 'response.interaction_request':
        print(f"\n需要互動處理: {event}")
    elif event_type == 'response.output_text.completed':
        print(f"\n\nToken 使用量: {event.get('usage')}")
    elif event_type == 'response.error':
        print(f"\n錯誤: {event.get('message')}")

# 使用方式
stream_chat(
    chat_id=12345,
    message="你們的營業時間是?",
    agent_id="your-agent-id",
    api_key="your-api-key"
)

Python(非同步)

使用 aiohttp

import aiohttp
import asyncio
import json

async def stream_chat_async(chat_id: int, message: str, agent_id: str, api_key: str):
    url = f"https://api.codeer.ai/api/v1/chats/{chat_id}/messages"
    headers = {
        "x-api-key": api_key,
        "Content-Type": "application/json",
        "Accept": "text/event-stream"
    }
    payload = {
        "message": message,
        "stream": True,
        "agent_id": agent_id
    }

    async with aiohttp.ClientSession() as session:
        async with session.post(url, headers=headers, json=payload) as response:
            async for line in response.content:
                line = line.decode('utf-8').strip()
                if not line:
                    continue

                if line.startswith('data: '):
                    data = line[6:]
                    if data == '[DONE]':
                        break

                    try:
                        event = json.loads(data)
                        if event.get('type') == 'response.output_text.delta':
                            print(event.get('delta', ''), end='', flush=True)
                    except json.JSONDecodeError:
                        pass

# 使用方式
asyncio.run(stream_chat_async(
    chat_id=12345,
    message="你好!",
    agent_id="your-agent-id",
    api_key="your-api-key"
))

最佳實踐

  1. 緩衝處理:SSE 資料可能以不符合事件邊界的片段到達。務必緩衝傳入資料並解析完整事件。

  2. 錯誤恢復:為網路故障實作重新連線邏輯。儲存最後收到的事件以便可能恢復處理。

  3. UI 更新:批次處理 UI 更新以避免過度重新渲染。考慮使用 requestAnimationFrame 來實現平滑的文字顯示。

  4. 清理:當用戶離開頁面或取消請求時,務必正確關閉連線。

  5. 逾時處理:實作符合或超過伺服器 180 秒逾時的用戶端逾時處理。

另請參閱