ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM调用链路容错设计:从MiroFish事故看网关级防护必要性

LLM调用链路容错设计:从MiroFish事故看网关级防护必要性

1. 事故现场还原:一次配置变更引发的“调用雪崩”

你有没有遇到过这种情况:改了一行配置,系统没报错,日志里也看不出异常,但第二天运营同学突然冲进会议室,甩出一张图表——过去24小时LLM调用量暴涨16000次,账单直接翻了三倍,而业务指标纹丝不动?这正是我们团队在停用MiroFish后端时遭遇的真实事故。核心关键词MiroFish和LLM不是泛泛而谈的技术名词,而是这次事故里两个关键角色:MiroFish是我们自研的一套轻量级API网关与流量治理中间件,而LLM调用则是所有AI功能的命脉。事故本质不是模型崩了、也不是服务挂了,而是下游服务在失去MiroFish的熔断与降级能力后,开始对LLM发起无节制、无校验、无兜底的裸奔式请求。它暴露的不是某个模块的缺陷,而是整个AI服务链路中“容错设计”的结构性缺失——当一个本该承担“交通警察”职能的组件被移除,整条高速立刻陷入连环追尾。这件事发生在我们上线新版本的灰度阶段,当时只停用了MiroFish的路由转发和限流模块,却忽略了它同时承担着请求预检、参数清洗、失败重试策略、fallback兜底触发四大隐性职责。更讽刺的是,所有LLM调用都带着"retry: true"标记,而新架构里根本没有重试控制器,结果就是每次超时或格式错误,客户端就原样重发——不是重试一次,是重试到超时阈值耗尽为止。我翻了三天日志才确认:那1.6万次调用里,73%是完全重复的相同payload,其中41%的请求甚至没带有效user_id,纯属前端埋点错误触发的空转。这不是技术债,这是设计债;不是运维事故,是工程认知断层。

2. MiroFish 的真实角色解构:它从来不只是个“网关”

2.1 被低估的四层隐性能力

很多人把MiroFish简单理解为“API网关”,就像把消防栓当成普通水管。实际上,在我们当前的AI服务架构中,它承担着远超传统网关的职责,且这些能力在停用前从未被文档化,全靠老员工口口相传。我们后来做了逆向梳理,发现它实际运行着四个关键子系统:

  • 请求净化层(Request Sanitizer):自动过滤掉null、空字符串、超长文本(>8192字符)、非法JSON结构等无效输入。比如前端传来的{"query": ""}会被直接拦截并返回400 Bad Request,而不是转发给LLM让它浪费token。停用后,这类请求全部透传,LLM模型层只能靠自身max_length截断,但截断后的残缺文本仍会触发推理,产生无意义输出。

  • 语义校验层(Semantic Validator):基于规则引擎对请求意图做轻量判断。例如检测到"action": "summarize"但"text"字段为空,或"action": "translate"却未提供"target_lang",会直接拒绝而非让LLM执行无效任务。这部分逻辑写在MiroFish的Lua脚本里,共23条业务规则,覆盖了87%的高频错误场景。

  • 智能重试控制器(Smart Retry Orchestrator):这才是最致命的缺失。它不盲目重试,而是根据错误码动态决策:503 Service Unavailable触发指数退避重试(最多2次),400 Bad Request直接失败不重试,429 Rate Limited则降级到本地缓存响应。停用后,所有错误统一走HTTP客户端默认重试(3次),而LLM服务恰好对429返回的是500(因上游限流器未正确映射状态码),导致本该降级的请求被反复重试。

  • Fallback兜底引擎(Fallback Resolver):当LLM调用失败且满足特定条件(如用户等级≥VIP2、请求耗时>3s),自动切换到规则引擎生成简版响应。比如摘要请求失败时,返回"抱歉,当前摘要服务繁忙,您可尝试分段发送"而非空白。这个兜底逻辑与业务强耦合,从未抽离成独立服务。

提示:MiroFish的这四层能力,没有一行代码写在LLM服务内部,全部由网关层完成。这意味着LLM服务本身是“无状态、无防御”的裸模型——它只负责推理,不负责纠错、不负责降级、不负责解释失败原因。这种分工在有网关时高效,在网关消失时灾难。

2.2 为什么没人意识到它的存在?

这个问题我们复盘会上争论了整整两小时。根本原因在于MiroFish的职责被“隐形化”了。它不像Kubernetes那样有明确的Operator角色,也不像Prometheus那样有独立的监控大盘。它的存在感体现在“一切正常”上——当它工作时,你永远看不到它;只有它失效时,你才意识到它挡下了多少洪水。更麻烦的是,它的配置分散在三个地方:Nginx conf文件里嵌入Lua脚本、Consul里存储的动态规则、以及部署时注入的环境变量。新同事入职培训只学“怎么调LLM API”,没人教“MiroFish在背后帮你挡了多少子弹”。我们查了Git历史,发现最早一条关于语义校验的提交是2022年3月,作者已离职三年,相关文档链接早已404。这种知识断层,比技术债务更危险。

