AI Agent网关的fail-closed与熔断器解析
在 AI Agent 应用从原型走向生产的过程中最容易被低估的往往是依赖边界。Agent 通常会同时调用模型服务、工具 API、知识库和内部系统任何一个上游抖动都可能被多轮重试放大成整条任务的失败甚至产生不可控的费用。Loopers 就是围绕这个问题出现的组件它把传统网关里的反向代理与熔断器思想改造成适配 AI Agent 调用链路的 fail-closed 组件。核心目的只有一个在不确定上游是否可用时宁可拒绝请求也不要让 Agent 拿着不可靠的响应继续执行后续动作。这篇内容从 AI Agent 网关的痛点讲起先说明为什么普通反向代理不够用再拆解 fail-closed 和熔断器的设计逻辑然后用一个最小环境把核心链路跑起来最后给出故障注入、常见排查和生产落地建议。读完以后你可以理解这类组件要解决什么问题也能在自己的项目里实现一个简化版本验证“拒绝比放行更安全”这个判断。1. 为什么要为 AI Agent 单独设计反向代理和熔断器1.1 普通反向代理与 Agent 网关的差距传统反向代理通常处理的是普通 HTTP 服务。Nginx、Envoy 或各类 API 网关能解决路由、TLS 终止、认证、限流和日志等问题但面对 AI Agent 流量时仍有几个明显盲区。第一个盲区是请求体语义。Agent 请求的 body 里有model、messages、tools、temperature等字段网关如果只看 URL就无法区分“请求哪个模型”“携带多少上下文”“是否允许调用工具”。这导致策略控制只能停留在路径级别做不了模型级、消息数级或 token 级的限制。第二个盲区是错误模型不同。普通后端返回 500 通常代表服务故障而大模型 API 返回 429 可能只是额度或并发限制返回 503 可能是模型服务正在扩容。如果把这些错误全部当成“上游故障”触发重试Agent 会浪费大量时间和成本。第三个盲区是流式响应。普通 HTTP 反向代理很容易转发普通 JSON但 Agent 场景经常使用 SSE 流式输出。代理需要支持流式转发、逐块记录、流中断检测否则客户端可能提前收到不完整内容却不知道连接已经失败。Loopers 这类组件把反向代理从“传输层工具”上升为“Agent 流量的安全边界”。它不只看路径还会结合请求头、模型名、上下文长度、上游健康状态和熔断状态来决定一个请求是否允许被转发。1.2 fail-open 和 fail-closed 的本质区别传统网关在配置不完整或依赖不可用时经常采用 fail-open 策略。fail-open 的意思是检查失败时默认放行。典型场景是认证服务不可用时网关直接让请求进入后端。这样做的优点是可用性高缺点是安全性差。一旦监听规则失效攻击请求也可能被放行。Loopers 提倡的是 fail-closed。fail-closed 的意思是检查失败时默认拒绝。代理发现无法确认请求是否安全、无法确认上游是否健康、无法确认熔断状态时直接返回错误响应不让请求进入上游。两种策略对 Agent 场景的影响差异很大。维度fail-openfail-closed上游不可用请求照常转发等待超时或 502直接拒绝快速失败认证服务异常可能放行未认证请求拒绝所有未认证请求熔断状态未知继续放行加重上游压力返回 503保护上游Agent 影响可能把错误响应当成有效结果快速失败Agent 可及时降级适用场景普通只读服务、可用性优先涉及费用、多步依赖、安全敏感场景在 Agent 链路里错误响应比不可用更危险。假设 Agent 调用一个工具接口拿到 502它可能认为“工具调用失败”然后换一种参数重试如果代理直接放行了一个被错误改写的请求上游虽然返回 200但内容可能是降级数据Agent 会把错误结果带入下一步。fail-closed 的核心价值就是减少这种“假成功”。1.3 AI Agent 调用链路的特殊性AI Agent 的调用链路有几个鲜明特点。第一单次请求耗时跨度大。普通 API 请求可能 100 毫秒内完成大模型生成一个复杂回答可能需要 10 秒以上。代理的超时配置不能只设一个固定值需要区分连接超时、读取超时和总超时。第二Agent 经常并发调用多个工具。一个 Agent 可能在等待模型响应的同时又去查询数据库、调用搜索 API。这些请求如果都经过同一个代理代理需要统一维护熔断状态否则一个上游故障可能在多个 Agent 中反复触发重试。第三成本和额度是硬约束。普通服务可以靠水平扩容解决压力模型 API 却有速率限制和 token 费用。网关需要识别 429、额度不足、模型不存在等错误并把这些信息转换为 Agent 可理解的错误码而不是简单返回 500。Loopers 的思路不是把传统网关做得更复杂而是专门面向这些特征把“允许什么请求进入上游”“上游什么时候不可信”“失败后如何快速失败”这三个问题集中解决。2. 读懂 Loopers 的两块核心机制fail-closed 与熔断状态机2.1 fail-closed 的判定链以 Loopers 的典型处理流程为例一个请求进入代理后会先经过一组前置检查只有全部通过才会转发到上游。任何一步不通过代理直接返回错误。一个典型的判定链如下请求路径是否在白名单内。是否携带要求的身份或来源请求头。请求体中的模型名是否在允许列表中。请求的上下文大小是否超过限制。上游健康检查是否通过。熔断器当前是否允许请求进入。用伪代码可以表示为def should_allow(request, state): if request.path not in state.allowed_paths: return False, path_not_allowed if state.require_header and request.headers.get(state.require_header) is None: return False, missing_required_header if request.model not in state.allowed_models: return False, model_not_allowed if estimate_tokens(request.messages) state.max_input_tokens: return False, input_too_large if not state.upstream_healthy(): return False, upstream_unhealthy if state.circuit_open(): return False, circuit_open return True, ok这段代码的关键在于“先判断再转发”。普通代理通常先建立上游连接再处理业务错误fail-closed 组件则把判断前置让无效请求根本接触不到上游。这样的好处是即使上游已经故障也不会因为无效请求增加额外压力。2.2 Circuit breaker 的三态语义熔断器通常有三个状态closed、open、half-open。closed 表示当前允许请求转发。open 表示熔断打开禁止请求转发。half-open 表示进入探测阶段只放行少量请求验证上游是否恢复。状态触发条件对请求的行为closed正常运行失败数未达阈值正常转发open连续失败或错误率达到阈值拒绝请求快速返回 503half-openopen 状态持续一段时间后自动进入放行少量探测请求回到 closed探测请求成功恢复正常转发回到 open探测请求失败重新熔断等待下一次探测在 Loopers 的场景里熔断器不能像普通 RPC 熔断那样只统计连接失败。它需要把超时、5xx、上游 429 或额度错误分别处理。否则模型 API 出现短暂限流时熔断器可能误伤正常请求。2.3 超时、重试与限流为什么要单独处理Agent 场景里的“超时”需要区分成几类连接超时TCP 连接建立失败。读取超时TCP 连接已建立但上游迟迟不返回数据。总超时从请求发出到完整响应结束的总时长。对于流式响应总超时还要考虑首个 token 延迟和整体流时长。一个模型可能在 5 秒内返回首个 token但完整输出需要 30 秒。如果把读取超时设成 10 秒正常请求会被误判为失败。重试也要谨慎。普通 HTTP 服务失败后重试一次通常影响不大但大模型请求重试意味着再次计费而且可能重复生成内容。推荐做法是只在连接失败或 502/503 时重试。不要在 429 时盲目重试除非能根据Retry-After等待。所有重试请求都携带同一个request_id便于追踪。限流也不能只看 QPS。模型 API 的限制可能是每分钟请求数、每分钟 token 数或最大并发数。Loopers 这类组件需要同时维护多个计数维度并在接近阈值时返回 429让 Agent 客户端主动退避。3. 用最小环境把核心逻辑跑起来为了不依赖具体镜像和版本这里用一个最小 Python 实现还原 Loopers 的核心逻辑。完整使用 Loopers 时配置和启动方式以项目最新文档为准但链路和判定思想是共通的。3.1 环境准备建议使用 Python 3.10 以上版本并安装以下依赖pip install fastapi uvicorn httpx如果使用虚拟环境先创建并激活python -m venv .venv source .venv/bin/activate pip install fastapi uvicorn httpx工具作用FastAPI提供最小 HTTP 服务模拟上游 LLM 和代理Uvicorn启动 ASGI 服务HTTPX在代理中转发请求到上游3.2 模拟一个会故障的 LLM 后端新建mock_llm.py内容如下import asyncio from fastapi import FastAPI, Request from fastapi.responses import JSONResponse app FastAPI() fail_mode False slow_mode False app.post(/__admin/fail) async def set_fail(request: Request): global fail_mode body await request.json() fail_mode bool(body.get(fail, False)) return JSONResponse({fail_mode: fail_mode}) app.post(/__admin/slow) async def set_slow(request: Request): global slow_mode body await request.json() slow_mode bool(body.get(slow, False)) return JSONResponse({slow_mode: slow_mode}) app.get(/health) async def health(): return {status: ok} app.post(/v1/chat/completions) async def chat(request: Request): global fail_mode, slow_mode if fail_mode: return JSONResponse( status_code502, content{error: mock upstream unavailable}, ) if slow_mode: await asyncio.sleep(20) body await request.json() return JSONResponse( { id: mock-chatcmpl, model: body.get(model, mock-model), choices: [ { message: { role: assistant, content: mock ok, } } ], } )/__admin/fail和/__admin/slow是故障注入接口。通过调用这两个接口可以让模拟后端返回 502 或延迟 20 秒后续验证熔断和超时都会用到。启动模拟后端uvicorn mock_llm:app --port 8000启动后先验证健康检查curl -s http://127.0.0.1:8000/health返回{status:ok}说明服务正常。3.3 一个还原 Loopers 核心链路的代理新建loopers_demo.py实现一个简化版 fail-closed 代理和熔断器。import time import httpx from fastapi import FastAPI, Request, Response app FastAPI() UPSTREAM http://127.0.0.1:8000 class CircuitBreaker: def __init__( self, failure_threshold: int 3, open_seconds: float 30.0, half_open_max: int 1, ): self.failure_threshold failure_threshold self.open_seconds open_seconds self.half_open_max half_open_max self.failures [] self.state closed self.state_changed_at time.time() self.half_open_requests 0 def allow_request(self): now time.time() if self.state open: if now - self.state_changed_at self.open_seconds: self.state half-open self.half_open_requests 0 self.state_changed_at now self.half_open_requests 1 return True return False if self.state half-open: if self.half_open_requests self.half_open_max: return False self.half_open_requests 1 return True return True def record_success(self): if self.state half-open: self.state closed self.state_changed_at time.time() self.failures.clear() def record_failure(self): if self.state half-open: self.state open self.state_changed_at time.time() return self.failures.append(time.time()) if len(self.failures) self.failure_threshold: self.state open self.state_changed_at time.time() self.failures.clear() cb CircuitBreaker(failure_threshold3, open_seconds5) app.post(/v1/chat/completions) async def proxy(request: Request): if request.headers.get(x-agent-id) is None: return Response( status_code403, content{error:missing x-agent-id}, media_typeapplication/json, ) if not cb.allow_request(): return Response( status_code503, content{error:circuit open}, media_typeapplication/json, ) body await request.body() try: async with httpx.AsyncClient(timeout10.0) as client: upstream_resp await client.post( UPSTREAM /v1/chat/completions, contentbody, headers{content-type: application/json}, ) except httpx.TimeoutException: cb.record_failure() return Response( status_code504, content{error:upstream timeout}, media_typeapplication/json, ) if upstream_resp.status_code 500: cb.record_failure() else: cb.record_success() return Response( contentupstream_resp.content, status_codeupstream_resp.status_code, media_typeapplication/json, )这个实现里有几个关键点x-agent-id是 fail-closed 的强制请求头没有这个头直接返回 403。CircuitBreaker记录了连续失败次数默认 3 次失败后熔断。open 状态持续 5 秒后进入 half-open放行一个探测请求。上游超时视为一次失败写入熔断器状态。启动代理uvicorn loopers_demo:app --port 80803.4 启动并验证基本转发在代理正常、上游正常时发送一个带请求头的请求curl -i http://127.0.0.1:8080/v1/chat/completions \ -H content-type: application/json \ -H x-agent-id: agent-demo \ -d {model:mock-model,messages:[{role:user,content:hello}]}预期响应是 HTTP 200内容来自模拟上游{id:mock-chatcmpl,model:mock-model,choices:[{message:{role:assistant,content:mock ok}}]}去掉请求头再试curl -i http://127.0.0.1:8080/v1/chat/completions \ -H content-type: application/json \ -d {model:mock-model,messages:[]}预期响应是 HTTP 403。这验证了 fail-closed 的“先判断再转发”逻辑请求根本没有进入模拟上游。4. 关键配置拆解超时、阈值、白名单与日志最小实现把参数写死在代码里生产环境则需要把参数外置到配置文件。下面是完整 Loopers 类组件的典型配置思路。4.1 路由、上游地址与健康检查proxy: listen: :8080 upstreams: - name: llm-primary address: http://llm-service:8000 health_path: /health health_interval: 5s health_timeout: 2shealth_interval表示健康检查间隔。间隔太短会频繁探测上游浪费资源间隔太长则可能在上游故障后仍然转发请求。在 K8s 环境中address通常指向 Service 名称例如http://llm-service:8000。如果使用域名解析要确保代理所在网络能访问到该地址。4.2 熔断阈值设计熔断参数需要结合 Agent 场景单独设计。下面是一个示例配置circuit_breaker: failure_threshold: 3 failure_ratio: 0.5 window: 10s open_timeout: 30s half_open_max_requests: 1参数含义建议failure_threshold连续失败多少次触发熔断3 到 5 比较稳妥failure_ratio滑动窗口内错误率阈值0.5 表示一半请求失败才熔断window统计窗口建议 10 到 30 秒open_timeout熔断持续时间建议 30 到 60 秒half_open_max_requestshalf-open 状态最多放行多少探测请求建议 1 到 2不要把阈值设得太小。模型 API 偶尔出现 429 或网络抖动时如果两三次就熔断Agent 的整个任务会被频繁打断。也不要设得太大否则上游故障要很久才能被感知。4.3 Fail-closed 白名单、请求头和请求体约束fail_closed: enabled: true require_header: x-agent-id allowed_paths: - /v1/chat/completions allowed_models: - mock-model - gpt-4o-mini max_messages: 100 max_input_tokens: 16000allowed_paths是允许进入上游的路径白名单。路径之外的所有请求都会被拒绝这能避免代理变成任意流量的转发入口。require_header是身份标识。在 Agent 平台中通常由 Agent 框架注入x-agent-id代理层根据这个字段做日志关联和配额统计。缺少该字段的请求很可能是误配置或外部探测。max_messages和max_input_tokens用于限制上下文过大导致的费用失控。对于长对话 Agent建议在客户端做截断代理层只做最终保护。4.4 日志、指标与请求追踪生产环境不能只看代理是否通还要能看到每个请求被拒绝的原因。日志至少需要包含request_id请求唯一标识。agent_id来自请求头。model请求的模型名。upstream_status上游返回的状态码。proxy_status代理返回给客户端的状态码。latency_ms从代理收到请求到返回响应的时间。blocked_reason被 fail-closed 拦截时的原因。指标至少需要总请求数。被 fail-closed 拦截的请求数。熔断器打开的时间比例。上游 5xx、429、超时数量。平均首 token 时延和整体时延。这些数据不仅用于排查问题也是后面做 Agent 评估的重要输入。5. 故障注入与验证fail-closed 和熔断是否真的生效最小环境的价值在于可以主动制造故障验证代理是否真的按预期工作。5.1 场景一缺少请求头触发 fail-closed现象请求路径正确但没有x-agent-id请求头。curl -i http://127.0.0.1:8080/v1/chat/completions \ -H content-type: application/json \ -d {model:mock-model,messages:[]}预期响应HTTP/1.1 403 Forbidden {error:missing x-agent-id}这个场景说明 fail-closed 在转发前完成了检查。有一个容易忽略的验证方法观察模拟后端的日志只有真正的转发请求才会在mock_llm打印访问记录。被代理拦截的请求不会出现在上游日志中。5.2 场景二连续失败触发熔断首先让模拟后端进入故障模式curl -s -X POST http://127.0.0.1:8000/__admin/fail \ -H content-type: application/json \ -d {fail: true}然后连续发送 4 个带请求头的请求for i in 1 2 3 4; do curl -s -o /dev/null -w %{http_code}\n \ http://127.0.0.1:8080/v1/chat/completions \ -H content-type: application/json \ -H x-agent-id: agent-demo \ -d {model:mock-model,messages:[]} done因为failure_threshold3前 3 个请求会返回 502第 4 个请求会返回 503。第 4 个请求代表熔断器已经打开代理直接拒绝不再转发给上游。请求序号预期状态码说明1502上游返回 502记录失败2502连续失败 2 次3502连续失败 3 次熔断器状态变为 open4503熔断器打开请求被快速拒绝5.3 场景三熔断恢复的半开探测在熔断器 open 状态持续 5 秒后放开故障curl -s -X POST http://127.0.0.1:8000/__admin/fail \ -H content-type: application/json \ -d {fail: false}等待 5 秒后再次发送请求curl -i http://127.0.0.1:8080/v1/chat/completions \ -H content-type: application/json \ -H x-agent-id: agent-demo \ -d {model:mock-model,messages:[{role:user,content:hello}]}预期响应恢复为 200。如果第一次探测仍然失败熔断器会重新回到 open 状态等待下一个 open 周期再探测。5.4 场景四上游慢导致超时设置模拟后端进入慢模式curl -s -X POST http://127.0.0.1:8000/__admin/slow \ -H content-type: application/json \ -d {slow: true}发送请求curl -i http://127.0.0.1:8080/v1/chat/completions \ -H content-type: application/json \ -H x-agent-id: agent-demo \ -d {model:mock-model,messages:[]}因为模拟后端会延迟 20 秒而代理的超时时间是 10 秒预期返回 504{error:upstream timeout}超时后熔断器也会记录一次失败。这验证了“读取超时”和“连接超时”都要单独配置不能只设一个笼统的超时值。6. 常见问题与排查链路6.1 代理转发成功但没有走策略现象请求能正常返回但强制请求头没有生效或某个被禁用的模型仍然被转发。可能原因客户端请求的路径和代理匹配的路由不一致。代理读取的是旧配置未触发重载。请求头名称拼写不一致例如代码要求x-agent-id客户端发的是X-Agent-Id。检查方式查看代理日志中的实际请求路径。确认请求头名称和大小写。确认配置是否已重新加载。HTTP 头名称在 HTTP 规范中不区分大小写但代码读取时要注意 ASGI 框架通常会把 header 转为小写。建议统一使用小写名称。6.2 熔断误触发现象上游偶尔出现 429 或 5xx熔断器频繁打开导致大量请求被 503 拒绝。原因把 429 当作熔断失败。把客户端 4xx 错误也计入失败。阈值设置太小。处理方式是对错误码分类。通常只有以下情况才应该触发熔断连接失败。读取超时。上游返回 502、503、504。连续失败数达到阈值。429 更适合交给限流器处理而不是熔断器。限流器可以返回 429让客户端等待后重试熔断器打开则会让所有请求快速失败容易造成雪崩。错误码是否计入熔断建议处理401/403否配置问题先看请求头或密钥404否路由或模型名不匹配429否限流按 Retry-After 退避500是上游异常502是上游不可用503是上游过载或不可用504是上游响应超时连接超时/读取超时是网络或上游处理过慢6.3 fail-closed 拦掉正常请求现象Agent 客户端本来正常但接入代理后请求全部 403 或 503。按顺序排查确认请求头是否正确携带。很多 Agent 框架默认不会注入自定义头需要在客户端配置里显式设置。确认模型名是否在白名单内。模型名必须和上游 API 接受的名字完全一致。确认上下文长度限制是否过小。如果max_input_tokens设成 1000而 Agent 的对话历史超过这个长度请求会被拒绝。日志里的blocked_reason是最直接的线索。拦截原因明确后再决定调整白名单还是调整请求体。6.4 Agent 客户端报 502、503、504 分别代表什么状态码含义常见原因502上游返回坏网关上游服务故障或代理转发地址错误503服务不可用熔断器打开或 fail-closed 拦截504网关超时上游响应超时或代理超时配置太小429请求过多被限流或上游额度不足排查时先看代理响应体中的error字段再看代理日志里的upstream_status和blocked_reason。如果 upstream 状态为空说明请求没有到达上游问题大概率出在代理的前置检查。6.5 三个容易踩的坑第一个坑是把 429 当成熔断失败。模型 API 的高频限流不是服务下线盲目熔断会让 Agent 在高负载时反而全部失败。推荐把 429 和 5xx 分开计数。第二个坑是忽略流式响应。如果代理把 SSE 流当成普通 JSON 缓存或重试会导致响应内容错乱。生产代理需要识别text/event-stream并逐块转发。第三个坑是只设置连接超时不设置读取超时。连接超时只能解决“连不上”的问题解决不了“连上后一直不返回”的问题。Agent 请求尤其需要区分首 token 超时和总超时。7. 生产落地从最小代理到 Agent 平台7.1 部署拓扑与高可用Loopers 类组件可以按两种方式部署。部署方式优点缺点适用场景Sidecar 独立代理配置隔离、故障边界清晰、单 Agent 影响可控每个副本都要管理代理资源单个 Agent 平台或按团队隔离集中式代理集群统一治理、指标集中、升级方便单点风险高需要额外高可用多 Agent 共享模型入口时集中式部署需要至少两个实例前端用负载均衡分发。代理实例之间是否共享熔断状态要根据实际场景判断。集中式共享能避免单个 Agent 的请求把熔断器打满隔离式能让不同租户互不影响。建议生产环境采用“集中式入口 按租户分流”的组合。入口统一做认证和审计每个租户或每个 Agent 类型使用独立的熔断器实例避免一个异常 Agent 拖垮所有流量。7.2 配置外置与热更新熔断阈值、白名单、超时时间这些配置不要写死在代码里。生产环境至少要把配置放入外部文件、配置中心或 K8s ConfigMap。配置变更是 AI Agent 流量治理最常见的操作。模型供应商调整了限流策略、某个模型下线、某个 Agent 出现异常都需要快速调整。如果配置变更需要重新发布服务就会拖