perf(feishu): tune streaming render speed

This commit is contained in:
zhayujie
2026-05-05 14:53:30 +08:00
parent a7cbd47a2f
commit 8f608223d7
4 changed files with 72 additions and 24 deletions

View File

@@ -533,10 +533,12 @@ class FeiShuChanel(ChatChannel):
import time as _time
# 共享状态(受 lock 保护)
committed_text = [""] # 已结束轮次的累积内容(含分隔符)
current_text = [""] # 当前轮 LLM 输出的累积内容
card_id = [None] # 创建出来的卡片实体 ID
message_id = [None] # 卡片发送后的消息 ID仅日志用
# 多轮 agent 模式下,每个"中间过场消息"会作为一张独立卡片发送。
# current_text 只承载当前正在流式渲染的那张卡片的内容message_end / agent_end
# 时会把它定型并 reset。
current_text = [""] # 当前卡片正在累加的 LLM 输出
card_id = [None] # 当前流式卡片的实体 ID每段独立
message_id = [None] # 当前卡片发送后的消息 ID仅日志用
last_update_time = [0.0]
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
# 事件各自触发一次创建+发送,导致发出多张卡片。
@@ -549,9 +551,13 @@ class FeiShuChanel(ChatChannel):
is_group = context.get("isgroup", False)
receiver = context.get("receiver")
receive_id_type = context.get("receive_id_type", "open_id")
# 后端推流间隔与客户端打字机渲染参数:飞书原生 streaming_config 默认值经验证
# 已能在大部分场景下取得平滑的打字机效果,无需暴露给用户配置。
interval_s = 0.3
# 后端推流节流:首个 chunk 立即推(最低首字延迟),之后每 200ms 一波。
# 客户端按 70ms/字 渲染(约 14 字/秒)是真正的速度瓶颈,再频繁推送也只会
# 在飞书云端排队,不会让用户感知更快,但会增加一倍以上的 PUT 请求。
# 飞书 streaming_mode 豁免 10qps 限制,但带宽和 CPU 成本仍是真实开销。
interval_s = 0.2
# 客户端打字机渲染参数:飞书默认 step=1约 14 字/秒)实测偏慢,
# 调成 step=2约 28 字/秒)更接近 ChatGPT 等同类产品的节奏。
print_freq_ms = 70
print_step = 2
print_strategy = "fast"
@@ -753,7 +759,7 @@ class FeiShuChanel(ChatChannel):
if disabled[0]:
return
# 第二段:累加当前文本,按节流推送(锁内只读写状态)
# 第二段:累加当前卡片文本,按节流推送(锁内只读写状态)
should_push = False
snapshot = ""
with lock:
@@ -761,29 +767,67 @@ class FeiShuChanel(ChatChannel):
now = _time.time()
if card_id[0] and (now - last_update_time[0] >= interval_s):
last_update_time[0] = now
snapshot = committed_text[0] + current_text[0]
snapshot = current_text[0]
should_push = True
if should_push:
_stream_update_text(snapshot)
elif event_type == "message_end":
# 一轮 LLM 输出结束。如果本轮触发了工具调用,当前轮内容定型到 committed
# 并加分隔符否则当前轮就是最终内容agent_end 会处理)。
# 一轮 LLM 输出结束。如果本轮触发了工具调用,说明当前轮的文本是
# "中间过场消息"(如"来看看!"),应该作为独立卡片定型,然后为下一轮
# 重新创建一张新卡片。这样最终用户看到的是:
# [卡片1: 中间过场1]
# [卡片2: 中间过场2]
# ...
# [卡片N: 最终回复]
# 与 wecom_bot 的多消息流式体验对齐。
tool_calls = data.get("tool_calls", []) or []
if tool_calls:
if not tool_calls:
# 没有工具调用:本轮即最终回复,留给 agent_end 统一处理。
return
with lock:
if current_text[0].strip():
committed_text[0] += current_text[0].rstrip() + "\n\n---\n\n"
text_to_finalize = current_text[0].rstrip()
current_text[0] = ""
if not text_to_finalize:
return
# 用最终文本覆盖当前卡片并关闭流式模式(凝固成普通卡片)
_stream_update_text(text_to_finalize)
_close_streaming_mode()
# 重置卡片状态,下一段 message_update 会触发新卡片的创建
with lock:
card_id[0] = None
message_id[0] = None
sequence[0] = 0
last_update_time[0] = 0.0
elif event_type == "agent_end":
# 用 final_response 强制覆盖整张卡片:丢弃中间累积,避免拼接错误
# 最终回复:用 final_response 覆盖当前流式卡片,然后关闭流式模式
final_response = data.get("final_response", "")
if final_response:
if not final_response:
return
final_text = str(final_response)
# 标记 streamed 让 chat_channel 跳过 send()
context["feishu_streamed"] = True
with lock:
has_card = card_id[0] is not None
init_busy = init_in_flight[0]
# 罕见情况agent_end 触发时还没创建过卡片(极快返回 / 没有
# message_update主动创建一张承载 final_text。
if not has_card and not init_busy:
with lock:
init_in_flight[0] = True
_create_and_send_card()
with lock:
if disabled[0]:
return
_stream_update_text(final_text)
_close_streaming_mode()

View File

@@ -44,6 +44,7 @@ class StoryTeller:
@plugins.register(
name="Dungeon",
desire_priority=0,
enabled=False,
namecn="文字冒险",
desc="A plugin to play dungeon game",
version="1.0",

View File

@@ -13,6 +13,7 @@ from config import conf
name="Hello",
desire_priority=-1,
hidden=True,
enabled=False,
desc="A simple plugin that says hello",
version="0.1",
author="lanvent",

View File

@@ -34,7 +34,9 @@ class PluginManager:
plugincls.version = kwargs.get("version") if kwargs.get("version") != None else "1.0"
plugincls.namecn = kwargs.get("namecn") if kwargs.get("namecn") != None else name
plugincls.hidden = kwargs.get("hidden") if kwargs.get("hidden") != None else False
plugincls.enabled = True
# enabled 默认 True示例性插件可在装饰器中显式传 enabled=False
# 首次启动写入 plugins.json 时即为关闭状态,避免拦截用户消息。
plugincls.enabled = kwargs.get("enabled", True)
if self.current_plugin_path == None:
raise Exception("Plugin path not set")
self.plugins[name.upper()] = plugincls