先给你看一个我每天都在用的画面。
我在飞书里私聊一个"人":帮我看看今天的数据。过几秒,它把结果回给我了。
我在一个工作群里 @ 它一句:把刚才那个结论整理一下发出来。它在群里 @ 着我、引用着我那条消息,把整理好的东西回了出来。
我又跟它说:这事告诉一下隔壁那个群。它真就跑到隔壁群里,@ 上对应的人,开口第一句还带着"某某找你"——办完回来跟我说一声"已经同步过去了"。
你是不是以为我在说某个同事?
不是。这是我自己接进飞书的一个 AI——我私下管它叫"分身"。它没有工位、不用发工资,但你私聊它、群里 @ 它、让它替你传话跑腿,它都接得住。
这一篇我换个讲法:不光说它"像不像个人",而是把我到底怎么把它做出来的,一段一段、连关键代码都摊给你看。但你别紧张——这篇不是让你去写代码,恰恰相反,我会告诉你每一块功能该怎么"说"给 AI 听,让它替你写出来。你负责想清楚要什么,代码交给它。
说明:下面的真实代码来自公开仓库
ArchAIHarness/feishu-bot(MIT 许可,配置已脱敏),基于 2026-04 的 OpenCode 插件机制和飞书开放平台 SDK。API 细节以官方文档为准,但"怎么跟 AI 把需求说清楚"这套方法,跟版本无关。
一、连接,其实是最薄的一层
先破一个最常见的误会。
一说"把 AI 接进飞书",大部分人第一反应是:这肯定是个大工程吧?要对接平台、要处理协议、要搭服务器……
我也是这么以为的,直到真动手才发现——接进去,是整件事里最简单的一步。
飞书给开发者留了官方口子:你注册一个机器人应用,拿到一对钥匙(一个app_id、一个app_secret),然后让程序跟飞书建一条长连接,飞书那边一有新消息,就顺着这条线推给你。
这条线叫 WebSocket。你不用记这个词,只要知道它的作用:它给你的 AI 装了一对"耳朵",飞书里谁说了话,它当场就听见。
核心就这么几行——建一个客户端,注册一个"收到消息"的回调:
constwsClient=newWSClient({appId,appSecret});constdispatcher=newEventDispatcher({}).register({'im.message.receive_v1':async(data)=>{constmsg=(data.event||data).message;// 收到一条消息了,下面开始处理……},});wsClient.start({eventDispatcher:dispatcher});填上那对钥匙、把连接打开、注册一个回调,齐活。耳朵就接上了。
这段你完全不用自己敲。你打开你的 AI 搭子,把需求讲清楚就行,比如这么说:
“我要做一个飞书机器人,用飞书官方的
@larksuiteoapi/node-sdk,通过 WebSocket 长连接收消息。帮我写一段:读app_id、app_secret,建好 WSClient,注册im.message.receive_v1事件,收到消息时把发送人、群 ID、正文先解析出来。密钥从一个feishu.yaml里读,别写死在代码里。”
你看这句话里其实就藏着四件事:用哪个库、走什么连接、监听哪个事件、密钥放哪。把这四件事说清楚,AI 就能给你一段能跑的连接代码。你不需要知道 WSClient 内部怎么握手——那是它该操心的,不是你。
真正费劲、真正决定这个分身"像不像个人"的,根本不在这一层。那在哪儿?在它听见消息之后,怎么做事、怎么做人。
二、凭什么它是"我的分身",而不是个陌生人
你有没有用过那种一问一答的客服机器人?你问一句它答一句,下一句它就不记得你刚说过啥了,每次都像第一次见面。
那种东西谈不上"分身",顶多是个复读机。
我这个分身不一样——你能跟它连着聊。上一句说"看看今天的数据",下一句直接说"那昨天的呢",它知道"昨天的"接的是"数据",不用你从头交代。
怎么做到的?说穿了就一句话:给每一个聊天,单独开一个"脑子"。
落到代码里是这样:每来一条消息,先按"谁在哪儿跟我说话"拼出一个标题,私聊用对方名字、群聊用"群名/发言人",然后拿这个标题去找有没有现成的会话,有就接着用,没有才新建:
constsessionTitle=chatType==='p2p'?`飞书私聊:${senderName}`:`飞书群聊:${chatName}/${senderName}`;// 用标题找现成会话,找到就复用,找不到才新建letsession=existingSessions.find((s)=>s.title===sessionTitle);if(!session)session=awaitclient.session.create({body:{title:sessionTitle}});就这一招,分身立刻有了记性:跟我私聊是一条线、在 A 群被 @ 是另一条、在 B 群又是一条,各记各的,互不串味。它在私聊里记得我们聊到哪了,到了群里又不会把私聊的事抖出来——这正是一个靠谱的人该有的分寸。
你要让 AI 写这一块,关键是把"会话怎么隔离"讲明白:
“每条飞书消息进来,按场景拼一个会话标题:私聊用对方名字,群聊用’群名/发言人’。先拿这个标题去已有会话里找,找到就复用、把新消息追加进去;找不到再新建。目的是让私聊、不同群各自独立记忆,互不干扰。”
注意我没让它"做个记忆系统"——那种说法太大,AI 容易给你整一套数据库。我只说了"按什么维度区分、找不到才新建",它就知道该用会话标题做 key 去复用。把隔离的"维度"说准,比堆术语管用得多。
三、它的"耳朵"很挑——这是我特意调的
你可能担心:把 AI 塞进群里,它会不会逮谁说话都插嘴,把群搅得鸡飞狗跳?
不会。因为它的耳朵是我特意调挑剔的。在"收到消息"那个回调里,我加了三道过滤,缺一道它都可能变成群里的灾难。
第一,太旧的消息,直接扔。这条我放在最前面,是血泪教训:
constmsgTime=parseInt(msg.create_time,10);if(msgTime&&Date.now()-msgTime>60000)return;// 超过 1 分钟的旧消息,丢弃设想一下:程序半夜重启了一次,飞书会把它"失联"那段时间积压的消息呼啦啦全补推过来。要是它照单全收、一条条都去回,那就是凌晨三点把整个群挨个 @ 一遍的社死现场。所以我给它定死:太旧的不是任务,是历史,看一眼就扔。
第二,群里没 @ 它,就装没听见。
if(chatType==='group'){constbotMentioned=mentions.some((m)=>/* 被 @ 的是不是机器人 */);if(!botMentioned)return;// 群里没点我名,不理}群是用来聊天的,它要是句句都接,那不叫助手,叫话痨。私聊不用这条,因为私聊里只有你俩,每句都是冲它说的。
第三,发太快就自己踩刹车。它对外发消息有节制——每秒最多几条、每分钟最多上百条,到顶就自己等一下。这不是抠门,是怕一股脑发太猛被平台当异常限制了。
你看,这三条没一条是高科技,全是"怎么做个有眼力见儿的人"。难的从来不是技术,是分寸。
怎么让 AI 把这层耳朵装上?我的经验是:别笼统说"做好健壮性",要把每条规矩连同"为什么"一起讲——讲清后果,AI 才知道轻重:
“在收消息的回调里加三道过滤:① 消息创建时间超过 60 秒的直接丢弃,防止程序重启后积压消息被补推、半夜炸群;② 群聊消息只有在机器人被 @ 时才处理,私聊不限制;③ 给发送消息加限流,每秒最多 5 条、每分钟最多 100 条,超了就排队等,避免被飞书限流。”
把"会出什么事"说给它听,它写出来的过滤就带着判断,而不是一堆没灵魂的 if。
四、给它立规矩:私聊、群聊、传话,各有各的章法
耳朵调好了,接下来是最花心思的部分——教它在不同场合怎么待人接物。
这部分我没写一行复杂代码,就是拿大白话把规矩讲给它听,写进一份叫AGENTS.md的"行为说明"里。我把它要会的场面归成三种:
场景一,私聊喊它办事。你私信发指令,它办完私信回结果。一对一,干脆利落。
场景二,群里 @ 它干活。群里 @ 它发指令,它办完要在群里 @ 回你、并且引用你那条原始消息——这样别人一看就知道它在回谁、回的哪件事。
场景三,让它替你传话。你让它把结果转发到另一个群或某个人,它转过去时开口带一句"谁找你"——比如"老王找你,今天的数据是这样……“,传完再回来跟你说"已经同步给老王了”。
这套规矩在AGENTS.md里长这样,几乎就是大白话:
### 场景3:群聊指令 群聊中 @ 机器人发指令,在群里 @ 发送方回复结果并引用原消息。 指令要求转发到其他群/人时,转发时告知接收人是谁找的: - 用户在群里 @ 机器人:"把结果发 yyy 群里" → 在 yyy 群 @ 接收人发"xxx 找你,结果……" → 群里 @ 发送方回复"消息已同步 yyy 群"你发现没有?这三套规矩,跟你培训一个新来的助理讲的是一模一样的东西:什么场合公开说、什么场合私下说、替人带话要报上是谁托的。AI 不会天生懂这些,得有人给它立规矩。立规矩的是人,照着做的是它。
这一块恰恰最不用写代码——你"说"出来的规矩,本身就是程序的一部分。你要做的,是把"什么场合怎么回"想清楚,一条条写成大白话喂给它。想让 AI 帮你拟这份说明,可以这么开口:
“帮我写一份飞书助手的行为说明(AGENTS.md),分三个场景:① 私聊发指令,办完私信回;② 群里 @ 我发指令,我办完在群里 @ 回发送方并引用原消息;③ 让我转发给别的群或人时,转发时先报’谁找你’,转完再回来告诉发送方已同步。每个场景给一个对话示例。”
写完你自己读一遍,像不像在交代一个新人?像,就对了。
五、给它几只"手"——但别让它自己瞎抓
光有规矩还不够。规矩是"该怎么做人",可它总得有几只"手"去真的把事做了。
我给它配了四只工具,每只管一摊,名字一看就懂:
| 工具 | 干什么 |
|---|---|
feishu_send_message | 发消息(私聊或群里,群里能自动 @ 人) |
feishu_search_user | 按名字找人,拿到对方的真实 ID |
feishu_list_chats | 列出它在哪些群里 |
feishu_list_chat_members | 看某个群里都有谁 |
在 OpenCode 里,一只"手"就是一段带说明的小函数。拿发消息这只手举例,骨架是这样:
feishu_send_message:tool({description:'发送飞书消息。群聊 @ 人传 at_names(人名,逗号分隔),工具会自动查 open_id。',args:{receive_id:tool.schema.string(),content:tool.schema.string(),at_names:tool.schema.string(),// 要 @ 的人名reply_to:tool.schema.string(),// 引用哪条原消息},asyncexecute(args){// 真正调飞书接口发消息……},}),注意那个description——它不是写给人看的注释,是写给 AI 看的说明书。AI 全靠这句话判断"什么时候该用这只手、每个参数填什么"。这正是上一篇聊"插件是聊出来的"那回讲过的道理:你把这只手的用途和参数讲清楚,AI 写出来的就是它自己看得懂、用得对的工具。
这里有个我踩过、也最值得你记住的坑:发消息之前,必须先把"发给谁"弄准。
AI 容易在这儿想当然——群里要 @ 一个人,它可能凭感觉去拼一个 @ 标记,八成 @ 错人或者 @ 了个不存在的。我的解法是:把"@ 谁"这件事从 AI 手里收走,交给工具自己办。AI 只管给一个人名,工具拿着名字先去群成员里找、找不到再翻通讯录,查到真实 ID 才拼 @:
// AI 只给人名,工具自己去查真实 ID,绝不让 AI 手搓 @ 标记const{found,missing}=awaitresolveAtNames(args.at_names,chatId);if(missing.length>0){return`没找到这些人:${missing.join(',')},请先确认姓名`;}所以我给它的铁律是:先查清楚,再动手。这跟一个靠谱的人发重要通知前会先核对收件人名单一个道理——宁可多查一步,不可错发一条。
你要让 AI 把这套手做出来,重点是讲清"每只手干一件事"和"动手前先查 ID"这条纪律:
“给这个飞书助手配四个工具,各管一摊:发消息、按名字搜用户拿 ID、列出所在群、列某群成员。发消息工具要支持群里 @ 人——但不要让模型自己拼 @ 标记,而是接收人名,工具内部先查群成员、再查通讯录拿到 open_id,查不到就报错让它确认姓名。每个工具的 description 写清楚什么时候用、参数怎么填。”
你给的不是代码,是"职责怎么切、纪律怎么定"。这些想清楚了,AI 落地成函数是顺手的事。
六、全篇的题眼:凭什么它说完,就一定真发出去了
现在说到我最得意、也最想掰开揉碎讲给你的一处设计。
你想过没有:AI 这东西,是出了名的会"自言自语"。你让它去群里回个话,它很可能在自己那边洋洋洒洒写了一大段"好的,我已经回复了……"——结果一个字都没真发出去。它以为自己说了,群里其实一片寂静。
这要是发生在真实办公里,就是事故。
那怎么治它这个毛病?我的答案是——不靠它自觉,靠规矩和兜底,给它焊死。我设了三道闸,一道比一道硬。
第一道,把话挑明(事前)。我在它的行为说明里,用最重的语气写死一条命令:
- **绝对禁止只在对话中输出文字**,任何回复必须调用 feishu_send_message 通过飞书发送,否则对方完全看不到 - 无论什么情况,最后一步必须是调用 feishu_send_message这是事先把丑话说在前头。但你也看出来了,这只是"嘴上约定",AI 心情不好照样可能违约。
第二道,盯着它到底做没做(事中)。光靠嘴说不够。我在每只手用完之后挂了个"埋点",专门盯着它有没有真动用"发消息"那只手:
"tool.execute.after":async(input)=>{if(input.tool==='feishu_send_message'){feishuToolCalledForSession.add(input.sessionID);// 真发过了,打个勾}},动了就打个勾,没动这个勾就一直是空的。这一步不打扰 AI,只是默默记账。
第三道,也是最关键的——它要是没做,我替它补上(事后)。当它这一轮活儿干完、安静下来(OpenCode 管这叫session.idle),程序回头查那个勾:要是发现勾是空的——说了一通,却没真发——兜底机制立刻启动,把它刚才写的最后那段话抓出来,替它真正发到该去的地方:
event:async({event})=>{if(event.type==='session.idle'){// 这一轮它调过发消息工具吗?调过就放过if(feishuToolCalledForSession.has(sessionId))return;// 没调过!把它最后写的那段话抓出来,替它发出去consttextContent=/* 取最后一条助手回复的文字 */;awaitfeishu.sendMessage(/* 发到该去的人或群 */);}},你品品这三道闸的层次:先用规矩劝它(事前),再盯着它做没做(事中),最后发现没做就强制补上(事后)。它干不干得对,不全押在它自觉上;漏了、错了,有一套机制在后面接着。
这套思路,其实就是这一路我反复念叨的那句话,落到了实处:人定规矩,AI 执行,系统兜底审计。一个真正能托付事情的 AI,靠的从来不是它有多聪明、多听话,而是哪怕它出岔子,你也兜得住。
这三道闸怎么讲给 AI 让它实现?关键是把"软约束 + 硬兜底"两层都点出来:
“我担心模型只在对话里说’已回复’却没真调发送工具。帮我加双保险:① 在行为说明里强制写明最后一步必须调 feishu_send_message;② 在代码里记录每轮有没有真的调过这个发送工具,等这一轮空闲下来(session.idle),如果发现没调过,就自动把它最后那段回复抓出来,替它发到对应的人或群。”
光说第①条,AI 给你的是一句口号;把第②条的"埋点 + 空闲兜底"也说出来,它才会给你一道真正焊死的闸。
七、最妙的一步:把它装进一个盒子,变成能交付的产品
到这儿,分身已经能在我电脑上跑了。但你想过没有——它只活在我这台开着 OpenCode 的电脑上。我电脑一关,它就"死"了;我想给同事用、想丢到服务器上长期跑,难道让每个人都装一遍环境、配一遍依赖?
这就是从"我自己能用的玩具"到"能交给别人的产品"之间,那道最容易被忽略、其实最关键的坎。
我跨过这道坎,靠的是一个特别朴素的东西——一份十几行的Dockerfile。它干的事,说白了就是:把这个分身和它需要的一切,打包进一个标准盒子,谁拿到盒子,谁就能原样跑起来。
FROM node:22-slim ENV TZ=Asia/Shanghai RUN npm install -g opencode-ai # 把 AI 运行时装进盒子 WORKDIR /app COPY .opencode/ .opencode/ # 把工具、规矩、依赖都搬进来 COPY AGENTS.md . # 把"怎么做人"的说明也带上 EXPOSE 4096 CMD ["opencode", "web", "--port", "4096", "--hostname", "0.0.0.0"]你仔细读这十几行,会发现它把前面所有东西——AI 运行时、四只手、那份待人接物的规矩——全都收进了一个盒子里。最后一行更妙:它不是把分身跑成一个黑乎乎的后台进程,而是opencode web——直接起成一个带网页界面的服务。这意味着盒子一跑起来,你打开浏览器就能看见它、跟它对话、看它在飞书里怎么应对。
这一下,性质就变了:
- 我电脑关不关机,不影响它——盒子跑在服务器上,它就 7×24 在线;
- 同事想用,不用配环境——把盒子拉下来一跑就行;
- 哪天想搬到别的机器、别的云上,整个盒子端走,落地即跑。
这才是这份 Dockerfile 真正有意思的地方:它不是部署的收尾杂活,它是那一下"点石成金"——把一个躺在我硬盘里的脚本,变成了一个能复制、能分发、能长期在线服务的轻量产品。一个分身和一支可交付的服务之间,差的就是这个盒子。
而盒子里还藏着一条安全线,特别值得说:真正的密钥绝不打进盒子。打包时进去的是一份占位的示例配置,真钥匙等盒子跑起来的那一刻,才从外部"挂"进去:
dockerrun-p4096:4096\-v"$PWD/.opencode/feishu.yaml:/app/.opencode/feishu.yaml:ro"\archaiharness/feishu-bot这样这个盒子你可以放心大胆地分发、公开——因为里头根本没有秘密,秘密永远在运行它的人自己手上。
怎么让 AI 帮你打这个盒子?把"打包成可分发的服务"和"密钥别进盒子"这两件事说清楚:
“给这个项目写一个 Dockerfile:基于 node:22,全局装 opencode-ai,把 .opencode 目录和 AGENTS.md 拷进去,启动命令用 opencode web 起成带网页的服务、暴露 4096 端口。注意:真实的 feishu.yaml 密钥配置绝对不要打进镜像,镜像里只放示例配置,真配置在 docker run 时用 -v 挂载进去。”
你交代的还是那两件你真正在乎的事——做成能跑的服务、别把钥匙焊死在盒子里。怎么写FROM、怎么COPY,AI 比你熟。
八、这套"分身",换个壳照样能搭
讲到这儿你可能会问:那我不用飞书,用企业微信、钉钉、Slack,是不是就白搭了?
恰恰相反。你回头看看我们一路拆下来的东西,会发现真正值钱的部分,跟飞书几乎没关系:
- 给每个聊天单开一个会话、让它有记性——这跟用哪个软件无关;
- 那对挑剔的耳朵——没点名就不理、旧消息就扔、发太快就刹车——换哪个平台都得这么做人;
- 三套待人接物的规矩——私聊私信回、群里 @ 着回、传话带上"谁找你"——这是做人的章法,不是飞书的功能;
- 三道闸保证"说了就一定发出去"——这更是跟平台八竿子打不着的治理思路;
- 还有那个把一切打包成服务的盒子——换哪个 IM,盒子都照打。
真正跟飞书绑定的,其实只有最表层那一点点:连它的 SDK、调它的接口发消息。换成别的 IM,把这层壳一换就行,里头的"做人章法"原样照搬。而且这层壳怎么换,同样是一句话讲给 AI 的事——把"换成钉钉/Slack 的消息事件和发送接口、其余逻辑不动"说清楚,它就能帮你改。
所以你别把它理解成"我做了个飞书机器人"。你做的是一个能做事、会做人、还兜得住、还能打包交付的 AI 分身——飞书只是它今天住的那间屋子。屋子可以换,人是同一个人。
九、写在最后
回到开头那个画面:私聊喊它办事、群里 @ 它干活、让它替我传话。看着挺像科幻,拆开了你会发现,没有一处是魔法——每一块都是一段能讲清楚的逻辑,而每一段逻辑,你都能用一句大白话讲给 AI,让它替你写出来。
它能"住"在飞书里,是因为有人给它装了耳朵、开了记性;它能在群里不闯祸,是因为有人替它定了分寸;它能让你放心托付,是因为哪怕它偷懒漏发,背后还有一道闸替它补上;它能从我的玩具变成谁都能跑的服务,是因为有人把它装进了一个盒子。
这个分身像不像个靠谱的人、能不能交到别人手上,从来不取决于 AI 本身有多强,而取决于你把规矩立得有多清楚、把兜底铺得有多扎实、把交付想得有多周全。而这些,恰恰都不需要你会写代码——只需要你想清楚,然后讲清楚。
未来真正会用 AI 的人,不一定是会敲多少代码的人,而是那种能把"这件事该怎么做、做错了怎么兜、怎么交到别人手上"想明白、再讲给 AI 去执行的人。AI 负责把话变成代码,你负责立规矩、定边界——这俩搭一块儿,才有那个让你眼前一亮的分身。
下一篇,我想带你看一个更野的场景:把这套"AI 分身"的思路,从一个办事员扩成一支能从头到尾干活的队伍——一个"制片总监"带着选题、对标、写稿、剪片、发布几个专员,把一条短视频从一个念头做成成片。一个人能使唤的分身已经够爽了,那一支能协作的呢?咱们下篇见。
关于 ArchAIHarness
这篇文章是「看懂 AI 与智能体」专栏的一部分,由ArchAIHarness持续输出。
ArchAIHarness 是一套面向 AI 时代软件工程的人机协同架构哲学与公开工程资产,主张:
架构师定义秩序,AI 在秩序中生长。人立法,AI 执行,体系审计。
如果你也希望 AI 在明确的架构边界内协作,而不是在混沌中碰运气,欢迎到 GitHub 上看看我们在做什么:
- 组织主页:github.com/ArchAIHarness — 了解完整理念与资产全景
- 本专栏:
zhuanlan-ai-and-agents— 所有文章的源码与发布记录 - 实践指南:
docs— 架构哲学、工程方法和落地指南 - 开源工具:
agent-workflows— 可复用的 AI 协作 Agents、Skills 与 Tools - 本文样本:
feishu-bot— 本文拆解的飞书分身真实仓库,clone 下来填上你自己的飞书密钥即可跑
Engineered by Architects · Empowered by AI · Audited by Discipline