飞书AI机器人流式输出实践:从消息编辑踩坑到卡片更新最佳方案

193 阅读5分钟

本文总结了在开发 AI Agent + 飞书机器人场景下,实现流式输出时踩过的坑和最终的工程方案。适合有飞书开放平台开发经验、正在构建 AI 应用的工程师阅读。

一、背景与问题

1.1 场景描述

在 AI Agent 场景下,大模型的输出往往是流式的(Streaming),即内容逐步生成、逐步返回。为了提升用户体验,我们希望在飞书机器人中实现类似 ChatGPT 的「打字机效果」——用户发送消息后,机器人先回复一条「正在处理...」,然后随着 Agent 的输出不断更新这条消息,最终显示完整回复。

sequenceDiagram
    participant User as 用户
    participant Bot as 飞书机器人
    participant Agent as AI Agent
    
    User->>Bot: 发送消息
    Bot->>User: 回复「正在处理...」
    Bot->>Agent: 调用 Agent
    loop 流式输出
        Agent-->>Bot: chunk_1
        Bot->>User: 更新消息为 chunk_1
        Agent-->>Bot: chunk_1 + chunk_2
        Bot->>User: 更新消息为 chunk_1 + chunk_2
    end
    Agent-->>Bot: 完整内容
    Bot->>User: 最终更新

1.2 踩坑:消息编辑次数上限

最初的实现方案是使用飞书的编辑消息 APIPATCH /open-apis/im/v1/messages/:message_id)来更新已发送的文本消息。

问题暴露:当 Agent 输出较长、chunk 较多时,编辑请求会静默失败——API 返回成功,但消息内容不再更新。

根因分析:飞书对单条消息的编辑次数有隐性上限(官方文档未明确说明具体数值),超过上限后编辑操作会被忽略。

方案编辑次数限制适用场景
消息编辑 API有上限(约 20-30 次)少量更新、非流式场景
卡片实体更新无明确上限高频更新、流式输出场景

二、解决方案:卡片实体(CardKit)

2.1 方案对比

飞书提供了两种消息更新机制:

flowchart LR
    subgraph 方案A["方案 A: 消息编辑"]
        A1[发送文本消息] --> A2[获取 message_id]
        A2 --> A3[调用编辑 API]
        A3 --> A4{超过上限?}
        A4 -->|是| A5[静默失败]
        A4 -->|否| A3
    end
    
    subgraph 方案B["方案 B: 卡片实体"]
        B1[创建卡片实体] --> B2[获取 card_id]
        B2 --> B3[发送卡片消息]
        B3 --> B4[调用卡片更新 API]
        B4 --> B4
    end

卡片实体的优势

  1. 无编辑次数限制:适合高频更新场景
  2. 支持 JSON 2.0:富文本、Markdown、交互组件等
  3. 流式更新专用配置streaming_mode 可优化客户端渲染

2.2 核心流程

sequenceDiagram
    participant User as 用户
    participant Bot as 飞书机器人
    participant CardKit as CardKit API
    participant Agent as AI Agent
    
    User->>Bot: 发送消息
    Bot->>CardKit: 创建卡片实体
    CardKit-->>Bot: 返回 card_id
    Bot->>User: 发送卡片消息(关联 card_id)
    Bot->>Agent: 调用 Agent
    loop 流式输出(带节流)
        Agent-->>Bot: accumulated_content
        Bot->>CardKit: 更新卡片内容(card_id, seq++)
        CardKit-->>User: 推送更新
    end
    Bot->>CardKit: 兜底更新(最终内容)

三、工程实现

3.1 模块架构

为了可维护性和可复用性,将飞书插件拆分为以下模块:

graph TB
    subgraph feishu["飞书插件模块"]
        client["client.py<br/>主客户端 & 事件路由"]
        card["card.py<br/>卡片创建/更新/发送"]
        message["message.py<br/>文本消息操作"]
        agent["agent.py<br/>Agent 流式调用"]
    end
    
    client --> card
    client --> message
    client --> agent
    agent --> card
    agent --> message
