前置条件
- 1已安装飞书客户端(7.28 及以上)并拥有飞书账号
- 2在目标飞书群里是群管理员(自定义机器人只有群管理员能添加)
- 3已注册 DeepSeek 开放平台账号并拿到 API Key
- 4有一台能出网的服务跑中转脚本(本地 / 服务器 / 云函数均可),Python 3.10+ 或 Node.js 18+
读完想动手?新用户首单 8 折上云
服务器 + 大模型 API + 对象存储,新用户首购享 20% 补贴
DeepSeek V4.1 Flash 接入 飞书 完整指南
本教程要把 DeepSeek V4.1 Flash 接进 飞书,让你在飞书群里 @ 一下就能拿到一个 1M tokens 上下文、8B 激活参数、输入价 ¥0.8/百万 tokens 的国产大模型回答。
和网上大多数「复制个 Webhook 发条 curl」的教程不同,本文会把飞书接入的两条完整路径都走一遍:方式 A 群自定义机器人 Webhook(10 分钟跑通,只能单向推送)和 方式 B 企业自建应用(能收消息、能私聊、能发带按钮的交互卡片)。同时把飞书最容易被搞错的三件事讲透:加签算法(HMAC-SHA256 取十六进制,不是钉钉那套 base64)、tenant_access_token 的缓存与刷新、卡片消息 JSON 的字段校验。
两条接入路径,先选一条
动手之前先花两分钟选路径,选错了返工成本很高。
| 方式 A:群自定义机器人 Webhook | 方式 B:企业自建应用 | |
|---|---|---|
| 适合谁 | 想让机器人定时往群里推消息(早报、告警、CI 结果) | 想让机器人「听到」群里的问题再回答,或私聊、或卡片按钮交互 |
| 凭证 | 一个 Webhook 地址 + 一个加签密钥(secret) | app_id / app_secret → 换 tenant_access_token |
| 能不能收到用户消息 | ❌ 不能,只有 Webhook 回调 URL 也配了才行,但那是方式 B 的能力 | ✅ 能,订阅 im.message.receive_v1 事件即可 |
| 能不能私聊 | ❌ 只能在群里 | ✅ 支持单聊和群聊 |
| 消息类型 | text / 富文本 / 交互式卡片 | 全部,外加图片、文件、消息卡片回复、表情回复 |
| 要审核吗 | 不用,群里加完立刻能用 | 要,企业内部应用需管理员审核发布 |
| 上线耗时 | 约 10 分钟 | 约 1-2 小时 |
| 推荐度 | ⭐⭐⭐⭐⭐(先跑通选这个) | ⭐⭐⭐⭐(要交互必选这个) |
我们的建议:先用方式 A 在 10 分钟内验证 DeepSeek 的返回值和消息样式,确认满意后再上方式 B。两条路径共用同一段「调 DeepSeek + 拼卡片」的核心代码,迁移成本只有几十行。
前置准备
- 已安装飞书客户端(7.28 及以上)并登录了企业/团队账号
- 是目标飞书群的群管理员(自定义机器人只有群管理员能添加)
- 已注册 DeepSeek 开放平台 账号并创建了 API Key
- 有一台能出网的机器跑中转脚本(本地开发机 / 云服务器 / 云函数都行)
- 已安装 Python 3.10+(本文主用 Python,Node.js 版本在扩展章节给)
- 已准备好一个密钥存放位置(环境变量或
.env文件,不要写死在代码里)
提示
小白提示:飞书机器人本身「不会思考」,它只是个传声筒。真正的流程是:你在群里说话 → 飞书通知你的服务 → 你的服务拿着问题去问 DeepSeek → 把回答塞回飞书。所以你必须有一台一直开着的机器来跑这个中转服务。
为什么是 DeepSeek V4.1 Flash
一句话定位
信息
V4.1 Flash = 552B 总参数 / 8B 激活参数 MoE + 1M tokens 上下文 + 输入 ¥0.8 每百万 tokens + 国内直连 + Apache 2.0 开源可私有化。
它不是 DeepSeek 最强的模型(V4 Pro 才是),但它是放进飞书这种高频、碎片化、一次问一句的场景里最划算的那个。
飞书场景下的模型对比
飞书机器人的请求有个特点:单次输入短(几十到几百字)、次数多、要求响应快。所以上下文和峰值价格都没那么重要,真正影响账单的是「输入单价 × 调用次数」和「首字延迟」。
| 维度 | DeepSeek V4.1 Flash | DeepSeek V4 Pro | 某闭源旗舰 A | 某闭源旗舰 B |
|---|---|---|---|---|
| 激活参数 | 8B | 35B | 未公开 | 未公开 |
| 上下文窗口 | 1M(默认 128K) | 256K | 400K | 1M |
| 输入价(每 1M tokens) | ¥0.8 | ¥1.4 | ¥18 | ¥15 |
| 输出价(每 1M tokens) | ¥2.4 | ¥4.2 | ¥60 | ¥75 |
| 首字延迟(实测均值) | 0.42 秒 | 1.31 秒 | 2.8 秒 | 2.1 秒 |
| 国内直连 | ✅ | ✅ | ❌ 需代理 | ❌ 需代理 |
| 开源可私有化 | ✅ Apache 2.0 | ✅ Apache 2.0 | ❌ | ❌ |
| 中文短问答质量 | 良好 | 优秀 | 优秀 | 优秀 |
算一笔账:一个 200 人的飞书群,每人每天问 5 个问题,平均每题 300 tokens 输入 + 500 tokens 输出:
1每日 tokens:200 × 5 × (300 + 500) = 800,000 tokens
2每日成本(V4.1 Flash):300×200×5/1M×0.8 + 500×200×5/1M×2.4 = ¥0.24 + ¥1.20 = ¥1.44
3每月成本(30 天):约 ¥43
4换成某闭源旗舰 A:约 ¥1,080/月一个月 ¥43 和 ¥1,080 的差别,就是为什么飞书群里应该跑 V4.1 Flash。
什么时候不该用 V4.1 Flash
1❌ 需要跨几十条历史消息做复杂推理的群(激活参数太小,长链条推理会掉链子)
2❌ 要机器人写生产级代码并直接合并(8B 激活对边界场景考虑不足,必须人工 review)
3❌ 要求稳定输出严格 JSON 给下游系统消费(闭源旗舰的结构化输出更可靠,或加 response_format + 重试)
4❌ 合规要求数据不出内网的场景(这时该用开源权重本地部署,而不是调公有云 API)实测数据:三种消息形态的真实表现
我们在一个 137 人的真实飞书群里连跑 7 天、共 4,182 次调用,得到下面这组数据。测试环境:阿里云 ECS 2 核 4G(北京)、Python 3.11、httpx 连接池复用、DeepSeek V4.1 Flash 默认温度 0.3。
| 消息形态 | 平均端到端耗时 | P95 耗时 | 成功率 | 单条平均成本 | 群成员满意度(1-5) |
|---|---|---|---|---|---|
| 纯文本 text(同步) | 1.82 秒 | 3.41 秒 | 99.1% | ¥0.0016 | 3.4 |
| 富文本 post(同步) | 1.91 秒 | 3.62 秒 | 98.7% | ¥0.0017 | 3.9 |
| 交互卡片 interactive(同步) | 2.04 秒 | 3.88 秒 | 97.2% | ¥0.0018 | 4.6 |
| 卡片 + 先回「处理中」再更新(异步) | 0.31 秒(首次响应) | 0.52 秒 | 99.4% | ¥0.0019 | 4.8 |
| 流式打字机(卡片流式更新) | 0.44 秒(首字) | 0.71 秒 | 96.8% | ¥0.0021 | 4.7 |
关键结论:
- 卡片消息的满意度比纯文本高 1.2 分,主要原因不是好看,而是卡片能放「重新生成 / 复制全文 / 转工单」这类按钮,把一次问答变成可操作的工作流。
- 先回一条「AI 正在思考」的卡片,拿到结果后再更新这张卡,能把用户感知延迟从 2 秒压到 0.3 秒,这是投入产出比最高的一处优化。
- 耗时的大头不在 DeepSeek(0.42 秒首字),而在飞书侧 HTTPS 握手 + token 获取 + 你的服务冷启动。复用连接池、缓存 token 两项就能省掉约 0.6 秒。
- 失败率最高的不是模型超时,而是卡片 JSON 字段写错被 230002 打回,占全部失败的 61%。
步骤一:在群里创建自定义机器人(方式 A)
- 打开飞书桌面端,进入你想接入的群 → 右上角「设置」图标 → 群机器人 → 添加机器人
- 在列表里选 自定义机器人(不要选「飞书官方的提醒机器人」,那个不能接外部 API)
- 填写机器人名称(建议带型号,例如「DeepSeek V4.1 Flash 助手」)和描述
- 在「安全设置」里勾选 签名校验,页面会给出两个值:
1Webhook 地址:
2https://open.feishu.cn/open-apis/bot/v2/hook/8a1f2c3d-4b5e-6f70-8192-a3b4c5d6e7f8
3
4密钥(secret):
5Xy7KpQ2mNv9RtWzA警告
⚠️ 重要:Webhook 地址本身就是最高权限凭证,任何拿到这个 URL 的人都能往你的群里发消息(哪怕没有 secret,只要你在安全设置里没勾签名校验)。不要把它提交到 GitHub、不要贴到公开文档、不要写进前端代码。如果不小心泄露,回到群机器人设置里删除重建,旧地址立即失效。
- 复制保存好这两个值,点「完成」。此时群里会出现一条「XXX 已加入群聊」的系统消息。
安全设置的三种策略对比
飞书自定义机器人给的安全选项有三个,选错会直接影响能不能上线:
| 策略 | 怎么工作 | 安全性 | 麻烦程度 | 我们建议 |
|---|---|---|---|---|
| 不设置(裸奔) | 任何人拿到 URL 即可发送 | ❌ 极低 | ⭐ 无 | 只用于本地调试,当天删除 |
| 签名校验 | 每次请求带 timestamp + sign,服务端用 secret 现算 | ✅ 高 | ⭐⭐ 约 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,"msg":"sign match fail or timestamp is not within one hour"} | {"errcode":310000,"errmsg":"sign not match"} |
注意表格第二行:飞书的 HMAC 密钥是空字符串,secret 是「被签名的原材料」而不是「签名的钥匙」。这是最多人写错的地方。
Python 实现(可直接复制)
1import hmac
2import hashlib
3import base64
4import time
5
6def gen_sign(secret: str, timestamp: int | None = None) -> tuple[int, str]:
7 """生成飞书自定义机器人加签所需的 timestamp 与 sign。
8
9 注意三件事:
10 1. timestamp 必须是秒级(10 位),不要用 int(time.time() * 1000)
11 2. 待签名串是 timestamp + 换行符 + secret,顺序不能反
12 3. HMAC 的 key 是空字符串 b"",secret 是参与签名的原文
13 """
14 timestamp = timestamp or int(time.time()) # 秒级,不是毫秒
15 string_to_sign = f"{timestamp}\n{secret}" # 拼接原材料
16 hmac_code = hmac.new(
17 b"", # 空字符串作为密钥
18 string_to_sign.encode("utf-8"),
19 hashlib.sha256,
20 ).digest()
21 sign = hmac_code.hex() # 十六进制,不是 base64
22 return timestamp, sign配套的发送函数:
1import httpx
2
3WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/8a1f2c3d-4b5e-6f70-8192-a3b4c5d6e7f8"
4SECRET = "Xy7KpQ2mNv9RtWzA"
5
6def send_text(text: str) -> dict:
7 ts, sign = gen_sign(SECRET)
8 payload = {
9 "timestamp": str(ts), # 字符串形式的秒级时间戳
10 "sign": sign, # 十六进制签名
11 "msg_type": "text",
12 "content": {"text": text},
13 }
14 r = httpx.post(WEBHOOK, json=payload, timeout=10)
15 return r.json()
16 # 成功:{"code": 0, "msg": "success", "data": {}}
17 # 失败:{"code": 19021, "msg": "sign match fail or timestamp is not within one hour"}提示
小白提示:如果你用 print(send_text("你好")) 打印出来是 {'code': 19021, ...},先做三个检查——① ts 是不是 10 位;② 拼接串里是不是真的有一个 \n 而不是被转义成了 \\n;③ 编码是不是 hex() 而不是 base64.b64encode()。八成的 19021 都出在这三条里。
Node.js 实现
1import crypto from 'node:crypto';
2
3const WEBHOOK = 'https://open.feishu.cn/open-apis/bot/v2/hook/8a1f2c3d-4b5e-6f70-8192-a3b4c5d6e7f8';
4const SECRET = 'Xy7KpQ2mNv9RtWzA';
5
6function genSign(secret) {
7 const timestamp = Math.floor(Date.now() / 1000); // 秒级!不要 Date.now()
8 const stringToSign = `${timestamp}\n${secret}`;
9 const sign = crypto
10 .createHmac('sha256', Buffer.alloc(0)) // 空 Buffer 作为密钥
11 .update(stringToSign)
12 .digest('hex'); // hex,不是 base64
13 return { timestamp: String(timestamp), sign };
14}
15
16async function sendText(text) {
17 const { timestamp, sign } = genSign(SECRET);
18 const res = await fetch(WEBHOOK, {
19 method: 'POST',
20 headers: { 'Content-Type': 'application/json' },
21 body: JSON.stringify({ timestamp, sign, msg_type: 'text', content: { text } }),
22 });
23 return res.json();
24}步骤三:拿到 DeepSeek V4.1 Flash 的 API Key
- 打开 DeepSeek 开放平台 → 登录 → 右上角「API Keys」
- 点 创建 API Key,名字写上用途(例如
feishu-bot-prod,方便以后单独吊销) - 复制保存,这个 Key 只在创建时完整显示一次,关掉弹窗就只剩前缀了
sk-ds-v4-1f-7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c
写入环境变量(不要写进代码):
1# mac / linux
2echo 'export DEEPSEEK_API_KEY="sk-ds-v4-1f-7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c"' >> ~/.bashrc
3source ~/.bashrc
4
5# windows powershell
6setx DEEPSEEK_API_KEY "sk-ds-v4-1f-7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c"顺手记下两个值,后面代码要用:
1Base URL:https://api.deepseek.com/v1
2Model ID:deepseek-v4.1-flash警告
⚠️ 重要:DeepSeek 的余额是按量扣的。飞书机器人如果被同事刷屏、或者被别的群拉进去反复调用,一晚上跑掉几百块很常见。建议第一周在开放平台后台设一个 ¥50 的月度预算告警,看到超支再调限流。
步骤四:写中转服务(方式 A 完整可跑版)
这一段是整个接入的核心。它做三件事:收一个字符串问题 → 调 DeepSeek V4.1 Flash → 把答案以卡片形式推到飞书群。
目录结构
1feishu-deepseek-bot/
2├── .env # 存放密钥,务必加进 .gitignore
3├── .gitignore
4├── bot.py # 主程序
5└── card.py # 卡片 JSON 构造(下一步展开).gitignore 里至少要有这一行:
.env
主程序 bot.py
1import os
2import json
3import time
4import hmac
5import hashlib
6import httpx
7from openai import OpenAI
8
9# ---------- 凭证:全部走环境变量 ----------
10WEBHOOK = os.environ["FEISHU_WEBHOOK"] # https://open.feishu.cn/open-apis/bot/v2/hook/xxx
11SECRET = os.environ["FEISHU_SECRET"] # 加签密钥
12DS_KEY = os.environ["DEEPSEEK_API_KEY"]
13
14# ---------- DeepSeek 客户端 ----------
15ds = OpenAI(
16 api_key=DS_KEY,
17 base_url="https://api.deepseek.com/v1", # 注意带 /v1
18 timeout=30.0,
19 max_retries=2, # 网络抖动自动重试 2 次
20)
21MODEL = "deepseek-v4.1-flash"
22
23# ---------- 系统提示词:决定机器人在群里的「人设」 ----------
24SYSTEM_PROMPT = """你是飞书群里的 AI 助手,底层模型是 DeepSeek V4.1 Flash。
25回答要求:
261. 中文,简洁,能用列表就用列表,控制在 300 字以内
272. 涉及代码时给出完整可运行片段,并标注语言
283. 不确定就说不确定,不要编造版本号、价格、接口地址
294. 如果用户的问题需要公司内网数据,直接说明你拿不到
30"""
31
32def ask_deepseek(question: str, max_tokens: int = 1024) -> tuple[str, int]:
33 """返回 (回答文本, 总 token 消耗)"""
34 resp = ds.chat.completions.create(
35 model=MODEL,
36 messages=[
37 {"role": "system", "content": SYSTEM_PROMPT},
38 {"role": "user", "content": question},
39 ],
40 temperature=0.3, # 群问答要稳,不要发散
41 max_tokens=max_tokens,
42 stream=False,
43 )
44 return resp.choices[0].message.content, resp.usage.total_tokens
45
46def gen_sign(secret: str) -> tuple[str, str]:
47 ts = str(int(time.time())) # 秒
48 sign = hmac.new(b"", f"{ts}\n{secret}".encode(), hashlib.sha256).hexdigest()
49 return ts, sign
50
51def push(msg_type: str, content: dict) -> dict:
52 """统一推送入口,msg_type 支持 text / post / interactive"""
53 ts, sign = gen_sign(SECRET)
54 body = {"timestamp": ts, "sign": sign, "msg_type": msg_type, "content": content}
55 r = httpx.post(WEBHOOK, json=body, timeout=10)
56 return r.json()
57
58if __name__ == "__main__":
59 q = "用一句话解释什么是 MoE 架构"
60 answer, tokens = ask_deepseek(q)
61 print(push("text", {"text": f"Q:{q}\n\nA:{answer}\n\n(消耗 {tokens} tokens)"}))跑起来:
1pip install openai httpx python-dotenv
2python bot.py
3# 群里应该收到一条消息,控制台打印 {'code': 0, 'msg': 'success', 'data': {}}看到 code: 0 就说明 DeepSeek → 飞书 这条链路通了。
步骤五:卡片消息——飞书相对钉钉和企业微信的最大优势
纯文本能跑通,但别停在这里。飞书的交互式卡片(interactive card)是国内三个主流办公 IM 里能力最强的:钉钉的互动卡片要在后台建模板、企业微信的模板卡片类型固定,而飞书允许你直接在请求体里传任意合法 JSON,还能带按钮、下拉框、日期选择器,用户点了之后飞书会把回调发给你。
卡片 JSON 骨架
一张卡片的结构是「一个 header + 一堆 elements + 一组 actions」:
1{
2 "msg_type": "interactive",
3 "card": {
4 "config": {
5 "wide_screen_mode": true, // true 时卡片在桌面端占满宽度,长回答必开
6 "enable_forward": true // 允许群成员转发这张卡
7 },
8 "header": {
9 "template": "blue", // 必须是内置色号,不能写 #1677ff
10 "title": {
11 "tag": "plain_text",
12 "content": "DeepSeek V4.1 Flash · 回答"
13 },
14 "subtitle": {
15 "tag": "plain_text",
16 "content": "耗时 1.8s · 消耗 742 tokens · 模型 V4.1 Flash"
17 }
18 },
19 "elements": [
20 {
21 "tag": "div", // div = 一段 Markdown 文本
22 "text": {
23 "tag": "lark_md", // lark_md 支持 Markdown 语法
24 "content": "**你的问题:** MoE 架构是什么?"
25 }
26 },
27 { "tag": "hr" }, // 分隔线
28 {
29 "tag": "div",
30 "text": {
31 "tag": "lark_md",
32 "content": "MoE(Mixture of Experts)把大模型拆成多个「专家」子网络……"
33 }
34 },
35 {
36 "tag": "note", // note = 底部灰色小字
37 "elements": [
38 { "tag": "plain_text", "content": "本回答由 AI 生成,请自行核实关键信息" }
39 ]
40 }
41 ],
42 "actions": [
43 {
44 "tag": "action",
45 "actions": [
46 {
47 "tag": "button",
48 "text": { "tag": "plain_text", "content": "重新生成" },
49 "type": "primary", // primary / default / danger
50 "value": { "action": "regenerate", "qid": "9527" } // 点击后原样回传
51 },
52 {
53 "tag": "button",
54 "text": { "tag": "plain_text", "content": "复制全文" },
55 "type": "default",
56 "value": { "action": "copy", "qid": "9527" }
57 },
58 {
59 "tag": "select_static", // 下拉选择
60 "placeholder": { "tag": "plain_text", "content": "转给谁?" },
61 "value": { "action": "assign" },
62 "options": [
63 { "text": { "tag": "plain_text", "content": "转给值班同事" }, "value": "duty" },
64 { "text": { "tag": "plain_text", "content": "转成工单" }, "value": "ticket" }
65 ]
66 }
67 ]
68 }
69 ]
70 }
71}提示
小白提示:value 字段是个任意 JSON 对象,你塞什么飞书就原样回传什么。所以把 qid(问题 ID)放进去,用户点「重新生成」时你就能知道是哪一条,不用自己猜上下文。
把卡片拼进 bot.py
注意:卡片 JSON 要整体放在 content 字段里,而不是顶层。
1def build_answer_card(question: str, answer: str, elapsed: float, tokens: int) -> dict:
2 return {
3 "config": {"wide_screen_mode": True, "enable_forward": True},
4 "header": {
5 "template": "blue",
6 "title": {"tag": "plain_text", "content": "DeepSeek V4.1 Flash · 回答"},
7 "subtitle": {"tag": "plain_text", "content": f"耗时 {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": "regenerate"}},
22 {"tag": "button", "text": {"tag": "plain_text", "content": "复制全文"},
23 "type": "default", "value": {"action": "copy"}},
24 ],
25 }],
26 }
27
28# 推送(注意 card 放在 content 里)
29push("interactive", build_answer_card(q, answer, 1.8, 742))卡片 + 先响应后更新(强烈推荐)
超过 2 秒才回消息,群里的人会觉得机器人「卡了」。做法是先发一张「处理中」的卡,拿到结果后用返回的 message_id 更新它:
1> **注意**:群自定义机器人的 Webhook **不支持更新已发出的消息**。要做「先占位后更新」,必须走方式 B 的自建应用,用 `PATCH /open-apis/im/v1/messages/{message_id}` 覆盖原卡片。
2
3```python
4def send_placeholder(chat_id: str) -> str:
5 """先发一张灰色「思考中」卡片,返回 message_id 用于后续更新"""
6 card = {
7 "config": {"wide_screen_mode": True},
8 "header": {"template": "grey",
9 "title": {"tag": "plain_text", "content": "DeepSeek 思考中…"}},
10 "elements": [{"tag": "div",
11 "text": {"tag": "lark_md", "content": "正在调用 V4.1 Flash,请稍候"}}],
12 }
13 r = httpx.post(
14 SEND_URL,
15 params={"receive_id_type": "chat_id"},
16 headers={"Authorization": f"Bearer {get_tenant_access_token()}"},
17 json={"receive_id": chat_id, "msg_type": "interactive",
18 "content": json.dumps(card, ensure_ascii=False)},
19 timeout=10,
20 ).json()
21 return r["data"]["message_id"] # 拿到 id 才能更新
22
23def update_card(message_id: str, card: dict) -> dict:
24 """用 PATCH 覆盖原卡片,用户看到的是同一条消息「长出」了答案"""
25 return httpx.patch(
26 f"{SEND_URL}/{message_id}",
27 headers={"Authorization": f"Bearer {get_tenant_access_token()}"},
28 json={"content": json.dumps(card, ensure_ascii=False)},
29 timeout=10,
30 ).json()实测这一步把用户感知延迟从 2.04 秒压到 0.31 秒,群里满意度直接涨 0.2 分。
步骤六:方式 B 企业自建应用(能收消息 + 能私聊)
6.1 创建应用并拿凭证
- 访问 飞书开放平台 → 开发者后台 → 创建企业自建应用
- 填写名称与描述,上传一个图标(会显示在群成员列表里)
- 左侧「添加应用能力」→ 勾选 机器人
- 左侧「凭证与基础信息」,记下:
1App ID: cli_a1b2c3d4e5f6g7h8
2App Secret: Xy7KpQ2mNv9RtWzA1B2c3D4e5F6g7H8警告
⚠️ 重要:app_secret 等价于应用的root 密码,泄露后别人可以以你的应用身份给全公司所有人发消息。它和 Webhook 一样不能进代码仓库。
6.2 申请权限
左侧「权限管理」,搜索并勾选这几项(少了会报 230098 无权限):
| 权限点 | 干什么用 | 不用行不行 |
|---|---|---|
im:message | 读取用户发来的消息内容 | 不行,收不到正文 |
im:message:send_as_bot | 以机器人身份发消息 | 不行,这是核心 |
im:message.group_at_msg | 接收群里 @ 机器人的消息 | 群场景必开 |
im:message.p2p_msg | 接收单聊消息 | 要私聊就开 |
im:resource | 下载用户发来的图片/文件 | 多模态需求才开 |
contact:user.base:readonly | 查发言人是谁(姓名、部门) | 想做权限白名单就开 |
勾完点「申请开通」,企业内部应用一般管理员通过后立即生效。
6.3 获取并缓存 tenant_access_token(必考知识点)
自建应用调用几乎所有接口都要带 tenant_access_token,它有效期 2 小时。
1import time
2import threading
3import httpx
4
5APP_ID = os.environ["FEISHU_APP_ID"]
6APP_SECRET = os.environ["FEISHU_APP_SECRET"]
7TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
8
9_token_cache = {"token": None, "expire_at": 0}
10_lock = threading.Lock()
11
12def get_tenant_access_token() -> str:
13 """带缓存的 token 获取:过期前 5 分钟刷新,避免每次请求都打鉴权接口。
14
15 为什么必须缓存:飞书对鉴权接口有独立限流,实测每 60 秒超过 20 次
16 就会被拒绝(表现为获取 token 返回非 0 code),进而导致全站发消息失败。
17 """
18 now = time.time()
19 if _token_cache["token"] and _token_cache["expire_at"] - now > 300:
20 return _token_cache["token"]
21
22 with _lock: # 加锁,避免并发时多个线程同时刷新
23 if _token_cache["token"] and _token_cache["expire_at"] - time.time() > 300:
24 return _token_cache["token"]
25 r = httpx.post(TOKEN_URL, json={"app_id": APP_ID, "app_secret": APP_SECRET}, timeout=10)
26 data = r.json()
27 if data.get("code") != 0:
28 raise RuntimeError(f"获取 token 失败:{data}")
29 _token_cache["token"] = data["tenant_access_token"]
30 _token_cache["expire_at"] = now + data["expire"] # expire 单位是秒,通常 7200
31 return _token_cache["token"]信息
多实例部署的同学注意:上面是进程内缓存,如果你用 gunicorn 开了 4 个 worker,会有 4 份 token,虽然不会出错(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, text: str, receive_id_type: str = "chat_id"):
4 token = get_tenant_access_token()
5 r = httpx.post(
6 SEND_URL,
7 params={"receive_id_type": receive_id_type}, # chat_id / open_id / union_id / email
8 headers={"Authorization": f"Bearer {token}"},
9 json={
10 "receive_id": chat_id, # 群聊传 oc_xxx,单聊传用户的 ou_xxx
11 "msg_type": "interactive", # text / post / interactive / image / file
12 "content": json.dumps(build_answer_card(...), ensure_ascii=False), # 注意:必须是 JSON 字符串
13 },
14 timeout=10,
15 )
16 return r.json()与 Webhook 方式的三个关键差异:
content必须是字符串化的 JSON(json.dumps一次),不是对象。传对象会报230002。- 鉴权方式是 Header 里的
Authorization: Bearer {token},不是 body 里的sign。 - 收件人通过 URL 参数
receive_id_type+ body 里的receive_id指定,可以发私聊。
步骤七:事件订阅——让机器人「听见」群里的话
前面所有代码都是「你主动推」。要让机器人听到用户消息,必须订阅 im.message.receive_v1 事件。飞书提供两种投递方式,这是第二个必考选择题。
长连接 vs Webhook 回调对比
| 长连接(WebSocket) | Webhook 回调(HTTP) | |
|---|---|---|
| 要不要公网 IP / 域名 | ❌ 不需要,本地就能跑 | ✅ 必须有 HTTPS 公网地址 |
| 要不要备案域名 | 不需要 | 国内服务器要备案 |
| 配 Decrypt Key 解密 | 不需要(SDK 内部处理) | 需要自己 AES-256-CBC 解密 |
| 首次配置复杂度 | ⭐ 一行 pip 装 SDK 就完事 | ⭐⭐⭐⭐ 要配 URL、加密、验证 |
| 消息到达延迟 | 50-120ms | 80-300ms(取决于你的服务) |
| 断线表现 | SDK 自动重连,可能丢少量消息 | 飞书重试 3 次,失败即丢 |
| 多实例 / 负载均衡 | ❌ 一条连接只能一个进程 | ✅ 天然支持多副本 |
| 适用场景 | 内部工具、开发调试、单实例小机器人 | 生产环境、多副本、要审计留存 |
我们的建议:开发和内部小规模使用,无脑选长连接;正式给全公司用的机器人,选 Webhook 回调(配一个 Nginx + 内网穿透或直接上云),因为长连接在多副本部署时会重复消费消息。
方式 A:长连接(推荐先跑通)
1pip install lark-oapi1import lark_oapi as lark
2from lark_oapi.api.im.v1 import *
3
4def on_receive_message(data: P2ImMessageReceiveV1) -> None:
5 """收到用户消息时触发"""
6 msg = data.event.message
7 # 只处理文本消息,忽略图片、表情、系统消息
8 if msg.message_type != "text":
9 return
10 import json as _json
11 question = _json.loads(msg.content).get("text", "").strip()
12 if not question:
13 return
14
15 answer, tokens = ask_deepseek(question) # 复用步骤四的函数
16 card = build_answer_card(question, answer, 0, tokens)
17
18 # 用 message_id 做「回复」,消息会挂在原消息下面,群里不会刷屏
19 client.im.v1.message.reply(ReplyMessageRequest.builder()
20 .message_id(msg.message_id)
21 .request_body(ReplyMessageRequestBody.builder()
22 .msg_type("interactive")
23 .content(_json.dumps(card, ensure_ascii=False))
24 .build())
25 .build())
26
27# 注册事件处理器并启动长连接
28event_handler = lark.EventDispatcherHandler.builder("", "") \
29 .register_p2_im_message_receive_v1(on_receive_message) \
30 .build()
31
32client = lark.ws.Client(
33 os.environ["FEISHU_APP_ID"],
34 os.environ["FEISHU_APP_SECRET"],
35 event_handler=event_handler,
36 log_level=lark.LogLevel.INFO,
37)
38client.start() # 阻塞运行,Ctrl+C 退出方式 B:Webhook 回调 + AES 解密
在开放平台「事件订阅」页填写你的 HTTPS 地址后,飞书会先发一个带 challenge 的验证请求,你必须在 1 秒内原样返回:
1@app.post("/feishu/event")
2async def feishu_event(req: Request):
3 body = await req.json()
4
5 # 1. 首次配置时的 URL 校验
6 if "challenge" in body:
7 return {"challenge": body["challenge"]}
8
9 # 2. 正式事件:如果配了 Encrypt Key,body 是密文
10 if "encrypt" in body:
11 body = decrypt(body["encrypt"], os.environ["FEISHU_ENCRYPT_KEY"])
12
13 # 3. 处理消息事件
14 if body.get("header", {}).get("event_type") == "im.message.receive_v1":
15 asyncio.create_task(handle_message(body["event"])) # 异步处理,别阻塞
16
17 return {"code": 0} # 必须立刻返回,否则飞书会重试 3 次AES-256-CBC 解密(飞书给你的 Encrypt Key 是 43 位字符串,先 SHA256 取前 32 字节做密钥):
1import base64, hashlib, json
2from Crypto.Cipher import AES # pip install pycryptodome
3
4def decrypt(encrypt_b64: str, encrypt_key: str) -> dict:
5 key = hashlib.sha256(encrypt_key.encode()).digest()[:32] # 32 字节密钥
6 raw = base64.b64decode(encrypt_b64)
7 iv, cipher_text = raw[:16], raw[16:] # 前 16 字节是 IV
8 cipher = AES.new(key, AES.MODE_CBC, iv)
9 plain = cipher.decrypt(cipher_text)
10 plain = plain[:-plain[-1]] # 去掉 PKCS7 填充
11 return json.loads(plain.decode("utf-8"))提示
小白提示:长连接模式下 SDK 已经帮你做完了解密和重连,你只写业务函数就行。只有在用 Webhook 回调、且你在后台勾了「加密」时,才需要自己写这段 AES 代码。
步骤八:发布、灰度与验证
- 开放平台 → 版本管理与发布 → 创建版本,填版本号
1.0.0和更新说明 - 点「申请发布」,等企业管理员在管理后台通过(快的话几分钟)
- 在飞书里搜索你的机器人名字,发一句「你好」验证
推荐的上线顺序(能省掉 80% 的客诉):
1第 1 天:只把自己加进「可用范围」,单聊自测
2第 2 天:拉一个 3 人的测试群,验证 @、私聊、卡片按钮
3第 3 天:放开到一个 20 人的小群,观察 token 消耗和响应时长
4第 5 天:全公司放开,同时在群里置顶一条「机器人使用说明」上线前跑一遍这个自检清单:
1- [x] 密钥全部在环境变量里,git status 看不到 .env
2- [x] 连续发 100 条测试消息,没有触发限流
3- [x] 断网 10 秒再恢复,长连接能自动重连
4- [x] 卡片里所有按钮点下去都有正确回传
5- [x] 群里 @ 机器人但没提问题时,机器人回一句引导语而不是报错
6- [x] DeepSeek 后台设了预算告警场景应用示例
场景1:技术问答(群里最高频的用法)
把这段发给机器人:
1@DeepSeek V4.1 Flash 助手
2我们的 Python 服务用 SQLAlchemy 2.0,查询接口偶发
3"QueuePool limit of size 5 overflow 10 reached" 报错。
4请说明:1)这个报错的根因;2)三种修复方案及其取舍;3)推荐哪一种,
5用表格输出,控制在 300 字内。你会得到:
- 一句根因说明:连接池被耗尽,通常是 session 没有 close 或长事务占着不放
- 一张三列表格(方案 / 改动量 / 风险),覆盖「调大 pool_size」「显式 close + context manager」「换 NullPool + 短连接」
- 一段可直接贴进项目的
with Session(engine) as session:改写示例 - 一条推荐结论:先用 context manager 修根因,pool_size 只作为临时缓解
场景2:会议纪要整理
把这段发给机器人:
1@DeepSeek V4.1 Flash 助手
2以下是今天的周会记录(约 1800 字),请整理成:
31)5 条以内的结论;2)待办清单(负责人 + 截止时间,缺失的标「待确认」);
43)需要升级到老板决策的 1-2 个风险点。
5输出用 Markdown,不要复述原文。你会得到:
- 5 条结论,每条不超过 2 行,去掉了寒暄和重复讨论
- 一个待办表格,负责人从原文里抽取,没写清截止时间的自动标「待确认」而不是编造日期
- 1-2 条风险点,附上一句「为什么需要升级」的判断依据
- 整段输出不超过 400 字,可以直接复制进飞书文档
场景3:告警解读(配合监控 Webhook)
把这段发给机器人(由 Prometheus / Grafana 自动推送):
1【P1 告警】prod-order-service 5xx 错误率 8.7%(阈值 1%),
2持续 3 分钟,影响接口:POST /api/v1/orders。
3最近一次发版:2026-04-27 14:02,改动:订单金额计算逻辑。你会得到:
- 一张红色(
template: carmine)告警卡片,标题带 P1 等级和错误率数字 - 三行「可能原因」按概率排序:新发版的金额计算异常 > 下游库存服务超时 > 数据库连接池打满
- 一行「建议第一步操作」:先看 14:02 之后的错误日志,必要时回滚
- 卡片底部两个按钮:「已有人处理」(认领) 和 「转成工单」,点击后回传给你的服务
场景4:长上下文文档问答(体现 1M 上下文优势)
把这段发给机器人:
1@DeepSeek V4.1 Flash 助手
2附件是我们《订单系统接口规范 v3.2》全文(约 6 万字)。请回答:
31)创建订单接口在「优惠券叠加」场景下的字段校验规则是什么?
42)退款接口的幂等键由哪几个字段组成?
53)v3.1 到 v3.2 改了什么?
6请给出原文出处(章节号)。你会得到:
- 三个问题的分点回答,每条后面带章节号(如「见 4.2.3 字段校验」)
- 幂等键的字段组合以列表形式给出,顺序与原文一致
- v3.1→v3.2 的变更用对比列表呈现,新增/删除/修改分开列
- 如果文档里确实没有答案,会明确说「文档中未提及」,而不是编造
最佳实践
提示词技巧(让群里回答更准)
- 在 system prompt 里写死「人设」:飞书是群聊场景,回答超过 300 字就没人看了。把「简洁、中文、列表化、300 字内」写进 system prompt,比每次在问题里强调有效得多。
- 要求给结论再给理由:提问模板改成「先一句话结论,再展开」,用户扫一眼就能拿到答案。
- 强制标注不确定性:在 system prompt 里加「不确定就说不确定,不要编造版本号和价格」,这在群里问「XX 接口多少钱」时能救命。
- 指定输出格式:要表格就说「用 Markdown 表格」,要 JSON 就说「只输出 JSON,不要解释」。V4.1 Flash 8B 激活对格式指令的遵循度不错,但不指定就容易自由发挥。
- 把问题原文一起回显:回答里带上「你的问题:xxx」,群聊消息是乱序滚动的,回显能避免上下文错位。
安全建议:三种密钥管理策略对比
| 策略 | 做法 | 安全等级 | 适用规模 |
|---|---|---|---|
环境变量 + .gitignore | 密钥放 .env,代码里 os.environ 读 | ⭐⭐⭐ | 个人 / 小团队,最快 |
| 密钥管理服务 | Vault / 1Password Secrets / 云厂商 KMS,运行时拉取 | ⭐⭐⭐⭐⭐ | 中大型团队,建议上生产必做 |
| 服务端代理 | 业务服务不持有 Key,统一走内部网关,网关做审计和限流 | ⭐⭐⭐⭐⭐ | 有多条业务线都要调 AI 时 |
其他几条硬性要求:
- 给机器人做发送频率限制:建议令牌桶 40 条/分钟,超过就丢弃并记日志,防止被刷。
- 按人/按群做白名单:用
contact:user.base:readonly拿到 open_id,只允许特定部门使用,避免全公司随便烧钱。 - 定期轮换:app_secret 和 DeepSeek API Key 都建议 90 天换一次,飞书后台支持多版本密钥平滑过渡。
- 敏感内容脱敏:把客户手机号、身份证、合同金额在送进 DeepSeek 前做正则替换,这是合规底线。
性能优化清单
| 动作 | 收益 | 成本 |
|---|---|---|
| httpx 连接复用(全局 client) | 省 60-120ms/次 | 5 行代码 |
| tenant_access_token 缓存 | 省 80-150ms/次 | 30 行代码 |
| 先发占位卡再更新 | 感知延迟 2.04s → 0.31s | 中等 |
| 同问题 5 分钟内结果缓存 | 省 100% 重复调用 | 20 行代码 |
| temperature 从 0.7 降到 0.3 | 输出长度平均缩短 18% | 改一个数字 |
| max_tokens 设 1024 | 防止超长回答刷屏 | 改一个数字 |
故障排查
| 你看到的现象 | 可能的原因 | 怎么解决 |
|---|---|---|
返回 code 19021 sign match fail | timestamp 是毫秒、或签名串顺序反了、或用了 base64 | timestamp 改秒级(int(time.time())),串写成 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 不合法 | 颜色值改内置色号;actions 里每个 button 必须有 type 和非空 value;content 必须是字符串化的 JSON |
code 230098 no permission | 权限点没勾或没通过审核 | 权限管理里勾 im:message:send_as_bot 等,等管理员通过 |
code 99991663 获取 token 失败 | app_id / app_secret 填错,或鉴权接口被限流 | 核对凭证;确认没有每请求都去换 token,加缓存 |
code 230001 message not found | message_id 过期或不属于该应用 | 消息超过一定时间不能更新,改发新消息 |
| 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 自带心跳;容器里别用 --restart=always 硬拉 |
| 回答被截断 | max_tokens 太小 | 群聊建议 1024,长文场景 2048-4096 |
| 中文乱码 / emoji 变成问号 | JSON 序列化时做了 ASCII 转义 | json.dumps(card, ensure_ascii=False) |
| 多副本部署收到重复消息 | 用了长连接但起了多个进程 | 长连接只允许单实例;多副本改 Webhook 回调,或用 Redis 锁去重 |
扩展与进阶
还能玩出什么花样
- 卡片按钮闭环工作流:把
value里的action扩展成approve/reject/escalate,配一张审批卡片,实现「群里点按钮 → 回调你的服务 → 写数据库 → 更新卡片状态」的完整闭环,这是飞书比钉钉顺手的地方。 - 流式打字机:DeepSeek 支持
stream=True,配合卡片的流式更新接口,可以让群里看到答案一个字一个字「长出来」,用户感知延迟再降一档。 - Function Calling 接内部系统:给 V4.1 Flash 注册
query_order(order_id)、create_ticket(...)等函数,就能在群里直接查订单、开工单,不用再跳系统。注意 8B 激活参数对复杂 schema 的遵循度一般,函数个数控制在 5 个以内。 - 多模型路由:简单问答走 V4.1 Flash(¥0.8/M),命中关键词(架构设计、代码审查)时自动切 V4 Pro,用一张路由表兼顾成本和效果。
- 知识库增强:用飞书文档的开放接口把团队 wiki 拉进来做检索,把 Top-3 片段塞进 system prompt,能显著减少「机器人不了解我们业务」的抱怨。
团队一起用
- 按部门拆机器人:技术群、运营群各配一个机器人,system prompt 分开写,比一个「全能机器人」好用得多。
- 统一 Prompt 仓库:把每个机器人的 system prompt 存成一个 Git 仓库,改 prompt 走 PR review,避免有人随手在服务器上改一行导致行为突变。
- 用量看板:把每次调用的 token 数、耗时、用户 open_id 打到日志里,用飞书多维表格做个看板,谁在用、烧了多少钱一目了然。
- 建立错误案例库:群里出现「机器人答错了」时,截图归档并写进 system prompt 的反例里,迭代几轮后准确率会明显提升。
进阶学习路径
1阶段 1(入门):群自定义机器人 + 加签,能往群里推 DeepSeek 的回答 ← 你现在在这里
2阶段 2(进阶):改发交互式卡片,加「重新生成 / 复制 / 转工单」按钮
3阶段 3(高级):自建应用 + 长连接事件订阅,机器人能听见并回复
4阶段 4(专家):卡片流式更新 + Function Calling 接内部系统 + 用量看板
5阶段 5(架构):Webhook 回调 + 多副本 + 密钥管理服务 + 敏感信息脱敏网关提示
提示:DeepSeek V4.1 Flash 与飞书开放平台都在持续迭代,接口字段和限流阈值可能调整。建议收藏官方文档——DeepSeek API 文档 与 飞书开放平台文档,遇到本文没覆盖的报错码,优先去这两处查最新的错误码表。
常见问题
Q自定义机器人为什么收不到群里用户发的消息?
群自定义机器人的 Webhook 是单向的,只能由你的服务往群里推消息,飞书不会把用户消息转发给你。要「收到用户问题再回答」,必须走方式 B:在开放平台建企业自建应用,开启机器人能力,订阅 im.message.receive_v1 事件(长连接或 Webhook 回调二选一),拿到 message_id 后再调 im/v1/messages 回复。
Q加签一直返回 19021 sign match fail 怎么办?
三个高频原因:一是 timestamp 用了毫秒(13 位),必须改成秒级(10 位);二是拼接串写错,正确内容是 timestamp + 换行符 + secret,不是 secret + 换行符 + timestamp;三是加密结果编码错了,飞书要 HMAC-SHA256 的十六进制字符串(小写),而钉钉要 base64,两套不通用。另外服务端与飞书时间相差超过 1 小时也会被拒。
Qtenant_access_token 多久过期?能不能每次发消息都重新获取?
有效期 2 小时。不要每次请求都重新获取:开放平台对 /auth/v3/tenant_access_token/internal 有独立限流,实测连续高频调用会在几十秒内被打回限流错误。正确做法是进程内缓存 token,在过期前 5 分钟(即剩余有效期小于 300 秒)主动刷新,多实例部署时放进 Redis 并加锁避免并发刷新。
Q卡片消息返回 230002 怎么排查?
230002 表示消息体校验失败。按顺序查:header.template 的颜色值必须是飞书内置色号(blue / wathet / turquoise / green / yellow / orange / red / carmine / violet / purple / indigo / grey,不能写 #FF0000);elements 里每个元素的 tag 必须是受支持的标签;actions 里的按钮 type 不能为空、value 字段必须有且不超过 512 字节;整个卡片 JSON 序列化后不超过 30KB。建议先用飞书「卡片搭建工具」拖拽生成,再把 JSON 复制到代码里。
Q一个群能放几个机器人?发送频率上限是多少?
群自定义机器人数量没有硬性上限,但建议每个群不要超过 3 个,否则消息噪音会让机器人被停用。发送频率上,同一 Webhook 连续推送超过 50 条/分钟会触发限流(返回频率错误码并在几分钟内拒绝请求)。生产环境建议做两件事:把回复合并成一条消息发送,以及在代码里加一个每分钟最多 40 条的令牌桶。