本文总结了在开发 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 踩坑:消息编辑次数上限
最初的实现方案是使用飞书的编辑消息 API(PATCH /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
卡片实体的优势:
- 无编辑次数限制:适合高频更新场景
- 支持 JSON 2.0:富文本、Markdown、交互组件等
- 流式更新专用配置:
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.py | CardMixin:卡片模板、创建、更新、发送 |
message.py | MessageMixin:文本消息发送、回复、编辑 |
agent.py | Agent 流式调用,节流回调,兜底更新 |
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 输出上万字的长文本,更新次数不受限制。