模块职责
client.py主类,WebSocket 事件处理,消息路由
card.pyCardMixin:卡片模板、创建、更新、发送
message.pyMessageMixin:文本消息发送、回复、编辑
agent.pyAgent 流式调用,节流回调,兜底更新

3.2 核心代码示例

3.2.1 卡片创建与更新

# card.py - 卡片相关功能

import json
from pathlib import Path
from lark_oapi.api.cardkit.v1 import (
    Card, CreateCardRequest, CreateCardRequestBody,
    UpdateCardRequest, UpdateCardRequestBody,
)

class CardMixin:
    """卡片相关方法的 Mixin 类"""
    
    http_client: "lark.Client"
    _card_sequence: dict[str, int]  # 卡片序号管理
    
    def _get_next_sequence(self, card_id: str) -> int:
        """获取并递增卡片操作序号(必须严格递增)"""
        seq = self._card_sequence.get(card_id, 0) + 1
        self._card_sequence[card_id] = seq
        return seq
    
    def create_card_entity(self, content: str, title: str = "AI 助手") -> str | None:
        """创建卡片实体,返回 card_id"""
        card_data = {
            "schema": "2.0",
            "config": {"update_multi": True},  # 共享卡片,所有人可见更新
            "header": {"title": {"tag": "plain_text", "content": title}},
            "body": {
                "elements": [{"tag": "markdown", "content": content}]
            }
        }
        request = CreateCardRequest.builder() \
            .request_body(
                CreateCardRequestBody.builder()
                .type("card_json")
                .data(json.dumps(card_data))
                .build()
            ).build()
        
        response = self.http_client.cardkit.v1.card.create(request)
        if response.success():
            return response.data.card_id
        return None
    
    def update_card_content(self, card_id: str, content: str) -> bool:
        """更新卡片内容"""
        card_data = {
            "schema": "2.0",
            "config": {"update_multi": True},
            "body": {"elements": [{"tag": "markdown", "content": content}]}
        }
        seq = self._get_next_sequence(card_id)
        
        # 注意:card 参数需要 Card 对象,不是字符串
        card_obj = Card.builder() \
            .type("card_json") \
            .data(json.dumps(card_data)) \
            .build()
        
        request = UpdateCardRequest.builder() \
            .card_id(card_id) \
            .request_body(
                UpdateCardRequestBody.builder()
                .card(card_obj)
                .sequence(seq)  # 必须严格递增
                .build()
            ).build()
        
        return self.http_client.cardkit.v1.card.update(request).success()

3.2.2 流式更新节流

Agent 输出非常快,但飞书 API 有频率限制,需要节流处理:

# agent.py - Agent 流式调用

import asyncio
import time
from typing import Callable

# 节流间隔(秒)- 控制更新频率
UPDATE_THROTTLE_INTERVAL = 0.5

def make_throttled_callback(
    do_update: Callable[[str], None],
    interval: float = UPDATE_THROTTLE_INTERVAL,
) -> Callable[[str, bool], None]:
    """
    创建带节流的回调函数
    - 每隔 interval 秒最多执行一次 do_update
    - is_last=True 时强制执行(保证最终内容同步)
    """
    state = {"last_update_time": 0.0, "last_content": ""}
    
    def on_chunk(accumulated: str, is_last: bool) -> None:
        state["last_content"] = accumulated or "正在处理..."
        now = time.time()
        
        # 节流逻辑:间隔足够 或 最后一次
        if is_last or (now - state["last_update_time"]) >= interval:
            do_update(state["last_content"])
            state["last_update_time"] = now
    
    return on_chunk


