Workflows steps 深入:重試、sleep 與等待事件 | Cloudflare 完整教學

2026/09/01
Workflows steps 深入:重試、sleep 與等待事件 | Cloudflare 完整教學

上一篇《Workflows 持久化執行入門》,我們寫出了第一個 Workflow,學會用 step.do(名稱, 函式) 把每個有副作用的動作包成一個持久化步驟。但那只是最基本的兩參數形式。真正讓 Cloudflare Workflows 強大的,是 step 的深水區:每一步要怎麼設定重試與指數退避?怎麼讓流程睡上七天卻完全不計費?怎麼暫停下來等待一個外部的人工審核事件? 這一篇,我們就把 step.dostep.sleepstep.waitForEvent 這三支 API 一次拆透,並釐清背後那條所有人都會踩的紅線——決定性(determinism)

前言

先把這一篇的定位講清楚:上一篇解決的是「Workflow 是什麼、骨架長怎樣、怎麼觸發」;這一篇解決的是「每一個步驟(step)內部能怎麼精細控制」。同樣一個 step.do,你可以只丟名稱和函式草草了事,也可以替它配上重試次數、退避策略、逾時上限,讓它在上游 API 抖動時自動重試、在業務錯誤時立刻放棄。除此之外,step 家族還有兩支讓 Workflows 真正與眾不同的 API:能睡上數天而不計費的 step.sleep,以及能暫停等待外部事件的 step.waitForEvent

打個比方。如果說上一篇的 step.do 是教你「在流程卡上打勾」,那這一篇就是教你認識這張卡上更精密的欄位:每個勾格旁邊其實還有「失敗了要重試幾次、每次隔多久」的小字(retries),有「這一格先擱著,七天後再回來」的便利貼(sleep),還有「這一格要等主廚來簽名才能繼續」的待簽章(waitForEvent)。同一張卡、同一套持久化保護,但這些欄位讓你能表達遠比「一步接一步」更真實的業務流程——會失敗、要等待、要仰賴外部決定的流程。

要特別強調的是,step.sleep 不是 setTimeoutsetTimeout 是讓當前程序原地空等、活活佔著資源;而 step.sleep 是把整個實例真正卸下、進入不計費也不佔並行額度的 waiting 狀態,時間到了平台再喚醒它。這個差別是理解 Workflows 成本模型的關鍵,我們會反覆回到它。

讀完你會掌握:

  • step.do 的完整簽名——step.do(name, { retries, timeout }, fn),以及 retrieslimit / delay / backoff 三欄位與 constant / linear / exponential 三種退避的取捨
  • step.sleepstep.sleepUntil——相對與絕對時間的睡眠,為什麼「睡眠不計費、不佔並行」,以及它與 setTimeout 的本質差異
  • step.waitForEvent——如何讓 Workflow 暫停等待外部事件(webhook、人工審核),再用 instance.sendEvent() 從外部喚醒
  • 決定性(determinism)要求——為什麼步驟外不能用 Date.now()Math.random(),step 名稱為何必須是確定性字串
  • NonRetryableError——如何在業務邏輯錯誤(認證失敗、餘額不足)時立即終止重試,不做無謂的重試浪費

核心概念

在動手寫程式前,先把三件事的「運作原理」講透:一個 step 的完整生命週期(持久化 + 重試 + 睡眠是怎麼交織的)決定性要求為什麼是所有規則的根源,以及貫穿全文的關鍵術語

一、一個 step 的生命週期:持久化、重試、睡眠如何交織

回顧上一篇的核心機制:Workflows 靠重播(replay)實現崩潰恢復——每次恢復都從頭再跑 run(),已完成的步驟直接讀回持久化的回傳值、不重跑。現在我們把鏡頭拉近到單一一個 step.do 內部,看看重試與睡眠是怎麼疊加上去的:

