跳至主要內容

文章

解法提供 | 想要發送多段訊息才一次回應,該怎麼做?

戴庭 @tyt0409發布於 已編輯

您好,我看到你的提問,覺得挺有趣的,就自己實作測試了一遍,來分享一下我的做法。

建議配合這張工作流截圖觀看文章

注意:此示範會使用到LINE Push,請注意您的免費額度

══════════════════════════════════════════════════════

這次拿我的第一隻 AI 小精靈—小貓程式員來示範
平常它講話就是這樣,你一句、它一句,句句有回應

為什麼會這樣?因為我們餵給它的流程是:webhook → AI Agent → LINE reply
它就照著走,你丟一句它跑一次完整 workflow、回一次
這不是 system prompt 的錯,是流程本身長這樣,你就算請 100 個專家來寫 prompt,它還是會一句一句回
當然,在改完後還是可以在user prompt或system prompt提一嘴,讓他知道這一堆都當成同一段話回應

那如果我想一段一段講、講完再讓它回呢?
先想想你跟朋友聊天長怎樣:對方劈哩啪啦講一堆 → 你記在腦子裡 → 想一下 → 等他嘴巴停了才接話。
機器人也可以這樣玩,所以我們要搞定三件事:記住內容、整合內容、什麼時候該回。

══════════════════════════════════════════════════════

記住內容與整合內容

訊息先別急著送 AI ,而是丟進 buffer(緩存,就當它是一個購物籃/清單),等你嘴巴停了,再整籃倒給 AI,讓它回你一次。
這就像去超商,店員不會跟在旁邊你拿一罐飲料就結一次帳,而是等你東西都擺上櫃檯了才結算一次。

程式碼
// 取得全域靜態資料,用來跨執行保存狀態,它是我們的筆記本
const sd = $getWorkflowStaticData('global');
// 第一次跑還沒有 buffers,先開一個空物件
if (!sd.buffers) sd.buffers = {};

// 這次 webhook 進來的原始內容
const body = $json.body || {};
const events = body.events || [];

// 一則一則檢查 LINE 丟進來的事件
for (const ev of events) {
  if (ev.type !== 'message') continue;                     // 不是訊息(例如加入好友)就跳過
  if (!ev.message || ev.message.type !== 'text') continue; // 不是純文字(貼圖、圖片)也跳過

  const src = ev.source || {};
  let key, to;
  // 依訊息來源決定「置物櫃編號」:群組 / 多人聊天室 / 一對一 各有不同
  if (src.groupId) { key = 'G:' + src.groupId; to = src.groupId; }
  else if (src.roomId) { key = 'R:' + src.roomId; to = src.roomId; }
  else { key = 'U:' + src.userId; to = src.userId; }

  // 這個人/群組第一次講話 → 發一個新的空籃子給他
  if (!sd.buffers[key]) sd.buffers[key] = { items: [], lastTs: 0, to };
  // 把訊息丟進籃子,順便記下它進來的時間
  sd.buffers[key].items.push({ text: ev.message.text, ts: ev.timestamp || Date.now() });
  // 更新「最後講話時間」,這是後面判斷你講完沒的關鍵
  sd.buffers[key].lastTs = Date.now();
}

// 這步只負責囤貨,回傳什麼其實不重要,給自己看日誌用的
return [{ json: { ok: true, keys: Object.keys(sd.buffers) } }];

buffer 就是那個「結帳前先放著的購物籃」。
就這麼簡單,一個「還沒回、先擱著」的籃子而已。

══════════════════════════════════════════════════════

那麼要不要回應的判斷留給誰呢?

留給 每 5 秒掃描(Schedule Trigger)
你就想像它是個心不在焉的學生,根本沒在聽老師講話,只是每 5 秒固定抬一次頭、翻一下筆記本。

那「為什麼他每 5 秒探一次,卻知道你講完沒?」
他真正做判斷的地方,是在 "講完了嗎" 節點裡:

程式碼
const SILENCE_MS = 8000;                    // 停 8 秒沒新訊息才回(耐心長度)
const sd = $getWorkflowStaticData('global'); // 再次打開同一本筆記本
if (!sd.buffers) sd.buffers = {};

const now = Date.now(); // 現在的時刻
const out = [];         // 準備放「真的可以回」的人

