最近在写一个企业多Agent智能体项目,以前我总以为,生产事故的沉淀方式是复盘文档。后来发现,文档写完了坑还在,只是坑底多了个会写文档的人。真正的沉淀是把坑砌进代码里:注释写清楚"这地方曾经死过人",逻辑写清楚"这里不许再死第二次"。
第一个坑:跨事件循环 checkpointer
AsyncPostgresSaver 的 asyncio.Lock 有户籍观念,在哪条事件循环里出生,就只认哪条循环。HTTP 请求在一个 loop 上排队,钉钉 Stream 在另一个 loop 上关灯睡觉。你拿着 HTTP 循环里造出来的 saver 去钉钉循环里用,它不报"不认识你",它报一个全家都看不懂的错。
于是代码变成了这样:
loop_id = id(asyncio.get_running_loop())
with self._lock:
cached = self._entries.get(loop_id)
...
async with build_lock:
with self._lock:
cached = self._entries.get(loop_id)一个 asyncio 的锁不能跨循环,但一个 threading.Lock 管着所有循环。翻译成人话:锁可以单相思,看门的必须海王。
第二个坑:钉钉重投去重
钉钉 Stream 掉线重连之后,没 ACK 的回调会被重投。如果你的进程刚好重建过,进程内那张去重表又白得像新买的本子,于是用户问一句,机器人答两次。产品叫它"智能助手",用户叫它"复读机"。
修复是 Redis SET NX:
grabbed = await client.set(
self._key(message_id),
"processing",
nx=True,
ex=self.PROCESSING_TTL_SECONDS,
)键存在就拦下,处理完把 processing 覆盖成 done,失败则 release 把窗口重新打开,但做完之后不许再撤销拦截。一句话总结:钉钉可以重投,答案不能重发。问一次答两次,在客服侧叫事故,在复读机侧才叫功能。
第三个坑:kNN 动态映射
OpenSearch 很贴心:索引不存在?第一次写文档时自动给你建。也很坑:自动建出来的映射把 embedding 当成普通 float 数组,kNN 查询从此静默失效。"静默"是所有故障里最残忍的两个字,比报错可怕十倍。报错至少还吵你,静默是它把嘴闭上,看着你上线,看着你开会,看着周报里写"检索一切正常"。
所以代码里必须有这一步:
"embedding": {
"type": "knn_vector",
"dimension": CHUNK_EMBEDDING_DIMENSION,
}而且启动时建一次,入库前再兜底一次,并发 PUT 撞车就认 resource_already_exists_exception。就像出门检查两次门锁:一次是习惯,一次是上次没锁门留下的心理阴影。
第四个坑:进程内后端的扩容警告
本地开发舒服得很:checkpointer 是内存的,审批是内存的,线程归属是内存的,长期记忆也是内存的。一切都好,直到你想上两台机器。那一刻你才明白,这套系统的横向扩展就是横向散架:会话断了,审批丢了,用户 A 的线程在机器 1 上被人从机器 2 访问,403 一道闪电劈下来。
所以启动时我们大声警告:
if degraded_backends:
logger.warning(
"single-process backends active (%s): horizontally scaling this "
"service or running multiple uvicorn workers will break session, "
"approval and ownership state; configure DATABASE_URL / "
"APPROVAL_STORE_URL before scaling.",
", ".join(degraded_backends),
)注意,这不阻断启动,只是大声。父爱如山,也可以像日志一样吵。
踩坑的最高境界不是把坑绕过去,而是把坑变成代码里的紧箍咒。每个注释里都有一次深夜,每个 nx=True 背后都有一个卸载复读机的用户。生产环境教我的事不多,但每件都很贵:锁有户籍,重投会串门,映射会装死,内存会失忆。现在我写代码,心里只有一句话:这地方,别再死第二次了。
评论
还没有人说话。