step.do('呼叫外部 API', { retries: { limit: 3, delay: '2s', backoff: 'exponential' } }, fn)
   │
   ├─ 第 1 次嘗試(ctx.attempt = 1)──► fn() 拋錯 ✗
   │        │
   │        └─ 等 2 秒(exponential 首次間隔)
   │
   ├─ 第 2 次嘗試(ctx.attempt = 2)──► fn() 拋錯 ✗
   │        │
   │        └─ 等 4 秒(2 → 4,間隔倍增)
   │
   ├─ 第 3 次嘗試(ctx.attempt = 3)──► fn() 成功 ✓
   │        │
   │        └─ 回傳值持久化保存 ✓ ──► 這一步從此不再重跑
   │
   ▼
下一步 step.sleep('等 7 天', '7 days')
   │
   └─ 實例進入 waiting 狀態:不執行、不計費、不佔並行額度
        ⋯⋯ 7 天後平台喚醒 ⋯⋯
   │
   ▼
繼續往下一步

有三個層次要分清楚:

  1. 步驟之間的持久化(上一篇):已完成的整個步驟不重跑,靠讀回快取。
  2. 步驟之內的重試(這一篇新增):一個步驟第一次執行時,若 fn() 拋錯,step.do在該步驟內部retries 設定自動重試,直到成功或達到 limit。這些重試發生在「這一步還沒被標記為完成」的期間,只有最終成功的那次回傳值才會被持久化。
  3. 步驟形態的睡眠(這一篇新增):step.sleep 本身就是一種特殊的步驟,它不執行你的程式碼,只是登記一個「醒來時間」,讓實例卸下進入 waiting

理解這三層,你就能精準地控制「哪些失敗該重試、重試幾次、隔多久」以及「流程該在哪裡停下來等」。

二、決定性(determinism):所有規則的根源

Workflows 幾乎所有「怪規則」(不能用 Date.now()、step 名稱要固定、副作用要包進步驟)都源自同一個底層前提:你的 run() 主體必須是決定性(deterministic)的

什麼叫決定性?就是「給同樣的輸入,每次重播都走出一模一樣的控制流路徑、產生一模一樣的步驟序列」。為什麼非得如此?因為崩潰恢復是靠重播實現的——平台重跑 run(),靠「依序遇到的 step.do 名稱」來對應「這一步上次做過沒」。如果重播時你的程式因為某個非決定性的值而走上不同分支、或產生了不同的步驟名稱,那麼「上次做到哪」的帳就對不上了,已完成的步驟會被誤判、狀態會錯亂。

第一次執行:                          崩潰後重播:
  now = Date.now() → 1000(偶數)       now = Date.now() → 1001(奇數)
  if (now % 2 === 0) 成立               if (now % 2 === 0) 不成立
    → step.do('走 A 路徑')               → step.do('走 B 路徑')  ✗ 對不上!

化解之道只有一條核心原則:把所有非決定性的東西(當下時間、隨機數、外部即時狀態)都關進 step.do()。因為步驟的回傳值會被持久化,第一次算出來就定格了,之後每次重播都讀回同一個值,決定性自然被保住。這條原則會貫穿本篇的每一段程式碼。

三、關鍵術語

先記下這一篇會反覆出現的名詞:

術語說明
retriesstep.do 設定物件中的重試設定,含 limitdelaybackoff 三欄位
backoff(退避)重試間隔的增長策略:constant(固定)/ linear(線性)/ exponential(指數)
timeout(逾時)單次嘗試的執行上限,超過即視為該次失敗
ctx.attempt步驟函式收到的當前嘗試次數(1 = 首次,2 = 第一次重試…)
step.sleep相對時間睡眠,期間實例進入 waiting,不計費、不佔並行
step.sleepUntil絕對時間睡眠,睡到指定的 Date 為止
step.waitForEvent暫停實例,等待外部以 sendEvent() 送入指定 type 的事件
決定性(determinism)run() 主體「同輸入每次重播走同路徑」的性質,是崩潰恢復正確的前提
NonRetryableErrorcloudflare:workflows 匯入,拋出後立即終止重試

實作範例

我們用一個貼近真實業務的訂閱扣款流程走一遍所有 API。這個流程包含:驗證與扣款(需要重試 + 業務錯誤要立即放棄)→ 等待對帳系統確認(等外部事件)→ 若未確認則睡一段時間後重查 → 寄送收據。它剛好把 retriesNonRetryableErrorwaitForEventsleep 全部串起來。

所有匯入都來自 cloudflare:workers,唯獨 NonRetryableError 來自 cloudflare:workflows,別匯錯。

