串流讓你的代理在回覆產生時即時顯示內容,效果類似 ChatGPT 或 Claude 的即時輸入。使用者不需要先等待一段長時間再看到一段完整文字,而是會逐字看到訊息被逐步填滿。
為什麼要用串流
不使用串流時,使用者在叫用你的機器人後會看到:
- 空白 3–10 秒(LLM 生成中)
- 一次性跳出完整訊息
有了串流後,使用者會看到:
- 立刻出現一則訊息,內容是「…」
- 文字隨模型生成逐步流入
- 最終訊息乾淨完整
這會明顯提升主觀回應速度,尤其是較長的 AI 生成內容。
三步驟協定
discli 的串流由三個 action 透過 stdin 傳送給 discli serve:
stream_start
在頻道建立一則占位訊息,回傳可被後續 chunk 使用的 stream_id。
→ {"action": "stream_start", "channel_id": "444555666", "reply_to": "101010", "req_id": "s1"}← {"event": "response", "req_id": "s1", "stream_id": "a1b2c3d4", "message_id": "131415"}| 欄位 | 是否必填 | 說明 |
|---|---|---|
channel_id | 是 | 要發送串流的頻道 |
reply_to | 否 | 要回覆的訊息 ID |
interaction_token | 否 | 回覆 Slash 指令互動時使用 |
req_id | 建議 | 用來接收 stream_id |
stream_chunk
將文字附加到串流緩衝區。discli 會每 1.5 秒自動更新一次占位訊息(編輯)將緩衝內容同步到 Discord。
→ {"action": "stream_chunk", "stream_id": "a1b2c3d4", "content": "Hello, "}→ {"action": "stream_chunk", "stream_id": "a1b2c3d4", "content": "I can "}→ {"action": "stream_chunk", "stream_id": "a1b2c3d4", "content": "help with that!"}| 欄位 | 是否必填 | 說明 |
|---|---|---|
stream_id | 是 | 來自 stream_start 回傳值 |
content | 是 | 要附加到緩衝區的文字 |
stream_end
發送最後一次編輯並清理串流狀態。
→ {"action": "stream_end", "stream_id": "a1b2c3d4", "req_id": "s2"}← {"event": "response", "req_id": "s2", "ok": true, "message_id": "131415"}| 欄位 | 是否必填 | 說明 |
|---|---|---|
stream_id | 是 | 來自 stream_start 回傳值 |
req_id | 否 | 用來確認完成 |
1.5 秒更新間隔
Discord 對訊息編輯有速率限制。discli 用 1.5 秒刷新間隔來避免超過限制:
- 送出
stream_chunk時,先寫入記憶體緩衝區 - 背景任務每 1.5 秒用目前緩衝區內容編輯 Discord 訊息
- 只有內容有變動時才編輯(不做重複 API 呼叫)
stream_end會用完整內容再做最後一次編輯
因此使用者大約每隔 1.5 秒看到一次更新,既有連續感,又不會踩到速率限制。
Time: 0s 1.5s 3.0s 4.5s 5.0s │ │ │ │ │Chunks: Hello, I can help with that!Edits: "..." "Hello, I can " "Hello, I can help with " (stream_end) "Hello, I can help with that!"完整可執行範例
這個範例連接到 OpenAI 相容 API,並將回覆以串流方式回傳到 Discord。
import jsonimport subprocessimport threadingimport timefrom queue import Queue
class StreamingAgent: def __init__(self): self.proc = subprocess.Popen( ["discli", "serve", "--events", "messages"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, ) self.events = Queue() self.pending = {} self._counter = 0
threading.Thread(target=self._reader, daemon=True).start()
def _reader(self): for line in self.proc.stdout: data = json.loads(line.strip()) rid = data.get("req_id") if rid in self.pending: self.pending[rid].put(data) else: self.events.put(data)
def send(self, action, wait=False): self._counter += 1 rid = f"r{self._counter}" action["req_id"] = rid if wait: q = Queue() self.pending[rid] = q self.proc.stdin.write(json.dumps(action) + "\n") self.proc.stdin.flush() if wait: result = q.get(timeout=30) del self.pending[rid] return result return None
def stream_to_discord(self, channel_id, reply_to, text_generator): """將生成器輸出的文字串流到 Discord 訊息。"""
# 步驟一:啟動串流 self.send({"action": "typing_start", "channel_id": channel_id}) result = self.send({ "action": "stream_start", "channel_id": channel_id, "reply_to": reply_to, }, wait=True) self.send({"action": "typing_stop", "channel_id": channel_id})
if "error" in result: # 備援:若串流失敗,將內容整段收集後直接發送 full_text = "".join(text_generator) self.send({ "action": "reply", "channel_id": channel_id, "message_id": reply_to, "content": full_text, }) return
stream_id = result["stream_id"]
# 步驟二:逐片段寫入 try: for chunk in text_generator: self.send({ "action": "stream_chunk", "stream_id": stream_id, "content": chunk, }) except Exception as e: # 以錯誤訊息作為最後片段 self.send({ "action": "stream_chunk", "stream_id": stream_id, "content": f"\n\n[Error: {e}]", })
# 步驟三:結束 self.send({"action": "stream_end", "stream_id": stream_id}, wait=True)
def call_llm(prompt): """呼叫 OpenAI 相容 API,並逐段回傳回覆內容。""" import openai
client = openai.OpenAI() # 使用 OPENAI_API_KEY 環境變數 stream = client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": prompt}], stream=True, ) for chunk in stream: delta = chunk.choices[0].delta.content if delta: yield delta
def main(): agent = StreamingAgent()
# 等待 ready while True: event = agent.events.get() if event.get("event") == "ready": print(f"Ready as {event['bot_name']}") break
# 處理訊息 while True: event = agent.events.get() if event.get("event") != "message": continue if not event.get("mentions_bot") or event.get("is_bot"): continue
agent.stream_to_discord( channel_id=event["channel_id"], reply_to=event["message_id"], text_generator=call_llm(event["content"]), )
if __name__ == "__main__": main()Slash 指令中的串流
使用者叫用 Slash 指令時,互動會先顯示「思考中」。你可以用 interaction_token 進行串流回應:
def handle_slash_command(agent, event): stream_result = agent.send({ "action": "stream_start", "channel_id": event["channel_id"], "interaction_token": event["interaction_token"], }, wait=True)
stream_id = stream_result["stream_id"]
for chunk in call_llm(event["args"].get("question", "")): agent.send({ "action": "stream_chunk", "stream_id": stream_id, "content": chunk, })
agent.send({"action": "stream_end", "stream_id": stream_id})互動 token 會在 15 分鐘後過期。若 LLM 運行超過 15 分鐘,使用 interaction_token 的 stream_start 會失敗。對於長時間任務,請改用只帶 channel_id 的一般 stream_start,並額外送出 followup 先回覆 Slash 指令收到。
處理長回應
Discord 訊息上限為 2000 字元。discli 會自動處理溢位:
- 串流時會將緩衝區編輯內容截斷為 2000 字元
stream_end時,如果最終內容超過 2000 字元,discli 會將前 2000 字元留在原訊息中,並以 followup 訊息傳送剩餘內容
代理程式不需要自己處理這些邊界。
邊界情況
如果沒有送出 stream_end 會怎麼樣?
stream 狀態會保留在記憶體中。flush 迴圈仍持續運作,但若沒有新 chunk,最後一次 flush 之後就不會再有編輯;訊息維持到最後一次刷新時的狀態。
務必以 try/finally 包住串流邏輯,保證一定呼叫 stream_end:
stream_id = Nonetry: result = agent.send({"action": "stream_start", ...}, wait=True) stream_id = result.get("stream_id") for chunk in text_generator: agent.send({"action": "stream_chunk", "stream_id": stream_id, "content": chunk})finally: if stream_id: agent.send({"action": "stream_end", "stream_id": stream_id})如果串流太快?
不會有問題。chunk 先放入記憶體緩衝區。flush 迴圈每 1.5 秒只做一次編輯,因此大量快速到達的 chunk 會被合併為同一次編輯。stream_chunk 本身沒有速率限制。
如果是空串流?
若你呼叫 stream_start 後立刻 stream_end 而沒有 chunk,訊息會被編輯為「(empty response)」。
可以同時串流到多個頻道嗎?
可以。每次 stream_start 會回傳獨立 stream_id。可以同時有多個進行中串流,每個都有自己的緩衝區與 flush 迴圈。
若 Discord 編輯失敗呢?
discli 會靜默攔截編輯時的 HTTPException。若 Discord 暫時無法使用,下一輪 flush 會用最新的緩衝區內容重試,不會丟失任何 chunk。
時序考量
| 場景 | 建議 |
|---|---|
| LLM 生成速度快於 1.5 秒一輪 | 正常。chunk 會被合併,使用者感受為順暢更新。 |
| LLM 每 token 超過 1.5 秒才產生 | 更新會有間歇性跳躍,建議先收集幾個 token 再做一次 chunk。 |
| 很短的回應(少於 10 個 token) | 串流對效益幫助不大,建議直接用 reply。 |
| 很長回應(超過 2000 字元) | 由 discli 自動處理,溢位會轉為 followup 訊息。 |