飞书 Streaming Card / CardKit 实战:从“能发卡片”到“流式更新”的 OpenClaw 落地指南(含踩坑与代码)
Streaming Card 的 header 可以做成“任务状态栏”。标题:任务名/场景名(例如“发布 CSDN 文章”“诊断 Gateway 启动失败”)副标题:当前阶段(例如“连接飞书中 / 读取日志中 / 生成中 / 完成”)颜色/状态:running / warning / done"title": {"content": "OpenClaw · 流式任务执行中"},return {${
目标读者:正在做企业 IM(飞书)集成、希望把 LLM 的“流式输出”以卡片形式实时推送给用户的工程师。
这篇文章来自我最近在 OpenClaw 里做 Streaming Card(流式卡片 / CardKit) 的真实开发与调试:卡片创建、增量更新、header 自定义、以及在
reply-dispatcher.ts里把“模型流”变成“卡片流”。
1. 为什么要用 Streaming Card?
纯文本流式回复在飞书里当然能用,但一旦你想做到:
- 边生成边展示(像 ChatGPT 一样逐字出现)
- 结构化展示(标题、状态、进度条、分段内容、按钮)
- 后续可更新(同一张卡片持续刷新,不刷屏)
你就会发现:CardKit / Streaming Card 是更像“产品形态”的输出。
一句话:
文本流适合“聊天”;Streaming Card 适合“交付结果 + 过程可视化”。
2. CardKit 的核心模型:Create / Update 两个动作
在工程实现上,流式卡片可以抽象成两步:
- create:先创建一张卡(拿到 cardId / messageId / sequence 等标识)
- update:之后的每一段增量输出,都更新到同一张卡
这跟“流式 token”非常匹配:
- 第一个 token 到达 → create
- 后续 token/段落到达 → update(批量/节流)
- 结束 → update(写入最终状态:完成/耗时/引用等)
3. OpenClaw 里的落地方式:把“模型流”变成“卡片流”
我在 OpenClaw 的实现里,关键点是把 LLM streaming 的事件(delta)接到 飞书卡片更新。
典型的 pipeline:
User message (Feishu DM)
-> Session / Agent loop
-> Model streaming (chunk by chunk)
-> reply-dispatcher: onDelta()
-> throttle
-> feishu_cardkit_update(cardId, patch)
3.1 在 reply-dispatcher.ts 里做两件事
- 首次输出触发 create
- 后续输出触发 update(节流)
伪代码(核心思想,不依赖具体框架):
let cardId: string | null = null
let buffer = ""
let lastFlush = 0
async function onDelta(textDelta: string) {
buffer += textDelta
// 1) 第一次有内容:创建卡片
if (!cardId) {
const created = await feishu_cardkit_create({
header: { title: "Streaming Reply" },
body: renderBody(buffer, { status: "running" }),
})
cardId = created.cardId
lastFlush = Date.now()
return
}
// 2) 节流更新:避免每个 token 都打 API
const now = Date.now()
if (now - lastFlush < 300) return
await feishu_cardkit_update({
cardId,
body: renderBody(buffer, { status: "running" }),
})
lastFlush = now
}
async function onDone() {
if (!cardId) return
await feishu_cardkit_update({
cardId,
body: renderBody(buffer, { status: "done" }),
})
}
这里的“节流”是最重要的工程细节之一:
- 不节流:飞书 API 可能限流 / 卡片频繁抖动
- 合理节流(比如 200~500ms):视觉上仍然是“实时”,但系统稳定得多
4. 卡片 header 自定义:让用户一眼知道“发生了什么”
Streaming Card 的 header 可以做成“任务状态栏”。我常用的几个字段:
- 标题:任务名/场景名(例如“发布 CSDN 文章”“诊断 Gateway 启动失败”)
- 副标题:当前阶段(例如“连接飞书中 / 读取日志中 / 生成中 / 完成”)
- 颜色/状态:running / warning / done
示例(简化版结构):
{
"header": {
"title": {
"tag": "plain_text",
"content": "OpenClaw · 流式任务执行中"
},
"template": "blue"
}
}
工程上我会把 header 渲染函数化:
function renderHeader(stage: string) {
return {
title: { tag: "plain_text", content: `OpenClaw · ${stage}` },
template: stage === "done" ? "green" : "blue",
}
}
5. CardKit API 的“最小可用”封装:create / update 两个工具
为了让 OpenClaw 的 Agent 能稳定调用,我做了一个 bridge(插件/工具层)把飞书 CardKit API 抽象成两个 tool:
feishu_cardkit_createfeishu_cardkit_update
工具层要注意两件事:
- 输入 schema 必须跟 OpenClaw 新 SDK 对齐(否则会把 Gateway 直接搞挂)
- 返回值要包含后续 update 必需的标识(cardId/sequence 等)
create 的输入建议最小化:
type CreateArgs = {
headerTitle: string
markdown: string
}
update 也一样:
type UpdateArgs = {
cardId: string
markdown: string
stage?: "running" | "done" | "error"
}
这样上层 dispatcher 不需要懂飞书复杂协议,只需要“更新 markdown”。
6. 我踩过的 3 个坑(以及解决方式)
坑 1:插件 tool schema 不兼容直接导致 Gateway 启动失败
典型报错长这样:
plugin manifest requires configSchemaCannot read properties of undefined (reading 'properties')
根因:OpenClaw 升级后 tool 定义格式变了,旧的 {schema, handler} 不能直接用。
解决策略:
- 先止血:把问题插件改名
.bak,并用plugins.allow白名单让系统先稳定 - 再改造:按新 SDK 的 tool 定义重写 register 逻辑
坑 2:流式更新频率太高,导致卡片抖动或限流
解决:节流 + 批量更新
- buffer token
- 200~500ms 刷一次
- done 时强制 flush
坑 3:同一条消息要“回复到同一张卡”而不是刷屏
解决:create 后拿到 cardId,后续 update 始终指向同一个 cardId。
7. 一套可直接照抄的“流式卡片”实现模板
如果你只想要一个能工作的模板,我建议这样做:
createCard():发一张初始卡(header=running,body=空)appendDelta():把 delta append 到 bufferflush():节流更新finish():写最终状态
伪代码:
class StreamingCard {
cardId: string | null = null
buffer = ""
lastFlush = 0
async start(title: string) {
const res = await feishu_cardkit_create({ headerTitle: title, markdown: "" })
this.cardId = res.cardId
}
async push(delta: string) {
this.buffer += delta
const now = Date.now()
if (now - this.lastFlush < 300) return
await this.flush("running")
}
async flush(stage: "running" | "done" | "error") {
if (!this.cardId) return
await feishu_cardkit_update({ cardId: this.cardId, markdown: this.buffer, stage })
this.lastFlush = Date.now()
}
async done() {
await this.flush("done")
}
}
结语:把“流式输出”当成一种产品能力
我现在越来越确信:
- LLM 的价值不只是“回答”
- 而是“把过程可视化、把结果结构化、把交互产品化”
Streaming Card / CardKit 正是把 Agent 从“会说话”推向“能交付”的关键一步。
如果你也在做企业 IM + Agent 集成,这条路非常值得投入。
更多推荐
所有评论(0)