1. step.do 的完整簽名:name、設定物件、函式

上一篇只用了 step.do(name, fn) 兩參數形式。完整簽名其實是三參數:在名稱與函式之間,可以插入一個設定物件,用來指定 retriestimeout

// step.do 的完整形態
const result = await step.do(
  "呼叫外部 API",                    // ① 步驟名稱:必須是「確定性」的靜態字串
  {
    retries: {
      limit: 5,                      // 最大重試次數(0 = 不重試,Infinity = 無限重試)
      delay: "10 seconds",           // 基準間隔(可寫數字毫秒,或 '10 seconds' 字串)
      backoff: "exponential",        // 退避策略:constant | linear | exponential
    },
    timeout: "30 seconds",           // ② 單次嘗試逾時上限,超過即算這次失敗
  },
  async () => {                      // ③ 實際要跑的函式,回傳值會被持久化
    const res = await fetch("https://api.example.com/data");
    if (!res.ok) throw new Error(`API 回應 ${res.status}`); // 拋錯 → 觸發重試
    return res.json();
  }
);

三種 backoff 的實際間隔差異(以 delay: '2 seconds' 為例):

// constant(常數):每次間隔固定不變
// 2s → 2s → 2s → 2s ...   適合上游恢復時間穩定的情境
await step.do("固定重試", { retries: { limit: 5, delay: "2 seconds", backoff: "constant" } }, fn);

// linear(線性):間隔線性遞增
// 2s → 4s → 6s → 8s ...   溫和的折衷
await step.do("線性重試", { retries: { limit: 5, delay: "2 seconds", backoff: "linear" } }, fn);

// exponential(指數):間隔倍增 —— 呼叫外部相依的預設首選
// 2s → 4s → 8s → 16s ...  上游抖動時快速重試,上游真掛時不狂轟濫炸
await step.do("指數重試", { retries: { limit: 5, delay: "2 seconds", backoff: "exponential" } }, fn);

實務準則:對所有外部相依(API、資料庫、第三方服務)一律用 exponential,limit 設 3 到 5,delay'2 seconds' 起跳。指數退避是分散式系統的標準做法,既能在上游短暫抖動時快速恢復,又能在上游真的掛掉時避免用密集請求把它壓得更慘。

函式還能收到一個 ctx,拿到當前是第幾次嘗試:

