我把 LangGraph 官方 Demo 扩成了生产级多 Agent 系统,这 3 个"反直觉"设计救了整个项目
最近三个月,我基于 LangGraph 做了一个真实的全栈项目「体育外卖」------上门私教 O2O 平台:微信小程序下单 → Spring Boot 业务后端 → 独立的 AI 微服务(FastAPI + LangGraph)负责教练推荐、评价摘要、证书审核三个 Agent 的调度与执行。
完整开源:github.com/muyiyang09/... ⭐
这篇文章不讲怎么跑通 Demo------官方教程已经讲得很好了。我只讲从 Demo 到生产之间那段没人写的路:三个差点翻车、最后靠"反直觉"决策救回来的点。
先交代架构:这个项目的 AI 层长什么样
scss
微信小程序 / Web ──> Spring Boot 3.5 业务后端(JDK21)
│ Service-Token 服务间鉴权
▼
ai-service(FastAPI + LangGraph 1.1.4)
├── Supervisor 路由(LLM 决策 + 关键词兜底 + 熔断器)
├── recommend_coach 教练推荐(3 节点 DAG)
├── review_summary 评价摘要(批量打标)
└── cert_review 证书审核(OCR + DB 比对 + HITL 人工确认)
│
BM25(jieba) + 向量(bge-m3/Milvus) 混合检索 · RedisSaver Checkpointer
│
MySQL / Redis / Prometheus-Grafana
单个请求的生命周期是:意图抽取(LLM)→ 五维加权检索排序(规则,无 LLM)→ 推荐理由生成(LLM)。听起来和官方 RAG 示例差不多,但下面这些坑,教程里一句都没提。
坑一:混合检索的融合公式,我们放弃了"教科书线性加权"
问题起点:要升级语义匹配能力,直觉方案是把 BM25 分数和向量相似度做线性加权:
python
final_score = alpha * bm25_norm + beta * vector_sim # ❌ 我们最初的方案
上线前的压测让我把这条公式扔了,原因有三:
- 尺度灾难。BM25 得分范围是 0~几十,向量余弦相似度挤在 0.6~0.99 的窄区间。想加权就必须先归一化------而 min-max 还是 z-score,本身又是一个超参。调参矩阵瞬间爆炸。
- 解释性塌方。产品经理问我"为什么这个教练排第二",我说不出人话:两个 β 变量 × 一个归一化策略的组合太抽象了。
- 全局融合破坏既有评分体系。我们的业务排序已经是五维加权(评分 40 / 匹配 35 / 等级 10 / 距离 10 / 档期 5),语义本来就该只占"匹配维",而不是开一个新的全局维度重新洗牌。
最终方案:Reciprocal Rank Fusion(RRF)折进 score_match 维度
python
def _rrf_fuse(
ranked_lists: list[list[tuple[int, float]]],
k: int = 60,
top_k: int = 30,
) -> list[tuple[int, float]]:
"""Reciprocal Rank Fusion:只用排名不用分数,跨尺度融合多路召回。
为什么用 RRF 而不是线性加权:BM25 分数(0~几十)与向量相似度(0~1)
尺度完全不同,线性加权要先归一化(而归一化策略本身又是一个超参);
RRF 只用 rank,天然跨尺度兼容。
"""
然后把融合出的相关度替换掉 score_match 维度的原始值(该维权重仍是 35%),五维预算一个不动。这样:
- 排序结果重新有了人类可读的解释;
- 不引入任何新超参,RRF 的
k=60也是社区默认值,几乎不用调; - 向后兼容变成一行逻辑:hybrid 开关关闭 / BM25 无结果 / 重依赖未安装时,score_match 自动退回原来的子串匹配------这是"加强"而非"替代"。
配套的一个防御性设计我特别想强调:多路召回按"缺谁都能跑"构建 。BM25 用 jieba + rank_bm25 纯 Python 实现,默认启用;向量路走 bge-m3 + Milvus,重依赖没装时 vectorstore.search() 恒返回 [],自动退化为单路 BM25 min-max 归一化;Cross-Encoder 重排默认关,缺失时 no-op。召回路数 3 → 2 → 1 逐级退化,任何一路的故障都不允许打断推荐主链路。
一句话总结:RAG 的鲁棒性不取决于最强的那条链路,而取决于最弱的那条能不能被安全地旁路掉。
坑二:Supervisor 路由没包熔断器------半个系统会跟着 LLM 一起挂
这是我犯过的最典型的错误,而且是从代码 review 里才捞回来的。
Supervisor 需要用 LLM 判断"这句话该交给哪个 Agent"。第一版实现就是裸调 LLM。想象一下 DeepSeek 抖动超时的场景:
- 推荐链路:LLM 失效,但有规则分支可以扛
- Supervisor 路由:LLM 失效 → 所有请求直接 500,包括原本不需要智能决策的推荐请求
也就是说,一个辅助性的 LLM 调用,成了全系统的单点故障源。修复后的代码:
python
async def route_query(user_query: str) -> str:
"""路由用户 query → agent 名。默认 recommend_coach。"""
if is_mock_mode():
return _route_by_keyword(user_query)
from app.prompts.loader import load_prompt
try:
text = await llm_breaker.call(achat, [ # ← 熔断器包裹
{"role": "system", "content": load_prompt("supervisor_route")},
{"role": "user", "content": user_query},
])
text = (text or "").strip().lower()
for agent in _AGENTS:
if agent in text:
return agent
return "recommend_coach"
except Exception as exc: # ← 失败降级关键词规则
logger.warning("Supervisor LLM 路由失败,降级关键词规则:%s", exc)
return _route_by_keyword(user_query)
两层保护:
llm_breaker熔断器:LLM 连续超时会触发熔断,之后的请求不再傻等 LLM 超时窗口;- 关键词规则兜底:"帮我约个减脂教练" 这种 query,用规则就能路由------永远要有"最小可服务版本(MSV)"。
教训一句话:LLM 是依赖,不是地基。每一个 LLM 调用都要回答"它挂了我怎么办"------回答不出来,那个调用就不该出现在主路径上。
坑三:缓存三防三个具体的工程决定
"缓存穿透/击穿/雪崩",它们是三个必须明确的决定:
1. 雪崩 → TTL 加抖动
同一批 key 大概率在同一时刻被写入,TTL 相同就会同时失效,瞬时全部回源打爆 MySQL。所以写入时统一经过抖动函数:
python
def _jittered(ttl: int) -> int:
... # 在 ttl 上叠加随机偏移,比如 ±10%
一行代码的事,但必须在封装层强制(cache.set() 内部处理)
2. 击穿 → singleflight:同一 key 只允许一个请求回源
热点 key 失效瞬间,几百个并发一起回源构图。解法是抢互斥锁:
python
ok = await r.set(f"{key}:lock", token, nx=True, ex=_LOCK_TTL_SECONDS)
抢到的去查库构图,其余的进 wait_for_result() 轮询等待而非排队阻塞------并且要设轮询预算上限(超时就取旧值/降级),否则锁持有者一旦崩溃,等待者会集体饿死。
这里还有个特别隐蔽的坑:锁释放必须是原子的。"先 GET 比对 value 再 DELETE" 两步操作之间存在 TOCTOU 窗口------你的锁可能刚好过期被别人持有,你却删掉了别人的锁。正确做法是 Lua 脚本原子释放,或者至少用 token 比对后再删(本项目 Java 侧用的是 Lua CAS 版本)。
3. 穿透 → 空值缓存 + 边界校验
查不到的 coach_id 也缓存一个短 TTL 的空标记,让恶意刷不存在 ID 的流量打到 Redis 而不是每次穿透到库。
顺带的架构决定:Checkpointer 千万别用 MemorySaver 进容器
LangGraph 的 MemorySaver 默认省事,但它是进程内存 :多副本部署时 A 副本发起的 HITL 人工确认,恢复可能落在 B 副本上------状态直接蒸发。我们的配置是 SERVICE_ENV=prod 时默认切换 RedisSaver,外加数据库兜底读 + 双写,HITL 会话在滚动更新和崩溃重启下都能存活。
写在最后:文档先行比代码先行更救命
这个项目还有一个可能有争议的做法:先写了 12 篇内部技术文档再补齐实现------循环工程、混合检索、商业化加固、MCP 工具层、Agent 面试题集......很多上面提到的决策(比如 RRF 该不该折进 score_match)都是写在文档里推演一遍才落地的。
如果你在准备 AI 工程化方向的面试,这些文档比代码更好看,浓缩版在这里:
如果这篇文章帮你避开了哪怕一个坑,请到仓库右上角点个 ⭐ Star------这是我持续更新系列的最大动力。
📌 下篇预告:《HITL 人工介入的并发难题:两个人同时审批一张证书怎么办》------聊聊 Checkpointer 状态双写、乐观锁和会话 TTL 膨胀治理。
项目地址:github.com/muyiyang09/... (许可证:非商业使用,欢迎学习交流)