1. 为什么会把机器人做成“中台”,而不是随便接个API了事
先说说背景。我所在的团队负责公司内部大量的自动化业务流程:销售要查订单状态、研发要拉取代码仓库统计、HR要处理入转调离的审批摘要、运营每天要把多维表格里的数据整理成日报……最开始的做法很简单,每个部门提需求,我们就给飞书群里扔一个机器人,转发一下对应的接口。一开始确实爽,一个群一个机器人,各管各的事。
但老实讲,这种“野路子”撑不过三个月。四个部门、十几个场景之后,问题全部涌出来了:机器人账号越来越多、事件回调地址越配越乱、权限全靠“拉人进群”来控制、日志散落在不同服务器上根本没法审计。最致命的是,当我们需要把AI能力加进来时——比如让模型自动总结日报、让Codex根据工单生成代码片段——没有一个统一的入口把“人的请求”和“AI的处理”衔接起来。
所以后来我下决心做了一套飞书智能交互中台。它的本质不是“一个机器人”,而是一个统一的交互层:所有飞书事件请求先进中台,由中台判断该调用哪个Skill(技能单元),做什么样的权限校验,是否需要多个Skill串联执行,然后统一返回结果。这篇文章就是把我实际落地的过程、取舍和踩坑整理出来,重点讲三件事:多Skill编排、权限管控、可插拔管理。
如果你正在做飞书机器人相关的项目,或者想把LLM能力接入飞书但不知道怎么组织代码结构,这篇文章应该能帮你少走不少弯路。
2. 整体架构设计:接入层、编排层、技能层要分开
2.1 中台的分层思想
我在设计时没有把代码写成一坨“接收消息 → 调API → 回复”,而是强行分了四层。这个分层是后面一切灵活性(尤其是可插拔)的基础。
- 接入层:只负责与飞书开放平台打交道。接收事件回调、处理URL验证、解密Encrypt Key、解析消息卡片回调。这一层不知道“业务是什么”,只把飞书的Event结构体转成平台无关的Request对象。
- 编排层:根据Request的意图,决定执行哪个Skill、多个Skill的执行顺序、是否需要人工审批等。这一层是“中台”的核心,也是“多Skill编排”的主战场。
- 技能层:每一个Skill是一个独立的功能单元。比如“订单查询Skill”“日报生成Skill”“表格发送Skill”。它们不关心飞书的消息格式,只输入结构化参数、输出结构化结果。
- 基础服务层:提供公共能力,比如Redis缓存、权限校验客户端、审批流引擎、AI模型网关。Skill如果需要调LLM,不直接对接,而是走基础服务层的网关,这样换模型供应商时不需要改Skill代码。
这个分层带来的直接收益是:后续加一个新的Skill,完全不碰接入层和编排层的代码。只需要在技能层新增一个模块,再去配置一个注册项就完事。这就是“可插拔”的起点。
2.2 飞书侧的资源模型
落地之前,要把飞书这边的几个概念理清楚:应用(App)、机器人(Bot)、事件订阅(Event Subscription)、消息卡片(Message Card)、多维表格(Bitable)、云文档(Docs)。
- 一个飞书自建应用可以开启“机器人”能力,生成一个Bot User。
- 用户@机器人或在群里@它,飞书会向你的回调URL发送事件,事件类型包括im.message.receive_v1等。
- 应用可以申请各种API权限,比如
im:message、bitable:app、docs:document等,权限审批是租户管理员在飞书管理后台做的。 - 如果你要发富文本卡片,走的是
im/v1/messages接口,需要构造卡片JSON。
我在项目里用的回调模式是“长连接”模式(WebSocket),而不是“Webhook模式”。原因是Webhook模式要求公网可访问的回调地址,本地调试很麻烦,而且飞书会频繁校验回调URL的签名和加密。长连接模式通过长连接SDK在本地维持连接,不用暴露公网端口,安全性和调试体验都好得多。这个选择后面会详细说,因为它直接影响了我如何做权限管控和“可插拔”。
3. 多 Skill 编排:从“单点回复”到“流程组合”
3.1 为什么要编排
很多初做飞书机器人的朋友,逻辑是这样的:收到消息 → if 消息包含“订单” → 调订单接口 → 回复。这是典型的“路由”,不是“编排”。但真实企业场景里,一个请求往往需要多个能力配合。
我遇到的一个典型案例:运营人员在群里发了一句“帮我把今天多维表格里的销售数据按区域汇总,生成表格,发给华东群”。
这句话要拆成几步:
- 解析“多维表格里” → 调用多维表格读取Skill
- 解析“销售数据汇总” → 调用数据聚合Skill
- 生成表格 → 需要调用表格生成Skill(生成xlsx或飞书电子表格)
- 发给华东群 → 调用消息发送Skill
这个过程中,每个Skill只做自己的一件事,但编排层要把它们串成一个有序的Pipeline。
3.2 三种编排模式
我在实际项目里整理出三种编排模式,分别应对不同场景:
路由式编排
最基础的一层。根据用户请求的意图(Intent)选择唯一的Skill。我用的方式不是传统的关键词正则(太脆),而是让LLM作为“意图分类器”:把用户消息和Skill清单一起送给LLM,让它返回该调用哪个Skill以及抽取出的参数。
用户消息:查一下订单OD-20250401的物流状态 → 中台调用意图识别模型 → 返回 Skill=order_query, 参数={"order_id":"OD-20250401"} → 编排层路由到订单查询Skill这里的核心是让Skill自己声明“我能处理什么”,然后LLM做匹配。只要Skill的声明写得清楚,新增Skill后不需要改路由代码。
管道式编排
当一个请求需要多个Skill按顺序协作时,我定义了一个Pipeline对象。每个Pipeline声明了Skill节点列表、每个节点从上游拿到什么参数、是否需要人工确认断点等等。
{ "pipeline_id": "report_send_pipeline", "name": "生成日报并发群", "nodes": [ {"skill": "bitable_fetch_skill", "input_mapping": {"table": "user_msg.table"}}, {"skill": "data_aggregate_skill", "input_mapping": {"source": "prev.result"}}, {"skill": "sheet_build_skill", "input_mapping": {"data": "prev.result"}}, {"skill": "message_send_skill", "input_mapping": {"file": "prev.result", "chat_id": "target_chat"}} ] }每个节点执行完后,把输出塞进一个上下文对象里,下一个节点通过prev.result引用。执行到“给外部群发送文件”这种高危节点前,Pipeline会暂停,等人点击审批卡片后再继续。这个“暂停恢复”机制是权限管控的重要一环,后面细讲。
状态机式编排
处理多轮对话时用的。比如用户先问“帮我查一下华东区销售数据”,机器人需要追问“你指的是本周还是本月?”,用户回答后继续推进。这种场景需要在Redis里保存会话状态,编排层根据状态转移判断下一轮该干嘛。我把它做成了一个通用的SessionManager,为每个用户维护一个状态机,状态机里每个状态绑定对应的Skill。
3.3 我最终选择的编排引擎
三种模式听起来很多,但实际在我的中台里,核心引擎只做一件事:根据Skill注册表+LLM意图识别结果,挑选合适的执行策略。
对于“无需多步但需要参数填充”的请求,用路由式;对于“明确需要多个Skill协作”的场景,先判断是否存在对应的Pipeline,没有则动态生成编排计划(让LLM根据Skill声明自动编排)。动态生成这块我做得比较保守,只允许LLM组合不超过4个Skill,避免模型“放飞自我”。
这套设计的核心是:Skill本身不感知编排。编排完全是中台调度层的行为,每个Skill只关心“给我一个合法参数,我返回一个结构化结果”。这样编排策略随时可以调整,Skill不需要有任何改动。这就是可插拔和可编排能同时成立的原因。
4. 权限管控:身份、技能、操作三层模型
4.1 不能只靠“在群里”来判断
飞书机器人最常见的一个权限漏洞是:任何把机器人拉进群、或能在群里@机器人的人,都能触发机器人的一切功能。这在内部小范围用还凑合,一旦涉及“发送文件到外部群”“读取多维表格数据”“调用Codex消耗token生成代码”这类操作,就必须有完善的权限管控。
我的权限体系分了三层:
身份层
先搞清楚“是谁在发消息”。飞书事件回调里带有open_id和user_id,我用open_id作为唯一用户标识。在系统里有一个用户表,把open_id映射到内部员工号、部门、角色标签。这个映射表通过飞书通讯录API定期同步(contact/user/batch_get),保证员工入离职状态及时更新。
技能层
每一个Skill在注册时要声明允许的“身份范围”。范围可以是某个部门(需要对接通讯录API判断用户是否属于该部门)、某个群聊(判断当前消息来自哪个chat_id)、某个角色标签(从内部权限系统同步)。
比如订单查询Skill只允许销售部门;代码生成Skill只允许研发Leader角色;多维表格读取Skill只允许该表格协作者。我在注册表里加了一个allowed_scopes字段:
{ "skill_id": "order_query_skill", "name": "订单查询", "allowed_scopes": { "departments": ["sales_dept"], "chat_ids": ["oc_xxx"], "roles": ["sales_manager"] } }校验是“或”逻辑,满足任一条件即可。这层校验在编排层执行,任何一个Skill命中之前都会检查,避免绕过编排层直接调用Skill内部的入口。
操作层
即使身份没问题、技能也开放了,某些具体操作仍然需要额外确认。这主要是针对“对外发送”“删除数据”“生成代码并写入仓库”这类不可逆或高风险动作。
我的做法是引入“操作审批点”。在Pipeline执行到某个高风险节点时,自动在管理群发送一张审批卡片,卡片上有“允许执行”和“拒绝拒绝”按钮,只有具备审批权限的管理员可以点击。审批通过后,编排层才会继续推进Pipeline,否则整个流程终止并通知发起人。
4.2 权限校验的工程实现
权限校验最关键的要求是快,不能每次消息进来都去远程调API查一遍部门和角色。我的方案是:
- 启动时全量加载用户权限快照到Redis,设置10分钟过期。
- 用户在消息进来时,先查Redis,命中直接用;没命中则回源飞书通讯录接口拉取并回填缓存。
- 用户主动触发“刷新我的权限”命令时,可以提前刷新缓存。
这样做的好处是:普通人在群里@机器人时,响应延迟基本不受权限校验影响;而权限变更最多10分钟后生效,企业内部不会觉得滞后期无法接受。
4.3 一个真实权限事故的教训
有一次公司销售总监投诉,说有人用机器人调了销售明细数据。排查后发现:最开始订单查询Skill只给销售部门开放,但后面接数据聚合Skill时,Pipeline配置文件里的allowed_scopes被漏掉了,导致Pipeline直接调了另一个查询Skill,等于“绕过了技能层校验”。
这次事故之后,我把权限校验逻辑改到了编排层调用链的必经环节上——也就是“执行Pipeline的引擎入口处”再统一校验一次,并让测试环境默认拒绝所有Skill执行,只有授权后的群聊才能真正跑通。这提醒所有做中台的朋友:权限管控点要放在不可绕过的公共链路上,而不是每个Skill内部自觉校验。
5. 可插拔管理:Skill 是“插上去就能用”的插件
5.1 设计一个Skill的最小接口
“可插拔”是我整个项目里最花心思的部分。要让团队里每个新人都能快速接入一个新功能,同时保证老功能不受影响,就必须定义一套稳定的Skill接口。
我在Python端定义了一个抽象基类:
# skill_base.py from abc import ABC, abstractmethod from typing import Dict, Any class BaseSkill(ABC): # 技能元信息,注册时读取 skill_id: str = "" name: str = "" description: str = "" input_schema: Dict[str, Any] = {} output_schema: Dict[str, Any] = {} @abstractmethod def execute(self, params: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: """执行技能核心逻辑,返回结构化结果""" pass def pre_check(self, user, chat_id, params) -> Dict[str, Any]: """技能级别的自定义权限/参数预检,返回(是否通过, 错误信息)""" return {"ok": True, "message": ""}每个Skill只需要继承BaseSkill,实现execute方法,再写一个skill_manifest.json做注册声明:
{ "skill_id": "bitable_fetch_skill", "name": "多维表格数据读取", "description": "用户提供多维表格token和视图名,读取指定视图的记录数据", "version": "1.2.0", "owner": "data_team", "allowed_scopes": { "departments": ["data_team", "ops_team"] }, "entry": "skills.bitable_fetch_skill:BitTableFetchSkill" }5.2 注册中心与热加载
系统中维护了一个“技能注册表”,本质上是一个SQLite表+Redis索引。启动时扫描所有skills/目录下的manifest文件插入注册表,并记录下来每个Skill对应的模块导入路径。
为了让“插拔”做到不用重启服务,我用importlib来实现模块动态加载:
# registry.py import importlib class SkillRegistry: def __init__(self): self._skills = {} def register_from_manifest(self, manifest_path: str): manifest = json.load(open(manifest_path, encoding="utf-8")) module_path, class_name = manifest["entry"].split(":", 1) module = importlib.import_module(module_path) skill_cls = getattr(module, class_name) skill_instance = skill_cls() skill_instance.manifest = manifest self._skills[manifest["skill_id"]] = skill_instance return skill_instance def reload_skill(self, skill_id: str): # 按skill_id找到旧的模块信息,移除缓存后重新加载 pass新开发一个Skill时,开发者只需要在skills/下新建目录放代码和manifest,然后运行一个python manage.py reload skill_id命令。热加载成功后,编排层马上能看到新Skill的声明。无需重启中台进程,这在联调阶段效率极高。
5.3 灰度与下线
可插拔不只是“能加”,还要能“灵活动态地减”。我在注册表里加了三个状态:active、gray、disabled。
- active:所有流量正常进入。
- gray:只对白名单内的用户/群聊生效,用于新版本Skill的小范围验证。
- disabled:彻底不接收流量,已有会话直接拒绝执行。
灰度发布时,我在编排层加了一个流量判断:如果Skill标记为gray,则检查当前请求的open_id是否在灰名单中。否则走旧版本逻辑。这个灰名单在管理后台配置,不需要改代码。
下线一个Skill时,我会先把它置为disabled,观察一周日志确认无新请求,再删除代码目录。因为注册表里还保留着entry字符串,编排请求如果命中了disabled的Skill,会返回“该功能已下线”的提示,而不是直接报错,用户侧体验更平滑。
5.4 可插拔带来的一个额外收益
可插拔设计让我能直接给“普通同事”放权。以前每个新需求都要我来改代码、部署、测试;现在我把“接入新Skill”的流程做成了文档化模板,数据分析团队的同学按照模板写一个Python文件+manifest,跑一遍自测命令,然后在管理后台提交注册申请,等代码Review通过后热加载即可上线。
后来我们团队把“申请新Skill”的流程也接进了飞书审批。开发者在多维表格里登记申请,审批机器人自动在管理群发卡片,点通过后自动触发部署流水线。一个简单的数据查询Skill,从提出需求到上线,最快半天搞定。
6. 实际操作中踩过的坑与排查链路
6.1 Codex接入飞书的连接方式
标题里提到的Codex,其实是把OpenAI的Codex能力封装成一个“代码生成Skill”。踩坑的点在于:飞书的事件回调机制和Codex的流式输出不匹配。
飞书的im.message.receive_v1事件要求你在3秒内响应回调,否则飞书会视为超时并重试。而Codex处理一个代码生成请求往往要几十秒甚至更久。如果直接在事件回调里同步调用Codex,必炸。
我的解决方案是异步任务+主动推送:
- 回调收到消息后,立刻返回HTTP 200空响应(回执给飞书),把真正任务丢进Celery队列。
- 任务执行期间,先在群里发一张“正在处理”的普通文本消息或卡片。
- Codex生成完成后,用消息更新接口
im/v1/messages/{message_id}把卡片内容更新为最终结果;如果是代码内容,用im/v1/messages?receive_id_type=chat_id创建富文本消息推送代码块。
这样做的另外一个好处是:用户看到“正在处理”卡片后,知道机器人没有卡死。新版飞书消息卡片支持msg_type=interactive,可以在卡片里放“重新生成”按钮,触发另一个Skill重新跑任务。这个交互闭环用户反馈非常好。
6.2 机器人发送表格的多维编码问题
“飞书机器人发送表格”是群里最常见的需求。这里最容易踩坑的是:你以为发送的是Excel文件,实际上飞书有两种“表格”完全不是一回事。
- 一种是上传一个真正的xlsx文件,走
im/v1/files接口,以file_type=xlsx发送。这种适合发送给用户下载编辑。 - 另一种是消息卡片内嵌表格,走
interactive卡片JSON,字段是table。卡片内表格只能展示,不能下载,数据量小时展示效果好。
我一开始没分清楚,写了个通用发送函数,结果用户说“收到的表格打开是乱的”。排查后发现:xlsx文件上传用的是二进制multipart表单,飞书那边要求文件名不能包含非UTF-8字符,且文件大小不能超过30MB;而卡片内嵌表格则要求每个单元格内容不超过2000字符,且不能出现\n换行符(需要替换成<br>)。
最终的发送函数做了两层判断:数据量大或需要导出编辑时走xlsx上传;数据量小(比如50行以内)则生成卡片表格。一句话总结:先问用户要“能下载的文件”还是“能直接看的卡片”,别自作主张。
6.3 多维表格API的速率限制与字段类型坑
用Bitable(多维表格)做数据读取时,最烦的不是授权,而是API的速率限制和字段类型多样性。
飞书多维表格的API按应用维度限流,默认大约是每秒10次请求。问题在于,当你一次性读取一个超大视图(比如1万行记录)时,必须分页拉取,每页最大500条。如果你为了赶时间并发拉取多个页,很容易触发限流,返回429。
我的处理方式是写了一个带限流器的Bitable客户端:采用令牌桶算法,每秒最多8个请求,所有Skill都走这一个客户端。宁可慢一点,也不要被限流断了任务。
字段类型是另一个大坑。多维表格的字段可以是文本、数字、日期、单选、多选、人员、附件、公式、关联等,API返回的字段值格式差异非常巨大。人员字段返回的是数组(每个元素是open_id),日期字段可能是时间戳或格式化字符串,公式字段可能是计算结果或错误信息。
我在数据聚合Skill里加了一个字段类型归一化层:所有字段读取后统一转为字符串或数字,并额外记录一个字段类型标签,供下游处理。否则,用LLM做数据摘要时,模型会把“人员字段的open_id数组”当成普通文本,生成完全没意义的总结。
6.4 消息事件回调和重试的重复处理
飞书的事件订阅有一个机制:如果回调地址没有在限定时间内返回成功,飞书会重试推送事件。重试间隔一般是3秒、30秒、5分钟等递增。如果你的接入层没有做幂等处理,重试就会导致同一个用户请求被多次执行。
最典型的场景是:中台执行一个耗时任务时,飞书认为回调超时了,自动重试,于是任务被重复触发。我在这块吃过亏:有次用户一句话“生成周报”被重复执行了三次,发出三份周报到群里。
解决方案是给每个事件生成一个唯一的事件ID(飞书回调会带header.event_id),在Redis里记录processed_events,设置TTL 24小时。处理前先判断event_id是否已存在,存在则直接忽略。后来扩展到所有回调处理前统一做幂等判断。这个经验值得所有做飞书机器人的朋友注意:飞书不会再发一次,不代表它不会重试。
6.5 如何把云文档内容嵌入到自己的网站
这个需求来自团队内部的Wiki整理:想把飞书云文档里的内容同步到我们的技术博客站,避免两处维护。
最靠谱的方式是用飞书开放平台的文档块接口(docx/v1/documents/{document_id}/blocks)拉取文档结构,按块类型(标题、正文、列表、表格、引用)转成HTML或Markdown,然后走站点发布流程。这里有几个注意点:
- 文档需要给应用授
docx:document:readonly权限,且文档对应用可见(要么添加协作者,要么使用租户内所有文档权限)。 - 图片块拉取到的还是media_id,需要再调用
drive/v1/medias/{file_token}/download拿到真实图片URL。 - 表格块转换时列宽可能丢失,最好在站点侧重新做响应式布局。
也有同事提到用lark sync去同步到Obsidian,这适合个人知识库;但做网站内容嵌入还是直接走开放平台API可控性最高,因为你能控制文档转HTML的每一步样式。
7. 踩过坑之后,我对这套架构的反思
整个项目上线至今运行已经有小半年,期间迭代了二十几个Skill,模块化架构给我带来了巨大的维护红利。最早的两个机器人发布机已经可以退役,所有请求都收拢到中台统一处理。现在我最深的体会是:
- 不要把权限管控寄托在“每个Skill自己想起来校验”上,一定要把校验放到编排层的公共路径。
- 可插拔的意义不只是“方便加功能”,更重要的是“危险的旧功能可以被优雅降级,而不是紧急删除代码”。
- 与AI模型相关的Skill一定要设计成异步+卡片更新模式,否则飞书事件回调超时重试会把你折磨到疯。
如果以后有朋友也想做类似的企业内机器人中台,我会建议他先想清楚三层模型:接入层只管协议,编排层只管调度,技能层只做业务。这三层中间不要互相渗透。哪怕前期多写一点胶水代码,也比后期把业务逻辑和机器人逻辑绞在一起强得多。
最后分享一个小技巧:团队里新同事接手的第一个小任务,我通常会安排他写一个新的Skill(比如“汇率查询”),从写manifest到热加载到群里@测试,完整走一遍。这个流程走完,他对整个中台的“插拔”机制基本就无师自通了。