跳到主要内容
版本:Next 🚧

延迟工具组

当一个 agent 接入多个 MCP 服务器时,它们合起来的 tool schema 在每一轮都 能吃掉数千个 token 上下文——即使模型这一回合只需要其中一两组。 DeferredToolGroup 用一份紧凑的目录替代完整 schema,让模型按需加载 工具组。

两种策略

延迟工具支持两种策略,由 deferred_tool_strategy 选择(默认 "dispatch"):

tools 参数system prompt每次加载的缓存代价调用路径
dispatch(默认)静态静态——schema 以消息尾部追加交付deferred_tool_call 转发器,引擎解包
inject随加载增长目录计数变化system + 全部历史重读一次原生工具调用

为什么默认 dispatch。 在所有前缀缓存的 provider 上,工具定义渲染在 prompt 最前端。会话中途注入工具等于在整个历史之前插入字节,每次加载 都要按未缓存价重读全部对话。dispatch 模式在首个请求之后再不触碰 tools 数组和 system prompt——schema 通过 load_tools 的工具结果交付,追加在 消息历史末尾,像普通轮次一样增量缓存。

何时选 inject 原生工具调用享有 provider 侧 schema 校验,也是模型 训练中最熟悉的调用方式。如果你的工具参数复杂、会话较短(单次加载的缓存 代价小),inject 用缓存效率换取调用可靠性。

dispatch 模式如何工作

  1. system prompt 携带一份简短的静态目录——每组一行,含描述和工具 名。它永不改变。
  2. 模型调用内置的 load_tools(group_id),在工具结果里拿到该组的 完整 schema。
  3. 模型通过内置转发器调用已加载的工具: deferred_tool_call(tool_name=..., arguments=...)
  4. 引擎在一切流程之前解包转发调用:参数校验、 before_tool_call/after_tool_call 钩子、权限系统、事件和 tracing 看到的都是真实工具名和参数——而非信封。
# Deferred tool groups

These tool groups are available but not yet loaded. Call `load_tools(group_id)`
to get their full schemas, then invoke them via
`deferred_tool_call(tool_name=..., arguments=...)`.

- `mcp:github` — GitHub: Issues, PRs, repos, code search (4 tools)
create_issue, search_repos, create_pr, list_comments
- `mcp:linear` — Linear: Project management and issue tracking (6 tools)
create_issue, update_issue, list_projects, ...

几个值得了解的性质:

  • 隐式加载。 模型对从未显式加载的工具直接调用 deferred_tool_call 时,中间件会即时加载并校验参数。校验失败时, 错误结果附带完整 schema,模型一个来回即可自我纠正。
  • 压缩自救。 load_tools 幂等——若上下文压缩丢掉了旧结果,模型 再调一次即可拿回字节相同的 schema。
  • Fork。 fork 出的 agent(fork_once)继承 dispatch resolver, 父 agent 已加载的工具在 fork 内仍可调用。

基本用法

Agent 传入 deferred_tool_groups,中间件自动创建,无需手动接线:

from cubepi import Agent
from cubepi.deferred import DeferredToolGroup

# load_github_tools / load_linear_tools 是零参 async 可调用对象,
# 返回 list[AgentTool]。具体怎么写见下面的「编写 loader」一节,
# 给了 MCP 后端和手写 @tool 函数两种常见形态。

github_group = DeferredToolGroup(
group_id="mcp:github",
display_name="GitHub",
description="Issues, PRs, repos, code search",
tool_names=["create_issue", "search_repos", "create_pr", "list_comments"],
loader=load_github_tools,
)

linear_group = DeferredToolGroup(
group_id="mcp:linear",
display_name="Linear",
description="Project management and issue tracking",
tool_names=["create_issue", "update_issue", "list_projects"],
loader=load_linear_tools,
)

agent = Agent(
model=provider.model("claude-sonnet-4-6"),
tools=[search_tool, calculator], # 始终可用的工具
deferred_tool_groups=[github_group, linear_group],
# deferred_tool_strategy="inject", # 选用 v1 行为
)