2.3 LLM调用链路的脆弱性真相

停用MiroFish后,LLM调用链路从“网关防护→模型服务→缓存/DB”变成了“裸客户端→模型服务→(无缓存)”。我们画了对比图才发现,原来有网关时,92%的失败请求在网关层就被拦截或降级;无网关后,100%的请求都抵达模型服务,其中68%因参数问题触发模型层异常(如torch.cuda.OutOfMemoryError),而模型服务的错误处理极其简单:记录error log + 返回500。更糟的是,我们的LLM SDK默认开启了auto_retry=True,且重试间隔固定为100ms——这意味着一个超时请求会在1秒内发起10次重试,而每次重试都消耗完整token配额。我们实测了一个典型bad case:前端传入{"query": "a" * 10000}(1万个字符a),MiroFish会在15ms内返回400;停用后,这个请求触发模型OOM,服务返回500,SDK立即重试,10次重试累计消耗3200 tokens,产生10条无意义日志,而用户看到的只是加载转圈。

3. 1.6万次白耗调用的量化归因分析

3.1 数据溯源:从日志洪流中打捞关键证据

要真正理解这1.6万次调用为何发生,不能只看总量,必须拆解到毫秒级。我们从三个维度交叉验证:

  • 时间维度:事故发生在停用操作后的第37分钟(UTC+8),持续11小时23分钟。峰值出现在第2小时,每分钟调用达2100次,是日常均值(120次/分钟)的17.5倍。有趣的是,第4小时起调用量开始阶梯式下降,到第8小时稳定在450次/分钟——这对应着前端逐步上线新埋点修正版本。

  • 请求维度:抽取1000条失败请求样本分析,发现:

    • 41.3% 请求缺少必要字段(如session_id,user_id)
    • 28.7% 请求携带超长文本(>5000字符)
    • 15.2% 请求action字段值非法(如"summarizee"拼写错误)
    • 9.8% 请求content_type为text/plain但期望application/json
    • 5% 其他(空body、非法JSON等)
  • 响应维度:LLM服务返回的状态码分布:

    • 500 Internal Server Error: 62.4% (OOM、CUDA error等)
    • 400 Bad Request: 23.1% (模型层参数校验失败)
    • 429 Too Many Requests: 12.7% (上游限流器误判)
    • 200 OK: 1.8% (极少数幸运请求)

注意:这1.8%的200响应里,有73%是空响应({"result": ""}),因为输入文本被模型截断后只剩空白符。这意味着即使“成功”,也是无效成功。

3.2 成本计算:每一笔调用都在烧钱

我们按实际账单反推,这笔事故的直接成本远超想象。以我们使用的云厂商LLM API为例(按token计费):

调用类型单次平均输入token单次平均输出token单次预估成本(USD)预估总成本
无效空请求(无user_id)125$0.0008$12.80
超长文本截断(10k chars)2800150$0.182$2,912.00
拼写错误action请求458$0.0032$51.20
OOM重试请求(10次/原始)3200×10150×10$2.128×10$34,048.00
合计———$37,024.00

别忘了还有间接成本:GPU资源被无效请求占满,导致真实用户请求排队超时,DAU下滑2.3%;运维团队紧急扩容LLM实例,额外支付$1,200;客户投诉激增,CSAT下降11个百分点。最讽刺的是,这笔钱买的不是功能,是“确认失败”——所有调用都返回了错误,但系统还在不知疲倦地重试。

3.3 根因穿透:三层漏斗式失效模型

我们最终用“三层漏斗”模型定位根本原因,这比单纯说“没做容错”更有指导价值:

  • 第一层漏斗(客户端层):前端SDK未实现请求前置校验。所有表单提交、按钮点击都直接调用llmService.invoke(),没有validateBeforeSend()钩子。这是最表层的缺失。

  • 第二层漏斗(网关层):MiroFish停用后,本该由它承担的净化、校验、重试控制全部消失。这是承上启下的断裂点。

  • 第三层漏斗(服务层):LLM服务自身缺乏防御性编程。它假设所有输入都合法,错误处理只做log+500,不提供结构化错误码(如INVALID_INPUT,RATE_LIMIT_EXCEEDED),导致客户端无法区分错误类型做差异化处理。

这三层漏斗叠加,形成“客户端放行→网关缺席→服务裸奔”的死亡链条。修复不能只堵一层,必须三管齐下。

4. 实操复盘:如何重建可靠LLM调用链路

4.1 立即止损的三板斧