def run_agent_with_card(
    plugin: "FeishuPlugin",
    card_id: str,
    user_text: str,
) -> None:
    """使用卡片进行流式更新"""
    
    def do_update(content: str) -> None:
        plugin.update_card_content(card_id, content)
    
    on_chunk = make_throttled_callback(do_update)
    
    async def _run():
        # 假设 run_agent_streaming 是 Agent 的流式调用函数
        final = await run_agent_streaming(user_text, on_chunk)
        # 兜底更新:确保最终内容已同步
        do_update(final or "处理完成")
    
    # 适配事件循环
    try:
        loop = asyncio.get_running_loop()
        loop.create_task(_run())
    except RuntimeError:
        asyncio.run(_run())

3.2.3 消息处理主逻辑

# client.py - 主客户端

class FeishuPlugin(CardMixin, MessageMixin):
    """飞书机器人主类"""
    
    def __init__(self, app_id: str, app_secret: str):
        # ... 初始化 http_client, ws_client 等
        self._card_sequence: dict[str, int] = {}  # 卡片序号
        self._processed_ids: set[str] = set()     # 消息去重
    
    def handle_message(self, message_id: str, user_text: str, open_id: str):
        """处理用户消息"""
        # 1. 创建卡片实体
        card_id = self.create_card_entity("正在处理...", title="🤖 AI 助手")
        
        if card_id:
            # 2. 发送卡片消息
            self.send_card_message(open_id, card_id)
            # 3. 启动 Agent 流式更新
            run_agent_with_card(self, card_id, user_text)
        else:
            # fallback: 普通消息(有编辑次数限制)
            reply_id = self.send_text_message(open_id, "正在处理...")
            run_agent_with_message(self, reply_id, user_text)

3.3 SDK 踩坑记录

坑 1:UpdateCardRequestBody.card 参数类型

错误写法

UpdateCardRequestBody.builder().card(card_json_str)  # ❌ 字符串

正确写法

card_obj = Card.builder().type("card_json").data(card_json_str).build()
UpdateCardRequestBody.builder().card(card_obj)  # ✅ Card 对象

坑 2:sequence 必须严格递增

# 错误:每次都传 1
.sequence(1)  # ❌ 第二次更新会失败

# 正确:维护递增序号
seq = self._card_sequence.get(card_id, 0) + 1
self._card_sequence[card_id] = seq
.sequence(seq)  # ✅

坑 3:asyncio.run() 不能在已有事件循环中调用

WebSocket 回调已在事件循环中,再调 asyncio.run() 会报错:

# 错误
asyncio.run(async_func())  # ❌ RuntimeError

# 正确
try:
    loop = asyncio.get_running_loop()
    loop.create_task(async_func())  # 挂到现有循环
except RuntimeError:
    asyncio.run(async_func())  # 无循环时新建

四、最佳实践总结

4.1 架构设计

原则说明
职责分离卡片、消息、Agent 调用拆分为独立模块
Mixin 模式功能模块通过 Mixin 组合到主类
优雅降级卡片创建失败时 fallback 到普通消息

4.2 流式更新

策略实现
节流每 0.5s 最多更新一次,避免打爆 API
兜底流式结束后强制同步最终内容
序号管理维护 card_id → sequence 映射,严格递增

4.3 异常处理

flowchart TD
    A[收到用户消息] --> B{创建卡片成功?}
    B -->|是| C[发送卡片消息]
    B -->|否| D[发送文本消息]
    C --> E[启动 Agent]
    D --> F[启动 Agent - fallback 模式]
    E --> G{更新失败?}
    G -->|是| H[记录日志, 继续]
    G -->|否| I[继续更新]
    F --> J[可能触发编辑上限]

五、总结

问题解决方案
消息编辑次数上限改用卡片实体 + CardKit API
Agent 输出过快节流回调,0.5s 更新一次
最终内容可能丢失流式结束后兜底更新
SDK 参数类型坑Card 对象而非字符串
异步上下文冲突判断是否已有事件循环

这套方案已在生产环境稳定运行,支持 Agent 输出上万字的长文本,更新次数不受限制。


附录:相关 API 文档