qwen-code Daemon MCP 预算护栏:WorkspaceMcpBudget 如何实现 workspace 级 MCP 客户端限额与超额拒绝
2026/9/13 6:16:33 网站建设 项目流程

qwen-code Daemon MCP 预算护栏:WorkspaceMcpBudget 如何实现 workspace 级 MCP 客户端限额与超额拒绝

【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code

本文基于 qwen-code 仓库的开发者文档与核心源码,解析 daemon(qwen serve)的 MCP workspace 预算护栏机制:WorkspaceMcpBudget如何把原来散落在每个会话内的 MCP 客户端限额状态机提升到 workspace 作用域,通过同步原子槽位预留、75%/37.5% 双阈值滞回告警和拒绝批次聚合,在enforce模式下硬拒绝超额 MCP 连接。读完本文,你将理解该控制器的完整状态机、与McpTransportPool的调用链、CLI 标志与环境变量的配置方式,以及 SSE 事件和GET /workspace/mcp快照两条可观测链路。

为什么预算控制器需要提升到 workspace 作用域

WorkspaceMcpBudget(实现文件)是 F2 共享 MCP 传输池(McpTransportPool,对应上游 issue #4175 的 commit 6)引入的workspace 作用域MCP 客户端预算控制器。它接管了McpClientManager原本内联携带的同一套状态机——槽位预留、75% 滞回告警、跨一次discoverAllMcpTools*发现通道的拒绝批次聚合——但实例化位置从“每个 ACP 子进程会话内的 manager 各持一份”变为每个 workspace 一份,内嵌于McpTransportPool中。池子把acquirerelease调用委托给它,因此上限约束的是整个 workspace,而不是每个会话各自一份。

这一点是 F2 架构改动的核心收益:在共享池模式下,多个会话共享同一个 MCP 子进程/连接池,若仍按会话独立计账,N 个会话就可以各自把预算用满,实际派生进程数远超配置上限。

需要特别注意双轨制:遗留的McpClientManager预算机制原样保留,用于独立(standalone)qwen 与 SDK MCP server——它们按 commit 4 的修复绕过池子直接连接。执行分工是:

  • Pool 模式WorkspaceMcpBudget负责执行限额;
  • standalone / SDK MCP→ manager 的内联机制负责执行限额。

两条路径不会重复计账,因为 pool 模式下的发现流程(discoverAllMcpToolsViaPool)从不调用 manager 的tryReserveSlot

配置、模式语义与内部状态

构造配置

控制器通过三个选项构造(构造函数):

new WorkspaceMcpBudget({ clientBudget?: number, // undefined = 不限量 mode: 'off' | 'warn' | 'enforce', onEvent?: (event: McpBudgetEvent) => void, });

mode三档语义:

  • off— 所有方法空操作;tryReserve无条件返回'reserved';不触发任何事件。构造函数还做了一层防御:mode === 'off'时把onEvent存为undefined,即使构造后出现误调用也不可能发射事件(源码注释称其为“defense in depth”)。
  • warn— 追踪槽位占用,在达到 75% 时触发mcp_budget_warning,但tryReserve从不拒绝
  • enforcetryReserve在超出clientBudget后拒绝;recordRefusal按服务器名排队拒绝记录;endBulkPass发射mcp_child_refused_batch聚合事件。

滞回常量(来自 mcp-client-manager.ts)

两个阈值常量定义并复用自 mcp-client-manager.ts:

  • MCP_BUDGET_WARN_FRACTION = 0.75— 上行告警阈值。源码注释说明取 0.75 是为了对齐eventBus.tsslow_client_warningWARN_THRESHOLD_RATIO,理由一致:“warning”要在“error”之前留出操作余量;
  • MCP_BUDGET_REARM_FRACTION = 0.375— 下行滞回重置阈值(正好是告警阈值的二分之一,对齐WARN_RESET_RATIO);
  • McpBudgetMode = 'off' | 'warn' | 'enforce'(类型定义)。

内部状态

状态用途
reservedSlots: Set<string>权威预留集合;滞回计算按size / clientBudget进行。
pendingRefusalNames: Set<string>当前beginBulkPass/endBulkPass窗口内累积的被拒服务器名;在endBulkPass时排空。
pendingRefusalTransports: Map<string, transport>旁路表,让聚合事件携带每个被拒服务器的 transport 家族。
lastRefusedServerNames: readonly string[]快照可见的最近一个已完成通道的拒绝列表。在下一个bulk pass 开始时清空,而非在发射时清空。
warnArmed: boolean滞回状态 ——true表示待发射;自上次 37.5% 回落以来已发射过则为false
bulkPassDepth: number嵌套 bulk pass 的重入计数器(嵌套 pass 不得重复发射)。

这些字段在 源码 中均带有详细 JSDoc,逐字段说明了生命周期(例如pendingRefusalTransportspendingRefusalNames同生命周期、lastRefusedServerNames支撑getAccounting().refusedServerNames快照)。

核心 API:tryReserve 与 release

tryReserve:同步即原子

tryReserve 实现 是同步函数,三态返回:

  • reserved— 新持有槽位(或off模式下的空操作);
  • already_held— 槽位已被同名服务器预留(重连、或同名不同指纹的第二次获取);
  • refusedenforce模式且上限已满。

同步性是并发正确性的关键:池子的acquire本身是异步的,但预留发生在任何await之前,因此两个并发Promise.allacquire 针对不同服务器名时,无法在 await 边界上交错地双双穿过上限。

池子侧调用链与失败回滚

预留结果如何驱动实际行为,可以在 McpTransportPool.acquire 中看到完整链路:

  1. acquire提交创建新连接之前按 NAME 调用budget.tryReserve(serverName)——注意预算检查统一施加于 poolable 与 unpooled(SDK MCP / 非池化 HTTP/SSE)两条分支;
  2. 若结果为refused,池子先调用recordRefusal(serverName, transport),再抛出BudgetExhaustedError(定义于 mcp-client-manager.ts),由调用方的 catch 转译为快照中的拒绝信息;
  3. 若派生(spawn)失败,池子通过rollbackReservationOnSpawnFailure回滚——但仅当本次 acquire 真正新占了槽位reservationResult === 'reserved')才release'already_held'表示槽位属于同名兄弟条目,本次 acquire 没有占任何槽,失败时绝不能释放,否则会幽灵性地减掉一个不属于自己的槽,导致计数器漂移(源码注释中记录了修复此 phantom release 竞态的完整背景)。

release(name)(实现)是幂等的(Set.delete语义),在池条目转入closed/failed且无其他条目共享同名时由池子调用。

75% / 37.5% 滞回告警

evaluateState 在每次tryReserve/release变更后被调用:

  • 上行越过 75%(warnArmed && ratio >= 0.75)时发射一次mcp_budget_warning并解除武装;
  • 仅当 ratio 回落到 37.5% 以下才重新武装。

滞回的意义在于避免工作负载在 75% 附近震荡时产生重复告警:第一次越线即发射,此后不回落不重复。事件负载同时携带liveCountreservedCount(workspace 作用域下二者都取reservedSlots.size——池子的 CONNECTED 数可由pool.getSnapshot().total单独查询,事件类型注释明确说明这一点)。这与McpClientManager.evaluateBudgetState的语义完全镜像,保证两种作用域下 SDK 消费者看到一致的事件形状。

拒绝批次聚合:beginBulkPass / endBulkPass

workspace 内一次discoverAllMcpToolsViaPool发现通道可能并发 acquire 多个服务器。若每个被拒服务器各发一个事件,SSE 总线会被事件洪水淹没。WorkspaceMcpBudget的解法是用beginBulkPass()/endBulkPass()括起整个发现通道,把逐服务器拒绝聚合为一次mcp_child_refused_batch事件:

几个实现细节值得展开:

  • 重入保护beginBulkPass在最外层(depth 0 → 1)时清空lastRefusedServerNames作为新通道的“干净起点”;endBulkPassbulkPassDepth计数器保证只有最外层关闭(depth 1 → 0)才排空并发射,内层关闭是空操作;未配对调用的endBulkPass只记 warn 日志而不会把深度变成负数(endBulkPass 实现)。
  • 通道外拒绝的补形:绕过 bulk pass 的拒绝(例如readResource懒加载触发的派生)在 recordRefusal 中检测到bulkPassDepth === 0立即内联发射长度 1 的批次,保持事件负载形状一致。
  • 快照可见性契约lastRefusedServerNamesendBulkPass发射后不清空,只在下一个 bulk pass 开始时清空。这样两个发现通道之间调用GET /workspace/mcp的快照仍能报告上一轮的拒绝集合——否则拒绝批次事件送达后立即轮询的仪表盘会看到空列表。
  • 防御性排空flushRefusedBatch遇到“理论上不可达”的状态(非 enforce 模式却有 pending 拒绝)时排空而非泄漏到下一通道(flushRefusedBatch 实现)。

recordRefusal还带一道模式闸门:mode !== 'enforce'时直接返回——warn模式从不拒绝,该路径在语义上只属于enforce

状态与生命周期

  • 预算控制器在池初始化时每个 workspace 构造一次
  • clientBudget构造后不可变;运行时调整需要重建池子。
  • mode同样不可变(off模式下onEvent被存为undefined,作为纵深防御)。
  • warnArmed初始为true;通过 37.5% 下行越线重置回true
  • lastRefusedServerNames不在endBulkPass发射时清空——只在下一个 bulk pass 开始时清空,这是快照路由的可见性保证(见上节)。

配置方式:CLI 标志、环境变量与能力标签

来源旋钮效果
Flag--mcp-client-budget=N设置 workspace 控制器的clientBudget
Flag--mcp-budget-mode={off,warn,enforce}设置modeenforce要求正的clientBudget,否则启动显式失败。
EnvQWEN_SERVE_MCP_CLIENT_BUDGETQWEN_SERVE_MCP_BUDGET_MODEchildEnvOverrides转发给 ACP 子进程;子进程内readBudgetFromEnv()读取。
Capability tagsmcp_guardrails(恒有;modes: ['warn', 'enforce'])、mcp_guardrail_events(恒有)见 能力版本化文档。

各旋钮在源码中的落地:

CLI 标志。serve 命令定义 中:

  • --mcp-client-budget:正整数,约束“绑定 workspace 内 ACP 子进程派生的 live MCP 客户端数”。帮助文本特别提示:未设置时 mode 默认为off(纯观测,GET /workspace/mcp仍报告clientCount),且它与 claude-code 的MCP_SERVER_CONNECTION_BATCH_SIZE语义不同——后者限制的是启动并发度,而非客户端总数;
  • --mcp-budget-mode:可选值enforce | warn | off。帮助文本说明warn是“设置 budget 时的默认值”:不拒绝、快照在 ≥75% 时呈现 warning;enforce下超出上限的连接被拒绝(disabledReason: "budget",按mcpServers声明顺序确定性拒绝);off为纯观测。启动校验(serve.ts)会拒绝--mcp-client-budget非正整数的取值,并在无 budget 时拒绝--mcp-budget-mode=enforce

环境变量转发与严格解析。daemon 在构造 ACP 子进程环境时把 CLI 值写入QWEN_SERVE_MCP_CLIENT_BUDGET/QWEN_SERVE_MCP_BUDGET_MODE(run-qwen-serve.ts)。子进程内的 readBudgetFromEnv 做严格解析:

  • budget 只接受纯十进制数字串(/^\d+$/+isSafeInteger+> 0),0x101e21.0这类能通过宽松Number()的值一律视为未设置;
  • 非法取值不再静默吞掉——子进程会向 stderr 写一行 breadcrumb(如qwen serve: ignoring invalid QWEN_SERVE_MCP_CLIENT_BUDGET='abc'...),让容器日志/journald 中的误配置可见;
  • “mode 无 budget”组合(enforcewarnclientBudget === undefined)被降级为off,同样写 stderr——因为无阈值时没有任何告警条件可能成立,静默降级曾导致“Docker Compose 里设了 enforce、启动正常、快照却显示budgetMode: 'off'”的隐蔽故障。

能力标签。capabilities 注册表 中mcp_guardrails: { since: 'v1', modes: ['warn', 'enforce'] }恒定声明,与mcp_guardrail_events正交(前者是快照面,后者是事件面),客户端可据此判断是否消费对应字段/事件。

可观测性:SSE 事件与快照路由

事件面。SDK 类型定义中(sdk-typescript daemon events)两个 SSE 帧类型:

  • mcp_budget_warning— 上行越过 75% 时发射,负载含liveCountreservedCountbudgetthresholdRatiomode,以及scope?: 'workspace' | 'session'
  • mcp_child_refused_batch— 每个发现通道至多一次(或通道外长度 1 内联批次),负载含refusedServers[](每项{name, transport, reason: 'budget_exhausted'})、budgetliveCountreservedCountmode: 'enforce'与同样的scope字段。

scope: 'workspace'的语义是一次底层事件向该连接上所有已附着的会话 SSE 总线扇出(N* 扇出),SDK 的 reducer 中mcpBudgetWarningCount/mcpChildRefusedBatchCount会在同一连接的各会话上锁步递增——文档与 SDK 注释都提示 UI 消费者应按scope === 'workspace'判别是否需要除以会话数还原真实事件数。相比之下,per-session 遗留事件不带scope(语义上默认'session')。

快照面。daemon 的只读路由GET /workspace/mcp(路由注册)读取getReservedSlots()getRefusedServerNames()getReservedCount()getBudget()getMode()。预算状态单元由 buildBudgetCells 组装,判定逻辑是:

  • mode === 'off'→ 返回空数组,不暴露预算面;
  • refusedCount > 0status: 'error'errorKind: 'budget_exhausted',hint 为 “Raise --mcp-client-budget or remove servers from mcpServers config.”(与BudgetExhaustedError的消息一致);
  • liveCount >= 0.75 * budgetstatus: 'warning',提示当前 live 客户端数超过预算的 75%;
  • scope字段由能力驱动:pool 可用时选中的运行时发出scope: 'workspace';池被禁用或不可用时遗留 manager 发出scope: 'session'。快照暴露的是budgets[]数组而非单个budget?字段,属于前向兼容设计——消费者遇到未知的scope值应丢弃而不是报错。

相关单元测试覆盖了mcp_guardrails能力的modes元数据、kill switch 下的能力包变化,以及QWEN_SERVE_MCP_CLIENT_BUDGET的透传与清理(见 server.test.ts、acpAgent.test.ts),控制器本体的状态机测试在 mcp-workspace-budget.test.ts。

注意事项与已知限制

  • 预留键是服务器 NAME,不是指纹。两个池条目若同名但指纹不同(例如不同会话注入了不同的 OAuth header),它们共享一个槽位。预算应理解为“已配置服务器槽位”,而不是“子进程数”——子进程计账由池快照的subprocessCount单独暴露。
  • 滞回触发于预留数,而非 live(CONNECTED)数。预留包含进行中的连接且能在短暂断连后存活,因此滞回在重连周期内保持稳定。live 数在事件负载中以liveCount字段暴露,供需要该视角的 SDK 消费者使用。
  • warn模式从不拒绝。它仍追踪预留并发射mcp_budget_warning,但tryReserve永远返回'reserved'。拒绝语义是enforce专属。
  • workspace 作用域事件携带scope: 'workspace',同时扇出到所有已附着会话;同一连接上的各会话中,SDK reducer 的mcpBudgetWarningCount/mcpChildRefusedBatchCount锁步递增。per-session 遗留事件不带scope(语义上默认'session')。
  • kill switchQWEN_SERVE_NO_MCP_POOL=1完全禁用池子(acpAgent.ts 处检查),workspace 预算随之失效,per-sessionMcpClientManager预算接管;能力包会同步丢弃mcp_workspace_poolmcp_pool_restart以如实报告该状态。
  • ServeMcpBudgetStatusCell.scope由能力驱动且前向兼容。快照暴露budgets[]而非单个budget?字段;pool 可用时运行时发出scope: 'workspace',池被禁用时遗留 manager 发出scope: 'session';消费者必须容忍(丢弃)额外的未知scope值。

参考路径

  • packages/core/src/tools/mcp-workspace-budget.ts —WorkspaceMcpBudget全类实现(tryReserve/release/recordRefusal/beginBulkPass/endBulkPass/evaluateState);
  • packages/core/src/tools/mcp-client-manager.ts —BudgetExhaustedErrorMcpBudgetEventMcpRefusedServer、滞回常量与readBudgetFromEnv(类型与常量);
  • packages/core/src/tools/mcp-transport-pool.ts — 池子acquire中调用tryReserve并抛出BudgetExhaustedError的调用点,及派生失败回滚逻辑;
  • packages/core/src/tools/mcp-workspace-budget.test.ts — 控制器状态机测试;
  • packages/cli/src/commands/serve.ts —--mcp-client-budget/--mcp-budget-mode标志定义与启动校验;
  • packages/cli/src/acp-integration/acpAgent.ts —GET /workspace/mcp预算状态单元组装;
  • packages/sdk-typescript/src/daemon/events.ts — 两个 MCP guardrail SSE 事件的 SDK 类型与 workspace 扇出说明;
  • F2 设计文档:docs/design/f2-mcp-transport-pool.md §11(workspace 级预算与 v2.2 变更日志中预算/指纹后续条目);
  • 相关文档:05-mcp-transport-pool.md、11-capabilities-versioning.md。

【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询