云计算百科
云计算领域专业知识百科平台

AI智能体项目实战记录(25):智能体添加限流,MCP Server 添加限流与鉴权

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

AI智能体项目实战记录(25):智能体添加限流,MCP Server 添加限流与鉴权

1. 为什么要限流

前 24 篇把智能体能力做通了:对话、子智能体编排、知识库、MCP Server、桌面工作台接入。功能跑通之后,成本与稳定性就成了必须处理的问题。

1.1 不限流会出什么事

风险具体表现
成本失控 一次对话会调用多次大模型(主管决策 + 子智能体 + 总结),单个用户刷一轮就可能上万 token;接口一旦被脚本刷,账单直接失控
资源打满 每次会话都要占用一个 SSE 长连接 + 线程池 + 向量库连接;3~5 个并发就足以把单机 GPU/CPU 吃满,其余用户直接卡死
单点雪崩 Milvus / 大模型响应变慢 → 请求在服务端堆积 → 内存上涨 → 整个服务不可用
能力裸奔 第 23 篇写好的 MCP Server 没有任何鉴权,谁拿到地址谁就能调用内部知识库
额度不可控 对外提供的 MCP 能力没有用量上限,一个客户端可以把额度用光

1.2 本篇要落地的 5 件事

对话侧(本项目前端 / 登录用户)
├─ ① 同一用户最多 3 个并发会话 → 保护服务端资源
└─ ② 同一用户每天 token 上限 → 控制成本

MCP 侧(WorkBuddy / 第三方客户端)
├─ ③ API Key 鉴权(无 key 提示先获取) → 防止裸奔
├─ ④ 并发上限 + 熔断 → 保护下游 Milvus / 大模型
└─ ⑤ 每个 key(用户)每天 token 上限 → 防止单客户端刷额度

设计原则:限流组件本身绝不能成为故障点。所有 Redis 操作都有降级路径,限流判断失败不能阻断正常业务。


2. 整体结构

┌──────────────────────────────┐
前端对话 ──────▶│ chat_controller │
│ ① session_limit_service │──▶ Redis(并发计数,TTL 兜底)
│ ② token_quota_service │──▶ Redis(当日用量)──▶ PG(ai_invoke_log 汇总)
└──────────────┬───────────────┘
▼
supervisor_graph(主管编排)
│ 结束时写 ai_invoke_log
▼
token_quota_service.add_tokens(chat)

WorkBuddy ────▶ knowledge_server(SSE)
├─ ③ API Key 校验(401 提示先获取 key)
├─ ④ ConcurrencyGuard(并发 + 熔断)
└─ ⑤ 工具内校验 MCP 每日额度
│
▼
RAGTool.run_with_usage → 返回答案 + token
│
▼
log_external_invoke(SKILL_ID_ = mcp:xxx)
└─▶ add_tokens(mcp)

新增/改动文件:

文件作用
app/service/system/session_limit_service.py 新增:并发会话计数
app/service/system/token_quota_service.py 新增:每日 token 配额
app/controller/chat_controller.py 接入两个校验,并在流结束时释放名额
app/service/system/invoke_log_service.py 落库后累加额度;新增外部调用日志
app/tools/rag_tool.py 新增 run_with_usage,返回答案与 token
app/mcp/knowledge_server.py Key 鉴权 + 并发熔断 + 每日额度
scripts/test_mcp_knowledge_sse.py 支持 –key
app/config/settings.py、.env 配置项

3. 配置项

.env:

# ============ 限流与配额 ============
# 同一登录用户同时进行的对话数上限
CHAT_MAX_CONCURRENT_SESSIONS=3
# 每个登录用户每天可消耗的 token 上限(<=0 不限制)
CHAT_DAILY_TOKEN_LIMIT=200000

