停止生成
run 是在伺服器端獨立執行的。這代表停止生成不是「切斷本地連線」——早期只呼叫
AbortController.abort() 的做法只是停止觀看,agent 其實還在跑、還在燒 token、還在寫對話紀錄,
而 UI 卻宣稱已經停了。
現在停止是送一個請求要後端暫停那個 run,而且非同步完成。
停止生成
run 是在伺服器端獨立執行的,所以「停止」不是切斷本地連線,而是送一個請求要後端暫停那個 run,並且是非同步完成的。stopGeneration() resolve 只代表請求被「受理」;真正停下來要等 SSE 串流走到終止事件。這兩個時刻之間,channel 處於 isStopping。
送出一則訊息,觀察輸入框上方的四個旗標。它們接的是真實 channel:run 期間 isConnecting 與 canStop 會亮起,送出鈕同時變成停止鈕。
注意:這個示範站用的公開 bot 是即時回覆的 echo bot,一則 run 幾乎瞬間就結束,所以實際上很難按到停止鈕——旗標往往一閃即逝。這頁能看的是「四個旗標怎麼接、分別代表什麼」;完整的停止生命週期請看下方說明。
用預設 footer 就有了
內建 footer 已經處理好整個流程,不需要接任何線。以下規則只有在你用 renderFooter 換掉 footer、或自己做送出入口時才需要在意。
自訂 footer 的兩條規則
- 每個送出入口都要同時擋 isStopping,不是只擋 isConnecting。舊的 run 還沒真的結束,這時送出會讓兩個 run 同時寫進同一份對話紀錄。使用者的草稿要保留,不要清掉。
- 只有 canStop 為真時才顯示停止控制。isConnecting 一詞四義——使用者自己的回合、RESET_CHANNEL 歡迎訊息、transcript 重新加入、隱形的 nudge——其中只有第一種可以停,canStop 精確地只表示那一種。
function CustomFooter() {
const {
sendMessage, isConnecting, isStopping,
canStop, canForceStop, stopGeneration,
} = useAsgardContext();
const [value, setValue] = useState('');
const canSend = !isConnecting && !isStopping && value.trim().length > 0;
if (canStop || isStopping) {
return (
<button
onClick={() => void stopGeneration?.({ force: canForceStop })
.catch(() => undefined)}
disabled={isStopping && !canForceStop}
>
{canForceStop ? '強制停止' : isStopping ? '停止中…' : '停止'}
</button>
);
}
return (
<button onClick={() => sendMessage?.({ text: value })} disabled={!canSend}>
送出
</button>
);
}對話會活過一次停止:紀錄保留、被中止的回合由後端回捲,下一則訊息延續同一段對話。
兩個時刻
stopGeneration() resolve 只代表請求被受理;run 真正停下來,是 SSE 串流走到終止事件的那一刻——
和一個正常結束的 run 走的是同一個事件。這兩個時刻之間,channel 處於 isStopping。
await stopGeneration(); // ← 受理(不是「已停止」)
// … isStopping === true …
// ← 串流上的終止事件抵達,此時才回到可送出狀態
用預設 footer 就有了
內建 footer 已經處理好整段流程,不需要接任何線。下面的規則只有在你用
renderFooter 換掉 footer、或自己做送出入口時才需要在意。
自訂 footer 的兩條規則
1. 每個送出入口都要同時擋 isStopping
不是只擋 isConnecting。舊的 run 還沒真的結束,這時送出會讓兩個 run 同時寫進同一份對話紀錄。
使用者的草稿要保留,不要清掉。
2. 只有 canStop 為真時才顯示停止控制
isConnecting 一詞四義——使用者自己的回合、RESET_CHANNEL 歡迎訊息、transcript 重新加入、
隱形的 nudge——其中只有第一種可以停。canStop 精確地只表示那一種。
function CustomFooter() {
const {
sendMessage, isConnecting, isStopping,
canStop, canForceStop, stopGeneration,
} = useAsgardContext();
const [value, setValue] = useState("");
const canSend = !isConnecting && !isStopping && value.trim().length > 0;
const onStop = (): void => {
// DOM handler 不能 reject。失敗時 SDK 會把狀態回捲成 idle,
// 控制項只是變回可按——想自己呈現錯誤就改成 await。
void stopGeneration?.({ force: canForceStop }).catch(() => undefined);
};
if (canStop || isStopping) {
return (
<button onClick={onStop} disabled={isStopping && !canForceStop}>
{canForceStop ? "強制停止" : isStopping ? "停止中…" : "停止"}
</button>
);
}
return (
<button onClick={() => sendMessage?.({ text: value })} disabled={!canSend}>
送出
</button>
);
}
狀態旗標
| 旗標 | 意義 |
|---|---|
isConnecting | channel 正忙——四種原因之一,不等於「可停止」 |
canStop | 目前是使用者發起的 run,可以停 |
isStopping | 停止已受理,終止事件尚未抵達 |
canForceStop | 受理後約 10 秒仍未收到終止事件,可升級為強制停止(隱含 isStopping) |
canForceStop 是給沒有回應的 agent 的退路,正常情況不該走到。再按一次會以 force: true
重呼端點,告訴後端放棄這個 run。
ChannelBusyError 防線
除了 UI 擋,core 的 sendMessage() 也會在 run 進行中直接 reject:
import { ChannelBusyError, isChannelBusyError } from "@asgard-js/core";
try {
await sendMessage({ text });
} catch (err) {
if (isChannelBusyError(err)) {
// 這則訊息沒有被送出,也沒有在對話裡留下任何痕跡
}
}
它在推出樂觀的 user 泡泡之前就拒絕,所以被擋下的訊息不會在對話串裡留下殘影。
對話會活過一次停止
紀錄保留、被中止的回合由後端回捲,下一則訊息延續同一段對話——不需要重建 channel。
也看看
- 自訂 Footer — 換掉整個 footer 時的注意事項
- 事件處理 — 連線與訊息事件
- Headless 模式 — 直接操作
Channel