企业微信API接口开发实战:如何构建支持流式回复的智能机器人
最近做的企微二开,业务方盯着 ChatGPT 那种"一个字一个字蹦"的回复体验,要求我们的企微机器人也搞流式——客户问一句话,机器人别憋半天才回一大坨,要边生成边发。但企微消息接口本身没有真正的流式(没有 edit message 能力),落地得绕一下。把踩的坑和最终方案记下来。
底层用的是 Eyun 平台开放的企微 API,统一 POST+JSON,鉴权用 App Token 加 appid,请求头带 Authorization: Bearer eyk_xxxx,路径统一 {BASE_URL}/wx-api/api/<模块>/<动作>,响应封套 {code, data, detail, message, time},code 为 0 成功。所谓"流式回复"在企微里本质是分段发送——大模型流式生成、缓冲到自然段切分点、调多次 sendText 把段落分批发出去,模拟流式体验。
第一步:消息进来——Webhook 收提问
客户在企微发问题,回调进来:
@app.route("/wx-api/webhook/", methods=["POST"])
def webhook():
if request.headers.get("X-Eyun-Event") != "message":
return "ok"
data = request.json["data"]
stream_reply(data["appid"], data["fromUin"], data["content"])
return "ok"
Webhook 路径 /wx-api/webhook/,首次创建返回 secret 用于验签。回调要 3 秒内回 200,所以流式生成放在后台任务跑,回调立即返回。否则平台会重试,导致同一问题生成多份答案。
第二步:调大模型流式接口(SSE)
大模型要开 stream=true,模型一边生成一边吐 token,首字延迟从几秒缩到几百毫秒。我们用 OpenAI 兼容接口:
def stream_llm(messages, on_chunk):
resp = requests.post(LLM_URL, json={
"model": "gpt-4",
"messages": messages,
"stream": True
}, stream=True)
buffer = ""
for line in resp.iter_lines():
if line.startswith(b"data: "):
chunk = json.loads(line[6:])["choices"][0].get("delta", {}).get("content", "")
if chunk:
buffer += chunk
on_chunk(chunk, buffer)
return buffer
on_chunk 是回调,每次模型吐 token 都被调用。但企微没有 edit message 接口,每吐一个字就发一条消息是灾难——客户会被刷屏刷到卸载企微。所以 on_chunk 里不能直接发,要缓冲。
第三步:缓冲到自然段切分点再发
策略是按"自然段"切分。模型吐出来的 token 累积到出现换行、句号、问号这种自然停顿点时,把累积的段落发出去。这样既保留了"边生成边发"的体验,又控制了消息条数。
def stream_reply(appid, to_uin, content):
messages = build_messages_with_history(appid, to_uin, content)
pending = ""
sent_count = 0
def on_chunk(chunk, buffer):
nonlocal pending, sent_count
pending += chunk
# 自然段切分点:换行、句号、问号
if should_flush(pending) and sent_count < 3:
send_text(appid, to_uin, pending.strip())
pending = ""
sent_count += 1
final = stream_llm(messages, on_chunk)
if pending.strip():
send_text(appid, to_uin, pending.strip())
should_flush 判断当前缓冲是否到了自然段末尾。sent_count < 3 是硬上限——一条回答最多分 4 段发,再多就是刷屏。超过 3 段就把剩余内容合并到最后一段一次发出去,宁可丢流式感也不能刷屏。
第四步:首字占位——别让客户干等
模型生成首字可能要 1-2 秒,这几秒客户看不到任何反应会以为机器人卡了。接收到回调后立刻发一条占位消息:
def stream_reply(appid, to_uin, content):
send_text(appid, to_uin, "正在思考...")
# 然后开始流式生成
...
占位消息用 message/sendText 发,to 字段填提问者 uin。占位不要发"请稍等"这种话——客户看了心烦,发个"正在思考..."这种拟人化提示就行。
第五步:超长答案分段策略
模型答案超过 500 字时,即使按自然段切分也会超长。企微单条消息有长度上限,超过会被截断。处理方式是按字符数硬切分:
def split_long_text(text, max_len=400):
paragraphs = text.split("\n")
chunks, current = [], ""
for p in paragraphs:
if len(current) + len(p) > max_len:
if current:
chunks.append(current)
current = p
else:
current = (current + "\n" + p) if current else p
if current:
chunks.append(current)
return chunks
def send_long_answer(appid, to_uin, text):
for chunk in split_long_text(text):
send_text(appid, to_uin, chunk)
time.sleep(0.3) # 避免发太快触发频控
按段落切分而不是按字符硬切,避免把一句话从中间断开。time.sleep(0.3) 是必要的——企微对同一会话的发送频率有上限,连发会触发频控报错。频控报错码在网关层是字符串 rate_limit,要识别后做退避重试。
第六步:流式中的失败兜底
流式生成中途模型断流(网络抖动、模型限流)怎么办?已经发了前几段,后半段没了,客户看到半截答案。处理方式是检测到流中断时,调一次非流式补全:
def stream_reply(appid, to_uin, content):
...
try:
final = stream_llm(messages, on_chunk)
except StreamInterrupted:
# 流断了,用非流式把剩余补全
final = non_stream_llm(messages + [{"role": "assistant", "content": pending}])
send_text(appid, to_uin, final[len(pending):])
把已经发出去的内容作为 assistant 消息拼回去,让模型从断点续写。客户感知不到断流,只看到答案继续往下走。
几个权衡点
流式回复的体验提升是实在的——客户首字等待从 5 秒缩到 1 秒,但代价是消息条数增加、消息历史被流式段落切碎。要权衡几个点:
-
短回答(< 100 字)不开流式,直接一次发完,避免占位消息和正式消息两条刷屏
-
长回答按自然段切分,最多 4 段
-
占位消息只在模型生成超过 1 秒时发,模型快就省掉占位
-
失败要兜底,别让客户看到半截答案
流式这套东西本质是多次 sendText 调用模拟出来,企微接口本身不支持真流式——把切分点选好、上限守住、失败兜底做严,客户用起来感觉是真的流式,就够了。
写在最后
流式回复这套,本质是 Webhook 收消息、大模型流式生成(SSE)、缓冲到自然段切分点调 message/sendText 多次发送、超长按段落硬切、失败用非流式补全。AI 流式吐 token 的体验好,但企微端没有 edit message 能力,靠分段发送模拟。把切分策略选对、上限守住、兜底做严,机器人回复体验从"半天憋一坨"变成"边想边答",业务方和客户的反馈都会明显不同。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)