事故发生后,我们没花时间开会,直接执行以下操作(全程17分钟):

  1. 紧急熔断:在Kubernetes Ingress层添加临时规则,对/api/v1/llm/*路径启用rate-limit: 5rps(每秒5次),并设置burst: 10。这招立竿见影,1分钟内调用量从2100↓降至45。

  2. 请求镜像:用Envoy配置将所有LLM请求复制一份到测试集群,同时保留主链路。这样既能观察真实流量模式,又不影响线上——我们借此捕获了92%的异常请求特征。

  3. 降级开关:在Redis里设置llm_fallback_enabled:true,让LLM SDK读取后自动切换到规则引擎。这个开关5分钟内上线,将用户可见错误率从83%降至12%。

实操心得:不要试图“修好再上线”,先用最小干预止血。我们曾想直接修复前端校验,但评估需2天测试,而业务方要求2小时内恢复。临时熔断+降级开关的组合,比完美方案更有效。

4.2 架构重构:把容错能力“显性化、可配置、可观测”

停用MiroFish是必然的(它维护成本太高),但不能因此放弃容错。我们设计了新架构,核心原则是能力下沉、职责清晰、配置外置:

  • 能力下沉到SDK:开发统一LLM SDK(支持Python/JS/Android),内置四大能力:

    • InputSanitizer:自动trim空格、截断超长文本、校验JSON schema
    • RetryPolicy:按错误码配置重试策略(如429重试2次,500不重试)
    • FallbackManager:支持三种降级策略(静态文案、缓存响应、规则引擎)
    • MetricsReporter:上报成功率、耗时、token消耗等指标
  • 职责清晰化:明确各层边界:

    • 客户端:负责输入合法性初筛、用户意图确认
    • SDK:负责网络层容错、重试、降级
    • LLM服务:只做推理,返回结构化错误码(新增X-LLM-Error-Codeheader)
  • 配置外置化:所有策略通过Apollo配置中心管理,支持灰度发布。例如重试策略配置:

    { "retry_policy": { "400": {"enabled": false}, "429": {"enabled": true, "max_attempts": 2, "backoff_ms": [100, 300]}, "500": {"enabled": true, "max_attempts": 1} } }

4.3 关键代码实现:一个可落地的SDK重试控制器

以下是Python SDK中重试控制器的核心实现(已脱敏),它解决了原生requests重试的三大缺陷:不区分错误码、不退避、不记录上下文。

import time import logging from typing import Dict, Any, Optional from requests import Session from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry class LLMRetryController: def __init__(self, config: Dict[str, Any]): self.config = config self.logger = logging.getLogger(__name__) def build_session(self) -> Session: session = Session() # 基于配置构建Retry策略 retry_strategy = Retry( total=0, # 禁用urllib3默认重试 raise_on_status=False, raise_on_redirect=False ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("http://", adapter) session.mount("https://", adapter) return session def execute_with_retry(self, method: str, url: str, json: dict, timeout: int = 30) -> dict: max_attempts = self.config.get("max_attempts", 3) for attempt in range(max_attempts): try: start_time = time.time() response = self._make_request(method, url, json, timeout) elapsed = time.time() - start_time # 根据响应码决定是否重试 if self._should_retry(response.status_code): if attempt < max_attempts - 1: backoff = self._get_backoff(attempt) self.logger.warning( f"LLM request failed with {response.status_code}, " f"retrying in {backoff}ms (attempt {attempt+1}/{max_attempts})" ) time.sleep(backoff / 1000.0) continue else: self.logger.error(f"LLM request failed after {max_attempts} attempts") return self._build_error_response(response, "RETRY_EXHAUSTED") # 成功或无需重试的失败,直接返回 return self._parse_response(response) except Exception as e: if attempt < max_attempts - 1: backoff = self._get_backoff(attempt) self.logger.warning( f"LLM request exception: {str(e)}, retrying in {backoff}ms" ) time.sleep(backoff / 1000.0) continue else: return self._build_error_response(None, "NETWORK_ERROR") return {"error": "Unexpected fallthrough"} def _make_request(self, method, url, json, timeout): # 实际HTTP请求,省略细节 pass def _should_retry(self, status_code: int) -> bool: # 从配置读取重试策略 policy = self.config.get("retry_policy", {}) return policy.get(str(status_code), {}).get("enabled", False) def _get_backoff(self, attempt: int) -> int: # 指数退避,避免雪崩 base = self.config.get("base_backoff_ms", 100) return min(base * (2 ** attempt), 5000) # 最大5秒 def _parse_response(self, response) -> dict: # 解析成功响应或结构化错误 try: data = response.json() if response.status_code == 200: return {"success": True, "data": data} else: error_code = response.headers.get("X-LLM-Error-Code", "UNKNOWN") return { "success": False, "error_code": error_code, "message": data.get("message", "Unknown error") } except: return {"success": False, "error_code": "PARSE_ERROR"}

这个实现的关键创新点:

  • 错误码驱动重试:不再盲目重试所有5xx,而是查配置表决定;
  • 智能退避:首次重试100ms,第二次200ms,第三次400ms,避免请求风暴;
  • 结构化错误:返回error_code而非原始status_code,便于前端做精准降级。

4.4 验证与压测:用数据证明可靠性提升

重构后,我们做了三轮验证:

  • 单元测试:覆盖所有错误码分支,100%行覆盖率;
  • 混沌测试:用Chaos Mesh注入网络延迟、Pod Kill,验证SDK在50%失败率下仍能维持72%成功率;
  • 生产灰度:先对1%流量开启新SDK,监控72小时,关键指标:
    • LLM调用失败率:从83% → 9.2%
    • 平均重试次数:从3.8次 → 0.7次
    • 用户端首屏加载失败率:从12.4% → 1.3%
    • token消耗降低:68.3%

最值得骄傲的是,我们实现了错误可追溯:当用户报告“摘要失败”,客服系统能直接看到error_code: INVALID_INPUT_LENGTH,而不是笼统的“服务异常”。

5. 经验沉淀:LLM工程化避坑清单

5.1 必须写进SOP的五条铁律

经过这次事故,我们把LLM调用规范升级为强制SOP,所有新项目必须遵守:

  1. 零信任输入原则:任何来自客户端的LLM请求,必须经过SDK层InputSanitizer校验,禁止直连LLM服务。校验项至少包括:user_id非空、query长度1-4096、JSON schema合规、action在白名单内。

  2. 错误码契约化:LLM服务必须返回标准X-LLM-Error-Code头,值域限定为:INVALID_INPUT、RATE_LIMIT_EXCEEDED、MODEL_UNAVAILABLE、TIMEOUT、INTERNAL_ERROR。前端/SDK据此做差异化处理。

  3. 重试策略最小化:默认禁用重试,仅对RATE_LIMIT_EXCEEDED和TIMEOUT启用,且最大重试次数≤2。重试间隔必须指数退避。

  4. 降级能力常态化:每个LLM接口必须配置fallback策略,且fallback响应需包含X-Fallback-Used: true头,便于监控降级率。

  5. 成本可观测:所有LLM调用必须上报input_tokens、output_tokens、model_name、latency_ms到Prometheus,设置告警:avg_over_time(llm_cost_usd[1h]) > 50。

5.2 团队协作的三个认知升级

事故暴露的不仅是技术问题,更是协作盲区:

  • 打破“LLM黑盒”迷信:很多同学认为“调用LLM API=搞定AI功能”,却忽略LLM服务本身是脆弱的。我们必须像对待MySQL一样对待LLM——它需要连接池、超时设置、熔断保护。现在我们要求:每个LLM接口文档必须包含“SLA承诺”(如P99<3s)、“错误码字典”、“降级方案”。

  • 文档即代码:MiroFish的隐性能力之所以丢失,是因为它没被当作“产品”来维护。现在所有中间件、SDK都遵循“文档即代码”原则:配置项、错误码、重试策略全部写进OpenAPI spec,用Swagger UI自动生成文档,Git commit必须关联文档变更。

  • 故障演练常态化:每月进行一次“LLM服务不可用”演练,随机关闭一个LLM实例,检验SDK降级、监控告警、客服话术是否到位。演练报告必须包含“本次暴露的3个新风险点”。

5.3 给同行的三个实操建议

作为踩过坑的人,这些建议可能帮你省下几万美金:

  • 永远保留一个“哑网关”:即使不用MiroFish,也要部署一个轻量网关(如Traefik+Lua),只做最基础的请求拦截和日志。它的存在价值不是功能,是“安全缓冲区”。我们现在的哑网关只有3个规则:拦截空body、拦截超长query、记录所有4xx/5xx。

  • SDK版本强制升级机制:LLM SDK必须支持自动检查更新,旧版本超过30天未升级则主动降级(返回{"error": "SDK_DEPRECATED"})。我们曾发现某App版本使用2年前的SDK,重试逻辑有严重bug,却无人知晓。

  • 建立LLM调用健康度仪表盘:不要只看成功率,要监控“有效成功率”——即返回非空、非错误结果的比例。我们发现,即使成功率99%,但其中30%是{"result": "I cannot assist with that."}这类无意义响应,实际业务价值为零。

最后分享一个细节:事故复盘会结束时,我们没宣布“谁负责”,而是全体起立,对着白板上的1.6万这个数字静默30秒。那个数字不再是一串冰冷的统计,而是我们对工程敬畏心的刻度。LLM很强大,但再强大的模型,也需要工程师用扎实的容错设计为它铺路。这条路,我们刚起步。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进