串流讓你的代理在回覆產生時即時顯示內容,效果類似 ChatGPT 或 Claude 的即時輸入。使用者不需要先等待一段長時間再看到一段完整文字,而是會逐字看到訊息被逐步填滿。

為什麼要用串流

不使用串流時,使用者在叫用你的機器人後會看到:

  1. 空白 3–10 秒(LLM 生成中)
  2. 一次性跳出完整訊息

有了串流後,使用者會看到:

  1. 立刻出現一則訊息,內容是「…」
  2. 文字隨模型生成逐步流入
  3. 最終訊息乾淨完整

這會明顯提升主觀回應速度,尤其是較長的 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 json
import subprocess
import threading
import time
from 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})
Warning

互動 token 會在 15 分鐘後過期。若 LLM 運行超過 15 分鐘,使用 interaction_tokenstream_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 = None
try:
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 訊息。

下一步