基于飞书多维表格的需求审核评分服务
我给飞书多维表格搭了个 AI 自动评分服务:有人往表里提交待评审的内容,服务监听到记录变更,把文本、飞书文档、附件全收集起来喂给大模型打分,再把分数、评语、通过/未通过状态写回表格,顺带用机器人给提交者发条通知。
听起来是个"接个 AI API 就完事"的活。真做下来,我在生产里踩了 10 个坑,其中 9 个和 AI 的智能程度毫无关系。
它们分两类。一类是平台不按你以为的方式工作,飞书 webhook 有一堆反直觉行为;另一类是 LLM 当裁判天生放水,你不摁住它,它就给同情分。真正调通"让模型打个分"这件事,大概占了我 10% 的时间。
这不算一篇夸 no-code + AI 有多神的文章,更像一份过来人把坑摊开给你看的清单:每个坑我给你现象、根因,和代码级的修复。如果你正打算在飞书、Airtable、Notion 这类平台上接 AI 做自动化,这些坑大概率也在等你。
这套流水线到底在做什么
先把架构讲清楚,后面的坑才有坐标。整条链路是事件驱动的:飞书表里一条记录变了 → webhook 收到事件 → 一个编排器(orchestrator)把这条记录的所有内容凑齐 → 发给 AI 打分 → 结果写回表格 → 通知提交者。
注意那条虚线:写回结果本身会触发一个新的记录变更事件。这条不起眼的回边是全文最深的坑,后面第二幕专门讲。
技术栈很朴素:FastAPI 收 webhook,lark-oapi SDK 跟飞书打交道,大模型用豆包(Doubao),httpx 下载文件。没有一样是花活。坑不来自技术选型,坑来自平台的真实行为和文档描述之间的那道缝。
下面按我踩坑的顺序,分四幕讲。
第一幕:平台不按你以为的方式工作
飞书 webhook 的头四个坑有个共同点:每一个都是闷声失败。代码没报错、服务正常跑、日志一片祥和,但事件就是收不到,或者字段就是写不进去。没有异常栈给你指路,你只能靠猜。这是最耗时间的一类 bug。
坑 1:配了回调 URL,事件却一条都收不到
我最早卡了大半天在这:webhook 地址配好了、事件类型在控制台勾了、握手验证也过了,可是往表里改记录,服务端一个事件都收不到。
根因反直觉到离谱:飞书云文档事件,光在控制台订阅是不够的,你必须用代码主动调一次订阅 API,去"激活"对某个具体文档的订阅。控制台里那个勾更像是"授权",真正的订阅动作得你自己发:
from lark_oapi.api.drive.v1 import SubscribeFileRequest
req = SubscribeFileRequest.builder() \
.file_token("<BITABLE_APP_TOKEN>") \
.file_type("bitable") \
.build()
client.drive.v1.file.subscribe(req)
这段代码不在我的服务主流程里,它是部署前的一次性手动操作,得对每个要监听的多维表格执行一次。我特意提这点,是因为你翻遍服务代码也找不到它,却又非它不可。需要 docs:event:subscribe 或 drive:drive 权限。
避坑:如果你的 webhook 握手能过、但业务事件收不到,先别怀疑代码,去确认这个订阅 API 调没调过。这一步没有任何报错提示你漏了它。
坑 2:事件 payload 里没有顶层 record_id
订阅通了,事件能收到了,下一个坑立刻来:我按直觉去 event.record_id 取记录 ID,拿到的是 None。
飞书多维表格变更事件(P2DriveFileBitableRecordChangedV1)根本没有顶层 recordid。ID 藏在 `actionlist` 里,而且一个事件可能携带多条 action(新增/编辑/删除混在一起),得遍历:
# action_list 里才是变更的记录,一个事件可能有多条
action_list = getattr(event, "action_list", []) or []
for action in action_list:
record_id = getattr(action, "record_id", None)
action_type = getattr(action, "action", "") or ""
if not record_id:
continue
# ... 逐条处理
这个坑的隐蔽之处在于:SDK 自动生成的事件模型里,record_id 这个属性名压根不在你以为的层级,IDE 补全也不会提示你去 action_list 里找。批量编辑时一个事件带多条 action,也是个容易漏的点:你要是只取第一条,批量操作就丢记录了。
坑 3:FastAPI 把 header 全转小写,SDK 却要原始大小写
这个坑更阴,因为它表现成签名验证失败,而不是"header 找不到"。
飞书用 X-Lark-Request-Timestamp、X-Lark-Request-Nonce、X-Lark-Signature 这几个 header 做签名校验。而 FastAPI 的 dict(request.headers) 会把所有 header 名转成全小写(HTTP 标准允许),lark-oapi SDK 却用大小写敏感的方式去查它们:查不到,拿到 None,签名计算直接崩,所有事件被拒。
修复就是在把请求转交给 SDK 之前,手动把这几个签名 header 的大小写复原:
raw_headers = dict(request.headers) # 已被 FastAPI 转成全小写
headers: dict[str, str] = {}
for k, v in raw_headers.items():
kl = k.lower()
if kl == "x-lark-request-timestamp":
headers["X-Lark-Request-Timestamp"] = v
elif kl == "x-lark-request-nonce":
headers["X-Lark-Request-Nonce"] = v
elif kl == "x-lark-signature":
headers["X-Lark-Signature"] = v
else:
headers[k] = v
只需复原这三个签名相关的,其余 header 不敏感。这个坑的教训是:当一个 Web 框架和一个 SDK 对同一份 HTTP 元数据有不同假设时,缝就夹在中间。两边各自都"没错",但拼一起就漏。
坑 4:日期字段要毫秒时间戳,不是 ISO 字符串
前三个坑卡在"收事件",这个坑卡在"写回去"。
我要把评分时间写进多维表格的日期字段,顺手塞了个 ISO 8601 字符串(2026-07-13T14:30:00Z),飞书默默拒绝,字段写不进去,还不给你清晰的报错。飞书 Bitable 的日期/时间字段吃的是毫秒级 Unix 时间戳(整数):
now = int(datetime.now(timezone.utc).timestamp() * 1000) # 例如 1752408000000
update_fields = {
FIELD_AI_SCORE: score,
FIELD_AI_SCORE_TIME: now, # 毫秒 int,不是 ISO 字符串
FIELD_SCORE_STATUS: new_status,
# ...
}
必须是 int,不能是 float、更不能是字符串。这类"格式对了才写得进、错了也不明说"的坑,飞书里散落着好几处(附件下载的认证也是一个,后面第三幕会讲)。
第一幕小结:这四个坑没有一个需要你懂 AI。它们是 no-code + AI 自动化里那部分"没人会在 demo 里提"的隐藏工时:平台的真实契约和文档、直觉之间的每一道缝,你都得亲自摔一次才知道。而下一个坑,比这四个加起来都深。
第二幕:最深的坑——写回把自己打进死循环
这是我在这个项目上花时间最多、也最能说明"事件驱动 + 自动写回"到底难在哪的一个坑。它不是一个 bug,是一串层层嵌套的 bug:每修好一层,下一层就冒出来。
病根:写回会触发一个新事件
回到开头那张流程图的虚线。评分完成后,我把分数、评语、状态写回记录。这个写回操作本身,在飞书看来就是一次"记录变更",于是又推给我一个 webhook 事件。服务收到这个事件,又开始评分,又写回,又触发……
如果评分结果落在一个"可触发评分"的状态(比如"未通过",用户改完还得再评),那这个回声就会稳稳地把系统拖进无限循环。第一次上线,我眼睁睁看着一条记录被反复评分,豆包的调用量和飞书的 API 请求量一起往上飙。
第一次尝试:用字段级 diff 识别写回,被击穿
最直觉的解法:事件里带了变更前后的字段值,那我比一下 diff。如果只有输出字段(分数/状态)变了、输入字段没变,就判定是我自己的写回,跳过。
这个方案在真实表里被彻底击穿,踩了三个雷:
- payload 携带的是全部字段,不是变更字段。飞书事件里
action.after_value/before_value实测带的是记录的所有字段,和官方文档给的"只含变更字段"示例矛盾。diff 的基础前提就不成立。 - 人员字段序列化不稳定。"提报人"这类人员字段每次序列化的结果都不完全一样,diff 因此恒为"有变更",假阳性,写回永远被误判成用户编辑。
- 文本字段偶发假阳性。连普通文本字段都会偶尔 diff 出不存在的变更。
三个雷叠加,字段级识别彻底不可靠。这是我从官方文档示例出发、结果被真实数据打脸的一次:文档描述的事件模型,和你的表实际推送的,不是一回事。
正解:内容指纹
既然没法靠"谁变了"判断,那就靠"内容本身变没变"判断。我给每条记录算一个内容指纹:只取真正参与评分的字段(原始描述、需求文档链接、需求附件),算个 MD5。附件用稳定标识(file_token,退化时用文件名+大小),刻意避开 tmp_url 这种每次拉取都变的易变值,保证同一份内容多次读取,指纹恒定。
def _content_signature(self, fields: dict) -> str:
text = str(fields.get(FIELD_TEXT_CONTENT, "") or "")
doc = fields.get(FIELD_DOC_LINK, "")
if isinstance(doc, dict):
doc = doc.get("link", "") or doc.get("text", "")
doc = str(doc or "")
atts = fields.get(FIELD_ATTACHMENT, []) or []
att_parts = []
for a in atts if isinstance(atts, list) else []:
if isinstance(a, dict):
# file_token 稳定;退化时用 名字+大小,避开每次都变的 tmp_url
att_parts.append(str(
a.get("file_token") or f"{a.get('name','')}:{a.get('size','')}"
))
raw = "".join([text, doc, *att_parts])
return hashlib.md5(raw.encode("utf-8")).hexdigest()
逻辑很简单:处理前算一次指纹,和上次存的比。一样,是我自己写回的回声,跳过;不一样,用户真改了内容,重评。指纹存内存,重启后每条至多多评一次,可接受。
content_sig = self._content_signature(fields)
if self._scored_sig.get(record_id) == content_sig:
logger.info("跳过写回回声(评分内容未变化): record=%s", record_id)
return
self._scored_sig[record_id] = content_sig
用"内容变没变"代替"字段 diff",绕开了人员字段序列化不稳定的假阳性。用户改内容,指纹变,自然重评;单纯的状态写回,指纹不变,一律跳过。
二级坑:空内容记录停在"待评分"态,又循环了
以为搞定了,结果又冒出一层,这层更狡猾。
我早期的守卫写的是 if status != 待评分 and 指纹相同: 跳过。加那个 status != 待评分 的豁免,是想着"待评分一定是用户显式想重新评,不该当回声跳过"。这个假设是错的。
问题出在两个分支上:一条记录没有可评审内容(比如只上传了图,而模型不支持图),或者附件格式不认,系统会把状态恢复成"待评分"等用户补充。可是,恢复"待评分"的这次写回又触发了一个事件;因为 status == 待评分 命中了豁免,回声没被跳过;于是又"无可评审→恢复待评分→事件→无可评审……"
实测:每 ~2 秒一轮,永不停止。不发 AI(空内容分支在调模型前就返回了),通知被冷却挡着,但它持续打飞书 API,约 1 次/秒,靠自身逻辑永远不会停。止损只能手动删记录。
修复很干脆:去掉那个豁免,回声守卫改成纯指纹比对:
# 改前:if status != STATUS_PENDING and 指纹相同: return # 待评分被豁免 → 漏
# 改后:
if self._scored_sig.get(record_id) == content_sig:
return
去掉 status != 待评分 后,首次到达仍会正常处理(此时还没指纹缓存,比对不相等),之后相同内容(含"待评分"态的空内容回声)一律跳过。实测:空内容记录只处理一次就稳住,死循环根除;而未通过的记录改成充实内容后,score 从 0 重新评到 79、轮次 1→2,没被误判成回声。
这一幕真正的教训
写回死循环这一串,本质是事件驱动自动化的一个结构性陷阱:只要你的系统既是事件的消费者、又是事件源的写入者,它就会听到自己的回声。飞书这个案例把它放大了,因为你连"这是不是我自己的回声"都没法可靠判断(字段 diff 被击穿)。
解法的通用形态值得记住:别问"谁改的",问"实质内容变了没有"。给你真正在乎的输入算一个稳定指纹,拿指纹当幂等键。这个思路能迁移到任何"写回会触发再处理"的自动化场景,不只是飞书。
第三幕:喂给 AI 之前,内容是脏的
前两幕在跟"事件"较劲。这一幕跟"内容"较劲:你从飞书拿到的内容,不是你以为的那份干净内容。这些坑不会让服务崩,而是会悄悄拉低评分质量,比崩溃更难发现,因为一切都"看起来正常"。
坑 7:文档里的图片,变成了污染评分的 image.png
这个坑我排查了半天,因为它表现成一个自相矛盾的现象:缓存里的文档文本明明很干净,评分详情却在抱怨"文档里有一堆 image.png 冗余占位符,格式扣分"。文本里没有的东西,模型怎么会抱怨?
根因:飞书文档的 raw_content 接口会把每一张内嵌图片渲染成一个独占一行的裸文件名,截图或粘贴图默认就叫 image.png,也可能是 image (1).png、截图.jpg。这些既不是用户写的内容,又被评分模型当成"排版里塞了一堆冗余占位符"莫名扣格式分。
而那个"自相矛盾"是这么来的:转写缓存用的提示词明令禁止输出占位符,把它们剔了,所以缓存干净;评分走的是另一条路,看到了带占位符的原文。同源内容,两套提示词,制造了一个假矛盾。
修复是在内容进评分和缓存之前,统一清洗掉"整行就是一个图片文件名"的行:
_IMAGE_PLACEHOLDER_LINE = re.compile(
r"^[\w一-鿿.\-()() ]{1,80}\.(?:png|jpe?g|gif|webp|bmp|svg|tiff?)$",
re.IGNORECASE,
)
def strip_image_placeholders(text: str) -> str:
if not text:
return text
kept = [ln for ln in text.splitlines()
if not _IMAGE_PLACEHOLDER_LINE.match(ln.strip())]
cleaned = "\n".join(kept)
return re.sub(r"\n{3,}", "\n\n", cleaned).strip("\n") # 顺手压掉多余空行
关键分寸:只删"整行就是图片文件名"的行,保留正文里顺带提到文件名的句子(比如"详见附件 方案.png"),也保留 .docx 这类非图片名。实测拿真实 wiki 文档跑,占位符从 4 行清到 0 行,评分详情不再无端抱怨格式。
通用教训:任何"富文本导出成纯文本"的接口,都会往文本里掺你没预期的渲染产物(图片占位符、表格边框字符、脚注标记)。进 LLM 之前务必过一道清洗,否则模型会把这些噪声当成内容质量的一部分来评判。
坑 8:wiki 链接里的 token,不是文档 token
飞书文档链接有好几种形态。普通文档是 /docx/<token>,知识库链接是 /wiki/<node_token>。这个 node_token 是知识库"节点"的 token,不是背后真实文档的 token。
坑在于:raw_content 接口能直接吃 wiki 节点 token(内部帮你自动解析了),于是纯文本读取一切正常,你毫无察觉。但导出 PDF 的 export_task 接口不认:直接拿节点 token 去导,报 1069914 file token invalid,于是永远回退到纯文本,文档里的图片永远不会被解析(多模态模型看不到图)。
修复是导出前先把 wiki 节点解析成真实文档 token:
# wiki 链接的 token 是节点 token,导出前必须先解析出挂载的真实文档 obj_token
req = GetNodeSpaceRequest.builder().token(node_token).build()
resp = client.wiki.v2.space.get_node(req)
# resp.data.node.obj_token / obj_type(docx/doc/sheet...)才是能拿去导出的
这里还埋了个权限坑:get_node 需要应用有 wiki 访问权限,否则报 99991672 Access denied。这个权限得去飞书控制台单独给应用开(wiki:wiki:readonly 或 wiki:node:read),开通前 wiki 文档只能走纯文本、看不到图。又是一个代码写对了、但权限没配就闷声降级的坑。
坑 9:附件下载,不带认证就是 400
顺带说个小而致命的:飞书返回的附件下载 URL,直接用 httpx.get() 裸拉会 400。必须带上租户级 access token:
token = TokenManager.get_self_tenant_token(self._client._config)
headers = {"Authorization": f"Bearer {token}"}
resp = await self._http.get(url, headers=headers)
飞书所有文件资源的下载都得带这个 header。它和坑 4(datetime 格式)是同一类:平台的隐性契约,文档里一笔带过,漏了就失败,而且报错不告诉你真正原因。
坑 10:AI 返回的 JSON 会破,直接 json.loads 就崩
内容干净了,喂给模型,模型该老老实实还我一个 JSON 评分了吧?也不一定。
我让模型返回 {score, detail, dimensions} 结构。但豆包在边界情况下会返回破碎的 JSON,最典型的是 detail(评语,有时还带文档转写)很长,撞上 max_tokens 被从中间截断,末尾的 JSON 直接非法。这时候 json.loads() 一抛异常,整个评分流程终止,用户永远等不到结果。
解法是三级降级解析,能捞多少捞多少:
def _parse_response(self, text: str) -> dict:
# 一级:直接解析,正常情况 95% 命中
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 二级:正则抠出最外层 {...} 再解析,容忍模型加的前后缀废话
m = re.search(r"\{[\s\S]*\}", text)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
# 三级:JSON 仍破(通常是尾部被截断),逐字段正则捞回 score/detail/维度分
score_match = re.search(r'"score"[\s:]*(\d+)', text)
score = max(0, min(100, int(score_match.group(1)) if score_match else 0))
# ... 各维度同理,捞不到才退 0
return {"score": score, ..., "_parse_fallback": True}
三级的设计有个讲究:为什么不直接在解析失败时把维度分填 0?因为响应被截断时,靠前的 score 和各维度分往往是完整可读的,只有靠后的长 detail 被切了。硬填 0 会造成 score 和四个维度分之和对不上,一个明显的数据不一致。逐字段正则捞,能保住这份一致性。结果里标个 _parse_fallback: True,方便事后统计降级率。
第三幕小结:这一幕的四个坑,没有一个会让你的服务挂掉,它们只会让评分悄悄变差或悄悄降级。这比崩溃危险,因为崩溃有栈、有告警,而"评分质量慢慢烂掉"没有任何信号。接 AI 做自动化,输入侧的数据清洗和输出侧的容错解析,值得的投入远超你的预期。
第四幕:AI 当评委,天生放水
前三幕的坑都在 AI 外围。这一幕才碰到 AI 本身,而它暴露的问题可能是这套系统最反直觉的一点:大模型当评委,默认是个老好人。
症状:"把要求做完了"就能拿 80 分
第一版提示词我写得挺"正经":要求模型"严格、客观、可复现,发现缺陷就扣分"。上线一跑,分数普遍虚高,只要内容不是空的、把要求大致做完了,轻松 80 分往上。想分出"哪份该打 60、哪份该打 90",分数却挤在一起分不开。
问题不在模型笨,在于 LLM 的默认人格是讨好型的。你说"严格",它理解成"别太苛刻";你让它评价,它倾向于找优点、给"努力分"和"同情分"。中性、礼貌的措辞,喂出来的就是中性偏高、区分度低的分数。这不是 prompt 写得不够详细,是方向就没摁住。
解法:从"印象给分"逼成"逐条扣分"
我把系统提示词重写了一版,核心是换掉模型的给分心智模型。对比一下改动前后的措辞:
| 维度 | 改前(放水版) | 改后(收紧版) |
|---|---|---|
| 基准心态 | "严格、客观、发现缺陷就扣分" | "近乎苛刻,对标专业交付、可直接投入使用;宁可偏低,绝不偏高" |
| 给分方式 | 逐维度对照标尺定档 | 默认从满分起扣,每发现一处缺陷即扣,逐条列缺陷、逐条标扣分 |
| "做完了"值几分 | (未明确) | "把要求做完"只是及格线附近,高分必须由超出预期的优点支撑 |
| 硬性上限 | 无 | 引入红线:事实错误 ≤60、关键缺失 ≤55、跑题 ≤40、敷衍 ≤25 |
| 注水内容 | (未明确) | 空话套话、重复堆砌、为凑数而写,不仅不加分,还因稀释信息密度倒扣 |
三个改动最关键。
一是"从满分起扣的扣分账"。让模型别再"看一眼给个印象分",而是从 100 分开始,强制逐条列出缺陷、逐条标注扣多少。这把打分从一个模糊的直觉动作,变成了一个可追溯的清算过程,顺带评语也更有理有据了。
二是"硬性上限红线"。有些缺陷是致命的,不该被别的维度救回来。比如内容有事实性错误、足以误导使用者,那不管排版多漂亮、篇幅多完整,整份分数封顶 60。这一条直接解决了"局部亮点掩盖致命伤"的虚高。
【硬性上限(红线,满足任一项则整份 score 被封顶)】
- 存在事实性错误、逻辑硬伤或自相矛盾,足以误导使用者:score ≤ 60。
- 关键要素缺失,导致内容无法据以落地/执行/理解:score ≤ 55。
- 通篇空泛、无实质信息或严重跑题:score ≤ 40。
- 内容近乎空白、敷衍了事或答非所问:score ≤ 25。
(多条红线同时命中时,取最低的上限。)
三是明令反注水。LLM 特别容易被"看起来很努力"骗到:篇幅长、态度诚恳、排版花哨,它就想加分。得显式禁止:不因篇幅、态度、排版加分,注水内容反而倒扣。
改完之后,分数分布明显拉开了,"做完了"落回中档,真正专业的交付才够得着高分。同一份内容重复评,分数也稳(配合 temperature=0 的确定性采样)。
但要诚实:LLM 评委有天花板
摁住放水之后,它变得能用了,但别指望它变成一个完美裁判。两个改不掉的局限得摊开说。
一是一致性只是"够用",不是"绝对"。即便 temperature=0,同一份内容多次评分仍可能有轻微漂移。它适合做初筛和分档,不适合做"精确到分、分数还直接决定钱和晋升"的终裁。
二是它评的是"文本表现",不是"事实真伪"。一段写得漂亮但事实错误的内容,模型不一定抓得住,除非错误明显到它的知识能覆盖。红线能兜住"它发现的"致命伤,兜不住"它没发现的"。所以我的定位始终是 AI 初筛加人工复核,而不是全自动终判。这条边界直接引出最后一幕:什么场景值得这么搭。
一张飞书 webhook + AI 评分踩坑速查表
最后把这 10 个坑压成一张表,你可以截图存下来,搭之前对着排雷:
| # | 坑 | 现象 | 一句话修复 |
|---|---|---|---|
| 1 | 事件订阅没激活 | 配了回调却收不到任何事件 | 代码主动调 SubscribeFile API,控制台勾选不够 |
| 2 | 没有顶层 record_id | event.record_id 是 None | 去 action_list[i].record_id 遍历取,一事件可含多条 |
| 3 | header 大小写 | 签名校验失败,事件被拒 | 转交 SDK 前手动复原 X-Lark-* 三个 header 大小写 |
| 4 | 日期字段格式 | ISO 字符串写不进去,不报错 | 用毫秒级 Unix 时间戳(int),timestamp()*1000 |
| 5 | 写回死循环 | 写回触发新事件,反复评分烧配额 | 内容指纹当幂等键,回声跳过(别用字段 diff) |
| 6 | 待评分态二级循环 | 空内容记录每 ~2s 自触发一轮 | 回声守卫去掉 status!=待评分 豁免,纯指纹比对 |
| 7 | 图片占位符污染 | 评语抱怨 image.png 但文本里没有 | 清洗"整行就是图片名"的行,进 LLM 前统一处理 |
| 8 | wiki token 陷阱 | 导出 PDF 报 1069914,图片永不解析 | 先 get_node 把 wiki 节点解析成真实文档 token |
| 9 | 附件下载 400 | 裸 httpx.get 拉不到文件 | 带 Authorization: Bearer <tenant_token> |
| 10 | AI JSON 破碎 | json.loads 崩,用户等不到结果 | 三级降级:直接→抠 {}→逐字段正则捞回 |
收个尾
回到开头那个数字:10 个坑,9 个和 AI 无关。
这不是说 AI 不重要。而是说,当"接个大模型"变得越来越简单,真正的工程量就整个转移到了别处:转移到平台集成那些没人写进文档的隐性契约里,转移到"事件驱动系统听到自己回声"这类结构性陷阱里,转移到把一个讨好型 LLM 摁成一个能用的评委上。
如果你也要在飞书、Airtable、Notion 上接 AI 做自动化,别被 demo 的丝滑骗了。demo 展示的是那 10%,这篇文章讲的是等着你的那 90%。搭之前,把上面那张速查表和那段提示词骨架存下来,至少能帮你把最深的两个坑(收不到事件、写回死循环)提前绕过去。
相关阅读:如果你对"LLM 当评委"这个话题感兴趣,可以继续了解 LLM-as-a-judge 的评测偏差,以及事件驱动架构里的幂等性设计。这两个主题在这个项目里被反复验证,值得单独深挖。