# ============ 对外 MCP Server 的鉴权与限流 ============
# 是否开启 API Key 校验(生产环境务必开启)
MCP_AUTH_ENABLED=true
# API Key 列表:"key:userId,key2:userId2";不写 userId 时仅鉴权、不做用户配额
MCP_API_KEYS="sk-mcp-demo-key:1001"
# MCP 工具并发执行上限
MCP_MAX_CONCURRENCY=3
# 连续拒绝多少次后熔断
MCP_BREAKER_TRIP_THRESHOLD=5
# 熔断持续时间(秒)
MCP_BREAKER_COOLDOWN_SECONDS=30
# 每个用户每天通过 MCP 调用可消耗的 token 上限(<=0 不限制)
MCP_DAILY_TOKEN_LIMIT=50000

settings.py:

# 同一登录用户同时进行的对话数上限(SSE 流未结束时算占用一个)
chat_max_concurrent_sessions: int = 3
# 每个登录用户每天可消耗的 token 上限(普通对话,<=0 表示不限制)
chat_daily_token_limit: int = 200000
# 是否开启 API Key 校验(生产环境务必开启)
mcp_auth_enabled: bool = True
# API Key 列表,格式 "key:userId,key2:userId2"
mcp_api_keys: Optional[str] = ''
# MCP 工具并发执行上限,超过后拒绝请求
mcp_max_concurrency: int = 3
# 连续拒绝多少次后触发熔断
mcp_breaker_trip_threshold: int = 5
# 熔断持续时间(秒)
mcp_breaker_cooldown_seconds: int = 30
# 每个用户每天通过 MCP 调用可消耗的 token 上限(<=0 表示不限制)
mcp_daily_token_limit: int = 50000