await step.do(
  "帶嘗試資訊的步驟",
  { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
  async (ctx) => {
    // ctx.attempt:1 = 首次,2 = 第一次重試,以此類推
    if (ctx.attempt > 1) {
      console.log(`這是第 ${ctx.attempt - 1} 次重試`);
    }
    return await doWork();
  }
);

2. NonRetryableError:業務錯誤就別重試了

不是所有錯誤都值得重試。網路逾時、上游 500 這類暫時性錯誤,重試很可能會成功;但「餘額不足」「付款方式無效」「認證憑證錯誤」這類業務性錯誤,你重試一百次結果還是一樣,只是白白浪費時間與配額。

NonRetryableError 就是用來表達「這個錯不要重試,立刻放棄這一步」。它從 cloudflare:workflows 匯入,一旦在步驟內拋出,step.do 會跳過剩餘的重試次數、直接讓該步驟失敗。

import { NonRetryableError } from "cloudflare:workflows";

const charge = await step.do(
  "驗證並扣款",
  { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
  async () => {
    const { paymentMethod, amount } = event.payload;

    // 業務前置條件不滿足 → 重試也沒用,立即放棄
    if (!paymentMethod) {
      throw new NonRetryableError("缺少付款方式,無法扣款");
    }

    const res = await fetch("https://payment.example.com/charge", {
      method: "POST",
      body: JSON.stringify({ paymentMethod, amount }),
    });

    if (res.status === 401) {
      throw new NonRetryableError("API 憑證無效"); // 認證錯誤:重試也不會過
    }
    if (res.status === 402) {
      throw new NonRetryableError("餘額不足");      // 業務狀態:不該重試
    }
    if (!res.ok) {
      // 500 / 503 等:可能是暫時性,交給 retries 機制重試
      throw new Error(`扣款 API 錯誤 ${res.status}`);
    }

    return res.json<{ transactionId: string }>();
  }
);

心法一句話:暫時性錯誤丟普通 Error(讓它重試),業務性/永久性錯誤丟 NonRetryableError(立刻放棄)

3. step.waitForEvent:暫停等待外部事件

有些流程走到一半必須停下來等外面的世界——等對帳系統回報、等使用者點擊確認信、等主管在後台按下核准。step.waitForEvent 讓實例暫停(進入 waiting,同樣不計費),直到外部用 sendEvent() 送入一個符合指定 type 的事件,或等到逾時。

// 等待對帳系統確認扣款(最多等 6 小時)
try {
  const reconcile = await step.waitForEvent<{ confirmed: boolean; ref: string }>(
    "等待對帳確認",
    {
      type: "reconcile-result", // 只接受此 type 的事件(命名限字母數字與 - _)
      timeout: "6 hours",        // 逾時後拋出錯誤
    }
  );

  if (!reconcile.confirmed) {
    throw new NonRetryableError(`對帳未通過:${reconcile.ref}`);
  }
} catch {
  // 逾時或未確認 → 進入下方的重查補救流程(見下一節)
}

從外部喚醒等待中的實例,用的是 instance.sendEvent(),type 必須與 waitForEvent 對得上:

// 另一個 Worker / handler:對帳系統回呼進來時,喚醒對應實例
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { instanceId, confirmed, ref } = await request.json<{
      instanceId: string;
      confirmed: boolean;
      ref: string;
    }>();

    const instance = await env.BILLING_WORKFLOW.get(instanceId);
    await instance.sendEvent({
      type: "reconcile-result", // ★ 必須與 waitForEvent 的 type 完全一致
      payload: { confirmed, ref },
    });

    return Response.json({ status: "event delivered" });
  },
} satisfies ExportedHandler<Env>;

4. step.sleepstep.sleepUntil:睡而不費

若對帳沒能即時確認,我們不急著失敗——先睡一段時間,讓對帳系統有機會補上,再回頭重查。這就是 step.sleep 的舞台:它讓實例真正卸下、進入 waiting,期間不執行程式碼、不計 CPU 費用、不佔用同時執行實例的並行額度,時間一到平台自動喚醒。

// 相對時間:從現在起睡多久(可寫字串或數字毫秒,最大 365 天)
await step.sleep("等對帳補上", "30 minutes");
await step.sleep("試用期", "7 days");
await step.sleep("短暫等待", 5000); // 5000ms = 5 秒

// 絕對時間:睡到某個明確的時間點
await step.sleepUntil("延到隔日凌晨對帳", new Date("2026-09-02T02:00:00Z"));

這與 setTimeout 的差別是本質性的,務必記牢:

面向setTimeout(fn, ms)step.sleep(name, duration)
等待期間程序原地空等,計時器活著佔資源實例卸下,進入 waiting
計費佔用執行資源(且 Worker 撐不了那麼久)不計 CPU 費用
並行額度佔用中不計入同時執行實例上限
崩潰後計時器丟失,等待歸零醒來時間已持久化,恢復後照樣醒來
最大時長受 Worker 生命週期限制(極短)最大 365 天

正因如此,「試用 7 天後提醒」「延到某個絕對時間再動作」在 Workflows 裡就是一行 step.sleep/step.sleepUntil,不需要 cron 輪詢、不需要自建計時基礎設施。

5. 串起來:完整的訂閱扣款長時任務

現在把上面所有片段組成一個可運作的 Workflow。它示範了一條真實流程如何橫跨「重試扣款 → 等待對帳 → 睡眠後重查 → 寄收據」,而每一步都享有持久化保護。

import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";

interface BillingParams {
  userId: string;
  paymentMethod: string;
  amount: number;
  email: string;
}

interface Env {
  BILLING_WORKFLOW: Workflow<BillingParams>;
  DB: D1Database;
}

