LobeHub Upstash Workflow 三层模式实战:以 welcome-placeholder 与 agent-welcome 两个真实工作流为例
LobeHub Upstash Workflow 三层模式实战:以 welcome-placeholder 与 agent-welcome 两个真实工作流为例
本文基于 LobeHub 仓库中的技能参考文档 examples.md,完整拆解两个已经落地代码库的 Upstash Workflow + QStash 异步工作流——welcome-placeholder(AI 欢迎占位符生成)与 agent-welcome(Agent 欢迎语与开放问题生成)。读完本文,你将掌握 LobeHub 统一的「入口 → 分页 → 单任务」三层工作流架构,理解 dry-run 预演、fan-out 扇出、单任务幂等执行三大核心模式如何在真实业务中落地,并学会如何以「实体替换」的方式低成本新增自己的工作流。
一、模式背景:为什么 LobeHub 的工作流都是同一个形状
在 LobeHub 中,基于 Upstash Workflow 的异步批量任务(例如为全量用户生成内容、为全量 Agent 预热数据)必须同时应对三个平台层面的约束(见 SKILL.md):
- 速率限制让无脑全量扇出(blind fan-out)非常危险;
- 步骤上限限制单个 workflow 的执行规模;
- 幂等性要求决定了重试时不能重复处理同一个对象。
因此每个工作流都由三个核心模式组合而成:
| 模式 | 作用 | 出现层级 |
|---|---|---|
| Dry-Run 模式 | 只统计不执行,预览「将会处理多少条」 | Layer 1 入口层 |
| Fan-Out 扇出模式 | 把超大批次切成小分片并行递归处理 | Layer 2 分页层 |
| 单任务执行 | 每次 workflow 执行恰好处理一个对象 | Layer 3 执行层 |
所有工作流遵循同一套三层架构:
Layer 1: 入口层 (process-*)
├─ 校验前置条件
├─ 计算待处理对象总数
├─ 过滤已有结果的对象
├─ 支持 dry-run 模式(仅统计)
└─ 有待处理工作时触发 Layer 2
Layer 2: 分页层 (paginate-*)
├─ 游标(cursor)分页
├─ 大批次 fan-out
├─ 递归处理所有页
└─ 为每个对象触发 Layer 3
Layer 3: 单任务执行层 (execute-* / generate-*)
└─ 针对单个对象执行真正的业务逻辑
文件布局约定上,API 路由放在 src/app/(backend)/api/workflows/{workflow-name}/ 下(三个 route 文件分别对应三层),而工作流类放在 apps/server/src/workflows/{workflowName}/index.ts。当前仓库的 apps/server/src/workflows 目录中可以看到多个按该约定组织的实现(如 onboardingUnderstanding、topicAutoSummary 等),印证了这是代码库的长期约定。
需要说明的是:下文引用的 welcome-placeholder、agent-welcome 两个示例的路径(/api/workflows/welcome-placeholder/... 等)来自 examples.md 文档的历史快照;从当前仓库源码结构看,代码库经过重构后部分具体文件已迁移,但「三层切分 + 三大模式」这一模式本身在文档与技能体系中仍被作为标准范式保留。
二、示例一:welcome-placeholder(用户欢迎占位符)
使用场景:为平台用户生成 AI 驱动的欢迎占位符(welcome placeholders)。
三层结构:
- Layer 1:
process-users— 入口层,检查哪些用户符合条件; - Layer 2:
paginate-users— 分页遍历所有活跃用户; - Layer 3:
generate-user— 为一个用户生成占位符。
关键特性(这一节是 examples.md 的核心内容,完整列出):
- 过滤掉 Redis 中已缓存占位符的用户——这是幂等性的第一道闸门;
paidOnly标志位,将处理范围限定为订阅(付费)用户;dryRun模式,只输出统计信息,不产生任何副作用;- 大批量用户时的 fan-out,分片大小
CHUNK_SIZE=20。
Layer 3 的代码形态:
export const { POST } = serve<GenerateUserPlaceholderPayload>(async (context) => {
const { userId } = context.requestPayload ?? {};
const workflow = new WelcomePlaceholderWorkflow(db, userId);
const placeholders = await context.run('generate', () => workflow.generate());
return { success: true, userId, placeholdersCount: placeholders.length };
});
注意这里的几个模式要点:
- 路由处理器通过
@upstash/workflow/nextjs的serve<Payload>包裹,payload 类型化(GenerateUserPlaceholderPayload); - 真正的业务被封装进
WelcomePlaceholderWorkflow类(构造函数接收db与单个userId),路由层只做「取 payload → 调类方法 → 返回统一响应」的薄壳; context.run('generate', ...)为这一步赋予唯一步骤名——步骤名是 workflow 持久化与断点恢复的依据,SKILL.md 的清单中明确要求「Uniquecontext.run()step names」。
文档中给出的对应文件(历史路径,供溯源):
/api/workflows/welcome-placeholder/process-users/route.ts
/api/workflows/welcome-placeholder/paginate-users/route.ts
/api/workflows/welcome-placeholder/generate-user/route.ts
/server/workflows/welcomePlaceholder/index.ts
三、示例二:agent-welcome(Agent 欢迎数据)
使用场景:为 AI Agent 生成欢迎消息与开放问题(welcome messages and open questions)。
三层结构:
- Layer 1:
process-agents— 入口层,检查哪些 Agent 符合条件; - Layer 2:
paginate-agents— 分页遍历活跃 Agent; - Layer 3:
generate-agent— 为一个 Agent 生成欢迎数据。
关键特性:与示例一完全对应的四件套——
- 过滤 Redis 中已有缓存数据的 Agent;
paidOnly标志位,只处理订阅用户的 Agent;dryRun统计模式;- 大批量 Agent 时 fan-out(
CHUNK_SIZE=20)。
Layer 3 的代码形态:
export const { POST } = serve<GenerateAgentWelcomePayload>(async (context) => {
const { agentId } = context.requestPayload ?? {};
const workflow = new AgentWelcomeWorkflow(db, agentId);
const data = await context.run('generate', () => workflow.generate());
return { success: true, agentId, data };
});
文档中给出的对应文件(历史路径,供溯源):
/api/workflows/agent-welcome/process-agents/route.ts
/api/workflows/agent-welcome/paginate-agents/route.ts
/api/workflows/agent-welcome/generate-agent/route.ts
/server/workflows/agentWelcome/index.ts
四、两者的相同与差异:「实体替换」才是这套模式的真正卖点
examples.md 用一句话点破了两个示例的本质关系:它们是同一个模式,只在三点上不同:
- 实体类型:users vs agents;
- 业务逻辑:placeholder 生成 vs welcome 数据生成;
- 数据源:不同的数据库查询。
除此之外的一切——三层切分、dry-run 处理、fan-out、filter-existing 过滤、flowControl 调参——完全一致。文档原话的结论是:
一旦内化了这个模式,新增一个工作流主要就是实体替换(entity-substitution)。
这正是「示例文档」的价值所在:读这两个端到端实现,等于同时读到了 LobeHub 工作流范式的模板实例。
五、纵深:三大模式在完整模板中的落点
为了把示例中「只展示了 Layer 3」的部分补全,references/implementation.md 给出了与上述两个示例逐行同构的完整代码模板,其中与示例一、示例二直接对应的实现细节如下。
5.1 工作流类:静态 trigger 方法 + 幂等过滤器
export class {WorkflowName}Workflow {
private static client: Client;
static triggerProcessItems(payload: ProcessItemsPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.processItems);
return this.getClient().trigger({ body: payload, url });
}
static triggerPaginateItems(payload: PaginateItemsPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.paginateItems);
return this.getClient().trigger({ body: payload, url });
}
static triggerExecuteItem(payload: ExecuteItemPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.executeItem);
return this.getClient().trigger({ body: payload, url });
}
/**
* Filter items that need processing (e.g. check Redis cache, database state).
* Return only the ones that actually need work — keeps the pipeline idempotent.
*/
static async filterItemsNeedingProcessing(itemIds: string[]): Promise<string[]> {
// ...
}
}
对应到两个示例:WelcomePlaceholderWorkflow / AgentWelcomeWorkflow 的实例方法 generate() 就是 Layer 3 调用的业务入口,而静态 trigger* 方法承担了「workflow 之间通过 QStash HTTP 触发」的桥接——每个静态方法都对应一个 HTTP 路由 URL。
5.2 Layer 1:dry-run 短路与并行度锁定
入口层模板中,dry-run 在一切副作用之前短路返回统计信息:
if (dryRun) {
console.log('[{workflow}:process] Dry run mode, returning statistics only');
return {
...result,
dryRun: true,
message: `[DryRun] Would process ${itemsNeedingProcessing.length} items`,
};
}
这解释了示例中「dryRun mode for statistics」的实际机制:先 get-all-items 查询全量符合条件的实体 id,再经 filter-existing 剔除已处理项,然后先判断 dryRun 再决定是否触发分页。入口层的 flowControl 被刻意压到 parallelism: 1, ratePerSecond: 1,从结构上看是为了保证「同一时刻只有一个入口实例在派发」,避免重复触发。
5.3 Layer 2:fan-out 的递归自触发
示例中标注的 CHUNK_SIZE=20 在模板中对应这样的分支逻辑:
const PAGE_SIZE = 50; // 每页拉取的对象数
const CHUNK_SIZE = 20; // 每个 fan-out 分片的对象数
if (itemIds.length > CHUNK_SIZE) {
const chunks = chunk(itemIds, CHUNK_SIZE);
await Promise.all(
chunks.map((ids, idx) =>
context.run(`{workflow}:fanout:${idx + 1}/${chunks.length}`, () =>
WorkflowClass.triggerPaginateItems({ itemIds: ids }),
),
),
);
} else {
// 直接为每个对象触发 Layer 3
await Promise.all(
itemIds.map((itemId) =>
context.run(`{workflow}:execute:${itemId}`, () =>
WorkflowClass.triggerExecuteItem({ itemId }),
),
),
);
}
两个值得注意的机制:
- fan-out 是递归自触发:分片不是在本 workflow 内继续循环,而是带着
itemIds重新trigger一次 paginate 路由,天然绕开了单个 workflow 的步骤数上限; - 双模式 payload:
PaginateItemsPayload同时支持cursor(常规分页)与itemIds(fan-out 分片直入)两种入参,收到itemIds时直接走执行分支,不再翻页。
分页层的 flowControl 默认 parallelism: 20, ratePerSecond: 5,与执行层的 parallelism: 10, ratePerSecond: 5 相配合,形成「入口串行 → 分页半并行 → 执行受控并行」的速率漏斗,对应 SKILL.md 中「速率限制让盲目 fan-out 危险」的约束。
5.4 环境变量与触发前提
三个 trigger* 方法在运行时依赖两个必需环境变量(缺失时直接抛错):
# Required for all workflows
APP_URL=https://your-app.com # Base URL for workflow endpoints
QSTASH_TOKEN=qstash_xxx # QStash authentication token
# Optional (for custom QStash URL)
QSTASH_URL=https://custom-qstash.com
即:APP_URL 用于拼接 /api/workflows/... 的完整触发地址,QSTASH_TOKEN 用于构造 Upstash Client 的鉴权;QSTASH_URL 可选,用于指向自建/自定义的 QStash 端点。
六、复用到你自己的实体:从示例到新工作流的最小路径
把示例一/二抽象后,在 LobeHub 代码库中新增一个「对全量实体做 X」的工作流,路径基本是填空:
- 确定实体与业务逻辑:明确处理对象(users / agents / items…)与单对象业务函数;
- 写工作流类:在
apps/server/src/workflows/{workflowName}/index.ts定义WORKFLOW_PATHS、三种 payload 接口、三个静态 trigger 方法与filterItemsNeedingProcessing(Redis 缓存或数据库状态判断); - 写三层路由:
process-*:查询全量候选 →filter-existing→ dry-run 短路或触发分页,flowControlparallelism: 1;paginate-*:PAGE_SIZE=50游标分页,CHUNK_SIZE=20超阈 fan-out,flowControlparallelism: 20, ratePerSecond: 5;generate-*/execute-*:单对象执行,context.run步骤名唯一,flowControlparallelism: 10, ratePerSecond: 5;
- 日志与响应:统一
[workflow:layer]前缀日志、统一{ success, ... }响应形状; - 验证顺序:先
dryRun冒烟,再用小批量试跑,最后全量。
SKILL.md 中还维护了一份覆盖「规划 / 实现 / 质量与部署」三段的新工作流清单,可作为发布前的逐项核对;flowControl 调参、错误处理、日志与测试的进一步细节见 best-practices.md,lobehub-cloud 环境下的 re-export 与云上运维见 cloud.md。
七、小结
- examples.md 用
welcome-placeholder与agent-welcome两个真实工作流证明:LobeHub 的 Upstash 异步工作流是一个模式、多次实例化——三层切分、dry-run、fan-out(CHUNK_SIZE=20)、filter-existing、flowControl 调参全部同构,差异仅在于实体、业务逻辑与数据源; - Layer 3 的
serve<Payload>+context.run+ 工作流类薄壳调用,是可直接套用的代码形态; - 从示例到落地,implementation.md 提供逐行同构的完整模板,配合
APP_URL/QSTASH_TOKEN环境变量即可在本地或云端触发; - 掌握「实体替换」这一视角后,新增一个面向任意实体的批量 AI 工作流,主要就是替换查询、过滤与生成函数三处代码。
更多推荐



所有评论(0)