文章目录
-
- 前言
- [1. 先选接入方式,别急着写提示词](#1. 先选接入方式,别急着写提示词)
-
- [1.1 两种"飞书机器人",别搞混](#1.1 两种"飞书机器人",别搞混)
- [1.2 长连接适合现在这个阶段](#1.2 长连接适合现在这个阶段)
- [2. 一条消息里,至少有三种编号](#2. 一条消息里,至少有三种编号)
-
- [2.1 一个群里可能同时吵两件事](#2.1 一个群里可能同时吵两件事)
- [2.2 找不到根就明说](#2.2 找不到根就明说)
- [3. 接收回调里不要等待模型](#3. 接收回调里不要等待模型)
-
- [3.1 回调只做短操作](#3.1 回调只做短操作)
- [3.2 一个 Worker 的自觉](#3.2 一个 Worker 的自觉)
- [4. 门店权限来自绑定,不来自聊天里的自我介绍](#4. 门店权限来自绑定,不来自聊天里的自我介绍)
-
- [4.1 别信群里的自报家门](#4.1 别信群里的自报家门)
- [4.2 两道过滤,挡住机器人自己](#4.2 两道过滤,挡住机器人自己)
- [4.3 附件:先记账,不装懂](#4.3 附件:先记账,不装懂)
- [5. 去重不是在内存里放一个集合](#5. 去重不是在内存里放一个集合)
-
- [5.1 两个唯一约束](#5.1 两个唯一约束)
- [5.2 Worker 的事务节奏](#5.2 Worker 的事务节奏)
- [6. 发送消息有另一种失败:不知道有没有发出去](#6. 发送消息有另一种失败:不知道有没有发出去)
-
- [6.1 Outbox 的状态机](#6.1 Outbox 的状态机)
- [6.2 稳定 UUID 与"权限关进笼子"](#6.2 稳定 UUID 与"权限关进笼子")
- [6.3 恢复与"不确定"](#6.3 恢复与"不确定")
- [7. 先用本地事件检查,再接真实应用](#7. 先用本地事件检查,再接真实应用)
-
- [7.1 本地回放,绝不真发消息](#7.1 本地回放,绝不真发消息)
- [7.2 装上 SDK,测试变 32 项](#7.2 装上 SDK,测试变 32 项)
- [7.3 真实接入清单](#7.3 真实接入清单)
- [8. 阿玲不用搬消息了,但还得有人记住前后文](#8. 阿玲不用搬消息了,但还得有人记住前后文)

P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看, 传送门https://blog.csdn.net/qq_74013365
前言
阿玲是我们客服组的人形消息搬运工。
那天她指着我的终端,问了一个让我当场想申报工伤的问题:"这个窗口,能不能省掉?"
说实话,本地 Demo 跑得挺欢。复制工单、填上信息、执行查询、把回复贴回飞书------四步走,比某些人的减肥计划还规律。放在测试环境里,它是乖巧的模范员工;放进客服的日常工作,它就成了"第 N 个要来回切换的窗口"。N 的数值,约等于客服当天想离职的次数。
阿玲上午同时在跟两家门店的问题。一家查支付,一家查出单。她刚把第一条问题复制过来,店长就在原话题里纠正:"截图发错了,看后面那张。"等她回头去找补充消息,另一个话题又有人开始催进度。
"你看,"她摊手,"问题本来就在这儿,回复也得回到这儿。我凭什么替它搬这一趟?"
要让 Agent 出现在飞书话题里,第一件要解决的就是归属:客户创建的话题、值班人的回复、机器人吐出的结果,得知道各自属于哪一单。
接入也不是收到文字就调一下模型那么省事。同一条事件可能被重投,几家门店会同时说话,机器人自己的回复不能再被当成新工单。而且接单接口得尽快返回,不能让大家对着"对方正在输入"干等。
这一篇,给上一版 Demo 加一个飞书入口:先接文字,保住消息、发送者和话题关系,去重后交给已有的调查函数,再把结果回复到原话题。图片和视频先记下引用,暂时读不了就明说------绝不装懂。
阿玲不用再复制粘贴了。但话题一接通,下一个问题马上冒出来:首帖经常只有一句"帮忙看看",真正有用的信息,都是后来才补的。
1. 先选接入方式,别急着写提示词
这是系列的第 02 篇。上一版已经能从文字里提取订单线索、查询合成数据、输出带来源的状态。本篇复用这些,只加四件事:事件接收、消息归属、持久化去重、回复交付。
1.1 两种"飞书机器人",别搞混
动手前先分清两种"飞书机器人"。搞混的后果,约等于把外卖送到隔壁楼,还觉得自己服务很周到。
群自定义机器人的 Webhook,能往指定群推消息,但听不见客户说话。它是个单向喇叭,你喊破喉咙它也没反应。要接收 im.message.receive_v1,得用开放平台应用的机器人能力,加上官方 SDK 的长连接事件接收。原先那个用来推审稿的 Webhook,不掺和这个 Demo。
1.2 长连接适合现在这个阶段
本地进程向平台建立连接,不用先给本机准备公网回调地址------相当于不用把家门钥匙挂在门口等快递。但应用凭证、订阅范围、可见范围、消息权限,一样都不能少配。它只是换了事件进程序的方式,并没有替我们决定"哪个客户能查哪个门店"。权限这玩意儿,给的时候要像挤牙膏,别像倒水。
示例固定用 lark-oapi==1.7.3,走底层事件分发器和回复接口,不依赖额外的高层会话封装。写这篇的时候我真装了这版本,查了事件对象和回复请求的字段,还跑了本地契约测试。没连真实飞书应用------这点不吹牛,吹了容易翻车。
哦对,如果换成 HTTP 事件订阅,下面的 accept() 不能直接暴露成接口。它默认上游已经通过可信 SDK 传输拿到事件,本身不验 HTTP 签名、不解密请求、不处理平台挑战。接入方式一换,入口的信任条件就得重写------就像搬了家,门锁不能还按老地址配。
2. 一条消息里,至少有三种编号
很多"回错话题"的惨案,都始于把所有编号都叫 id。程序员的第一大错觉:字段名一样,它们就是同一个东西。第二大错觉:这个 Bug,我十分钟能修好。
| 编号 | 本篇用途 | 不能替代什么 |
|---|---|---|
event_id |
标识一次平台事件,去重 | 不能当业务订单号 |
message_id |
定位用户消息,做第二层去重 | 不能代表整个群 |
root_id |
找话题根消息,作为回复目标 | 不能用最后一条子回复随便代替 |
chat_id |
找群和受信任的门店绑定 | 不能作为每张工单的唯一键 |
tenant_key |
飞书租户隔离 | 不能省掉后只看群内文字 |
| 支付号或订单号 | 提供业务查询线索 | 不能拿来给消息去重 |
2.1 一个群里可能同时吵两件事
同一个群里可能同时有两条话题在吵。只用 chat_id 建状态,支付问题和出单问题就会像两碗面煮进一口锅,捞都捞不清。反过来,同一话题里客服、前端、后端加起来十条回复,也不能当成十个互不相关的问题------不然你就是在要求机器人当那种"刚听你讲完一段就反问'所以呢'"的同事。
2.2 找不到根就明说
本篇先把这些关系存下来,不做完整会话合并。根消息没有 root_id 时,用自己的 message_id;子回复有 parent_id 却没有根消息信息,就返回 missing_root,暂不处理。宁可留一个需要补查的事件,也不退化成往群里随手甩一条结果。这年头,乱回复比不回复更伤人。
thread_id 在数据里会保留,但不假定每个事件都有它。最终用回复接口的 message_id 路径参数指向根消息,并设置 reply_in_thread=True。具体话题形态和应用权限,得在实际群里验证------本地一切正常、上线炸成烟花,是这行的保留节目。
3. 接收回调里不要等待模型
流程分三段:接收回调、后台调查、消息交付。图里的 Inbox 和 Outbox 都在同一个 SQLite 文件里,不是额外部署的两套服务------都是打工人,别搞那么多工位。
3.1 回调只做短操作
回调只做短操作:检查应用和发送者、找到授权绑定、规范化事件、持久化入库。这里没有模型请求,也没有查订单。代码就这么点:
python
def receive(data):
payload = json.loads(lark.JSON.marshal(data))
status = inbox.accept(payload, bindings, app_id)
print("intake:", status, flush=True)
原因很直白:模型可能要想几秒甚至更久,消息接收却不能跟着一起挂住。回调成功只表示"已接住这条输入",不等于"调查已经完成"。就像快递柜显示"已签收",不代表你妈已经拆开包裹核对过尺码。
本地数据库写入失败时,也不能假装接受成功。当前实现让失败向上传播,交给接收机制和运行人员处理------锅该谁背就谁背,不搞报喜不报忧。
3.2 一个 Worker 的自觉
为了控制第一版复杂度,后台只有一个 Worker,按入库顺序调查。多家门店同时进来的消息,得排队。这是本章明确的容量限制------不叫高并发,叫"只有一个窗口的银行"。后面再引入优先级、事故聚合和调度。不能因为用了线程就自称高并发系统,就像不能因为换了双跑鞋就自称马拉松选手。
4. 门店权限来自绑定,不来自聊天里的自我介绍
绑定文件长这样:
json
{
"tenant-demo:chat-demo": {
"brand": "demo-brand",
"store": "store-001"
}
}
左边是已确认的飞书租户与群,右边是当前允许查询的业务范围。一组租户和群映射一个门店,最小配置,只适合本章专用测试群。
4.1 别信群里的自报家门
现实中,一个客户群往往讨论多家门店。到那时必须引入商户身份、允许门店集合、工单对象确认这些机制。不能因为群里有人说"我是某某门店"就写进 Scope------群里自报家门的人,身份可信度约等于直播间里喊"家人们"的。
4.2 两道过滤,挡住机器人自己
进 Inbox 前还有两道过滤:app_id 必须是当前应用,sender_type 必须是 user。后者专门挡住机器人自己发出的回复,避免"接到自己的回复→再次调查→再次回复"的永动机。它不判断人类消息是不是真工单;测试群里每条文本都可能进调查。这也是后面需要路由和会话状态的原因。
4.3 附件:先记账,不装懂
本篇的附件不送进文字模型。图片的 image_key、视频的 file_key 会随原消息保存,Worker 只回复"已保存附件引用,本版本尚未解析"。没下载文件,没有 OCR,也没有视频理解。至少客服知道哪些材料还没被用,而不是看到机器人给了答案,就以为它把所有附件都看完了------它连封面都没翻。
5. 去重不是在内存里放一个集合
事件再来一次时,最容易写出的就是 seen_ids 集合。进程不重启,岁月静好;一重启,集合清空,同一条消息又进模型。这是典型的失忆症患者:同一个笑话,每次听都能笑出声。
5.1 两个唯一约束
本篇在 SQLite 上建了两个唯一约束:
sql
CREATE TABLE inbox (
event_id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
message_id TEXT NOT NULL,
-- 还有根消息、发送者、时间、授权范围、内容和处理状态
UNIQUE (tenant, message_id)
);
事件编号挡住原样重投;租户+消息编号的组合,挡住"事件编号不同、但业务上仍是同一条"的情况。数据库决定插入成不成功,回调只看受影响行数返回 accepted 或 duplicate。比"先查再插"更不容易踩竞争窗口------先查再插就像过马路先看左边再看右边,大多数时候没事,出事就是大事。
注意,这里订阅的是"接收消息"事件,不是已完成的消息编辑同步。将来处理编辑、撤回时,要为不同事件类型和版本定义各自的去重规则。不能拿现在的消息唯一约束把所有后续变化都当重复输入丢掉------用户改个错别字,你就当没看见,这客服也太好当了。
5.2 Worker 的事务节奏
Worker 认领一条 pending 后,改成 running,提交事务,再调模型。不会把数据库写锁一直拿到模型结束------锁拿太久,后面的消息会在门口排成春运。调查完成后,在同一个事务里创建 Outbox 记录,并把 Inbox 改成 done。
这个事务保证"结果已保存"和"该条调查完成"一起成立。但如果模型已调用、进程在保存结果前崩溃,重启后只读调查可能重新执行,模型费用也可能重复发生。本篇不承诺模型调用恰好执行一次------"恰好一次"在分布式世界里,和"只刷五分钟手机"一样,都是美好的愿望。
6. 发送消息有另一种失败:不知道有没有发出去
假设调查结束,程序调用回复接口。平台已经创建了消息,网络却在响应回来之前断了。
这时候如果立刻把状态重置成 pending,下一轮就可能发出第二条一模一样的回复。对正在处理团餐的店长来说,这不只是多一条通知,还会让他误以为有两次独立的调查结果------就像收到两条一模一样的"在吗",你会怀疑对方是不是群发。
6.1 Outbox 的状态机
所以 Outbox 分开记录这些状态:
| 状态 | 含义 | 自动行为 |
|---|---|---|
pending |
本地已保存,尚未开始发送 | Worker 可以认领 |
sending |
已开始向平台发送 | 不被其他发送轮次重复认领 |
sent |
已取得平台消息编号并落库 | 不再发送 |
uncertain |
请求异常、返回无法确认,或进程在发送中退出 | 停止自动重发,等待核对 |
开始发送前先持久化 sending。拿到明确的平台消息编号才写 sent。本章把发送异常统一保守归入 uncertain,还没按错误码区分"肯定没送达"和"可能已送达",所以需要人工处理的情况会偏多。宁可多喊人,不可多发条------客服行业的社死,就是给客户连发两遍一模一样的道歉。
6.2 稳定 UUID 与"权限关进笼子"
每条待发记录还生成稳定 UUID:由租户和输入消息编号计算,存在 Outbox,构建回复请求时带上。它可以用于平台支持的请求去重,但本例不靠猜测平台的去重窗口来承诺永久恰好一次。
python
body = (
ReplyMessageRequestBody.builder()
.content(json.dumps({"text": text}, ensure_ascii=False))
.msg_type("text")
.reply_in_thread(True)
.uuid(send_uuid)
.build()
)
注意 content 在这里是个 JSON 字符串,文本字段藏在里面,不能把普通文本直接塞进去。这接口大概是想提醒我们:你以为在传字符串,其实在传协议。
模型没有权限决定 root_id、UUID 或请求接口,这些都来自已经保存的输入关系。权力要关进笼子里,模型也不例外。
6.3 恢复与"不确定"
recover_single_worker() 只在唯一 Worker 启动时运行:未完成的只读调查恢复成 pending,中断的 sending 变成 uncertain。不能在还有另一个 Worker 工作时调用,否则可能把正常调查误认成崩溃任务------别人好好上着班,你上去就喊"快叫救护车"。
本篇也没有提供"自动解决 uncertain"的按钮。真实部署前,需要补上平台消息核对与人工重发流程。有些按钮,不存在的意义,比存在的意义更大。
7. 先用本地事件检查,再接真实应用
完整代码快照包括第 01 篇实现、新增的 inbox.py、feishu.py、四个合成事件和测试。解压后在 code/ 运行:
bash
python3 -m unittest discover -s tests -v
python3 -m ticket_agent.feishu --db /tmp/ticket-agent-ch02.sqlite3
7.1 本地回放,绝不真发消息
这个命令默认是本地回放,绝不会向飞书发送消息。四个输入分别是:文字工单、同一事件重复一次、同话题图片、机器人消息。发送器也由本地函数替代,只记录它本来准备发到哪个根消息------像彩排,演员是替身,但剧本是真的。
用全新的运行数据库,实际接收结果是:
text
accepted duplicate accepted ignored
两条输入被保存,两条回复记录进入 sent。注意这里的 sent 只表示本地替身发送器成功,不代表飞书平台送达------就像"已读"不代表"已懂"。两条记录分别是文字查询结果和"附件尚未解析"的说明。
用同一个数据库再跑一次,前三个用户事件全被识别成重复,不增加回复;机器人消息仍被忽略。删除或更换测试数据库会得到新的实验起点,但业务运行时不能随便清空它------测试可以重来,生产库清了就只能重开。
7.2 装上 SDK,测试变 32 项
想检查官方 SDK 的类型与请求构建,再装固定依赖:
bash
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-feishu.txt
.venv/bin/python -m unittest discover -s tests -v
没有 SDK 时,31 项测试运行、1 项 SDK 契约测试跳过;装完实际执行 32 项,全部通过。其中 19 项来自上一章,新增部分检查重启后去重、跨应用过滤、不同话题隔离、附件处理、发送结果不确定,以及 SDK 事件和回复结构。它们仍是脚本化测试,不是平台稳定性统计------别把单元测试当护身符。
7.3 真实接入清单
真实接入需要准备应用自己的 FEISHU_APP_ID、FEISHU_APP_SECRET、DEEPSEEK_API_KEY,启用机器人,配置长连接事件订阅和必要消息权限,把应用加进测试群,再把已核实的租户与群写进绑定文件。不同权限组合能收到哪些消息,以当前开放平台配置为准------文档说可以,实际不行,这俩并不矛盾。
bash
.venv/bin/python -m ticket_agent.feishu \
--live \
--db /tmp/ticket-agent-live.sqlite3 \
--bindings /path/to/private-bindings.json
凭证从环境变量读取,不放进绑定示例,也不写进仓库。SQLite 会保存工单正文、发送者和调查结果;测试数据没有个人信息,接真实客户后需要受限的运行目录、保留期限和脱敏策略,不能把运行库提交成示例材料。
本次没有应用凭证,没有执行长连接握手、平台实际投递或真实回复。已完成的是官方 SDK 适配、持久化收发流程和本地事件回放。实际联调时还要确认:订阅是否生效、群范围是否正确、回复是否落在预期话题、平台错误能否在本地状态里找到。接口结构测试不能代替这些检查------背熟了菜谱,不代表你会颠勺。
8. 阿玲不用搬消息了,但还得有人记住前后文
把本地回放结果对应到开头的场景。阿玲指了指输出里的根消息编号:"这条回原来的支付话题,图片提示也留在那里,不会跑到另一家店下面,对吧?"
"本地回放核对的是这个关系。"我说,"真实群还要联调,先别把本地发送成功当成平台送达。"
我们重新投递同一事件,数据库里没有多一条调查,也没有多一条待发回复。再模拟发送超时,状态停在 uncertain,没有为了表现得积极而连续刷出几条相同结果------真正的成熟,是知道什么时候不该回消息。
阿玲随后问:"那店长新补一个订单号,它知道是在补刚才那张工单吗?"
目前还不知道。本篇保存了话题关系,但仍逐条处理消息,补充信息不会自动与之前的问题合并。下一篇开始保存调查进度:等谁补充什么、当前采用哪个订单、谁已经接手,以及新消息到来后,哪些旧结果必须作废。
就像聊天记录还在,但那个"还记得上次说到哪了"的人,还没上岗。
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365