// 翻每一個人的筆記本,逐一判斷
for (const key of Object.keys(sd.buffers)) {
  const buf = sd.buffers[key];
  if (!buf.items || buf.items.length === 0) continue; // 籃子是空的,沒啥好回
  if (now - buf.lastTs < SILENCE_MS) continue;        // 距上次講話不到 8 秒 → 還在講,跳過

  // 通過考驗 → 把籃子裡的訊息組成一段逐字稿,前面加時間方便 AI 理解先後順序
  const transcript = buf.items.map((m) => {
    const d = new Date(m.ts);
    const hh = String(d.getHours()).padStart(2, '0');
    const mm = String(d.getMinutes()).padStart(2, '0');
    return '[' + hh + ':' + mm + '] ' + m.text;
  }).join('\n');

  out.push({
    json: {
      key,                     // 誰講的
      to: buf.to,              // 要回給誰
      count: buf.items.length, // 這次合併了幾則
      transcript,              // 合併後的內容
    },
  });

  buf.items = []; // 清空籃子,避免等等 AI 回完又被重複處理
}

// 有人達標就往下送;都沒人就回一個空殼,讓後面的 AI 節點不會被觸發
return out.length ? out : [{ json: { key: '', to: '', transcript: '', count: 0, empty: true } }];

也就是說,(現在 − 最後記錄) ≥ 8 秒 → 嗯,你停了 → 把筆記本拿去給 AI 回
還沒到 8 秒 → 你還在碎念 → 縮回去,5 秒後再探一次
學生每次抬頭只是在比「這個老師安靜多久了」,超過 8 秒,就當你收工了(或是準備開罵了)

所以實際回覆時間 = 你停手後 8~13 秒之間(8 秒門檻 + 最多再等一輪掃描)
這也是為什麼 "5 秒" 跟 "8 秒" 是兩個獨立判斷:
5 秒是「學生多久抬一次頭」,8 秒是「你停多久才算講完」,一個管頻率,一個管耐心,互不相關

══════════════════════════════════════════════════════

為什麼這裡非得用 Push,不能用 Reply?

LINE 回訊息有兩條路:
Reply:免費,但要靠 webhook 每則訊息附帶的 reply token。這個 token 有兩個限制,一則訊息配一個 token,而且 60 秒內要花掉,過期即失效
Push :不用 token,直接對 user Id / group Id 推播,想什麼時候送、送幾則都行,代價是會消耗你的主動推播額度

現在我們的做法是「先囤進籃子、等安靜 8~13 秒後才合併回一次」
問題就來了:
你連傳三則,LINE 給了三個 token,但我們最後只回一則 → 另外兩個 token 白白浪費掉。
要是你碎念太久,或排程剛好多等了一兩輪,等你真的回來處理時早就超過 60 秒 → token 直接過期,Reply 回不了

所以這種「延遲 + 合併」的場景,Reply 基本上行不通,只能改用 Push API。
它的規則天生就適合「晚一點、一次回」,就像寄平信,隨時丟進郵筒都行,不用像限時掛號那樣趕在時間前把信送完

══════════════════════════════════════════════════════

小結:從沒禮貌搶答仔,變成會聽人把話說完的朋友

整套流程用一句話收尾:Webhook 只負責把話丟進籃子、排程只負責每 5 秒探一次頭,真正的判斷其實只有一條式子:「你安靜超過 8 秒了嗎?」

還沒安靜 → 籃子繼續放著,不打擾你
安靜了 → 整籃倒給 AI,合併成一次好好的回覆
改完之後,小貓程式員終於不再你一句它一句,而是會等人類碎念完,再好好回一次。
購物籃還是那個購物籃(buffer)、學生還是那個學生(trigger),但湊在一起,它就學會了「等人把話講完」這件事。

現在能夠多段訊息回應一次了

如果能夠回應到提問,就再好不過了,需要幫助或有其他問題的話,歡迎隨時留言,我會盡己所能的 :)

11 個愛心3 則留言

登入並驗證手機後就能留言。登入

留言

  1. 上哲 @nuva.ceo

    大神!

  2. 疊疊青蛙 @radix405225

    來學習了

  3. 戴庭 @tyt0409

    被真正的大神說大神,只好慶祝一下了(這是一長串慶祝和喜悅)