DeferredToolGroup 字段

字段类型说明
group_idstr模型在 load_tools 调用中使用的唯一标识(如 "mcp:github"
display_namestr目录中展示的人类可读标签
descriptionstr该组能力的一行摘要
tool_nameslist[str]目录里列出的 tool 名。必须和 loader 返回的每个工具的 AgentTool.name 完全一致 —— 选择性展开(load_tools(group_id, tool_names=[…]))就靠这个字段匹配。
loaderasync () -> list[AgentTool]返回该组完整工具集的回调

编写 loader

loader 是一个零参 async 可调用对象,返回 list[AgentTool]。CubePi 只看返回类型——里面的 AgentTool 怎么来由你决定。两种典型写法:

从 MCP server 加载。 load_mcp_tools_stdio / load_mcp_tools_http 返回 MCPDiscoveryResult,里面 .tools 字段就是你想要的 list[AgentTool]。包一层:

from cubepi.deferred import DeferredToolGroup
from cubepi.mcp import load_mcp_tools_stdio

async def load_github_tools():
result = await load_mcp_tools_stdio(
command="npx",
args=["-y", "@modelcontextprotocol/server-github"],
env={"GITHUB_TOKEN": "ghp_…"},
)
return result.tools # list[AgentTool]

github_group = DeferredToolGroup(
group_id="mcp:github",
display_name="GitHub",
description="Issues, PRs, repos, code search",
tool_names=["create_issue", "search_repos", "create_pr"],
loader=load_github_tools,
)

tool_names 里写的名字必须和 MCP server 公布的工具名一致——这些名字 discovery 之后会成为 AgentTool.name。如果目录里写 create_issue 但 server 发布的是 github_create_issue,选择性展开就匹配不到。

从手写 @tool 函数加载。 任何被 @tool 装饰的函数都是一个 AgentTool.name 默认取函数名,可用 @tool(name="…") 覆盖)。 loader 就是一个返回列表的 async 函数:

from cubepi import tool
from cubepi.deferred import DeferredToolGroup

@tool
async def create_issue(*, repo: str, title: str, body: str) -> str:
"Open a GitHub issue."
...

@tool
async def search_repos(*, query: str) -> str:
"Search public repos."
...

async def load_github_tools():
return [create_issue, search_repos] # 已经是 AgentTool

github_group = DeferredToolGroup(
group_id="mcp:github",
display_name="GitHub",
description="Issues, PRs, repos, code search",
tool_names=["create_issue", "search_repos"],
loader=load_github_tools,
)

两种可以混用——同一个 list 里既有 MCP 工具又有手写工具——只要 tool_names 里每一个名字都能在返回的 list 里找到对应的 AgentTool.name 就行。如果 loader 抛异常,错误会作为 tool error 回给 模型,组保持未展开。

load_tools 工具

模型调用 load_tools 加载一组工具,两种模式:

# 加载整组
load_tools(group_id="mcp:github")

# 只加载指定工具
load_tools(group_id="mcp:github", tool_names=["create_issue", "search_repos"])

dispatch 模式下结果携带完整 schema:

{
"group_id": "mcp:github",
"expanded": true,
"tool_names": ["create_issue", "search_repos"],
"remaining": 2,
"schemas": [
{"name": "create_issue", "description": "...", "parameters": {"...": "..."}},
{"name": "search_repos", "description": "...", "parameters": {"...": "..."}}
]
}

inject 模式下省略 schemas——定义直接加入模型可见的 tools 数组。)

加载后工具在同一轮内即刻可用。

Loader 缓存

loader 回调每组每 run 恰好调用一次。首次加载触发;后续的选择性 加载从缓存结果中过滤。loader 失败时错误返回给模型,该组保持未加载。 已加载的工具幂等——重复请求是 no-op(dispatch 模式下会重新返回相同的 schema)。

加载状态

中间件在 ctx.extra 里记录各组的加载情况:

ctx.extra["expanded_groups"] = {
"mcp:github": None, # 全量加载(None = 全部工具)
"mcp:linear": ["create_issue"], # 部分加载
# mcp:slack 不存在 = 未加载
}

