飞书 Webhook AI 评分:我把上一篇文章的解法删了
上个月我写了篇文章,讲我搭了个飞书多维表格 AI 评分服务:有人提交需求,webhook 收到事件,大模型打分,分数写回表格,机器人给提交者发卡片。文章列了十个坑,最深的一个是写回死循环——评分本身要写表,写表又触发新事件,服务再评一次,无限循环。当时的解法是内容指纹:把参与评分的输入算个 MD5,内容没变就跳过。
这个月我把那个解法删了。不是找到了更好的哈希,而是想明白问题根本不在哈希上。新代码连让回声进入处理流程的机会都不给。
这篇是续集。讲三件事:上篇文章的解法为什么必须死;替掉它的是什么(ReviewFlow 现在是 v2.0.0);以及两个比调提示词重要得多的设计决定——用状态机门控触发,而不是猜意图;对 AI 输出做严格校验,而不是打捞。
第一个被删的解法:内容指纹
先回顾 v1 的链路。记录变更事件进来 → 收集文本、文档链接、附件 → 发给模型 → 分数和状态写回 → 通知。写回本身就是一次记录变更,于是又以新事件的形式弹回来。v1 的准入判断是"内容到底变没变"——对参与评分的输入算 MD5,存在内存里。
生产环境里三个雷把它炸穿了:
- 事件 payload 带的是全部字段,不是变更字段。 飞书
record_changed事件的before_value/after_value实测是整条记录,和文档里"只含变更字段"的示例对不上。diff 的前提从一开始就不成立。 - 人员字段序列化不稳定。 "提报人"这类字段每次读出来序列化结果都不一样,diff 恒为"有变更",假阳性。自己写回的事件永远认不出来。
- 普通文本字段也会偶发假阳性。 连文本字段都偶尔能 diff 出根本不存在的变更。
就算指纹偶尔生效,它也是个带内存的启发式:进程一重启,每条记录多评一次。当时我觉得"可接受"。不可接受。指纹是在给一个允许系统自我触发的设计打补丁,而且靠猜。
指纹后面还藏着一层二级循环:没有可评内容(比如只传了图,模型读不了)的记录会被复位成"待评分"等用户补充,这次复位写回又触发新事件,而我的守卫对"待评分"状态开了豁免,于是"无可评审→复位待评分→事件→无可评审……"实测一轮约 2 秒,每秒打一次飞书 API,永不停,只能手动删记录止损。
替掉它的是什么:你没有资格触发自己
v2 的准入逻辑是两个变量的纯函数:记录当前状态 + 谁在请求。就这些。不看内容,不比 diff,不算哈希。
状态 × 触发来源 → 是否准入
- 初始事件 / 崩溃恢复:只准入
待评分。 - 用户重评:只准入
未通过,只能点卡片里的按钮,而且点击人的open_id必须和提报人字段一致。隐藏的修改轮次递增,满 5 轮进入已驳回,按钮消失。 - 管理员重试:只准入
评分异常,只能来自管理员群的卡片(chat_id必须匹配)。 - 其余一切:静默拒绝。
以前制造回声的那次写回,现在落进 已通过 / 未通过 / 评分异常——这三个状态对初始事件来源全部不可触发。门控没有放行的分支,循环在结构上就形不成。没有启发式可以猜错。
诀窍就一句话:把写回结果放进事件源碰不了的状态里,回声靠设计吸收,不靠检测硬扛。
光有状态门控还不够,世界不是单线程的。三个机制把它补严:
Fencing。 每条记录的任务拿到一个递增的 fence 序号。最终写回前检查 is_current(key, fence),如果已经被更新的任务接管(或评分过程中用户重评了),旧结果直接作废——僵尸写回盖不掉新分数。对应的回归测试叫 test_fencing_discards_zombie_result_before_final_write。
每记录串行 + 幂等。 完整记录键(app_token:table_id:record_id)同时只允许一个在岗任务;webhook 的 event_id 在 300 秒滑动窗口里去重。卡片回调用 message_id + 操作人 + 动作值 派生幂等键,连点两次"重新评分"只会产生一个任务,重复投递自动折叠。
清道夫兜底崩溃场景。 fencing 和串行都在内存里,进程被杀就丢。清道夫每 60 秒扫一次卡在 评分中 的记录,系统 last_modified_time 超过 900 秒且没有活任务 的,复位成待评分重新准入。注意那个"没有活任务"——跑得慢但还活着的评分永远不会被复位,哪怕时间戳看起来很旧。test_slow_live_task_is_never_reset_even_when_timestamp_is_old 把这条钉死了。
准入矩阵可以直接抄走:
| 记录状态 \ 触发来源 | 初始事件 | 用户重评 | 管理员重试 | 清道夫 |
|---|---|---|---|---|
| 待评分 | ✓ | — | — | ✓ |
| 评分中 | ✗ | ✗ | ✗ | ✗(有活任务) |
| 未通过 | ✗ | ✓(仅本人,轮次<5) | — | — |
| 已通过 | ✗ | ✗ | — | — |
| 已驳回 | ✗ | ✗ | — | — |
| 评分异常 | ✗ | — | ✓(仅管理员群) | — |
第二个被删的解法:JSON 打捞
v1 解析模型返回用了三级降级:直接 json.loads → 正则抠最外层 {} → 逐字段正则捞。每一级成功都会产出一个"分数",其中一些内部根本对不上账。典型故障:长 detail 撞上 max_tokens 被截断,JSON 后半截非法,于是降级分别捞 score 和四个维度分……加起来不等于总分。系统返回了一个错的数字,打上 _parse_fallback 标记,继续运行。
当时管这叫健壮性。它比失败更糟:静默降级没有告警。 崩溃有堆栈,打捞出来的分数什么都没有——评分质量悄悄烂掉,没有任何信号。
v2 把方向反过来:
- 让截断不发生。
max_tokens调到 4000,schema 又把内容压住(detail ≤ 500 字、highlights ≤ 150、improvements ≤ 250),正常响应是段短 JSON,留足余量。 - 全量校验。 响应用 Pydantic 严格模式解析:字段精确、类型精确、
score必须等于四维度之和(model_validator强制)、维度在各自区间内、extra="forbid"。校验不过就拒绝,绝不修复。 - 只允许两种语法恢复。 剥一层 Markdown 代码围栏,或者从前后废话里提取一个完整 JSON 对象。字段级打捞删除——测试名就叫
test_parse_response_rejects_non_json_without_field_salvage。 - 响亮地失败,然后升级。 非法响应抛异常,工作流重试最多 3 次,仍失败进
评分异常,通知管理员群而不是提交者,修改轮次不加。
原则:先让输入不容易坏(token 余量、temperature 0、提示词里写清 JSON schema),真坏了就响亮地失败到有人盯的通道里。永远不要静默返回一个没有意义的数字。
| v1 三级降级 | v2 严格校验 | |
|---|---|---|
| 恢复策略 | 直接 json.loads → 正则抠 {} → 逐字段正则 | 剥一层代码围栏 → 提取一个完整 JSON 对象 |
| 失败处理 | 填默认值、打 _parse_fallback 标记、继续跑 | 抛错 → 重试最多 3 次 → 评分异常 + 管理员告警 |
| 坏数据 | 静默产出(分数与维度之和对不上) | 拒绝(model_validator 强制相等) |
| 根因 | 被掩盖 | 暴露(max_tokens=4000 从源头防截断) |
上篇文章里没删的坑
不是所有东西都被删了。四个坑原样保留,因为它们就是飞书的真实行为:
- 订阅 API 是硬前提。 控制台勾选事件类型不会推任何东西,必须对具体多维表格调一次
drive/v1/file/subscribe。零报错,静默,仍然是"事件收不到"的头号原因。 - 没有顶层
record_id。 ID 在action_list[i].record_id里,一个事件可能带多条 action。 - FastAPI 把 header 转小写,SDK 不认。 转交
lark-oapi前把X-Lark-*复原成规范大小写,否则每次签名校验都失败。 - wiki 链接给的是节点 token,不是文档 token。 导出接口直接拒收(
1069914),必须先wiki/v2/space/get_node解析,还要给应用开wiki:wiki:readonly,否则静默降级。
两个坑换了形态。日期字段要毫秒时间戳那个坑没了——v2 干脆不写评分时间字段,detail、亮点、改进建议只进通知卡片,不进表。图片占位符污染评分的坑也没了,但原因更彻底:v2 把 raw_content 纯文本整个移除了。
替掉纯文本的内容管线
两篇文章之间最大的架构变化:评分输入从"你抓来的文本"变成"你造出来的一份 PDF"。所有在线文档经导出任务 API 转 PDF,所有附件转 PDF(图片走 Pillow,处理 EXIF 方向、压平透明通道;Word/Markdown/纯文本走无头 LibreOffice,独立 profile、60 秒硬超时、超时杀进程组),然后合并成一份确定性总 PDF——文档在前、附件在后,每份材料前有来源分隔页,按解析后的真实 token / file_token 去重。
这一下消灭了一类 bug(模型看到的就是用户看到的:排版、截图、表格),也带来一批必须一起上线的限制:
- 附件最多 20 个、单个 20MB、总共 100MB、PDF 最多 300 页、图片最多 20 张,能提前查的都在下载前查。
- 加密或损坏的 PDF 是用户可修复的材料问题,不是技术故障:提交者收到一张列明全部问题文件的卡片,记录落
未通过,不烧修改轮次。 - 任何一份文档导出或附件转换失败,整次采集中止——没有部分 PDF,没有"差不多得了"的降级。
test_transient_retry_repeats_only_failed_download_step证明重试按步骤隔离,test_pdf_bundle_failure_never_calls_ai_or_writes_partial_result证明 AI 永远不会收到残缺的 PDF。 - 启动时校验:模型不支持 PDF 文件输入、或 LibreOffice 不在,直接拒绝启动。开机就失败,而不是凌晨两点静默降级。
还有两条生产经验值得抄:
写工作流之前先给失败分类。 每个错误都有类别:瞬时故障(重试最多 3 次)、用户可修复的材料问题(→ 未通过 + 列明哪些文件坏了的卡片)、系统硬失败(→ 评分异常,只进管理员群,提交者永远看不到技术报错卡片)。上面那张准入矩阵之所以成立,靠的就是这套分类。
熔断发送侧,不熔断提报侧。 单条记录 5 分钟窗口最多 20 张卡片,超限停发并给管理员群发一次告警。正常提报永远不限流,熔断只拦"一条记录不断产卡片"的病态循环。
动手之前先问三个问题
如果你要把 AI 接进飞书、Airtable、Notion,或者任何"你的服务既消费事件又往同一个存储里写"的平台,先跑一遍这个:
- 我是不是这张表的写回者兼消费者? 不是,普通幂等(按 event_id 去重)就够了。是,往下看。
- 我的写回能不能落进事件源碰不了的状态? 能,就做状态门控 + 显式人工动作(按钮、管理员重试)——这是让回声在结构上不可能发生的设计。
- 如果不能(比如 A 表写回必须立刻驱动 B 表的处理),门控关不掉回声,这时才轮到指纹/幂等键,而且要把它们当启发式用:预期假阳性,预期重启丢内存,再加个清道夫兜底。
AI 输出侧同理:优先"不容易坏",而不是"坏了再捞"。 token 余量、确定性采样、提示词里写清 JSON schema、严格校验、响亮失败带重试、升级到人工通道。你要是发现自己正在写第四个解析兜底,停下来问一句前三个为什么存在。
什么时候这套不适用
状态门控模式的前提是:流程能建模成少量状态 + 少量触发来源。它失效的情况:需要连续自由编辑持续触发重评(没有离散状态可以设门);多实例部署但没把串行和 fencing 外置(内存守卫是单实例假设);写回者/消费者跨表分离(问题 2 的答案是"不能")。这些场景里指纹方案不算错——它只是启发式,你应该预算它的失败模式,而不是像我一样在生产里踩出来。
相关阅读:这个项目的上一篇,飞书 webhook AI 评分踩坑清单,把平台坑讲得很细。LLM 评委那部分,评分提示词在仓库的 app/ai.py 里,飞书官方文档的文件订阅和文档导出是两个最先接触的 API 的权威参考。