export class BillingWorkflow extends WorkflowEntrypoint<Env, BillingParams> {
  async run(event: WorkflowEvent<BillingParams>, step: WorkflowStep): Promise<void> {
    const { userId, amount, email } = event.payload;

    // ── 步驟 1:扣款(暫時性錯誤重試,業務錯誤立即放棄)──
    const charge = await step.do(
      "驗證並扣款",
      { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" }, timeout: "30 seconds" },
      async () => {
        const { paymentMethod, amount } = event.payload;
        if (!paymentMethod) throw new NonRetryableError("缺少付款方式");

        const res = await fetch("https://payment.example.com/charge", {
          method: "POST",
          body: JSON.stringify({ userId, paymentMethod, amount }),
        });
        if (res.status === 402) throw new NonRetryableError("餘額不足");
        if (!res.ok) throw new Error(`扣款 API 錯誤 ${res.status}`); // 交給重試
        return res.json<{ transactionId: string }>();
      }
    );

    // ── 步驟 2:等待對帳確認(最多 6 小時),逾時則睡 30 分鐘後重查一次 ──
    let confirmed = false;
    try {
      const evt = await step.waitForEvent<{ confirmed: boolean }>(
        "等待對帳確認",
        { type: "reconcile-result", timeout: "6 hours" }
      );
      confirmed = evt.confirmed;
    } catch {
      // 逾時:先睡一段時間,給對帳系統補上的機會
      await step.sleep("等對帳補上", "30 minutes");

      // 主動向對帳 API 重查一次(這一步同樣有持久化與重試保護)
      confirmed = await step.do(
        "主動重查對帳",
        { retries: { limit: 3, delay: "10 seconds", backoff: "exponential" } },
        async () => {
          const res = await fetch(
            `https://payment.example.com/reconcile/${charge.transactionId}`
          );
          const data = await res.json<{ confirmed: boolean }>();
          return data.confirmed;
        }
      );
    }

    if (!confirmed) {
      // 仍未確認 → 標記並結束(此為業務決定,不重試)
      await step.do("標記對帳失敗", async () => {
        await this.env.DB.prepare(
          "UPDATE billings SET status = ? WHERE txn = ?"
        ).bind("reconcile-failed", charge.transactionId).run();
      });
      return;
    }

    // ── 步驟 3:寄送收據(副作用包進步驟,保證只寄一次)──
    await step.do("寄送收據", async () => {
      await fetch("https://mail.example.com/send", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          to: email,
          subject: "扣款成功收據",
          body: `已成功扣款 ${amount} 元,交易編號 ${charge.transactionId}。`,
        }),
      });
    });

    // ── 步驟 4:更新最終狀態 ──
    await step.do("標記完成", async () => {
      await this.env.DB.prepare(
        "UPDATE billings SET status = ? WHERE txn = ?"
      ).bind("completed", charge.transactionId).run();
    });
  }
}

這個流程的精妙之處在於:扣款只重試暫時性錯誤、餘額不足立刻放棄;對帳可以等 6 小時、逾時還能睡半小時再重查;而寄收據與更新狀態全包在步驟裡,任何環節崩潰都不會重複扣款、不會重寄收據。這就是把 step 的深水區用起來的樣子。

常見錯誤與最佳實踐

step 的細節多,踩坑也集中在幾個固定位置。以下是最高頻的幾個。

坑一:把 step.sleep 當成 setTimeout 用。

新手常想「我在步驟裡呼叫 setTimeoutawait new Promise(r => setTimeout(r, ...)) 來等待不就好了?」——大錯。setTimeout 讓程序原地空等,不但佔資源,Worker 也根本活不了幾天;更糟的是它不會被持久化,崩潰後計時器直接消失。要「等一段時間」,永遠用 step.sleep / step.sleepUntil,它才享有不計費、不佔並行、可恢復的待遇。

// ❌ 錯誤:用 setTimeout 空等 —— 佔資源、撐不久、崩潰即失效
await step.do("等 7 天", async () => {
  await new Promise((r) => setTimeout(r, 7 * 24 * 3600 * 1000));
});

// ✅ 正確:用 step.sleep —— 卸下實例、不計費、可恢復
await step.sleep("等 7 天", "7 days");

坑二:step 名稱重複或用了非確定性字串。

Workflows 靠 step 名稱來對應「這步做過沒」。同一次執行裡若兩個步驟撞名,或名稱含 Date.now() 這類每次都變的值,重播時就對不上帳。名稱要嘛靜態、要嘛基於確定性的資料(如來自前一步回傳值的穩定 key)。