该状态随 checkpoint 持久化,驱动跨 run 重放。

跨 run 重放

从上一个 run 恢复会话时,需要还原加载状态,让转发调用立即可解析。 prepare_resumed_state 负责这件事——strategy 参数必填,且必须与 中间件的 strategy 一致:

from cubepi.deferred import DeferredToolsMiddleware

# saved_extra 是上一个 run 持久化的 ctx.extra
resumed = await DeferredToolsMiddleware.prepare_resumed_state(
groups=all_groups,
expanded=saved_extra["expanded_groups"],
strategy="dispatch",
)

agent = Agent(
model=model,
tools=[*builtin_tools, *resumed.pre_loaded_tools],
deferred_tool_groups=resumed.remaining_groups,
)

prepare_resumed_state 返回的 ResumedState 包含:

字段说明
pre_loaded_tools之前已加载组的工具,随时可被解析(dispatch 模式下对 payload 隐藏)
remaining_groups仍可通过 load_tools 加载的组
loader_cache预载的工具缓存(传给 resumed_loader_cache 避免重复调用 loader)

dispatch 模式下没有其他要恢复的东西:模型见过的 schema 留在消息历史 里,由 checkpointer 随会话带回。inject 模式下,全量加载的组退出延迟 集合(与 v1 相同)。

进阶:直接构造中间件

需要完全控制目录 header 或恢复种子时,自行构造 DeferredToolsMiddleware

from cubepi.deferred import DeferredToolsMiddleware

mw = DeferredToolsMiddleware(
groups=[github_group, linear_group],
extra_ref=lambda: agent_extra,
strategy="dispatch",
catalog_header="# Available integrations\n\nLoad with load_tools().",
)

agent = Agent(
model=model,
tools=[search_tool],
middleware=[mw],
)

构造参数

参数类型默认值说明
groupslist[DeferredToolGroup]必填要延迟的组
extra_ref() -> dict必填返回实时的 ctx.extra 字典
strategy"dispatch" | "inject""dispatch"披露策略(见上文)
catalog_headerstr | None(按策略内置)目录段的 header 文本
resumed_loader_cachedict[str, list[AgentTool]] | NoneNone上一个 run 的工具缓存(避免恢复时重复调用 loader)
on_tools_expanded(list[AgentTool]) -> None | NoneNone新工具加载后回调(内部用于跨轮持久化)

使用 Agent(deferred_tool_groups=...) 简写时,extra_ref 自动绑定到 self._extra

从 0.10 迁移

延迟工具组在 CubePi 0.10 发布时的行为即现在的 inject 策略。升级后 行为变化:

  • 默认策略现在是 dispatch 目录措辞改变,出现 deferred_tool_call 内置工具,加载的工具不再加入模型可见的 tools 数组。用 Agent(deferred_tool_strategy="inject")DeferredToolsMiddleware(strategy="inject") 恢复 0.10 行为。
  • inject 模式不再把 schema 渲染进 system prompt。 定义本来就在 tools 数组里;重复渲染(及其双倍 token 计费)已移除。因此 resumed_schemas 构造参数和 ResumedState.expanded_schemas 不复存在。
  • prepare_resumed_state 要求显式传 strategy=,避免恢复时与中间件 的策略静默错配。

何时使用

适合:

  • agent 接入 5 个以上 MCP 服务器,但每次会话通常只用 1–2 个。
  • tool schema 很大(参数多、描述长)。
  • 你希望跨轮保持高 prompt 缓存命中率。

不适合:

  • agent 只有少量工具——目录和 load_tools 调用的开销不值得。
  • 每一轮都需要全部工具——延迟只是徒增一个来回。
  • tool schema 很小——上下文节省微乎其微。

另请参阅

  • 加载 MCP 工具——如何从 MCP 服务器获得 AgentTool 列表。
  • 9 个 Hook——驱动延迟工具的中间件 hook (transform_system_promptafter_tool_callresolve_tool_call)。
  • 组合——与其他中间件叠加时的组合行为。