两个设计取舍:

  • key:userId 而不是纯 key:MCP 侧的额度是按人算的,key 必须能映射到一个用户;
  • 两套额度分开:CHAT_DAILY_TOKEN_LIMIT 与 MCP_DAILY_TOKEN_LIMIT 互不挤占,避免桌面工作台的调用把网页端的额度吃光。

  • 4. 对话侧①:同一用户最多 3 个并发会话

    4.1 为什么用"计数"而不是"查会话表"

    用户历史会话是持久化的,数量只增不减,不能用来判断"同时进行"。真正要限制的是正在进行的对话流:从请求开始生成,到最后一帧 SSE 返回为止。

    所以用原子计数:请求进来 INCR,流结束 DECR,超过阈值就拒绝。

    4.2 实现

    app/service/system/session_limit_service.py:

    _KEY_PREFIX = "chat:running:"
    _KEY_TTL_SECONDS = 7200 # 兜底过期,防止进程崩溃后名额不释放

    def acquire(user_id: str, limit: int) –> bool:
    if not user_id or limit <= 0:
    return True # limit<=0 视为不限制
    try:
    client = redis_service.get_client()
    key = _key(user_id)
    current = client.incr(key) # 原子自增
    if current == 1:
    client.expire(key, _KEY_TTL_SECONDS)
    if current > limit:
    client.decr(key) # 超限立刻回滚,避免计数虚高
    logger.warning(f"[limit] 用户{user_id}并发会话已达上限 {limit}")
    return False
    return True
    except Exception as e:
    logger.warning(f"[limit] Redis 不可用,降级为进程内计数: {e}")
    return _acquire_local(user_id, limit)

    release 里有一个细节:decr 可能因为 key 已过期变成负数,所以要钳制到 0。

    def release(user_id: str) –> None:
    ...
    if client.exists(key):
    if client.decr(key) < 0:
    client.set(key, 0, ex=_KEY_TTL_SECONDS)

    4.3 接入对话接口

    app/controller/chat_controller.py:

    # ———- 限流一:同一用户并发会话数 ———-
    max_sessions = int(setting.chat_max_concurrent_sessions or 0)
    slot_acquired = False
    if max_sessions > 0:
    slot_acquired = await asyncio.to_thread(
    session_limit_service.acquire, user_id, max_sessions
    )
    if not slot_acquired:
    return _reject_resp(
    request_body,
    f'当前同时进行的会话已达上限({max_sessions} 个),请结束其它会话后再试',
    )

    释放必须在 finally,且流式接口要放到生成器的 finally 里,因为客户端中途断开时生成器会被关闭:

    finally:
    # 无论正常结束、异常还是客户端断开,都要归还并发会话名额
    if release_user_id:
    await asyncio.to_thread(session_limit_service.release, release_user_id)

    流式接口的 reject 事件前端已经认识(StreamDataRespType.REJECT),不需要改前端。


    5. 对话侧②:每个用户每天 token 上限

    5.1 统计口径

    token 数据不重新采集,直接复用第 15/19 篇落库的调用日志:ai_invoke_log.COST_TOKEN。

    SELECT COALESCE(SUM(COST_TOKEN), 0)
    FROM ai_invoke_log
    WHERE CREATE_BY_ = :user_id
    AND CREATE_TIME_ >= :day_start
    AND (SKILL_ID_ IS NULL OR SKILL_ID_ NOT LIKE 'mcp%') — 普通对话额度不含 MCP 调用

    5.2 为什么要 Redis 包一层

    每次对话前都 SUM 一遍全表是按用户+时间扫的,QPS 上来会很重。改成 Redis 计数器 + 数据库回源:

    场景行为
    正常读 读 Redis quota:tokens:chat:{userId}:{yyyymmdd}
    key 缺失(首次 / Redis 重启) 查库汇总 → 回填 Redis(TTL 到当天结束)
    每次落库后 INCRBY 累加,不再查库
    Redis 挂了 直接查库,功能不受影响

    app/service/system/token_quota_service.py:

    def used_tokens(user_id: str, scope: str = "chat") –> int:
    key = _key(user_id, scope)
    try:
    client = redis_service.get_client()
    value = client.get(key)
    if value is not None:
    return int(value or 0)
    used = _sum_from_db(user_id, scope)
    client.set(key, used, ex=_ttl_seconds())
    return used
    except Exception as e:
    logger.warning(f"[quota] Redis 不可用,直接查库: {e}")
    return _sum_from_db(user_id, scope)

    def add_tokens(user_id: str, tokens: int, scope: str = "chat") –> None:
    ...
    if not client.exists(key):
    client.set(key, _sum_from_db(user_id, scope), ex=_ttl_seconds())
    client.incrby(key, int(tokens))

    add_tokens 里"先回源再自增"很关键:Redis 重启后 key 不存在,如果直接 INCRBY 会把数据库里已有的用量覆盖掉,等于给用户重置了额度。

    5.3 累加时机

    落库成功后立即累加,保证"写日志"与"扣额度"是同一步发生的(invoke_log_service._do_finalize):

    logger.info(f"[invoke_log] 已写入调用日志 … tokens={run_log.total_tokens}")

    # 累加当天普通对话额度(MCP 调用在 MCP Server 侧单独累加)
    token_quota_service.add_tokens(run_log.user_id, run_log.total_tokens, "chat")

    5.4 接入对话接口

    # ———- 限流二:每日 token 配额 ———-
    daily_limit = int(setting.chat_daily_token_limit or 0)
    if daily_limit > 0:
    allowed, used, quota = await asyncio.to_thread(
    token_quota_service.check, user_id, daily_limit, 'chat'
    )
    if not allowed:
    if slot_acquired:
    await asyncio.to_thread(session_limit_service.release, user_id)
    return _reject_resp(
    request_body,
    f'您今日的 token 额度已用完(已用 {used} / 上限 {quota}),暂不可用,请明天再试',
    )

    注意这里先归还并发名额再返回,否则用户每被额度拒绝一次就白占一个坑。


    6. MCP 侧③:API Key 鉴权

    6.1 现状:FastMCP 1.4.1 的 SSE 没有鉴权

    # mcp 1.4.1 的 FastMCP.run_sse_async 内部实现(节选)
    async def handle_sse(request):
    async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
    await self._mcp_server.run(...) # 直接进入协议,没有任何校验

    starlette_app = Starlette(routes=[
    Route("/sse", endpoint=handle_sse),
    Mount("/messages/", app=sse.handle_post_message),
    ])

    它也没有暴露 sse_app(),所以不能简单加中间件。方案:照它的结构自己搭一个 Starlette 应用,在 handle_sse 里先校验 key。

    6.2 校验逻辑

    def _parse_api_keys(raw: str) –> Dict[str, str]:
    """解析 MCP_API_KEYS:'key1:userId1,key2:userId2' → {key: userId}"""
    result: Dict[str, str] = {}
    for item in (raw or "").split(","):
    item = item.strip()
    if not item:
    continue
    key, _, user_id = item.partition(":")
    if key.strip():
    result[key.strip()] = user_id.strip()
    return result

    def _extract_key(request: Request) –> str:
    """从 Authorization: Bearer / ?key= / X-API-Key 中取 key"""
    auth = request.headers.get("authorization") or ""
    if auth.lower().startswith("bearer "):
    return auth[7:].strip()
    return (request.query_params.get("key") or request.headers.get("x-api-key") or "").strip()

    def _authenticate(request: Request) –> Tuple[str, str]:
    if not setting.mcp_auth_enabled:
    return "", ""
    if not _API_KEYS:
    return "", "服务端未配置任何 API Key(MCP_API_KEYS),请先在 .env 中配置"
    key = _extract_key(request)
    if not key:
    return "", "缺少 API Key,请先获取 key 后再访问"
    for candidate, user_id in _API_KEYS.items():
    if secrets.compare_digest(key, candidate):
    return user_id, ""
    return "", "API Key 无效,请确认 key 是否正确"

    三个细节:

    • 用 secrets.compare_digest 而不是 ==,避免时序攻击;
    • 支持三种传递方式,是因为不同客户端能力不一样:WorkBuddy 的 mcp.json 里在 URL 上追加 ?key= 最省事,IDE / Claude Desktop 更适合 Authorization 头;
    • 返回的 userId 就是后面算额度用的身份。

    6.3 自己搭 SSE 应用

    def _build_sse_app() –> Starlette:
    sse = SseServerTransport("/messages/")

    async def handle_sse(request: Request):
    caller, err = _authenticate(request)
    if err:
    logger.warning(f"[mcp-server] 鉴权失败: {err}")
    return JSONResponse(
    {"error": err, "hint": "请先获取 API Key 后再连接"},
    status_code=401,
    )
    if _GUARD.is_open():
    return JSONResponse({"error": "服务繁忙(熔断中),请稍后重试"}, status_code=503)

    # 同一条 SSE 连接上的所有工具调用都归属这个调用方
    _caller_var.set(caller)

    async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
    await mcp._mcp_server.run(
    streams[0], streams[1], mcp._mcp_server.create_initialization_options()
    )

    return Starlette(
    debug=mcp.settings.debug,
    routes=[
    Route("/sse", endpoint=handle_sse),
    # /messages 用 SSE 握手时下发的 session_id 作短期凭证,不再重复校验 key
    Mount("/messages/", app=sse.handle_post_message),
    ],
    )

    两个关键点:

  • /messages 不重复校验 key:SSE 客户端拿到 event: endpoint 里的 /messages/?session_id=xxx 后,POST 时不会带 key。session_id 是握手时随机生成的,本身就是一条短期凭证,所以只校验握手即可;
  • 身份通过 contextvar 透传:工具是在 SSE 这条连接的任务里执行的,handle_sse 里 set() 的值能被工具读到;写进 POST 请求的上下文反而读不到(不是同一个任务)。
  • _caller_var: ContextVar[str] = ContextVar("mcp_caller", default="")

    @mcp.tool(name="search_knowledge_base", ...)
    async def search_knowledge_base(question: str) –> str:
    caller = _caller_var.get() # 由 key 解析出的用户ID

    6.4 没有 key 的表现

    $ curl.exe -s -i –max-time 3 http://127.0.0.1:18766/sse
    HTTP/1.1 401 Unauthorized
    content-type: application/json

    {"error":"缺少 API Key,请先获取 key 后再访问","hint":"请先获取 API Key 后再连接"}

    客户端(WorkBuddy)侧表现为连接失败、连接器红点。文档第 24 篇的接法要改成带 key:

    {
    "mcpServers": {
    "测试知识库": {
    "type": "sse",
    "url": "http://127.0.0.1:18765/sse?key=sk-mcp-demo-key"
    }
    }
    }

    项目内测试脚本同步加了 –key:

    D:\\anaconda3\\envs\\test01\\python.exe scripts\\test_mcp_knowledge_sse.py `
    —url http://127.0.0.1:18766/sse —key sk-mcp-demo-key `
    —question "知识库里有没有记录当前项目使用了什么技术栈?"


    7. MCP 侧④:并发上限与熔断

    7.1 为什么要"拒绝"而不是"排队"

    排队看起来更友好,但在 Agent 场景里是灾难:每次工具调用 2~10 秒,排队会让客户端超时、SSE 长连接堆积,最后内存被打爆。快速失败 + 客户端稍后重试才是安全的做法。

    7.2 实现

    class ConcurrencyGuard:
    """
    – 并发数达到 MCP_MAX_CONCURRENCY 时直接拒绝,不排队;
    – 连续被拒绝 MCP_BREAKER_TRIP_THRESHOLD 次后熔断,
    冷却 MCP_BREAKER_COOLDOWN_SECONDS 秒,期间所有请求快速失败。
    """

    def try_acquire(self) –> Tuple[bool, str]:
    now = time.monotonic()
    if now < self._open_until:
    return False, f"服务已熔断,请 {int(self._open_until – now) + 1} 秒后重试"

    if self.inflight >= self.max_concurrency:
    self._trips += 1
    if self.cooldown_seconds > 0 and self._trips >= self.trip_threshold:
    self._open_until = now + self.cooldown_seconds
    self._trips = 0
    return False, f"并发超过上限({self.max_concurrency}),服务熔断 {self.cooldown_seconds} 秒"
    return False, f"当前请求过多(并发上限 {self.max_concurrency}),请稍后重试"

    self.inflight += 1
    return True, ""

    def release(self) –> None:
    if self.inflight > 0:
    self.inflight -= 1
    self._trips = 0 # 有请求成功完成就重置连击计数

    工具里的用法(finally 必须释放):

    ok, reason = _GUARD.try_acquire()
    if not ok:
    logger.warning(f"[mcp-server] 拒绝调用 caller={caller or '-'} reason={reason}")
    return f"【限流】{reason}"
    try:
    ...
    finally:
    _GUARD.release()

    返回的是一段普通文本而不是抛异常,这样大模型拿到后能正常转述给用户,WorkBuddy 里看到的是"当前请求过多,请稍后重试",而不是一个红色报错。

    7.3 实测

    直接把守卫拿出来跑(max=1, 阈值=2, 冷却=30):

    1 (True, '')
    2 (False, '当前请求过多(并发上限 1),请稍后重试')
    3 (False, '并发超过上限(1),服务熔断 30 秒')
    open True
    4 (False, '服务已熔断,请 31 秒后重试')

    前两个请求占满并发 → 第 2 次拒绝记 1 次连击 → 第 3 次拒绝达到阈值 → 熔断打开 → 之后全部快速失败。


    8. MCP 侧⑤:每个用户每天 token 上限

    8.1 token 从哪来

    原来的 RAGTool._run 只返回字符串,拿不到 token。给它加一个带用量的方法,_run 保持 LangChain 的工具契约不变:

    def _run(self, question: str) –> str:
    """执行知识库问答(LangChain 工具契约:只返回文本)"""
    answer, _ = self.run_with_usage(question)
    return answer

    def run_with_usage(self, question: str) –> Tuple[str, int]:
    ...
    resp = deepseek_client.chat.completions.create(...)
    answer = resp.choices[0].message.content or ""

    # 统计 token:优先取 usage,缺失时按字符估算(2 字符 ≈ 1 token)
    usage = getattr(resp, "usage", None)
    tokens = 0
    if usage is not None:
    tokens = int(getattr(usage, "prompt_tokens", 0) or 0) + int(
    getattr(usage, "completion_tokens", 0) or 0
    )
    if tokens <= 0:
    tokens = max(1, (len(prompt) + len(answer)) // 2)
    return answer, tokens

    8.2 落日志 + 扣额度

    MCP 调用不经过主管编排,所以要单独落一条日志。SKILL_ID_ 记 mcp:knowledge-base,这就是和对话额度区分的标记:

    def log_external_invoke(user_id, question, answer, tokens,
    source="mcp:knowledge-base", success=True, fail_msg=""):
    ...
    with db_service.get_engine().begin() as conn:
    conn.execute(text(text_insert_invoke()), {
    ...
    "cost_token": int(tokens or 0),
    "skill_id": _safe_text(source, 64), # mcp:knowledge-base
    "create_by": _safe_text(user_id, 64) or "mcp",
    ...
    })
    # 台账写成功后再累加 MCP 额度,避免日志失败时额度被多扣
    token_quota_service.add_tokens(user_id, tokens, "mcp")

    8.3 工具内校验

    # 1. 每日额度校验(未绑定用户的 key 不做额度限制)
    quota_limit = int(setting.mcp_daily_token_limit or 0)
    if caller and quota_limit > 0:
    allowed, used, quota = await asyncio.to_thread(
    token_quota_service.check, caller, quota_limit, "mcp"
    )
    if not allowed:
    return (f"【额度】您今日通过 MCP 调用已消耗 {used} / {quota} token,"
    f"暂不可用,请明天再试")

    8.4 实测

    先用默认额度跑一次正常调用(MCP_DAILY_TOKEN_LIMIT=50000):

    [mcp-server] SSE 连接建立 caller=1001
    [invoke_log] 已写入外部调用日志 source=mcp:knowledge-base user=1001 tokens=1068
    [mcp-server] 调用完成 caller=1001 tokens=1068 cost=2.583s inflight=1

    Redis 里能看到当日用量:

    quota:tokens:mcp:1001:20261027 = 1068

    把额度调到 1000 后再启动一个实例,同样的调用直接被拦截:

    [tools] count=1
    [call] search_knowledge_base question=技术栈是什么
    [call] isError=False
    【额度】您今日通过 MCP 调用已消耗 1068 / 1000 token,暂不可用,请明天再试

    再验证两套额度确实隔离:用户 1001 已经有 1068 的 MCP 消耗,查对话额度仍然是 0:

    chat_quota (True, 0, 200000)


    9. 联调与验证

    9.1 启动

    cd e:\\myProject\\my-langchain\\agent\\chat_py_server
    D:\\anaconda3\\envs\\test01\\python.exe –m app.mcp.knowledge_server —preload

    [mcp-server] starting name=knowledge-base transport=sse host=127.0.0.1 port=18765
    [mcp-server] auth=True keys=1 max_concurrency=3 breaker=5次/30s daily_token_limit=50000

    9.2 三个必测点

    场景命令预期
    无 key curl.exe -s -i –max-time 3 http://127.0.0.1:18765/sse 401 + “缺少 API Key,请先获取 key 后再访问”
    带 key curl.exe -s -i –max-time 3 "http://127.0.0.1:18765/sse?key=sk-mcp-demo-key" 200 + event: endpoint
    工具调用 脚本加 –key isError=False,返回知识库答案

    1.mcp连接器状态变为需要认证
    在这里插入图片描述
    2.后端提示
    在这里插入图片描述
    3.连接器添加key
    在这里插入图片描述
    4.连接成功
    在这里插入图片描述

    9.3 会话并发实测

    [True, True, True, False] # 第 4 次申请被拒
    count 3
    after_release 0

    9.4 检查清单

    • 第 4 个并发会话收到 reject 事件,提示"已达上限(3 个)"
    • 关闭其中一个会话后,可以重新发起
    • 额度用尽时收到"token 额度已用完(已用 X / 上限 Y)"
    • MCP 无 key 返回 401,带 key 正常
    • ai_invoke_log 里 MCP 调用行的 SKILL_ID_ 是 mcp:knowledge-base
    • Redis 里 quota:tokens:mcp:{userId}:{日期} 随调用递增

    10. 常见问题

    现象原因处理
    所有 MCP 请求都 401 MCP_AUTH_ENABLED=true 但 MCP_API_KEYS 为空 配置 key;启动日志会打印 keys=0 的告警
    改了 key 不生效 key 在进程启动时解析一次 重启 MCP Server
    并发没到上限也被拒 熔断已打开 等 MCP_BREAKER_COOLDOWN_SECONDS 秒
    额度永远是 0 CREATE_BY_ 为空(stdio 模式没有用户身份) stdio 不校验额度,属预期
    额度算少了 Redis 里 key 被手工改过 / 数据库写入失败 删掉 quota:tokens:* 让系统回源重算
    会话名额一直不释放 进程被强杀,finally 没执行 等 TTL(2 小时)自动归零;必要时手动删 chat:running:*
    用户被限流后一直进不去 前端没处理 reject 前端已支持,若自定义客户端需单独处理该事件

    11. 小结

    本篇把"能跑"变成了"敢开出去":

    维度手段落点
    并发资源 Redis 原子计数 + TTL 兜底 session_limit_service
    对话成本 ai_invoke_log 汇总 + Redis 计数 token_quota_service(scope=chat)
    接入安全 API Key(Bearer / query / header) knowledge_server._authenticate
    服务稳定 并发闸门 + 熔断 ConcurrencyGuard
    外部成本 外部调用日志 + 独立额度 log_external_invoke + scope=mcp

    三条经验:

  • 限流状态放 Redis,进程内计数在多 worker 下会失真;但必须有进程内降级,否则 Redis 一挂全站不可用。
  • 额度要和"台账"绑定:先写日志再扣额度,出现争议时以 ai_invoke_log 为准,Redis 只是加速缓存。
  • 失败要可读:MCP 侧返回文本而不是异常,对话侧返回 reject 事件,用户看到的是"请稍后重试",而不是 500。
  • 后续还能继续做:网关层按 IP 限流、滑动窗口替代固定日额度、把 inflight / 熔断状态暴露成 Prometheus 指标、按 key 做更细的 QPS 限制。


    附:本文配置汇总

    CHAT_MAX_CONCURRENT_SESSIONS=3
    CHAT_DAILY_TOKEN_LIMIT=200000
    MCP_AUTH_ENABLED=true
    MCP_API_KEYS="sk-mcp-demo-key:1001"
    MCP_MAX_CONCURRENCY=3
    MCP_BREAKER_TRIP_THRESHOLD=5
    MCP_BREAKER_COOLDOWN_SECONDS=30
    MCP_DAILY_TOKEN_LIMIT=50000

    WorkBuddy mcp.json:

    {
    "mcpServers": {
    "测试知识库": {
    "type": "sse",
    "url": "http://127.0.0.1:18765/sse?key=sk-mcp-demo-key"
    }
    }
    }

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » AI智能体项目实战记录(25):智能体添加限流,MCP Server 添加限流与鉴权
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!