// ❌ 錯誤:名稱含非確定性值,重播對不上
await step.do(`process-${Date.now()}`, fn);

// ❌ 錯誤:迴圈裡步驟撞名(每圈都叫 "process item")
for (const item of items) await step.do("process item", () => handle(item));

// ✅ 正確:用確定性的、彼此唯一的名稱
for (const item of items) await step.do(`process item ${item.id}`, () => handle(item));

坑三:把有副作用或非決定性的邏輯放在步驟外。

這是決定性要求的直接推論。任何 Date.now()Math.random()、對外呼叫,只要寫在 step.do() 外面,就會在每次重播時重新執行,導致重複副作用或分支翻轉。全部關進步驟,結果才會被持久化固定。

// ❌ 錯誤:步驟外取當下時間做判斷,重播時值會變
const now = Date.now();
if (now % 2 === 0) await step.do("A", fnA); else await step.do("B", fnB);

// ✅ 正確:非決定性取值包進步驟,固定成持久化值
const now = await step.do("capture now", async () => Date.now());
if (now % 2 === 0) await step.do("A", fnA); else await step.do("B", fnB);

坑四:對業務錯誤還一直重試。

沒用 NonRetryableError,「餘額不足」也會被 retries 反覆重試,白等一輪指數退避才失敗,浪費時間與配額。凡是重試不可能改變結果的錯誤(認證失敗、資料格式錯誤、業務狀態拒絕),一律拋 NonRetryableError

坑五:忘記 await 步驟,造成 dangling promise。

step.do(...) 沒有 await,步驟可能沒真正完成就往下走,持久化與重試都會失準。每一個 step.* 呼叫前都要有 await

最佳實踐小結:等待一律用 step.sleep/step.sleepUntil(絕不用 setTimeout);step 名稱用確定性且唯一的字串;非決定性取值與所有副作用一律包進步驟;暫時性錯誤丟普通 Error 交給 retries、業務錯誤丟 NonRetryableError 立即放棄;對外部相依用 exponential 退避、limit 設 3–5;每個 step.* 前都要 await。守住這幾條,你的 Workflow 才能在崩潰、重播、長時等待下都保持正確。

小結

上一篇《Workflows 持久化執行入門》,我們搭好了 Workflow 的骨架:WorkflowEntrypoint + run(event, step),用 create() 觸發實例,理解了「崩潰也不重來」的持久化魔法。這一篇,我們鑽進 step 的深水區,把每一步都練成生產級:

  • step.do(name, { retries, timeout }, fn)——完整三參數形態。retrieslimit / delay / backoff 組成,對外部相依一律用 exponential 指數退避,limit 設 3–5。
  • NonRetryableError——從 cloudflare:workflows 匯入,業務性/永久性錯誤(餘額不足、認證失敗)拋它立即終止重試,暫時性錯誤才丟普通 Error 交給重試。
  • step.sleep / step.sleepUntil——相對與絕對時間睡眠,睡眠期間實例進入 waiting,不計費、不佔並行額度,最大 365 天。它與 setTimeout 是本質不同的東西。
  • step.waitForEvent——暫停等待外部以 sendEvent() 送入的指定 type 事件(人工審核、對帳回呼),可設 timeout
  • 決定性(determinism)——所有規則的根源。步驟外不可用 Date.now()Math.random(),step 名稱必須確定且唯一,非決定性取值與副作用一律關進步驟。

至此,你已經能寫出會重試、會等待、會睡眠、能被外部事件喚醒的成熟 Workflow。但一個更上層的問題浮現了:面對一個非同步需求,我到底該選 Queues、Workflows,還是 Cron?三者的邊界在哪、又該怎麼搭配? 這個「選型」的全局視角,下一篇《非同步編排選型》,我們會用一張決策地圖把它講清楚。

想深入查閱官方對 step API 各參數的完整定義,可以隨時參考 Cloudflare Workflows Workers API 官方文件。把 step 的深水區踩熟了,你就真正握住了 Workflows 最鋒利的那把刀,我們下一篇《非同步編排選型》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →