目标读者:正在做企业 IM(飞书)集成、希望把 LLM 的“流式输出”以卡片形式实时推送给用户的工程师。

这篇文章来自我最近在 OpenClaw 里做 Streaming Card(流式卡片 / CardKit) 的真实开发与调试:卡片创建、增量更新、header 自定义、以及在 reply-dispatcher.ts 里把“模型流”变成“卡片流”。


1. 为什么要用 Streaming Card?

纯文本流式回复在飞书里当然能用,但一旦你想做到:

  • 边生成边展示(像 ChatGPT 一样逐字出现)
  • 结构化展示(标题、状态、进度条、分段内容、按钮)
  • 后续可更新(同一张卡片持续刷新,不刷屏)

你就会发现:CardKit / Streaming Card 是更像“产品形态”的输出

一句话:

文本流适合“聊天”;Streaming Card 适合“交付结果 + 过程可视化”。


2. CardKit 的核心模型:Create / Update 两个动作

在工程实现上,流式卡片可以抽象成两步:

  1. create:先创建一张卡(拿到 cardId / messageId / sequence 等标识)
  2. 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 里做两件事

  1. 首次输出触发 create
  2. 后续输出触发 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_create
  • feishu_cardkit_update

工具层要注意两件事:

  1. 输入 schema 必须跟 OpenClaw 新 SDK 对齐(否则会把 Gateway 直接搞挂)
  2. 返回值要包含后续 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 configSchema
  • Cannot 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. 一套可直接照抄的“流式卡片”实现模板

如果你只想要一个能工作的模板,我建议这样做:

  1. createCard():发一张初始卡(header=running,body=空)
  2. appendDelta():把 delta append 到 buffer
  3. flush():节流更新
  4. 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 集成,这条路非常值得投入。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