跳至主要内容

停止生成

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 的兩條規則

  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;

  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 已經處理好整段流程,不需要接任何線。下面的規則只有在你用 renderFooter 換掉 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>
);
}

狀態旗標

旗標意義
isConnectingchannel 正忙——四種原因之一,不等於「可停止」
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。

也看看