关于 luftpause
luftpause 是面向 Agent harness、共享推理引擎的调度运行时。它优先恢复交互式运行、跟踪工具调用间的运行状态,并提供 OpenAI 兼容代理和 MCP 运行时。
主要功能
- 01优先调度已暂停后恢复的运行
- 02在不同调用方间公平调度
- 03依据 KV 缓存放置运行
- 04OpenAI 兼容代理
- 05持久化 MCP 子 Agent 运行
适用场景
- 01批处理负载下保持交互式 Agent 响应
- 02通过 MCP 并行运行子 Agent
- 03运行时重启后恢复已启动的运行
MCP 相关
其 MCP 运行时提供 spawn、await、run、status、cancel、approve、tools 和 decide 等子 Agent 控制工具。子 Agent 运行会写入日志并可在重启后恢复;工具调用可使用已配置的上游 MCP 服务。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
docker run -i --rm -e Runtime__Model -e DataPlane__Url -e LUFTPAUSE_TOKEN ghcr.io/luftpause/runtime:v0.1.4Runtime__ModelModel the sub-agents run on, as served by an engine behind the luftpause dataplaneDataPlane__UrlURL of the luftpause dataplane that places each turn on the engine poolLUFTPAUSE_TOKEN密钥Bearer token required on the MCP endpoint and sent to the dataplane; unset leaves both open本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
ghcr.io/luftpause/runtime:v0.1.4Streamable HTTP工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
luftpause
位于 Agent 框架之下的运行时层。每次工具调用都是一次 await;推理引擎会忘记这次运行,而 luftpause 会将其保留下来。
工具调用会把 Agent 拆分成多个轮次。对于推理引擎来说,每一轮都是来自未知对象的新请求:工具返回后,后续执行会排在等待期间到达的所有批处理请求之后;而且它的 KV 缓存可能已经消失。 luftpause 位于框架与推理引擎之间,让这次运行得以延续:
- 后续执行优先。 因工具调用而暂停的运行会优先于批处理工作恢复;批处理轮次会为之后返回的交互式运行留出空间。
- 运行会留在拥有其前缀的位置。 运行正在生成内容或因工具而暂停时,其轮次只会发送到持有其 KV 缓存的副本;新轮次则由引擎自己的队列接纳。
- 一个调用方不会饿死另一个调用方。 暂存的轮次会在各调用方之间公平地处理。
- 适用于任何框架,两种接入方式。 将
base_url指向它(兼容 OpenAI,或使用你自己的循环),或者通过 MCP 连接,使用可在重启后继续运行的子 Agentspawn/await。
在一台配备 8 GB GPU 的笔记本上测得,引擎处于饱和状态(48 个批处理运行持续涌入,旁边有 4 个交互式运行):
| 吞吐量 | 延迟 | 代价 | |
|---|---|---|---|
| 使用各自循环的 Agent,经 luftpause 与直接连接 SGLang 路由器相比 | 2.0× 交互式(12.7 对 6.4 次运行/分钟) | 交互式 p50 为 19 秒,而非 36 秒 | 批处理 −9 % |
| 通过 MCP 运行的子 Agent,luftpause 与运行时直接连接路由器相比 | 2.7× 交互式(8.0 对 3.0 次运行/分钟) | 交互式 p50 为 27 秒,而非 58 秒 | 批处理不变 |
| 扇出:16 个子 Agent 与逐个运行相比 | 5.3×(全部 16 个耗时 23 秒,而非 120 秒) | 每个子 Agent 的 p50 为 15–22 秒,与通过路由器相同 | 无:仅路由器就需要 21.5 秒 |
| Claude Code 作为客户端,查询六次 Wikipedia | 2.9×(耗时 12.5 秒,而非不加提示、连续执行的 36 秒) | 70B 子 Agent:一轮完成全部六次查询,耗时 9.3 秒 | 0 次工具调用失败,0 次重新生成 |
试用
docker compose up -d # fake engines, no GPU needed
claude mcp add --transport http --scope local luftpause http://localhost:7000/mcp
docker compose up -d 会拉取已发布的镜像;添加 --build 可改为从源代码构建(首次构建需要几分钟)。该命令会拉取 latest;使用 LUFTPAUSE_VERSION=v0.1.4 docker compose up -d 固定版本。该运行时也已列入官方 MCP Registry,名称为 io.github.vmedvedovskiy/luftpause。
Claude Code 现在提供 spawn、await、run、status、cancel、approve、tools 和 decide。模拟引擎会返回填充内容;子 Agent 只能使用 fake-mcp 的 lookup 和 deep_search 工具,这些工具会等待大约一秒的随机时间,并同样返回填充内容——足以展示扇出效果。若要使用已在本机运行的 Ollama 获取真实答案,并让子 Agent 使用 Wikipedia 而非模拟工具:
docker compose -f compose.yaml -f compose.ollama.yaml up -d scheduler dataplane runtime
Wikipedia 的 API 会对匿名调用方进行限流,因此 wikipedia-mcp 重试时,一次工具调用可能需要几十秒。
Llama 3.2 1B 有时会传入工具的 schema 而不是参数;运行时会拒绝此调用,子 Agent 可能会在不调用工具的情况下作答,因此请再次提问或拉取更大的模型。
Ollama 需要已拉取模型(运行 ollama pull llama3.2:1b,或设置 OLLAMA_MODEL),
并且必须监听环回地址以外的地址(设置 OLLAMA_HOST=0.0.0.0),除非 Docker Desktop 已能连接到它(在 Windows 和 macOS 上,使用默认的环回绑定即可)。Ollama 一次只处理一个请求,除非提高 OLLAMA_NUM_PARALLEL,因此子 Agent 会在此处彼此排队;luftpause 无法获取它的队列指标,也不会限制它。你也可以使用 GPU 上的 SGLang(compose.testbed.yaml),或任何兼容 OpenAI 的端点(compose.runpod.yaml 展示了一个托管的 vLLM Pod),效果相同。
与其他方案的区别
路由器会将请求分配到 GPU 池;MCP 服务则会把子任务委派给本地模型。它们都不知道一次运行是什么。luftpause 则知道:无论请求通过哪种入口,它都能看到每次运行中的每一轮对话和每次工具调用,并将它们调度到一个共享池中,同时掌握引擎永远不具备的三个信息:暂停的运行会恢复、任务所属的类别,以及请求者是谁。
性能提升来自准入控制,而非路由。仅靠路由时,luftpause 与 SGLang 的缓存感知路由器(见下文)打平;引擎空闲时,各方案也都打平。只有引擎达到饱和时,差异才会显现,而这正是关键时刻。
两种接入方式
- 兼容 OpenAI 的代理 — 保留自身循环的 Agent 将
base_url指向 luftpause。它会为每次运行发送x-luftpause-run-id,并可选地发送x-luftpause-class和x-luftpause-caller;随后,其循环中的每次工具调用都会成为一次经调度的等待。 - MCP 运行时 — Claude Code 等 Agent 通过 MCP 连接,并获得
spawn/await。luftpause 会通过同一个代理运行每个子 Agent 的循环:在池上执行大模型对话轮次,将工具调用发送到上游 MCP 服务,以 MCP 会话作为请求者,并使用日志记录确保运行不会随进程退出而结束。
examples/ 中有三个保留自身循环、并设置了全部三个标头的 Agent:openai
包、OpenAI Agents SDK 和 LangGraph。
可连接的客户端
无法为每次运行设置标头的框架仍可通过代理使用:代理会根据其回传的 tool_call_id 识别运行,而固定的
x-luftpause-caller 足以实现公平调度。
| 工具 | MCP 接入 | 代理接入 |
|---|---|---|
| Claude Code | claude mcp add --transport http luftpause http://localhost:7000/mcp | 不适用:使用 Anthropic Messages API,而非 Chat Completions |
| Codex CLI | ~/.codex/config.toml:[mcp_servers.luftpause] url = "http://localhost:7000/mcp" | 不适用:wire_api 仅接受 responses;该代理提供 Chat Completions |
| Gemini CLI | gemini mcp add --transport http luftpause http://localhost:7000/mcp,或 .gemini/settings.json:{"mcpServers": {"luftpause": {"httpUrl": "http://localhost:7000/mcp"}}} | 不适用:使用 Gemini API(未经验证) |
| Goose | goose session --with-streamable-http-extension "http://localhost:7000/mcp",或 goose configure → Add Extension → Remote Extension (Streamable HTTP) | GOOSE_PROVIDER=openai OPENAI_HOST=http://localhost:8080 OPENAI_API_KEY=x OPENAI_CUSTOM_HEADERS="x-luftpause-caller=goose" |
| Cursor | ~/.cursor/mcp.json:{"mcpServers": {"luftpause": {"url": "http://localhost:7000/mcp"}}} | 不适用:“Override OpenAI Base URL”由 Cursor 的服务器发起,这些服务器无法访问 localhost(未经验证) |
| Zed | settings.json:"context_servers": {"luftpause": {"url": "http://localhost:7000/mcp"}} | settings.json:"language_models": {"openai_compatible": {"luftpause": {"api_url": "http://localhost:8080/v1", "available_models": [{"name": "<model>", "max_tokens": 128000}], "custom_headers": {"x-luftpause-caller": "zed"}}}} |
| OpenHands | Settings → MCP Servers → 添加 http://localhost:7000/mcp | Settings → LLM → Advanced:Custom Model openai/<model>,Base URL http://localhost:8080/v1,API 密钥可任意填写;不支持设置标头 |
| Cline / Roo Code | Cline cline_mcp_settings.json:{"mcpServers": {"luftpause": {"type": "streamableHttp", "url": "http://localhost:7000/mcp"}}};Roo .roo/mcp.json:相同配置,但使用 "type": "streamable-http" | Provider 选择“OpenAI Compatible”,Base URL 填写 http://localhost:8080/v1,API 密钥可任意填写;在 Custom Headers 下添加 x-luftpause-caller(未经验证) |
架构
%%{init: {"themeVariables": {"fontFamily": "-apple-system, Segoe UI, Helvetica, Arial, sans-serif"}, "flowchart": {"wrappingWidth": 260}}}%%
flowchart LR
mcp["Claude Code<br/>or any MCP client"]
own["Agents with<br/>their own loop"]
subgraph lp["luftpause"]
runtime["<b>runtime</b> · .NET<br/>spawn · await · journal<br/>sub-agent loops"]
dp["<b>dataplane</b> · Rust<br/>OpenAI-compatible proxy"]
sched["<b>scheduler</b> · .NET<br/>decide(state, event)"]
end
tools[("MCP tool<br/>servers")]
subgraph pool["Engine pool"]
direction TB
engines["SGLang · vLLM"]
router["SGLang router"]
hosted["Hosted OpenAI-<br/>compatible APIs"]
end
mcp -- MCP --> runtime
own -- "HTTP / SSE" --> dp
runtime -- "tool calls" --> tools
runtime -- "LLM turns" --> dp
dp -- "turns" --> pool
sched <-- "Admit · Report · run ended" --> dp
sched -. "health · load" .-> pool
- runtime (
runtime/) — MCP 服务。每个启动的运行都是一个执行 Agent 循环的 .NETTask;工具调用会发送到已配置的 MCP 服务。每次运行都会记录到runs.jsonl,并在重启后恢复;运行结束后,会向数据平面报告(DELETE /v1/runs/{id}),调度器随即将其遗忘。 - dataplane (
dataplane/crates/dataplane) — tokio/axum 代理。解析运行(通过标头或工具调用 ID),读取类别和调用方标头,询问调度器该轮应由何处处理,以原样流式传输引擎响应,并报告首个 token、用量和结束原因。 - scheduler (
scheduler/) — 决策核心是基于不可变状态的纯函数(Luftpause.Scheduling);gRPC 主机负责执行操作。 - 测试套件 —
fake-engine(OpenAI +/classify,带模拟延迟)、fake-mcp(提供lookup和较慢的deep_search工具)、tools/loadgen(Agent、通过 MCP 扇出的子 Agent、闭环吞吐量测试)。
功能
| 能力 | 工作方式 |
|---|---|
通过 MCP 执行 spawn / await | spawn(task, model?, serviceClass?, system?, maxToolCalls?, tools?, maxTokens?, parallelToolCalls?, parent?, budgetTokens?, budgetToolCalls?, budgetSeconds?, approveTools?) 会立即返回一个句柄;await(handles, all|any, timeout) 会收集结果,其中包含 toolCalls / failedToolCalls;还提供 tools、status、cancel 和 decide(input, labels?, model?):不传 labels 时使用单次推理模型的标签;传入 2 至 20 个 labels 时,生成式模型会执行一次 max_tokens: 1 的推理,并按首个 token 的 logprobs 评分,返回 label、probabilities 和 unscored |
| 持久化运行 | 每次运行都会写入日志(Runtime:StateDir 下的 runs.jsonl):启动、每轮工具调用的消息以及结束状态;重启时,运行时会重放日志,为已完成的运行响应 await,并从最后一个完整工具轮次恢复未完成的运行 |
| 运行树 | spawn(parent) 会将运行设为子运行;status 会列出 parent 和 children;取消父运行时,cancel 会取消其所有正在运行的后代;运行时的 GET /status 会返回运行树,其中包含每个运行自身的工作情况及其子树的汇总数据(轮次、工具调用、失败的工具调用、提示词和补全 token、轮次和工具耗时) |
| 预算 | spawn(budgetTokens?, budgetToolCalls?, budgetSeconds?) 会限制一个运行的整个子树(包括该运行及其下启动的所有运行,也包括之后启动的运行)的提示词加补全 token 数、工具调用数和实际耗时;每当预算运行之下的任意位置开始新一轮或新一轮工具调用时都会检查,秒数预算还会由计时器检查,因此空闲子树也会触发限制;超过限制后,子树会被取消,并显示 budget exceeded: <which> <used>/<limit>;status / await 会显示 budget,并包含 limits、used 和 exceeded |
| 工具审批 | 当服务器将工具标记为 destructiveHint: true(且未标记 readOnlyHint: true),或 spawn(approveTools) 指定了该工具时,子 Agent 的工具调用会等待运行器用户审批;运行状态会显示为 AwaitingApproval,并包含 pendingApproval {tool, arguments}。如果客户端声明支持 elicitation,且其 await 或 run 涵盖该运行,就会收到询问,其中包含一个字段的表单(allow):在有会话时作为 elicitation 请求;在无会话的 2026-07-28 请求中作为需要输入的结果。其他客户端可通过 approve(handle, allow) 作答。若调用被拒绝,子 Agent 会收到 Error: the user declined this tool call。审批决定会写入日志:重启前已作答的不会再次询问,尚未作答的则会再次询问。客户端:Claude Code 2.1.218 在终端中会于 await 期间显示提示,回答也会传给运行(已测试);Claude 桌面应用的 Code 标签页在任何权限模式下都会不显示提示而直接拒绝,因此在该处运行会收到被拒绝的工具调用;Python mcp 2.3 客户端在模拟环境中通过两种方式都收到过询问;任何客户端都可以使用 approve |
| MCP Tasks | run(task, ...) 接受与 spawn 相同的参数,并在同一次调用中等待运行结束。选择启用 Tasks 扩展(io.modelcontextprotocol/tasks,协议版本 2026-07-28)的客户端会改为获取一个任务:通过轮询 tasks/get 获取状态;运行中的审批会显示为 input_required,并附带 elicitation 输入请求;客户端通过 tasks/update 作答。Tasks 会与 runs.jsonl 一同写入 tasks.jsonl;运行时停止时仍处于打开状态的任务会在重启时失败,其运行则照常恢复。其他客户端会将 run 作为一次阻塞调用执行。客户端:C# SDK 2.2 客户端(已在进程内测试);Claude Code 2.1.218 不会选择启用 Tasks,因此对它而言,run 是阻塞调用 |
| 状态 | 调度器在 :5001 上的 GET /status(HTTP/1;gRPC 仍使用 :5000)会返回各副本的运行中轮次数和容量、按类别和调用方统计的等待轮次及最早等待时间,以及按阶段和类别统计的运行;该端口上的 GET / 会返回每 2 秒轮询一次状态的页面,添加 ?runtime=http://host:7000 后还会显示该运行时的运行树和子树开销 |
| 调用方公平性 | 每轮调用都会携带 x-luftpause-caller(子 Agent 使用 MCP 会话,自己的循环 Agent 可使用任意字符串);调度等待中的轮次时,会先处理 interactive,再处理当前运行中轮次数最少的调用方,因此某个调用方的请求洪流不会使其他调用方挨饿 |
| 不占用轮次的工具调用 | 运行等待工具调用时不会占用轮次槽位;工具调用由运行时通过 HTTP 或 stdio 对 MCP 服务器执行;使用 spawn(parallelToolCalls: true) 时,模型可以在单次回复中请求多个工具调用,这些调用会同时运行(默认关闭:Llama 3.x 每次回复只进行一次调用) |
| 按引擎负载准入 | 引擎报告没有等待队列(SGLang 和 vLLM /metrics)时会接收轮次;一旦出现排队,luftpause 就会将其轮次数限制为引擎当前运行的数量,并暂存其余轮次;不会报告负载的引擎不受限制 |
| 严格的运行亲和性 | 正在生成或因工具调用而暂停的运行,只会被分配到处理其上一轮的副本(该副本已缓存其前缀),即使其他副本有空余容量也不例外;副本不健康或处于空闲状态的运行可以迁移。下文的测量结果表明,交互性能的提升正是来自这一机制;此外,基于 KV 内存的估算会降低吞吐量,因此已移除 |
| 服务类别 | 批处理轮次会为引擎上每个因工具调用而暂停的交互式运行留出空间,因为这些运行之后会继续执行;luftpause 暂存的轮次会先按 interactive 处理,再按调用方公平性排序,最后按到达顺序处理,并等待分配或客户端断开连接 |
| 将路由器用作后端 | 副本可以是路由器(例如 SGLang 路由器),由它选择引擎;其负载会根据其后端引擎读取(LoadFrom) |
| 按模型路由 | 端点会声明其提供的模型;轮次只会发送到提供对应模型的副本;未知模型会立即收到 404 |
| 托管端点 | 可将任何兼容 OpenAI 的 API(RunPod 上的 vLLM、OpenAI、OpenRouter,……)用作副本:API 密钥从环境变量读取,每个端点可单独指定健康检查路径,并支持 https |
| 调度器重启 | 数据平面会保存每次运行的上一个副本、阶段、类别和调用方(空闲运行会在 30 min 后清除),以及所有正在执行的轮次;启动时及每次重新连接后,都会在重新打开事件流之前将这些信息发送给调度器(Resync),因此调度器重启后,亲和性、暂停的交互式运行预留容量以及容量计数都能保留;中断期间暂存的轮次会在调度器恢复后,以相同的轮次 ID 重新准入 |
| 健康状态 | 每 2 s 探测一次 /health(或每个端点单独指定的路径);不健康的副本不会收到轮次 |
| 工具调用规范性 | 参数会转换为架构指定的标量类型并进行验证;不符合要求的调用会按预期形状拒绝,而不会静默地执行错误调用;以文本形式发出的调用会被恢复 |
| 运行标识 | x-luftpause-run-id,或 Agent 回传的 tool_call_id |
| 单次推理引擎 | 独立副本上的 POST /v1/decide(vLLM /classify 格式)不涉及运行状态;带调用方指定 labels 的 decide 无需第二个模型,并会作为一次交互式轮次在生成式副本上运行 |
| 追踪 | 设置 OTEL_EXPORTER_OTLP_ENDPOINT 后,每个服务都会通过 OTLP 导出 OpenTelemetry span(采用 GenAI 约定):运行时为每次运行创建一个 invoke_agent span(作为父运行的子项,或作为调用 spawn 的 MCP 请求所含 traceparent 的子项),为每次模型轮次创建一个包含 token 和结束原因的 chat span,并为每次工具调用创建一个 execute_tool span;数据平面会为每个代理轮次创建一个 span;调度器会创建一个包含 luftpause.replica 和 luftpause.held_ms 的 admit span;W3C traceparent 会传递给数据平面、调度器、引擎和 HTTP 工具服务器,轮次事件也会携带追踪 ID;未设置该变量时不会导出任何内容 |
| 身份验证 | 设置 LUFTPAUSE_TOKEN 后,数据平面的所有 /v1 路由以及运行时的 /mcp 和 GET /status 都需要 Authorization: Bearer <token>(使用恒定时间比较),否则会返回带 JSON 错误的 401;运行时会向数据平面发送相同的 Bearer token;客户端的 Authorization 不会向上游转发,引擎会使用为其配置的密钥;未设置该变量时,两个服务都会记录警告并接受所有请求 |
失败语义
当数据平面中的一轮对话(/v1/chat/completions、/v1/decide)发生故障时会发生什么。错误响应体为 {"error": {"message": "..."}};除调度器不可达的情况外,每个响应都会携带 x-luftpause-run-id。失败的一轮会立即释放其槽位,并使其运行保持空闲状态,而不会暂停在某个工具上,因此重试会像该运行的任何下一轮对话一样获得准入。使用相同的 x-luftpause-run-id 重试会保留运行的亲和性和公平性排序。
| 情况 | 客户端看到的内容 | luftpause 的处理方式 | 自有循环 Agent 应采取的操作 |
|---|---|---|---|
| 客户端在其轮次等待期间断开连接 | 无(客户端已离开) | 取消调度器调用;该轮次离开等待队列(TurnAbandoned),不会发出引擎请求;如果当时正在路由该轮次,则将其报告为失败并释放其槽位 | 如果客户端侧超时导致放弃,请使用相同的运行 ID 再次发送该轮次:它从未运行 |
| 客户端在流式传输过程中断开连接 | 无(客户端已离开) | 关闭引擎请求,从而停止生成;将该轮次报告为失败 | 使用相同的运行 ID 再次发送该轮次;部分回复不会保存在任何地方 |
| 引擎不可达 | 502,engine <replica>: <error>;连接被拒绝时立即返回,对于无响应的主机,则在操作系统连接超时后返回 | 将该轮次报告为已开始并失败;副本会继续参与轮询,直到其健康探测失败(周期为 2 秒) | 短暂等待后使用相同的运行 ID 重试;在探测将副本标记为不可用之前,重试可能会发送到同一副本 |
| 引擎在流式传输过程中失败 | 200、已发送的分块,然后连接关闭,不会发送最后一个分块或 [DONE] | 将该轮次报告为失败 | 将回复视为丢失并丢弃,使用相同的运行 ID 重试完整轮次 |
| 引擎立即拒绝(429、5xx) | 引擎的状态码和响应体原样返回,并带有其 Retry-After | 将该轮次报告为已开始并失败 | 对于 429,等待 Retry-After 指定的时间(否则等待约 1 秒),然后使用相同的运行 ID 重试;对于 5xx,采用退避策略重试几次,然后放弃 |
| 未知模型 | 404,no Generative replica serves model '<model>' | 在入队前拒绝;不发出引擎请求,也不记录轮次 | 放弃:修正模型名称或端点配置;重试会得到相同的结果 |
| 调度器不可达或正在重启 | 无:该轮次会一直等待,如同已排队,直到调度器响应或客户端断开连接 | 每秒使用相同的轮次 ID 重试准入;重新连接时,在再次准入前重新同步运行和正在处理的轮次;在此之前不会发出引擎请求 | 无需操作;短于故障持续时间的客户端侧超时与任何等待中的轮次相同:使用相同的运行 ID 重试 |
常量
luftpause 使用的固定数值很少。它没有队列截止时间,也没有轮次上限:挂起的轮次会像在引擎自己的队列中一样等待,运行会在工具预算耗尽时结束。其余常量如下:
| 常量 | 值 | 作用 | 位置 |
|---|---|---|---|
| 回复长度 | 每轮 256 个 token,通过 spawn(maxTokens) 为每次运行设置,0 表示交由引擎决定 | SGLang 会为请求可能生成的最长回复预留内存;如果不设上限,批处理的请求数会减少到原来的七分之一(测试环境中为每分钟 6 次运行,而非 42 次),失控的回复也会破坏 Llama 每次回复仅调用一次的格式 | runtime/…/LuftpauseTools.cs |
| 工具调用预算 | 每次运行 8 次,通过 spawn(maxToolCalls) 为每次运行设置 | 预算耗尽后,不再向模型提供工具,模型必须作答;这也将每次运行的轮次数限制在预算 + 1 | runtime/…/LuftpauseTools.cs |
await 超时 | 默认 60 s,最多 600 s | 单次 await 调用等待运行当前状态后返回的时长;60 s 不会超过 MCP 客户端自身的单请求限制(Claude Code 的 HTTP 限制为 60 s) | runtime/…/LuftpauseTools.cs |
| 轮次超时 | 10 min | 如果某轮始终没有报告完成(数据平面在轮次中途崩溃),则会丢弃该轮并释放其槽位 | scheduler/…/Expiry.cs |
| 运行空闲过期时间 | 30 min | 运行在这段时间内没有轮次,就会被遗忘;其下一个轮次开始时不再保持亲和性。运行时会报告其子 Agent 的结束状态;有自己循环的 Agent 永远不会说自己已完成,因此需要为其状态指定结束时间 | scheduler/…/Expiry.cs |
| 探测周期 | 2 s | 每隔此时长读取一次引擎健康状态和负载(/metrics);准入会在此延迟后跟随引擎负载变化;这也是探测超时时间 | scheduler/…/Probing.cs |
| 时钟滴答间隔 | 1 s | 驱动上述两种过期机制 | scheduler/…/Clock.cs |
| 运行时 → 数据平面超时 | 10 min | 单个轮次允许耗费的最长时间(包括队列等待),超过后运行失败;与 OpenAI 和 Anthropic 客户端的默认值相同 | runtime/…/Program.cs |
| 429 重试延迟 | 响应中的 Retry-After,否则为 1 s | 如果引擎以 429 拒绝某个轮次(托管 API 的速率限制),运行时会持续重试,直到运行被取消 | runtime/…/LlmClient.cs |
| 引擎连接超时 | 2 s | 超过此时长后,数据平面会放弃连接引擎 | dataplane/…/main.rs |
| 报告重连间隔 | 1 s | 暂停此时长后,数据平面会将事件流重新连接到调度器 | dataplane/…/report.rs |
| 工具调用 ID 记忆时长 | 30 min,每 60 s 清理一次 | 数据平面根据 Agent 回传的 tool_call_id 识别运行的时长(适用于不发送运行标头的 Agent) | dataplane/…/run_identity.rs |
| 运行日志 | Runtime:StateDir,默认为 state,容器中为 /var/lib/luftpause | runs.jsonl 所在位置;这是运行时在重启之间保留的唯一状态 | runtime/…/Program.cs |
| 调用方标头 | x-luftpause-caller,截断至 128 字节 | 标识调用方以实现公平性;未提供时,所有调用方共用一个匿名调用方 | dataplane/…/proxy.rs |
运行
docker compose up -d --build # scheduler, dataplane, runtime, fake engines, fake MCP tools
dotnet run --project tools/loadgen -- --spawn 10 # fan 10 sub-agents out over MCP and await them
dotnet run --project tools/loadgen -- --agents 8 --duration-s 60 --class batch --caller me # 8 own-loop agents through the proxy, closed loop
tools/check.sh # both .NET test projects, loadgen, cargo test (when cargo is installed), image builds
发布:tools/release.sh vX.Y.Z 会构建、标记并将三个镜像推送到 ghcr.io/luftpause(可通过 LUFTPAUSE_REGISTRY 覆盖),并创建 git 标签;DRY_RUN=1 只会打印命令。
使用令牌:运行 LUFTPAUSE_TOKEN=<token> docker compose up -d,然后运行 claude mcp add --transport http luftpause http://localhost:7000/mcp --header "Authorization: Bearer <token>"。
将主机上的 Ollama 作为唯一副本(模型通过 OLLAMA_MODEL 设置,默认为 llama3.2:1b):
docker compose -f compose.yaml -f compose.ollama.yaml up -d scheduler dataplane runtime
在单个 GPU 上运行两个 Llama-3.2-1B FP8 的 SGLang 副本(下文使用的测试环境):
docker compose -f compose.yaml -f compose.testbed.yaml up -d --build scheduler dataplane runtime fake-mcp sglang-a sglang-b
在 Jaeger 中查看跟踪:添加 compose.otel.yaml,它会启动 jaegertracing/all-in-one,并将三个服务的 OTEL_EXPORTER_OTLP_ENDPOINT 指向它;发送 MCP 调用时带上 traceparent 的测试工具可在自身跟踪中看到其子 Agent、这些 Agent 的轮次以及调度器的准入情况。UI 位于 http://localhost:16686。
docker compose -f compose.yaml -f compose.otel.yaml up -d --build
将托管的 vLLM Pod 作为额外副本(密钥和 Pod ID 从环境中获取):
RUNPOD_POD_ID=<pod-id> RUNPOD_KEY=<key> docker compose -f compose.yaml -f compose.testbed.yaml -f compose.runpod.yaml up -d scheduler runtime
测量结果
一台笔记本电脑,RTX 4060 Laptop 8 GB,两份 Llama-3.2-1B-Instruct FP8 的 SGLang 副本,除非另有说明。每张表中均注明重复次数;差异落在波动范围内时可视为噪声。压力测试表来自 tools/bench/flood.sh(通过 MCP 调度子 Agent)和 tools/bench/proxy-flood.sh(自行循环的 Agent);两者均将待比较的 git 修订版作为测试组,并分别构建为各自的镜像标签。
扇出:spawn × 16 + await all 与一次运行一个子 Agent 的对比(重复 3 次)
16 个子 Agent,任务约 2.8k 个 token,lookup 工具固定耗时 2 秒,每个最多调用 3 次。
| 全部 16 个的耗时 | 相较于顺序执行 | |
|---|---|---|
| 顺序执行(spawn、await、下一个) | 120.5 秒 | — |
| luftpause 扇出 | 22.9 秒 | 5.3× |
| 直接扇出到 SGLang 路由器 | 21.5 秒 | 5.6× |
每个子 Agent 的耗时方面,扇出和路由器相同(p50 为 15–22 秒);1.4 秒的差异处于每次运行的波动范围内。
批量压力测试期间的交互式运行(重复 2 次,均列出)
48 次批量运行对副本施加压力,同时运行 4 次交互式运行,每个时间窗口 3 分钟,上下文为 8000 个字符,每次运行最多调用 3 次工具,采用闭环模式(一项运行结束后立即补上新的运行)。在不启用调度器的测试组中,两种引擎都会对请求排队,因此资源池已饱和。每一行中 SGLang 均使用 --enable-priority-scheduling 运行;未发送优先级。
使用各自循环的 Agent,base_url 指向 luftpause(tools/loadgen --agents):
| 交互式运行次数/分钟 | 交互式运行耗时 p50 | 批量运行次数/分钟 | |
|---|---|---|---|
| 直接连接到 SGLang 路由器,不区分类别 | 6.0 / 6.7 | 38.5 秒 / 33.6 秒 | 83.7 / 82.3 |
| luftpause,引擎负载准入,运行可自由切换副本(0afb28a) | 8.7 / 9.0 | 23.2 秒 / 25.9 秒 | 79.3 / 79.0 |
| luftpause,引擎负载准入,并严格保持运行与副本的亲和性 | 12.0 / 13.3 | 18.5 秒 / 19.8 秒 | 75.7 / 76.0 |
| luftpause,额外估算 KV 内存(已移除) | 13.0 / 10.7 | 17.8 秒 / 20.8 秒 | 76.3 / 76.0 |
通过 MCP 调度的子 Agent(tools/loadgen --spawn):
| 交互式运行次数/分钟 | 交互式运行耗时 p50 | 批量运行次数/分钟 | |
|---|---|---|---|
| 运行时直接连接到 SGLang 路由器 | 3.0 / 3.0 | 56.6 秒 / 59.5 秒 | 39.2 / 36.5 |
| luftpause,引擎负载准入,运行可自由切换副本(0afb28a) | 4.0 / 5.0,4.3 / 5.6 | 44.4 秒 / 36.7 秒 | 35.5 / 36.6 |
| luftpause,引擎负载准入,并严格保持运行与副本的亲和性 | 8.0 / 8.0(基准测试分支上为 8.0 / 8.0、7.3 / 9.3) | 29.2 秒 / 25.2 秒 | 36.2 / 38.5 |
| luftpause,额外估算 KV 内存(已移除) | 6.3 / 6.6,6.0 / 7.3 | 29.7 秒 / 35.4 秒 | 35.5 / 35.5 |
通过代理时,交互式吞吐量是不区分类别的路由器方案的 2×;通过 MCP 时为 2.7×。批量吞吐量通过代理时下降 9%,通过 MCP 时则没有下降。各行之间的差异仅在准入策略。允许运行在副本间切换,导致 48 次批量运行中有 39 次在运行中途切换,并丢失其前缀缓存;将生成中或暂停的运行固定到其所在副本,就能获得全部增益。在此基础上额外估算每轮的 KV 内存,并与引擎报告的资源池数据进行核对后发现,通过 MCP 时交互式吞吐量约降低五分之一,通过代理时则没有影响,因此移除了这项估算。在负载较轻的情况下(上下文为 2000 个字符,24 次批量运行),引擎从不排队,所有行的结果都持平,约为每分钟 14 次交互式运行。
将 Claude Code 用作客户端
要求查找六条河流的信息时,Claude Code 启动了六个任务,并在一次调用中等待它们完成:整个并行任务耗时 12.5 秒(包括自身的往返时间),而按顺序逐个运行相同任务则需要 36 秒。
在一个全新会话中,仅要求它“使用本地子 Agent 研究这六条河流”时,它列出了子 Agent 工具,将任务限定为 Wikipedia 工具,启动六个任务,在一次调用中等待它们完成,并重新启动了答案不够好的任务。工具:wikipedia-mcp,通过 stdio 使用。
| 子 Agent | Claude 重新启动的任务数 | 被拒绝的工具调用 | 结果 |
|---|---|---|---|
| 本地 1B,参数强制转换前 | 4 | 38 次,共 46 次 | 答案大多来自模型记忆 |
| 本地 3B,启用参数强制转换 | 2 | 0 次,共 20 次 | 长度和河口正确,所在国家列表不可靠 |
| RunPod vLLM Pod 上的 Llama 3.3 70B AWQ | 0 | 0 次,共 6 次 | 六条河流的信息均来自 Wikipedia,一轮完成(9.3 秒) |
70B Pod 与本地 3B 副本位于同一个池中:请求使用 llama-3.3-70b 的轮次会通过 https 发送到该 Pod,并使用其 API 密钥;其他请求则留在本地。await 中的 failedToolCalls 会告知调用方答案是否并非来自工具。
路由(N = 8 个子 Agent 正在运行,重复 4 次)
| 每分钟运行数 | 来自缓存的提示词 Token | |
|---|---|---|
| luftpause 亲和性路由 | 30.1 ± 1.7 | 44.6 % |
SGLang 路由器,cache_aware | 30.1 ± 0.5 | 44.6 % |
SGLang 路由器,round_robin | 28.2 ± 0.5 | 25.7 % |
亲和性路由的效果与 SGLang 路由器的缓存感知路由相当,在中等负载下比轮询路由快 7–15 %;当有 16–24 个运行中的任务时,缓存会频繁更替,路由策略便不再重要。
单次推理(vLLM 分类器,RoBERTa-base)
luftpause 在同一个池中提供两类模型。生成式引擎(SGLang、vLLM、托管 API)负责运行 Agent 的各轮对话:它逐个解码 Token,为每个任务保留 KV 缓存;一轮对话可能以工具调用结束,从而暂停任务。单次推理引擎通过一次前向传播给出答案:编码器或分类器(RoBERTa 风格,由 vLLM 的 /classify 提供服务)会返回带概率的类型化标签,无需解码、没有数据流,也不会在调用之间保留状态。Agent 用它处理不需要生成回复的决策,例如路由请求、评估工具结果或选择下一步;耗时只有几毫秒,而非一次生成式对话轮次。子 Agent 通过 decide MCP 工具访问它;自有循环的 Agent 则通过 POST /v1/decide 访问它。单次推理轮次会分配到独立副本上,且不会进入任务状态,因此既不会占用也不会等待生成式引擎的槽位。如果分类器没有针对某组标签进行训练,decide(labels: [...]) 会改用生成式副本的首 Token 对数概率为这些标签打分,代价是进行一次仅生成一个 Token 的轮次。在 1B 测试环境中,它会将“服务器连续三次返回 500,值班电话响了”归为 urgent,置信度为 0.89;将人口问题归为 lookup,置信度为 0.99;但也会将“2 + 2”和“谢谢,没别的问题了”归为 lookup:机制本身可用,但 1B 模型倾向于选择第一个标签。请使用更大的模型,或校准分数。
| 负载 | 数据平面的延迟 |
|---|---|
| 一次一个调用 | p50 20.6 ms |
| 20 个并发 Agent | p50 85 ms,p99 180 ms |
许可证
Business Source License 1.1,每个版本发布四年后转为 Apache License 2.0。实际而言:你可以免费使用、修改、发布,也可以将其集成到自己的产品或公司中并重新分发;自己的代码不受任何著佐权约束。在许可证转换之前,你唯一不能做的事,是将 luftpause 本身作为托管或管理式服务提供给第三方。因此,它是源代码可用的软件,并非 OSI 开源软件,我们对此坦诚说明。原因是基础设施领域常见的情况:小团队无法与将项目包装进控制台并出售的云服务提供商竞争,这种结果只会让项目终结,而非为其提供资金。变更日期回答了“它将来会开源吗”这个问题:会,许可证中写明了日期,并按版本逐一转换。如果这项限制妨碍了你在此之前想做的事,请提交一个 issue;个案授权很容易办理。我们按照相同条款接受贡献(参见 CONTRIBUTING.md)。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。