在测试中发现两个问题:
- 消息不是流式更新: LLM 回复是一次性显示,而不是打字机效果
- Markdown 格式未被识别: 发送的内容格式不正确
- 飞书支持流式卡片 (Streaming Card)
- 使用
Cardkit.V1.Card.CreateAPI 创建流式卡片 - 使用
Cardkit.V1.CardElement.ContentAPI 更新卡片元素 - 采用定时更新机制(每 300ms 更新一次)
第一步: 创建流式卡片
cardTemplate := map[string]interface{}{
"schema": "2.0",
"header": map[string]interface{}{
"title": map[string]interface{}{
"content": "AI 回答",
"tag": "plain_text",
},
},
"config": map[string]interface{}{
"streaming_mode": true, // 启用流式模式
"summary": map[string]interface{}{
"content": "",
},
},
"body": map[string]interface{}{
"elements": []map[string]interface{}{
{
"tag": "markdown", // 使用 markdown 标签
"content": initialContent,
"element_id": "markdown_content", // 元素 ID,用于后续更新
},
},
},
}
cardID, err := h.client.CreateStreamingCard(ctx, "AI 回答", initialContent)第二步: 发送卡片消息
cardContent := map[string]interface{}{
"type": "card",
"data": map[string]string{
"card_id": cardID,
},
}
_, err = h.client.SendCardMessage(ctx, receiveIDType, receiveID, cardID)第三步: 流式更新卡片元素
updateTicker := time.NewTicker(300 * time.Millisecond)
sequence := 0
for {
select {
case content := <-responseCh:
fullResponse.WriteString(content)
case <-updateTicker.C:
sequence++
h.client.UpdateCardElement(ctx, cardID, "markdown_content", currentContent, sequence)
}
}Client.CreateStreamingCard() - 创建流式卡片
func (c *Client) CreateStreamingCard(ctx context.Context, title, initialContent string) (string, error) {
req := larkcardkit.NewCreateCardReqBuilder().
Body(larkcardkit.NewCreateCardReqBodyBuilder().
Type("card_json").
Data(string(cardData)).
Build()).
Build()
resp, err := c.client.Cardkit.V1.Card.Create(ctx, req)
// ...
return *resp.Data.CardId, nil
}Client.SendCardMessage() - 发送卡片消息
func (c *Client) SendCardMessage(ctx context.Context, receiveIDType, receiveID, cardID string) (*string, error) {
cardContent := map[string]interface{}{
"type": "card",
"data": map[string]string{
"card_id": cardID,
},
}
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(receiveIDType).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(receiveID).
MsgType("interactive").
Content(string(contentJSON)).
Build()).
Build()
// ...
}Client.UpdateCardElement() - 更新卡片元素
func (c *Client) UpdateCardElement(ctx context.Context, cardID, elementID, content string, sequence int) error {
uuid := fmt.Sprintf("%d-%d", ctx.Value("timestamp"), sequence)
req := larkcardkit.NewContentCardElementReqBuilder().
CardId(cardID).
ElementId(elementID).
Body(larkcardkit.NewContentCardElementReqBodyBuilder().
Uuid(uuid).
Content(content).
Sequence(sequence).
Build()).
Build()
resp, err := c.client.Cardkit.V1.CardElement.Content(ctx, req)
// ...
}之前使用 "text" tag,飞书不会解析 Markdown 格式。
之前(错误):
{
"tag": "text",
"text": content,
}现在(正确):
{
"tag": "markdown", // 使用 markdown 标签
"content": content, // 使用 content 字段而不是 text
"element_id": "markdown_content",
}- 使用流式卡片模板,设置
streaming_mode: true - 使用
"markdown"tag 支持 Markdown 格式 - 使用
element_id标识要更新的元素 - 使用递增的
sequence保证更新顺序
用户发送消息
↓
接收消息事件
↓
调用 LLM 流式 API
↓
创建流式卡片 (cardID)
↓
发送卡片消息 (messageID)
↓
┌─────────────────────────────────┐
│ 流式接收循环 │
│ ┌──────────────────────────┐ │
│ │ 每 300ms 更新一次 │ │
│ │ sequence 递增 │ │
│ └──────────────────────────┘ │
│ │
│ 收到新内容 → 累积 │
│ ↓ │
│ 定时器触发 → 更新卡片元素 │
│ ↓ │
│ 继续接收... │
└─────────────────────────────────┘
↓
流结束
↓
发送最终更新(带时间戳)
新增导入:
import (
larkcardkit "github.com/larksuite/oapi-sdk-go/v3/service/cardkit/v1"
)新增方法:
func (c *Client) CreateStreamingCard(ctx context.Context, title, initialContent string) (string, error)
func (c *Client) SendCardMessage(ctx context.Context, receiveIDType, receiveID, cardID string) (*string, error)
func (c *Client) UpdateCardElement(ctx context.Context, cardID, elementID, content string, sequence int) error修改流式处理逻辑:
func (h *MessageHandler) processLLMMessage(...) error {
// 1. 创建流式卡片
cardID, err := h.client.CreateStreamingCard(ctx, "AI 回答", initialContent)
// 2. 发送卡片消息
_, err = h.client.SendCardMessage(ctx, receiveIDType, receiveID, cardID)
// 3. 流式接收和更新
sequence := 0
for {
select {
case content := <-responseCh:
fullResponse.WriteString(content)
case <-updateTicker.C:
sequence++
h.client.UpdateCardElement(ctx, cardID, "markdown_content", currentContent, sequence)
}
}
}❌ 一次性显示
[等待3秒...]
完整回复内容一次性出现
❌ 格式问题
**问题:** 帮我查询一下腾讯云的cvm服务器
**回答:**
[内容未格式化]
✅ 流式显示(打字机效果)
正在思考中...
↓ (300ms 后)
**问题:** 帮我查询...
**回答:**
我来帮您...
↓ (300ms 后)
我来帮您查询腾讯云的CVM...
↓ (持续更新)
完整内容
✅ 格式正确
问题: 帮我查询一下腾讯云的cvm服务器
回答:
我来帮您查询腾讯云的CVM服务器信息。
[格式化的 Markdown 内容]
---
⏰ 2025-01-12 20:00:00
为什么是 300ms?
- 飞书 API 有频率限制
- 太频繁: 浪费 API 调用,影响性能
- 太慢: 用户体验差
- 300ms: 平衡性能和体验,比之前的 500ms 更流畅
if currentContent != lastUpdate && len(currentContent) > len(questionHeader) {
sequence++
h.client.UpdateCardElement(ctx, cardID, "markdown_content", currentContent, sequence)
lastUpdate = currentContent
}- 只在内容变化时更新
- 避免重复更新相同内容
- 减少 API 调用次数
- 每次更新 sequence 递增
- 确保更新顺序正确
- 飞书服务端根据 sequence 排序
-
重新编译
go build -o zenops main.go
-
重启服务
./zenops run --config config.yaml
-
测试流式更新
- 在飞书中发送: "帮我查询一下腾讯云的cvm服务器"
- 观察消息是否逐步更新
- 检查是否有打字机效果
-
测试 Markdown 格式
- 发送: "帮助"
- 检查格式是否正确显示
- 确认标题、列表、代码块等格式
✅ 流式更新:
- 消息先显示 "正在思考中..."
- 然后逐步显示完整内容
- 每 300ms 更新一次
- 有打字机效果
✅ 格式正确:
- 粗体、斜体 正常显示
- 标题、段落正确显示
- 列表、分隔线正常
- 代码块格式正确
- 时间戳在底部
- 卡片更新频率: 建议不超过每秒 3 次
- 卡片大小: 单个卡片最大 100KB
- Sequence 范围: 0-2^31-1
- UUID 唯一性: 每次更新需要唯一 UUID
- 仅支持 Markdown: 使用 markdown 元素
- 固定更新频率: 300ms 不可配置
- 单一元素更新: 仅更新一个 markdown 元素
- 无重试机制: 更新失败仅记录日志
-
优化 UUID 生成
- 使用真正的 UUID 库
- 当前使用时间戳+序列号的简单方案
-
支持更丰富的卡片元素
- 图片、链接等
- 多个 markdown 元素
- 交互式按钮
-
自适应更新频率
- 内容变化大时更快更新
- 内容变化小时降低频率
-
错误重试机制
- 更新失败时自动重试
- 指数退避策略
-
卡片模板管理
- 支持自定义卡片模板
- 支持多种卡片风格
-
性能监控
- 统计更新成功率
- 监控 API 调用延迟
更新时间: 2025-01-12 版本: v2.0 (基于飞书流式卡片)