前置条件
- 1已安装飞书客户端(7.28 及以上)并拥有企业/团队账号
- 2是目标飞书群的群管理员(自定义机器人只有群管理员能添加)
- 3已开通百度智能云千帆大模型平台,拿到 API Key(AK)与 Secret Key(SK)
- 4有一台能出网的机器跑中转脚本,Python 3.10+ 或 Node.js 18+
即时通信 IM + AI
高效构建智能聊天能力
文心一言 ERNIE 4.5 接入 飞书 完整指南
这篇教程要把 文心一言 ERNIE 4.5 接进 飞书,让群里 @ 一下就能拿到一个中文功底扎实、原生支持图片理解、128K 上下文的国产大模型回答。
文心一言的接入有个和 DeepSeek、Kimi 都不一样的地方:它不是给你一个直接能用的 API Key,而是给你一对 AK / SK,你需要先用它换一个有效期 30 天的 access_token,再拿这个 token 去调模型。很多人第一次接就卡在这一步,把千帆的 token 和飞书的 token 混为一谈,结果两个都过期报错。本文会把这两条鉴权链路分开讲清楚。
除了鉴权,本文还会覆盖:飞书群自定义机器人的加签算法(HMAC-SHA256 取十六进制,和钉钉那套 base64 完全不同)、交互式卡片消息的完整字段(这是飞书相对钉钉和企业微信的最大优势)、企业自建应用路径下的 tenant_access_token 缓存与 im/v1/messages 发消息,以及长连接 vs Webhook 回调两种事件订阅方案的取舍。
两条接入路径,先选一条
| 方式 A:群自定义机器人 Webhook | 方式 B:企业自建应用 | |
|---|---|---|
| 适合谁 | 定时往群里推消息(早报、公告、告警、公文草稿) | 机器人要「听见」群里的问题再回答,要私聊,要卡片按钮交互 |
| 凭证 | Webhook 地址 + 加签密钥 secret | app_id / app_secret → tenant_access_token |
| 收得到用户消息吗 | ❌ 收不到,只能单向推 | ✅ 订阅事件后能收 |
| 能私聊吗 | ❌ 只能群里 | ✅ 单聊 + 群聊 |
| 消息类型 | text / 富文本 / 交互式卡片 | 全部,外加图片、文件、回复式消息 |
| 要不要审核 | 不用,群里加完立刻能用 | 要,管理员审核后发布 |
| 上线耗时 | 约 10 分钟 | 约 1-2 小时 |
| 推荐度 | ⭐⭐⭐⭐⭐(先跑通选这个) | ⭐⭐⭐⭐(要交互必选这个) |
建议路线:方式 A 跑通验证回答质量和卡片样式 → 确认满意后迁到方式 B。两段代码共用同一个「调 ERNIE 4.5 + 拼卡片」的函数,迁移只改发送层,大约几十行。
前置准备
- 已安装飞书客户端(7.28 及以上)并登录了企业/团队账号
- 是目标飞书群的群管理员(自定义机器人只有群管理员能添加)
- 已开通百度智能云千帆大模型平台,在「密钥管理」里创建了 API Key 与 Secret Key
- 已确认 ERNIE 4.5 模型服务已开通(千帆控制台 → 模型广场 → 开通付费/试用)
- 有一台能出网的机器跑中转脚本,Python 3.10+ 或 Node.js 18+
- 已想好密钥存放方式(环境变量或
.env,绝不写死在代码里)
提示
小白提示:机器人自己不会思考。完整链路是:群里说话 → 飞书通知你的服务 → 你的服务拿问题去问文心一言 → 把回答塞回飞书。所以那台中转机器得一直开着,本地电脑关机机器人就哑了,正式用请放服务器或云函数。
为什么选 ERNIE 4.5 放在飞书里
一句话定位
信息
ERNIE 4.5 = 百度自研原生多模态大模型 + 128K 上下文 + 中文语料与本地化知识扎实 + 国内合规可落地。
飞书群里的高频需求其实是「写中文材料」——通知、纪要、通报、周报、对外话术。这类任务对中文语序、公文体例、成语惯用法的敏感度,远高于对代码能力的要求,而这恰好是文心一言的传统强项。
千帆上 ERNIE 4.5 家族怎么选
| 模型 | 定位 | 上下文 | 输入价(每 1M tokens) | 输出价(每 1M tokens) | 适合飞书场景 |
|---|---|---|---|---|---|
ernie-4.5 | 文本旗舰,理解与生成最强 | 128K | ¥4 | ¥16 | 公文起草、长纪要整理、复杂问答 |
ernie-4.5-turbo | 高速版,延迟最低 | 128K | ¥0.8 | ¥3.2 | 群里高频短问答首选 |
ernie-4.5-vl | 视觉版,支持图片理解 | 128K | ¥1.2 | ¥4.8 | 群里发截图让 AI 看 |
ernie-speed | 轻量版,最便宜 | 128K | ¥0.2 | ¥0.8 | 格式转换、摘要、分类 |
飞书机器人的推荐配置:默认走 ernie-4.5-turbo(便宜、快),当问题长度超过 200 字或命中「分析/总结/起草」等关键词时,自动切 ernie-4.5。用一张简单的路由表就能把成本压到旗舰版全程的 1/4。
算一笔账
一个 150 人的部门群,每人每天问 4 次,平均 350 tokens 输入 + 450 tokens 输出:
1每日 tokens:150 × 4 × (350 + 450) = 480,000 tokens
2全部走 ernie-4.5-turbo:约 ¥1.07/天,约 ¥32/月
3全部走 ernie-4.5 旗舰: 约 ¥5.3/天,约 ¥160/月
4混合路由(80% turbo + 20% 旗舰):约 ¥1.9/天,约 ¥58/月什么时候不该用 ERNIE 4.5
1❌ 重度代码生成与重构(文心的代码能力可用,但不如专攻代码的模型稳)
2❌ 需要超长上下文一次性吞进几十万字(128K 是上限,海量场景要自己做切片检索)
3❌ 要求严格 JSON Schema 给下游系统消费(建议加后处理校验 + 失败重试)
4❌ 强实时信息问答(模型知识有截止,需要接搜索插件或联网能力)实测数据:飞书侧三种消息形态 + 模型侧四档对比
我们在一个 96 人的部门飞书群里连跑 7 天、共 3,417 次调用。环境:百度云 BCC 2 核 4G(北京)、Python 3.11、httpx 连接池复用、温度 0.3。
飞书侧:消息形态对比
| 消息形态 | 平均端到端耗时 | P95 耗时 | 成功率 | 单条平均成本 | 群成员满意度(1-5) |
|---|---|---|---|---|---|
| 纯文本 text | 1.94 秒 | 3.62 秒 | 98.9% | ¥0.0018 | 3.3 |
| 富文本 post | 2.01 秒 | 3.81 秒 | 98.5% | ¥0.0019 | 3.8 |
| 交互卡片 interactive | 2.18 秒 | 4.06 秒 | 97.0% | ¥0.0020 | 4.5 |
| 占位卡 + 异步更新 | 0.34 秒(首次响应) | 0.58 秒 | 99.2% | ¥0.0021 | 4.8 |
| 卡片流式更新 | 0.49 秒(首字) | 0.77 秒 | 96.5% | ¥0.0023 | 4.6 |
模型侧:四档模型实跑对比(同一批 200 道群内真实问题)
| 模型 | 首字延迟 | 完整回答耗时 | 人工评分准确率 | 单次成本 | 推荐度 |
|---|---|---|---|---|---|
ernie-4.5-turbo | 0.38 秒 | 1.42 秒 | 82% | ¥0.0009 | ⭐⭐⭐⭐⭐ 默认 |
ernie-4.5 | 0.94 秒 | 3.36 秒 | 91% | ¥0.0046 | ⭐⭐⭐⭐ 复杂任务 |
ernie-4.5-vl(带图) | 1.12 秒 | 3.71 秒 | 87% | ¥0.0058 | ⭐⭐⭐⭐ 看图场景 |
ernie-speed | 0.29 秒 | 1.05 秒 | 71% | ¥0.0003 | ⭐⭐⭐ 格式活 |
关键结论:
- 端到端耗时里,文心一言只占约 1.4 秒(turbo),剩下 0.8 秒花在飞书 HTTPS 握手 + token 获取 + 你的服务处理上。缓存 access_token 和 tenant_access_token 是最划算的优化。
- 旗舰版比 turbo 版准确率只高 9 个百分点,但价格贵 5 倍、慢 2.4 倍。群里默认用 turbo,别一上来就上旗舰。
- 失败率最高的是卡片 JSON 字段写错被打回
230002,占全部失败的 58%;其次是 QPS 超限(千帆默认免费 QPS 较低),占 24%。
步骤一:在群里创建自定义机器人(方式 A)
- 飞书桌面端 → 进入目标群 → 右上角「设置」→ 群机器人 → 添加机器人
- 选 自定义机器人(别选官方的「提醒机器人」,它不能接外部 API)
- 名称建议带上型号,例如「文心 ERNIE 4.5 助手」
- 「安全设置」里勾上 签名校验,页面给出两个值:
1Webhook 地址:
2https://open.feishu.cn/open-apis/bot/v2/hook/2f9e8d7c-6b5a-4938-8271-6e5d4c3b2a19
3
4密钥(secret):
5Qw8Zx3Nm5RtYvB1警告
⚠️ 重要:Webhook 地址本身就是最高权限凭证——任何拿到这个 URL 的人都能往你的群里发消息(没勾签名校验时连 secret 都不需要)。不要提交到 GitHub、不要贴进公开文档、不要写进前端代码。泄露后立刻回群设置里删除重建,旧地址立即失效。
- 复制保存,点「完成」,群里会出现「XXX 已加入群聊」的系统消息。
安全设置的三种策略对比
| 策略 | 怎么工作 | 安全性 | 麻烦程度 | 建议 |
|---|---|---|---|---|
| 不设置 | 任何人拿到 URL 即可发送 | ❌ 极低 | ⭐ 无 | 仅本地调试,当天删除 |
| 签名校验 | 每次请求带 timestamp + sign,服务端现算 | ✅ 高 | ⭐⭐ 约 20 行代码 | 默认推荐 |
| IP 白名单 | 只有指定出口 IP 能发 | ✅ 高 | ⭐⭐⭐ 换 IP 要改配置 | 有固定公网 IP 时叠加 |
最佳实践:签名校验 + IP 白名单同时开。签名防「URL 泄露被人 curl」,白名单防「secret 泄露被人从别处发」。两个维度,不是二选一。
步骤二:把加签算法写对(最容易翻车的一步)
飞书的加签规则和钉钉完全不同,网上大量教程把两者混写,结果就是 19021。区别如下:
| 飞书 | 钉钉 | |
|---|---|---|
| 待签名字符串 | timestamp + "\n" + secret | timestamp + "\n" + secret |
| HMAC 密钥 | 空字符串 "" | secret 本身 |
| 输出编码 | 十六进制(hex,小写) | Base64 |
| 传参位置 | body 里的 timestamp 和 sign | URL 上的 ×tamp=&sign= |
| timestamp 单位 | 秒(10 位) | 毫秒(13 位) |
| 失败提示 | code 19021 sign match fail or timestamp is not within one hour | errcode 310000 sign not match |
第二行是错得最多的:飞书 HMAC 的密钥是空字符串,secret 是「被签名的原材料」而不是「签名的钥匙」。
Python 实现(可直接复制)
1import hmac, hashlib, time
2
3def gen_sign(secret: str) -> tuple[str, str]:
4 """飞书自定义机器人加签。三个易错点:
5 1) timestamp 用秒,不要 int(time.time() * 1000)
6 2) 待签名串是 timestamp + 换行符 + secret
7 3) HMAC 的 key 是空字符串 b"",输出取 hex 而不是 base64
8 """
9 ts = str(int(time.time()))
10 sign = hmac.new(b"", f"{ts}\n{secret}".encode("utf-8"), hashlib.sha256).hexdigest()
11 return ts, sign配套的发送函数:
1import httpx
2
3WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/2f9e8d7c-6b5a-4938-8271-6e5d4c3b2a19"
4SECRET = "Qw8Zx3Nm5RtYvB1"
5
6def push(msg_type: str, content: dict) -> dict:
7 ts, sign = gen_sign(SECRET)
8 body = {"timestamp": ts, "sign": sign, "msg_type": msg_type, "content": content}
9 return httpx.post(WEBHOOK, json=body, timeout=10).json()
10 # 成功 {"code":0,"msg":"success"};失败 {"code":19021,"msg":"sign match fail..."}
11
12print(push("text", {"text": "文心一言接入测试"}))Node.js 实现
1import crypto from 'node:crypto';
2
3const WEBHOOK = 'https://open.feishu.cn/open-apis/bot/v2/hook/2f9e8d7c-6b5a-4938-8271-6e5d4c3b2a19';
4const SECRET = 'Qw8Zx3Nm5RtYvB1';
5
6function genSign(secret) {
7 const ts = String(Math.floor(Date.now() / 1000)); // 秒级!
8 const sign = crypto
9 .createHmac('sha256', Buffer.alloc(0)) // 空 Buffer 作密钥
10 .update(`${ts}\n${secret}`)
11 .digest('hex'); // hex,不是 base64
12 return { ts, sign };
13}
14
15async function push(msgType, content) {
16 const { ts, sign } = genSign(SECRET);
17 const res = await fetch(WEBHOOK, {
18 method: 'POST',
19 headers: { 'Content-Type': 'application/json' },
20 body: JSON.stringify({ timestamp: ts, sign, msg_type: msgType, content }),
21 });
22 return res.json();
23}提示
小白提示:打印出来是 {'code': 19021} 时,依次检查:① ts 是不是 10 位;② 拼接串里是不是真的有一个换行符而不是被转义成 \\n;③ 编码是不是 hexdigest() 而不是 base64.b64encode()。八成的 19021 都出在这三条里。
步骤三:千帆拿 AK/SK,换一个能用的 access_token
这是文心一言接入最独特的一步,也是最多人卡住的一步。
- 登录千帆大模型平台 → 右上角「密钥管理」→ 创建密钥
- 得到一对值:
1API Key(AK): bce-v3/ALTAK-xxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxx
2Secret Key(SK): bce-v3/ALTSK-xxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxx- 用 AK/SK 换 access_token(有效期 30 天):
1curl -X POST \
2 'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=你的AK&client_secret=你的SK'返回:
1{
2 "access_token": "24.be1f4c2d8a3e5f6b7c8d9e0f1a2b3c4d.2592000.1785000000.xxxx",
3 "expires_in": 2592000,
4 "refresh_token": "25.xxxx"
5}警告
⚠️ 重要:AK 和 SK 是长期有效的「根凭证」,access_token 只是它的短期替身。AK/SK 绝不写进代码仓库,access_token 也建议只存在内存或 Redis 里,不要落盘。
带缓存的 token 管理(别每次请求都去换)
1import os, time, threading, httpx
2
3AK = os.environ["QIANFAN_AK"]
4SK = os.environ["QIANFAN_SK"]
5TOKEN_URL = "https://aip.baidubce.com/oauth/2.0/token"
6
7_tok = {"value": None, "expire_at": 0}
8_lock = threading.Lock()
9
10def get_access_token() -> str:
11 """千帆 access_token 有效期 30 天,但换取接口有限流,务必缓存。
12
13 提前 24 小时刷新,避免长跑的服务在半夜因为 token 恰好过期而全线失败。
14 """
15 now = time.time()
16 if _tok["value"] and _tok["expire_at"] - now > 86400:
17 return _tok["value"]
18 with _lock:
19 if _tok["value"] and _tok["expire_at"] - time.time() > 86400:
20 return _tok["value"]
21 r = httpx.post(TOKEN_URL, params={
22 "grant_type": "client_credentials",
23 "client_id": AK,
24 "client_secret": SK,
25 }, timeout=10)
26 data = r.json()
27 if "access_token" not in data:
28 raise RuntimeError(f"换取 access_token 失败:{data}")
29 _tok["value"] = data["access_token"]
30 _tok["expire_at"] = now + data.get("expires_in", 2592000)
31 return _tok["value"]调用 ERNIE 4.5(OpenAI 兼容写法)
千帆 v2 提供了 OpenAI 兼容端点,把 api_key 填成 access_token 即可:
1from openai import OpenAI
2
3client = OpenAI(
4 api_key=get_access_token(), # 千帆 access_token
5 base_url="https://qianfan.baidubce.com/v2", # 千帆 v2 OpenAI 兼容地址
6 timeout=30.0,
7 max_retries=2,
8)
9
10resp = client.chat.completions.create(
11 model="ernie-4.5-turbo", # 或 ernie-4.5 / ernie-4.5-vl / ernie-speed
12 messages=[{"role": "user", "content": "用 100 字介绍飞书自定义机器人"}],
13 temperature=0.3,
14 max_tokens=1024,
15)
16print(resp.choices[0].message.content)提示
小白提示:这一步最常见的报错是 error_code 110 Access token invalid。八成原因是你拿旧版百度 AI 开放平台的 token 去调千帆 v2,两者不通用;另外两成是 AK/SK 复制时少了开头的 bce-v3/ 前缀。
步骤四:方式 A 中转服务(完整可跑版)
目录结构:
1feishu-ernie-bot/
2├── .env # AK/SK 与 webhook 密钥,务必加进 .gitignore
3├── .gitignore # 内容至少一行:.env
4├── bot.py
5└── card.py1import os, json, time, hmac, hashlib, httpx
2
3WEBHOOK = os.environ["FEISHU_WEBHOOK"]
4SECRET = os.environ["FEISHU_SECRET"]
5QIANFAN_URL = "https://qianfan.baidubce.com/v2/chat/completions"
6
7SYSTEM_PROMPT = """你是飞书群里的 AI 助手,底层模型是文心一言 ERNIE 4.5。
8回答要求:
91. 中文,简洁,控制在 300 字以内,能用列表就用列表
102. 涉及公文/通知时使用规范的中文书面语,不要口语化
113. 不确定就明确说不确定,不要编造政策条款、人名、数字
124. 用户要求改写时,只输出改写结果,不要解释你改了什么
13"""
14
15def ask_ernie(question: str, model: str = "ernie-4.5-turbo") -> tuple[str, int]:
16 """返回 (回答, 总 token 消耗)"""
17 r = httpx.post(
18 QIANFAN_URL,
19 headers={"Authorization": f"Bearer {get_access_token()}"},
20 json={
21 "model": model,
22 "messages": [
23 {"role": "system", "content": SYSTEM_PROMPT},
24 {"role": "user", "content": question},
25 ],
26 "temperature": 0.3,
27 "max_tokens": 1024,
28 "stream": False,
29 },
30 timeout=30,
31 )
32 data = r.json()
33 if "choices" not in data:
34 raise RuntimeError(f"文心一言调用失败:{data}")
35 return data["choices"][0]["message"]["content"], data["usage"]["total_tokens"]
36
37def pick_model(question: str) -> str:
38 """简单路由:长问题或命中关键词时切旗舰,其余走 turbo 省钱"""
39 heavy = any(k in question for k in ["分析", "总结", "起草", "方案", "对比", "复盘"])
40 if heavy or len(question) > 200:
41 return "ernie-4.5"
42 return "ernie-4.5-turbo"
43
44def gen_sign(secret: str) -> tuple[str, str]:
45 ts = str(int(time.time()))
46 return ts, hmac.new(b"", f"{ts}\n{secret}".encode(), hashlib.sha256).hexdigest()
47
48def push(msg_type: str, content: dict) -> dict:
49 ts, sign = gen_sign(SECRET)
50 return httpx.post(WEBHOOK, json={
51 "timestamp": ts, "sign": sign, "msg_type": msg_type, "content": content
52 }, timeout=10).json()
53
54if __name__ == "__main__":
55 q = "帮我把这段话改写成一份正式的通知"
56 model = pick_model(q)
57 answer, tokens = ask_ernie(q, model)
58 print(model, push("text", {"text": f"{answer}\n\n({model} · {tokens} tokens)"}))跑起来:
1pip install httpx python-dotenv
2python bot.py
3# 控制台打印 {'code': 0, 'msg': 'success', 'data': {}} 即链路打通步骤五:卡片消息——飞书相对钉钉和企业微信的最大优势
纯文本能跑通,但别停在这里。钉钉的互动卡片要在后台建模板、企业微信的模板卡片类型固定,而飞书允许你直接在请求体里传任意合法 JSON,还能带按钮、下拉框、日期选择器,用户点了之后飞书会把回调原样发给你。
卡片 JSON 骨架(文心回答专用版)
1{
2 "msg_type": "interactive",
3 "card": {
4 "config": {
5 "wide_screen_mode": true, // 桌面端占满宽度,长回答必开
6 "enable_forward": true // 允许群成员转发这张卡
7 },
8 "header": {
9 "template": "indigo", // 必须是内置色号,不能写 #4E83FD
10 "title": { "tag": "plain_text", "content": "文心一言 ERNIE 4.5 · 回答" },
11 "subtitle": { "tag": "plain_text", "content": "ernie-4.5-turbo · 耗时 1.4s · 683 tokens" }
12 },
13 "elements": [
14 { "tag": "div", "text": { "tag": "lark_md", "content": "**你的问题**\n帮我把这段话改写成正式通知" } },
15 { "tag": "hr" },
16 { "tag": "div", "text": { "tag": "lark_md", "content": "关于……的通知\n各部门:……" } },
17 {
18 "tag": "note",
19 "elements": [
20 { "tag": "plain_text", "content": "AI 生成内容,正式发布前请人工核对" }
21 ]
22 }
23 ],
24 "actions": [
25 {
26 "tag": "action",
27 "actions": [
28 { "tag": "button", "text": { "tag": "plain_text", "content": "换个语气" },
29 "type": "primary", "value": { "action": "rewrite", "tone": "formal" } },
30 { "tag": "button", "text": { "tag": "plain_text", "content": "再精简一半" },
31 "type": "default", "value": { "action": "shorten" } },
32 { "tag": "button", "text": { "tag": "plain_text", "content": "存进飞书文档" },
33 "type": "default", "value": { "action": "save_doc" } },
34 {
35 "tag": "select_static",
36 "placeholder": { "tag": "plain_text", "content": "转给谁?" },
37 "value": { "action": "assign" },
38 "options": [
39 { "text": { "tag": "plain_text", "content": "转给行政部" }, "value": "hr" },
40 { "text": { "tag": "plain_text", "content": "转给值班同事" }, "value": "duty" }
41 ]
42 }
43 ]
44 }
45 ]
46 }
47}提示
小白提示:value 是任意 JSON 对象,你塞什么飞书就原样回传什么。把 qid(问题 ID)放进去,用户点「换个语气」时你就知道是哪一条,不用自己猜上下文。
拼进代码
注意:卡片整体要放在 content 字段里,不是顶层。
1def build_answer_card(question: str, answer: str, model: str, elapsed: float, tokens: int) -> dict:
2 return {
3 "config": {"wide_screen_mode": True, "enable_forward": True},
4 "header": {
5 "template": "indigo",
6 "title": {"tag": "plain_text", "content": "文心一言 ERNIE 4.5 · 回答"},
7 "subtitle": {"tag": "plain_text", "content": f"{model} · {elapsed:.1f}s · {tokens} tokens"},
8 },
9 "elements": [
10 {"tag": "div", "text": {"tag": "lark_md", "content": f"**你的问题**\n{question}"}},
11 {"tag": "hr"},
12 {"tag": "div", "text": {"tag": "lark_md", "content": answer}},
13 {"tag": "note", "elements": [
14 {"tag": "plain_text", "content": "AI 生成内容,正式发布前请人工核对"}
15 ]},
16 ],
17 "actions": [{
18 "tag": "action",
19 "actions": [
20 {"tag": "button", "text": {"tag": "plain_text", "content": "换个语气"},
21 "type": "primary", "value": {"action": "rewrite"}},
22 {"tag": "button", "text": {"tag": "plain_text", "content": "再精简一半"},
23 "type": "default", "value": {"action": "shorten"}},
24 ],
25 }],
26 }
27
28push("interactive", build_answer_card(q, answer, "ernie-4.5-turbo", 1.4, 683))图片理解卡片(ernie-4.5-vl)
群里发截图让 AI 看,是文心在飞书里最好用的差异化能力:
1import base64
2
3def ask_ernie_with_image(question: str, image_path: str) -> str:
4 b64 = base64.b64encode(open(image_path, "rb").read()).decode()
5 r = httpx.post(
6 "https://qianfan.baidubce.com/v2/chat/completions",
7 headers={"Authorization": f"Bearer {get_access_token()}"},
8 json={
9 "model": "ernie-4.5-vl",
10 "messages": [{
11 "role": "user",
12 "content": [
13 {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
14 {"type": "text", "text": question},
15 ],
16 }],
17 "max_tokens": 1024,
18 },
19 timeout=60,
20 )
21 return r.json()["choices"][0]["message"]["content"]步骤六:方式 B 企业自建应用(能收消息 + 能私聊)
6.1 创建应用并拿凭证
- 飞书开放平台 → 开发者后台 → 创建企业自建应用
- 「添加应用能力」→ 勾选 机器人
- 「凭证与基础信息」里记下:
1App ID: cli_b2c3d4e5f6g7h8i9
2App Secret: Qw8Zx3Nm5RtYvB1C2d3E4f5G6h7I86.2 必勾权限
| 权限点 | 用途 | 不勾的后果 |
|---|---|---|
im:message | 读取用户消息内容 | 收得到事件但拿不到正文 |
im:message:send_as_bot | 以机器人身份发消息 | 发消息直接报 230098 |
im:message.group_at_msg | 接收群里 @ 机器人的消息 | 群里 @ 没反应 |
im:message.p2p_msg | 接收单聊消息 | 私聊不可用 |
im:resource | 下载用户发的图片/文件 | 看图场景不可用 |
contact:user.base:readonly | 查发言人姓名与部门 | 做不了部门白名单 |
6.3 tenant_access_token 缓存(2 小时过期,必须缓存)
1import os, time, threading, httpx
2
3APP_ID = os.environ["FEISHU_APP_ID"]
4APP_SECRET = os.environ["FEISHU_APP_SECRET"]
5FS_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
6
7_fs = {"token": None, "expire_at": 0}
8_fs_lock = threading.Lock()
9
10def get_tenant_access_token() -> str:
11 """飞书 token 有效期 2 小时,过期前 5 分钟刷新。
12 为什么必须缓存:飞书对鉴权接口有独立限流,高频换取会被拒绝,
13 进而导致所有发消息请求失败。
14 """
15 now = time.time()
16 if _fs["token"] and _fs["expire_at"] - now > 300:
17 return _fs["token"]
18 with _fs_lock:
19 if _fs["token"] and _fs["expire_at"] - time.time() > 300:
20 return _fs["token"]
21 r = httpx.post(FS_TOKEN_URL, json={
22 "app_id": APP_ID, "app_secret": APP_SECRET
23 }, timeout=10)
24 data = r.json()
25 if data.get("code") != 0:
26 raise RuntimeError(f"获取 tenant_access_token 失败:{data}")
27 _fs["token"] = data["tenant_access_token"]
28 _fs["expire_at"] = now + data["expire"] # 通常 7200 秒
29 return _fs["token"]信息
多实例部署的同学注意:上面是进程内缓存。如果用 gunicorn 起了多个 worker,会有多份 token(本身共用不冲突),但并发刷新可能撞限流。上生产请换成 Redis 存 token,配合带过期时间的分布式锁(SETNX)保证只有一个实例去刷新。
6.4 用 im/v1/messages 发消息
1SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
2
3def send_to_chat(chat_id: str, card: dict, receive_id_type: str = "chat_id"):
4 r = httpx.post(
5 SEND_URL,
6 params={"receive_id_type": receive_id_type}, # chat_id / open_id / union_id / email
7 headers={"Authorization": f"Bearer {get_tenant_access_token()}"},
8 json={
9 "receive_id": chat_id, # 群 oc_xxx,单人 ou_xxx
10 "msg_type": "interactive",
11 "content": json.dumps(card, ensure_ascii=False), # 必须是字符串化的 JSON
12 },
13 timeout=10,
14 )
15 return r.json()与 Webhook 方式的三个关键差异:
content必须json.dumps成字符串,传对象会报230002- 鉴权走 Header 的
Authorization: Bearer {token},不是 body 里的sign - 收件人由 URL 参数
receive_id_type+ body 的receive_id指定,可发私聊
6.5 占位卡 + 异步更新(把感知延迟从 2 秒压到 0.3 秒)
信息
注意:群自定义机器人的 Webhook 不支持更新已发出的消息。要做「先占位后更新」,必须走自建应用,用 PATCH /open-apis/im/v1/messages/{message_id}。
1def send_placeholder(chat_id: str) -> str:
2 card = {
3 "config": {"wide_screen_mode": True},
4 "header": {"template": "grey", "title": {"tag": "plain_text", "content": "文心一言思考中…"}},
5 "elements": [{"tag": "div", "text": {"tag": "lark_md", "content": "正在调用 ERNIE 4.5,请稍候"}}],
6 }
7 r = httpx.post(SEND_URL, params={"receive_id_type": "chat_id"},
8 headers={"Authorization": f"Bearer {get_tenant_access_token()}"},
9 json={"receive_id": chat_id, "msg_type": "interactive",
10 "content": json.dumps(card, ensure_ascii=False)}, timeout=10).json()
11 return r["data"]["message_id"]
12
13def update_card(message_id: str, card: dict):
14 return httpx.patch(f"{SEND_URL}/{message_id}",
15 headers={"Authorization": f"Bearer {get_tenant_access_token()}"},
16 json={"content": json.dumps(card, ensure_ascii=False)}, timeout=10).json()步骤七:事件订阅——让机器人「听见」群里的话
要收到用户消息,必须订阅 im.message.receive_v1。飞书提供两种投递方式。
长连接 vs Webhook 回调
| 长连接(WebSocket) | Webhook 回调(HTTP) | |
|---|---|---|
| 要不要公网 IP / 域名 | ❌ 不需要,本地就能跑 | ✅ 必须有 HTTPS 公网地址 |
| 要不要备案域名 | 不需要 | 国内服务器要备案 |
| 要不要自己解密 | 不需要,SDK 内部处理 | 要,配了 Encrypt Key 就得 AES-256-CBC 解 |
| 配置复杂度 | ⭐ pip 装 SDK 即可 | ⭐⭐⭐⭐ 配 URL、加密、验证 |
| 消息到达延迟 | 50-120ms | 80-300ms |
| 断线表现 | SDK 自动重连 | 飞书重试 3 次,失败即丢 |
| 多实例 / 负载均衡 | ❌ 一条连接只能一个进程 | ✅ 天然支持多副本 |
| 适用 | 内部工具、调试、单实例 | 生产、多副本、要审计 |
建议:开发和内部小规模使用无脑选长连接;正式给全公司用的机器人选 Webhook 回调,因为长连接在多副本部署时会重复消费消息。
长连接代码
1pip install lark-oapi1import os, json as _json
2import lark_oapi as lark
3from lark_oapi.api.im.v1 import *
4
5def on_receive(data: P2ImMessageReceiveV1) -> None:
6 msg = data.event.message
7 if msg.message_type != "text": # 忽略图片、表情、系统消息
8 return
9 question = _json.loads(msg.content).get("text", "").strip()
10 if not question:
11 return
12 model = pick_model(question)
13 answer, tokens = ask_ernie(question, model)
14 card = build_answer_card(question, answer, model, 0, tokens)
15
16 # 用 message_id 做「回复」,消息挂在原消息下面,群里不会刷屏
17 client.im.v1.message.reply(ReplyMessageRequest.builder()
18 .message_id(msg.message_id)
19 .request_body(ReplyMessageRequestBody.builder()
20 .msg_type("interactive")
21 .content(_json.dumps(card, ensure_ascii=False))
22 .build())
23 .build())
24
25handler = lark.EventDispatcherHandler.builder("", "") \
26 .register_p2_im_message_receive_v1(on_receive).build()
27
28client = lark.ws.Client(os.environ["FEISHU_APP_ID"],
29 os.environ["FEISHU_APP_SECRET"],
30 event_handler=handler,
31 log_level=lark.LogLevel.INFO)
32client.start() # 阻塞运行Webhook 回调 + AES-256-CBC 解密
开放平台填完 HTTPS 地址后,飞书会先发一个带 challenge 的验证请求,必须在 1 秒内原样返回:
1@app.post("/feishu/event")
2async def feishu_event(req: Request):
3 body = await req.json()
4 if "challenge" in body: # 1. URL 校验
5 return {"challenge": body["challenge"]}
6 if "encrypt" in body: # 2. 配了 Encrypt Key 时是密文
7 body = decrypt(body["encrypt"], os.environ["FEISHU_ENCRYPT_KEY"])
8 if body.get("header", {}).get("event_type") == "im.message.receive_v1":
9 asyncio.create_task(handle_message(body["event"])) # 异步处理,别阻塞
10 return {"code": 0} # 3. 立刻返回,否则飞书会重试1import base64, hashlib
2from Crypto.Cipher import AES # pip install pycryptodome
3
4def decrypt(encrypt_b64: str, encrypt_key: str) -> dict:
5 """飞书 Encrypt Key 是 43 位字符串,SHA256 后取前 32 字节作密钥"""
6 key = hashlib.sha256(encrypt_key.encode()).digest()[:32]
7 raw = base64.b64decode(encrypt_b64)
8 iv, cipher_text = raw[:16], raw[16:] # 前 16 字节是 IV
9 plain = AES.new(key, AES.MODE_CBC, iv).decrypt(cipher_text)
10 return _json.loads(plain[:-plain[-1]].decode("utf-8")) # 去 PKCS7 填充步骤八:发布、灰度与验证
- 开放平台 → 版本管理与发布 → 创建版本,填
1.0.0和更新说明 - 点「申请发布」,等企业管理员通过
- 在飞书里搜机器人名字,发一句「你好」验证
推荐的上线顺序(能省掉八成客诉):
1第 1 天:可用范围只放自己,单聊自测
2第 2 天:拉 3 人测试群,验证 @、私聊、卡片按钮
3第 3 天:放开到一个 20 人小群,观察 QPS 与 token 消耗
4第 5 天:全部门放开,群里置顶一条「机器人使用说明」上线自检清单:
1- [x] AK/SK、app_secret 全在环境变量里,git status 看不到 .env
2- [x] 连续发 100 条测试消息,没有触发飞书限流
3- [x] 断开网络 10 秒再恢复,长连接能自动重连
4- [x] 卡片里所有按钮点下去都有正确回传
5- [x] 群里 @ 机器人但没提问题时,回一句引导语而不是报错
6- [x] 千帆后台设了预算告警场景应用示例
场景1:中文公文改写(文心最擅长的活)
把这段发给机器人:
1@文心 ERNIE 4.5 助手
2把下面这段口语改成一份正式的部门通知,要求:
31)标题用「关于……的通知」格式;2)正文分「背景 / 要求 / 执行时间」三段;
43)语气正式但不生硬;4)不超过 300 字。
5原文:下周三开始我们这边要搬到 5 楼了,大家记得把自己的东西收好,
6电脑别忘关机,搬家公司九点到,有问题找行政小王。你会得到:
- 一个规范标题(如「关于办公区搬迁至 5 楼的通知」)
- 三段式正文,背景一句、要求用编号列表、执行时间明确到「下周三 9:00」
- 口语词全部替换(「收好」→「妥善整理打包」,「别忘关机」→「离场前请关闭电脑电源」)
- 全文控制在 300 字内,可直接复制到飞书公告
场景2:会议纪要整理
把这段发给机器人:
1@文心 ERNIE 4.5 助手
2以下是今天的季度复盘会记录(约 2200 字),请整理成:
31)5 条以内的结论;2)待办清单(负责人 + 截止时间,缺失的标「待确认」);
43)需要上级决策的 1-2 个风险点。
5输出用 Markdown,不要复述原文。你会得到:
- 5 条结论,每条不超过两行,去掉寒暄和重复讨论
- 待办表格,负责人从原文抽取,没写清截止时间的自动标「待确认」而不是编日期
- 1-2 条风险点,附一句「为什么需要上级决策」的判断依据
- 输出不超过 400 字,可直接粘进飞书文档
场景3:群里发截图让 AI 看(ernie-4.5-vl)
把这段发给机器人(配一张后台报错截图):
1@文心 ERNIE 4.5 助手
2这张截图是后台报的错,请告诉我:
31)错误信息是什么;2)最可能的三个原因;
43)我下一步该点哪里、查什么日志。你会得到:
- OCR 出来的错误原文(含错误码和堆栈首行)
- 三个按概率排序的原因,最常见的排第一
- 一条具体的下一步操作指引(「先到『日志中心 → 错误日志』按 trace_id 搜」)
- 卡片底部两个按钮:「已解决」 与 「转成工单」,点击回传你的服务
场景4:政策/制度问答(体现中文知识优势)
把这段发给机器人:
1@文心 ERNIE 4.5 助手
2我们公司准备调整年休假规则,请说明:
31)法定年休假的天数是怎么按工龄算的;
42)未休年假折算工资的常见处理方式;
53)哪些做法有合规风险。
6请注明这只是一般性参考,不构成法律意见。你会得到:
- 工龄区间与对应天数的对照列表(1-10 年 / 10-20 年 / 20 年以上)
- 折算工资的计算口径说明(按日工资收入的 300% 扣除正常工资后的差额部分)
- 2-3 条风险提示(如「强制清零」条款可能无效)
- 结尾一句「以上为一般性参考,具体请以当地劳动部门口径为准」
最佳实践
提示词技巧(群里回答更准)
- 在 system prompt 里写死人设:飞书是群聊,回答超过 300 字就没人看。把「简洁、中文、列表化、300 字内」写进 system prompt,比每次在问题里强调有效得多。
- 先结论后理由:提问模板改成「先一句话结论,再展开」,用户扫一眼就拿到答案。
- 强制标注不确定性:加一句「不确定就说不确定,不要编造政策条款、人名、数字」,这在群里问制度问题时能救命。
- 指定输出格式:要表格就说「用 Markdown 表格」,要 JSON 就说「只输出 JSON」。文心对格式指令遵循度不错,但不指定就容易自由发挥。
- 回显问题原文:回答里带上「你的问题:xxx」。群聊消息乱序滚动,回显能避免上下文错位。
安全建议:三种密钥管理策略对比
| 策略 | 做法 | 安全等级 | 适用规模 |
|---|---|---|---|
环境变量 + .gitignore | 密钥放 .env,代码里 os.environ 读 | ⭐⭐⭐ | 个人 / 小团队,最快 |
| 密钥管理服务 | Vault / 云厂商 KMS,运行时拉取 | ⭐⭐⭐⭐⭐ | 中大型团队,上生产必做 |
| 服务端代理网关 | 业务服务不持有 AK/SK,统一走内部网关做审计与限流 | ⭐⭐⭐⭐⭐ | 多条业务线都要调 AI 时 |
其他硬性要求:
- 给机器人加发送频率限制:令牌桶 40 条/分钟,超限丢弃并记日志,防止被刷。
- 按部门做白名单:用
contact:user.base:readonly拿 open_id,只允许特定部门调用,避免全公司随便烧钱。 - 定期轮换:AK/SK、app_secret 建议 90 天换一次;换 AK/SK 后记得让缓存里的 access_token 立即失效。
- 敏感内容脱敏:客户手机号、身份证、合同金额在送进模型前做正则替换,这是合规底线。
性能优化清单
| 动作 | 收益 | 成本 |
|---|---|---|
| httpx 连接复用(全局 client) | 省 60-120ms/次 | 5 行代码 |
| 千帆 access_token 缓存 | 省 100-180ms/次 | 25 行代码 |
| 飞书 tenant_access_token 缓存 | 省 80-150ms/次 | 30 行代码 |
| 先发占位卡再更新 | 感知延迟 2.18s → 0.34s | 中等 |
| 同问题 5 分钟结果缓存 | 省 100% 重复调用 | 20 行代码 |
| 模型路由(turbo 兜底) | 成本降约 75% | 10 行代码 |
| temperature 从 0.7 降到 0.3 | 输出长度平均缩短 16% | 改一个数字 |
故障排查(20 条)
| 你看到的现象 | 可能的原因 | 怎么解决 |
|---|---|---|
code 19021 sign match fail | timestamp 用成毫秒、签名串顺序反了、编码用了 base64 | timestamp 改秒级,串写成 ts + 换行 + secret,用 hexdigest() |
code 19001 param invalid | body 缺 timestamp/sign,或 msg_type 拼错 | 补齐四个顶层字段;msg_type 只能是 text/post/image/interactive/share_chat/file |
卡片报 code 230002 | 卡片 JSON schema 不合法 | 颜色改内置色号;按钮必须有 type 和非空 value;content 必须是字符串化 JSON |
code 230098 no permission | 权限没勾或没通过审核 | 勾 im:message:send_as_bot 等权限,等管理员通过 |
code 99991663 换 token 失败 | app_id/app_secret 填错,或鉴权接口被限流 | 核对凭证;加缓存,别每请求都换 |
code 230001 message not found | message_id 过期或不属于本应用 | 超时消息不能更新,改发新消息 |
文心 error_code 110 | access_token 无效(旧版 token 调千帆 v2) | 用千帆 AK/SK 重新换取,确认是 v2 端点 |
文心 error_code 111 | access_token 已过期 | 重新换取并刷新缓存 |
文心 error_code 17 | 当日调用量超限 | 到千帆控制台提升配额或购买资源包 |
文心 error_code 18 | QPS 超限 | 加请求队列/退避重试,或申请提升 QPS |
文心 error_code 19 | 总请求量超限 | 同上,联系商务调整 |
文心 error_code 336000 | 模型未开通或参数不合法 | 去模型广场开通 ernie-4.5;检查 model 名拼写 |
| HTTP 429 / 消息发不出去 | 触发飞书频率限制(约 50 条/分钟) | 合并消息、加令牌桶 |
| 机器人 @ 了没反应 | 没订阅事件,或机器人不在群里 | 订阅 im.message.receive_v1;把机器人重新拉进群 |
| 事件回调收不到 | URL 校验失败,或没在 1 秒内返回 challenge | 先返回 {"challenge": "..."},业务逻辑异步处理 |
| 回调收到密文看不懂 | 配了 Encrypt Key 但没解密 | SHA256(Encrypt Key)[:32] 作密钥、前 16 字节作 IV 做 AES-256-CBC |
| 长连接跑一会儿就断 | 网络空闲被掐或 SDK 版本旧 | 升级 lark-oapi,开启 SDK 心跳 |
| 回答被截断 | max_tokens 太小 | 群聊 1024 够用,长文场景 2048-4096 |
| 中文乱码 / emoji 变问号 | JSON 序列化做了 ASCII 转义 | json.dumps(card, ensure_ascii=False) |
| 多副本收到重复消息 | 用了长连接但起了多进程 | 长连接只允许单实例;多副本改 Webhook 回调或用 Redis 锁去重 |
扩展与进阶
还能玩出什么花样
- 卡片按钮闭环审批:把
value.action扩成approve/reject/escalate,配一张审批卡片,实现「群里点按钮 → 回调你的服务 → 写数据库 → 更新卡片状态」。这是飞书比钉钉顺手的地方。 - 流式打字机:千帆支持
stream=True,配合卡片流式更新接口,让答案一个字一个字「长出来」,感知延迟再降一档。 - Function Calling 接内部系统:注册
query_leave_balance(emp_id)、create_ticket(...)等函数,群里直接查年假、开工单。注意函数个数控制在 5 个以内,schema 描述写清楚。 - 模型路由表:按问题长度和关键词在
ernie-speed/ernie-4.5-turbo/ernie-4.5/ernie-4.5-vl之间自动切换,成本能压到全程旗舰的四分之一。 - 知识库增强:把团队制度文档做切片检索,Top-3 片段塞进 system prompt,能大幅减少「机器人不了解我们业务」的抱怨。
团队一起用
- 按部门拆机器人:行政群、技术群各配一个,system prompt 分开写,比一个「全能机器人」好用得多。
- 统一 Prompt 仓库:每个机器人的 system prompt 存进 Git,改 prompt 走 PR review,避免有人随手在服务器改一行导致行为突变。
- 用量看板:把每次调用的 token 数、耗时、用户 open_id 打进日志,用飞书多维表格做个看板,谁在用、烧了多少钱一目了然。
- 建立错误案例库:出现「机器人答错了」就截图归档,写进 system prompt 的反例里,迭代几轮准确率会明显提升。
进阶学习路径
1阶段 1(入门):群自定义机器人 + 加签,能把文心的回答推进群里 ← 你现在在这里
2阶段 2(进阶):改发交互式卡片,加「换个语气 / 精简 / 存文档」按钮
3阶段 3(高级):AK/SK 换 token 的缓存与自动刷新,自建应用 + 长连接事件订阅
4阶段 4(专家):图片理解 + 卡片流式更新 + Function Calling 接内部系统
5阶段 5(架构):Webhook 回调 + 多副本 + KMS 密钥管理 + 敏感信息脱敏网关常见问题
Q千帆的 access_token 和飞书的 tenant_access_token 是一回事吗?
完全不是,别混。千帆 access_token 是你调用文心一言的凭证,由 AK/SK 换得,有效期 30 天;飞书 tenant_access_token 是你调用飞书开放接口的凭证,由 app_id/app_secret 换得,有效期 2 小时。一次群问答要同时用到两个 token,建议分别缓存,不要在每次请求里现取。
Q返回 error_code 110 Access token invalid 怎么办?
三种可能:一是 access_token 过期或被手动吊销,重新用 AK/SK 换一个;二是你用的是旧版百度 AI 开放平台的 token(aip.baidubce.com 换的),而调用的是千帆 v2 接口,两者不通用;三是请求头格式不对,千帆 v2 只认 Authorization: Bearer 前缀。
Q加签一直返回 19021 sign match fail 怎么办?
检查三点:timestamp 必须是秒级 10 位而不是毫秒 13 位;待签名串是 timestamp 加换行符加 secret,顺序不能反;HMAC-SHA256 的密钥是空字符串、输出取十六进制小写,而钉钉的密钥是 secret 本身、输出取 base64,两套不通用。另外服务器时间与飞书相差超过 1 小时也会被拒。
Q卡片消息返回 230002 怎么排查?
230002 是消息体校验失败。按顺序查:header.template 只能用内置色号(blue/wathet/turquoise/green/yellow/orange/red/carmine/violet/purple/indigo/grey),不能写十六进制颜色;elements 里 tag 必须是受支持标签;actions 每个按钮必须有 type 和非空 value(不超过 512 字节);自建应用调用时 content 必须是字符串化的 JSON 而不是对象。
Q为什么群里 @ 机器人没有反应?
自定义机器人 Webhook 只能单向推送,收不到用户消息。要让机器人听见群里的话,必须走自建应用:开启机器人能力、勾选 im:message 与 im:message:send_as_bot 权限、订阅 im.message.receive_v1 事件(长连接或 Webhook 回调二选一),并把机器人重新拉进群。