章节导航 第 8 章 / 共 21 章
ExecutionContext:依赖注入的核心
ContextVar 传播、async with 生命周期、多 Runtime 懒加载、Worker Venv 自动创建、MCP 动态加载
问题:如何在 Task → Agent → Tool 链中传递依赖
行业视角:2026 年 7 月,AI Engineer World’s Fair 将 Context Engineering 列为独立 Track,与 Evals、Sandbox & Platform Engineering、Memory 并列。上下文管理正在从 Agent 框架的内部实现细节升级为独立的基础设施学科。Nexus 的 ExecutionContext 设计(ContextVar + 懒加载 + 生命周期管理)可以放在这个学科框架下理解——它不是”一种注入方式”,而是”上下文工程在评测框架中的具体实现”。
Agent 需要 LLM Client 来推理,需要 Runtime 来执行工具。但 Agent 函数签名只有一个参数(AgentRunInput)。依赖从哪来?
传统做法是把 Runtime、Client 作为参数层层传递。Nexus 选了另一条路:ContextVar 依赖注入。
ExecutionContext 的结构
nexus/core/context.py,327 行。核心字段:
class ExecutionContext(BaseModel):
# Runtime
runtime_provider: RuntimeProvider | None
runtime_configs: dict[str, dict] # per-runtime 配置
# LLM
openai_clients: dict[str, OpenAICompatibleClient] # 多 client
# Task 元数据
task_name: str | None
task_config: dict
run_id: str | None
# Tool
tool_configs: dict # Tool 专属配置(如 API Key)
tool_state: dict # 跨 Tool 共享状态
# MCP
mcp_tools: dict # 动态加载的 MCP 工具
# Trace
trace_id: str | None
# Compaction
compaction_manager: Any
# 私有状态
_runtimes: dict = {} # 已获取的 Runtime 缓存
_mcp_manager: Any = None # MCP 管理器
_context_token: Token = None # ContextVar token
ContextVar 传播机制
_current_context: ContextVar[ExecutionContext | None] = ContextVar(
'execution_context', default=None
)
进入 async with ctx: 时,将自己设为当前 ContextVar:
async def __aenter__(self):
self._context_token = _current_context.set(self)
return self
async def __aexit__(self, ...):
# 清理 MCP、释放 Runtime
await self.close_mcp_tools()
if self.runtime_provider:
await self.runtime_provider.release_all()
# 恢复之前的 Context
_current_context.reset(self._context_token)
在 Task/Agent/Tool 内部,通过 require_context() 获取:
def require_context() -> ExecutionContext:
ctx = _current_context.get()
if ctx is None:
raise RuntimeError("No active ExecutionContext")
return ctx
这是 Python contextvars 的标准用法——每个 async 协程有自己的 context 副本,天然线程安全。
ContextVar 的注意事项
“天然线程安全”不意味着”没有坑”。以下是 Nexus 开发中需要注意的 ContextVar 边界:
-
asyncio.create_task()默认不继承上下文:需要context=copy_context()显式传递。Nexus 当前所有 Task → Agent → Tool 调用都在同一个协程链内,不涉及create_task(),但未来如果引入并行 Agent 执行,需要显式传递 context。 -
run_in_executor中 ContextVar 不自动传递到子线程:Nexus 的 RUNTIME Tool 在沙箱进程中执行,require_context()在那边拿不到值。这正是_is_inside_runtime_tool_function()检查存在的原因——不是”你不应该用”,而是”用了会拿到 Host 侧的值,这是 bug 源头”。正确做法是使用ToolContext参数。 -
子 Task 的
set()不影响父 Task:ExecutionContext 的修改是 Task 局部的——子协程修改了 context,父协程看不到变化。这是特性而非 bug,保证了 Task 间的隔离性。 -
纯同步代码路径:如果一段代码完全不涉及
await,用threading.local更轻量。Nexus 统一用 ContextVar 是因为混合场景(sync 函数被 async 调用)必须统一用 contextvars。
为什么不直接传参
一个常见的质疑:为什么不把 ctx 作为函数参数传递?
原因有三:
- 签名约束:Agent/Task/Tool 的函数签名是严格约定的(恰好一个业务参数)。如果加一个
ctx参数,签名就不统一了。 - 深度嵌套:Tool 可以调用 Runtime,Runtime 内部还需要 context。层层传参会让接口臃肿。
- 框架代码 vs 用户代码:
require_context()只在框架代码里用——用户写的 Agent 函数通过它获取 Client 和 Runtime,不需要关心 context 是怎么来的。
Runtime 懒加载
async def get_runtime(self, name='main', **params) -> Runtime:
if name in self._runtimes:
return self._runtimes[name] # 缓存命中
runtime = await self.runtime_provider.acquire(name, **params)
# 如果配置了 worker_venv,自动创建
venv_raw = self.runtime_configs.get(name, {}).get('worker_venv')
if venv_raw is not None and venv_raw is not False:
runtime = await self._setup_worker_venv(runtime, venv_raw)
self._runtimes[name] = runtime
return runtime
设计要点:
- 懒加载:Task 可能声明了多个 Runtime(
main、eval),但只有真正get_runtime()时才创建 - 缓存:同名 Runtime 只创建一次
- Worker Venv 自动化:检测配置中的
worker_venv,自动构建 artifact wheel、上传、创建 venv
Worker Venv 自动创建流程
当 runtime_configs.main.worker_venv 配置了值时:
1. build_artifact_cached(source='.')
→ 将当前项目打包为 .whl(含 extensions 代码)
→ 带 hash 缓存,源码没变不重新打包
2. base64 编码 whl → 发送到 Runtime
3. runtime.create_venv({
'python_version': '3.11',
'artifacts': [{whl_data}],
'extra_requirements': [...],
})
→ Runtime Server 在沙箱内创建隔离的 Python venv
→ 安装 whl + 额外依赖
4. runtime.default_venv_id = venv.venv_id
→ 后续所有 invoke() 自动路由到这个 venv
这个机制使得扩展代码(自定义 Tool、自定义 Executor)可以被打包并注入到 Runtime 中执行。
RUNTIME Tool 的安全检查
一个微妙的设计:require_context() 会检查调用栈,如果发现是在 RUNTIME 类型的 Tool 函数内部调用,会报错。
def _is_inside_runtime_tool_function() -> bool:
for frame_info in inspect.stack():
func = frame_info.frame.f_locals.get(frame_info.function)
if func and hasattr(func, 'nexus_meta'):
if func.nexus_meta.get('tool_type') == ToolType.RUNTIME:
return True
return False
原因:RUNTIME Tool 的代码在沙箱里执行,那边没有 ExecutionContext。如果你在 RUNTIME Tool 里调用 require_context(),你拿到的是 Host 侧的 context——这是一个 bug 源头。正确做法是使用 ToolContext 参数。
MCP 工具的动态加载
async def load_mcp_tools(self, extra_servers=None, clear_existing=False):
specs = [*default_servers, *extra_servers]
self.mcp_tools = await self._mcp_manager.load(specs)
MCP(Model Context Protocol)工具可以在运行时动态加载——不需要预先注册。Agent 的 ToolExecutor 会同时查找静态注册的 Tool 和动态加载的 MCP Tool。
✅ 2026-07-28 协议最终版确认:MCP 2026-07-28 最终版已正式发布,确认移除
initialize/initialized握手与Mcp-Session-Id,引入Mcp-Method/Mcp-Name头、server/discover能力发现、ttlMs/cacheScope标准化缓存。load_mcp_tools()的实现需从”先建立会话→获取 tools/list”重构为”直接调用server/discover→缓存全量能力列表”,可在首次调用后缓存结果直至ttlMs过期。
缓存的作用域不只是”多久”:
ttlMs只回答”缓存多久”,与它并列的cacheScope回答”缓存在哪一层”——"public"表示响应不含用户相关数据,任何客户端、共享网关或缓存代理都可以存储并复用;"private"表示响应含私有数据,缓存只能在同一授权上下文内复用,跨授权上下文(例如换了一个 access token)必须重新取。规范只对ttlMs规定了缺省值(缺省按0即立即过期处理),没有给cacheScope规定缺省值,因此实现上应按最保守的private处理。Nexus 的load_mcp_tools()缓存挂在ExecutionContext上(per-Task),天然是 private 语义,安全;但若为了省掉重复的server/discover而把缓存提到进程级共享,就必须显式检查cacheScope == "public",否则会把一个 Task 的 MCP 能力列表泄漏给另一个 Task。另有两条配套约束:分页列表的各页必须使用同一个cacheScope;cacheScope本身不是访问控制手段,服务端不得只靠它阻止未授权访问。
降级路径不是可选项:
server/discover的义务分配是不对称的——服务端必须实现,客户端可以调用但不被要求(官方原话是客户端可以自由地”直接发起任意 RPC”)。所以load_mcp_tools()的健壮写法是”直接发tools/list→ 若返回版本错误再退回server/discover”,而不是假定server/discover一定可用。版本不支持时服务端必须返回-32022(UnsupportedProtocolVersionError),错误数据的supported字段列出服务端支持的版本清单,客户端应当从中挑一个双方都支持的版本重试,无兼容版本时才把错误抛给上层。12 个月迁移窗口内新旧两种服务端都会遇到,这条路径必须实现。
为什么这个不对称在当下特别重要:同一份规范,服务端读出来是 MUST、客户端读出来是 MAY,两边读出来的义务是相反的。后果是窗口期内必然同时存在”实现了 server/discover”与”没实现”的两类服务端,load_mcp_tools() 的两条路径一条都不能省。
这件事在 SDK 层面已经有可观测证据了。MCP Python SDK 2.3.0 的修复清单里有一条(#3629):客户端 OTel span 现在会记录 JSON-RPC 错误响应;并特别写明——以 mode="auto" 连上一个没有 server/discover 的服务端时,探测会产生一条 ERROR span。也就是说,SDK 自己就预期你会撞上没有 server/discover 的服务端,还专门给这种情况留了一条可观测信号。对 Nexus 的实用含义:不要只看 load_mcp_tools() 的返回,把这类 ERROR span 接进评测的故障归因通道——它正好回答”这次是协商失败还是工具真的没有”。
Tasks 扩展变更:Tasks 扩展正式化为 poll-based 生命周期——
tasks/get/tasks/update+ 协作式tasks/cancel。这影响基于 MCP 动态加载的上下文构建路径:长时间运行的 MCP 任务不再依赖持久连接,而是通过轮询状态推进;Roots/Sampling/Logging/DCR 弃用进入 12 个月窗口。
Tasks 的三个实现细节:① Tasks 是独立 Extension(扩展标识
io.modelcontextprotocol/tasks,见 SEP-2663),不是核心协议的一部分——客户端在 per-request capabilities 的extensions里声明它,服务端在server/discover的 capabilities 里同样声明,双方各声明一次即完成协商;② 是否把请求物化为任务由服务端逐请求决定(官方用词 “Server-directed”),客户端只能”一次性声明支持,然后两种返回形态都能处理”,既没有 per-tool 预热也没有 per-request 开关,不能主动要求服务端建任务;③CreateTaskResult(resultType: "task")会同时返回taskId、初始状态、TTL 和建议轮询间隔pollIntervalMs,客户端不需要自己猜轮询频率。状态机为working/input_required/completed/failed/cancelled,其中input_required表示服务端需要客户端补输入,由客户端通过tasks/update回传。
服务端生态迁移:FastMCP 4 基于 MCP Python SDK v2 重写,反映服务端生态向新协议的迁移节奏——Nexus 的 MCP 集成在对齐新协议时,可参考 FastMCP 4 的适配路径。
安装时注意装的是哪一个:已核实 PyPI 现状(2026-10-03 快照)——
fastmcp与fastmcp-slim均为 4.0.10(4.0.10 上传于 2026-09-25T17:19),MCP Python SDKmcp为 2.3.0(mcp-types同为 2.3.0,均上传于 2026-10-02T22:06)。4.x 把核心拆到了fastmcp-slim,fastmcp退化为 extras 聚合包(其依赖直接写死fastmcp-slim[client,server]==4.0.10——本轮回 PyPI 重取requires_dist确认这个写死的串确实跟着升到了 4.0.10);fastmcp-slim对mcp的约束是>=2.0.0,<3.0.0,基础依赖则是mcp-types>=2.0.0,<3.0.0。也就是说:装fastmcp得到的是”开箱即用的聚合包”,装fastmcp-slim得到的是”自己挑 extras 的精简包”,两者版本号当前一致但依赖树不同,引用或锁定版本时要分清。发版节奏值得记一笔:
mcp2.2.0 发布于 2026-09-07,2.3.0 发布于 2026-10-02;TypeScript SDK 也在同一天(2026-10-02)发出 v2.3.0,另有 1.x 维护线的 1.32.0 同日发出——跨语言 SDK 是协同发版的。对 Nexus 这种要同时对齐多语言 server 的框架,版本基线应按”协议修订 + SDK 代次”记录,而不是按单个包。⚠️ 一条会实际撞上的升级阻塞:2.3.0 起
httpx2>=2.10.0是硬要求(此前为>=2.5.0),原因是新的max_sse_event_size选项依赖它。而fastmcp-slim的 client / server extras 对 httpx2 的约束仍写的是>=2.5.0——两者不冲突(2.10 同时满足),但若 Nexus 的依赖里把 httpx2 锁在 2.5~2.9 区间,升到mcp2.3.0 会直接解析失败。属内部事实,需内部核实。
一个容易被忽略的可复现性要求:2026-07-28 规范要求服务端 SHOULD 以确定性顺序返回 tools/list(底层工具集未变时跨请求保持同一顺序),官方给出的理由是让客户端能可靠缓存工具列表,并提高工具进入模型上下文时的 LLM prompt cache 命中率。
对 Nexus 这种评测框架,这条约束的分量比协议作者设想的更大(以下为本书分析推论,非官方结论):工具列表顺序一旦不稳定,会同时污染三处——① 工具列表缓存能否命中;② LLM 侧的 prompt cache 命中率;③ 跨轮评测的可复现性。第三点是评测框架的生命线:同一 Task 两次运行若因工具顺序不同触发不同的 prompt cache 行为,得分差异就无法归因到被测 Agent 本身。因此 load_mcp_tools() 在拿到工具列表之后应显式排序(或至少记录原始顺序以备复现),而不是依赖 MCP server 的返回顺序。
静默失效:清单里少了一个工具,以及协商选错了版本
上面三条(顺序、作用域、降级路径)讲的都是”列表内容的不确定性”。还有一类更麻烦的失效本章此前没覆盖:服务端一切正常,但客户端拿到的列表就是不对,而且全程没有报错。MCP Python SDK 2.3.0(2026-10-02) 一次性修掉了两个这样的子类,二者都只在跨版本对比时才看得见——这正是最容易漏掉的一类。
其一:工具被静默从 tools/list 里丢掉。 2.3.0 的 release notes 原文(#3620):
A tool with an invalid
x-mcp-headerannotation fails at registration.@mcp.tool(),add_toolandTool.from_functionraiseInvalidSignature, naming the tool and the problem. Until now the server started, and 2026-07-28 clients silently dropped the tool from their listing.
被判为非法标注的情形比直觉宽:参数不是 plain str / int / bool 都算——所以 str | None、float、list、enum 全部非法;header 名不是合法 token;两个仅大小写不同的名字。可选 header 参数要改成直接给 schema:Annotated[str | None, WithJsonSchema({"type": "string", "x-mcp-header": "Region"})] = None。
在 2.3.0 之前,这条路径的后果是服务端照常启动、注册照常返回成功,而客户端的清单里就是没有这个工具。对 load_mcp_tools() 的直接推论:拿到工具列表之后,除了排序,还应该校验条数与预期是否一致——“少了一个工具”在旧版本上完全没有信号,而评测框架里少一个工具意味着被测 Agent 的能力面被静默削掉一块,得分下降却找不到原因。
其二:自动版本协商会静默选错版本。 2.3.0 的另一条(#3635):交互式的 OAuth 登录原先会被计入请求超时,导致
…
Client(mode="auto")settling on2025-11-25when the login took longer than 10 seconds.
含义比字面重:使用自动协商的客户端,可能因为一次慢登录而在整条会话上跑在旧协议上,且没有任何告警。你以为在用 2026-07-28,实际协商结果是 2025-11-25。
推论与本章 Trace 那条天然同路:若 load_mcp_tools() 采用自动协商,应把协商结果(实际使用的协议版本)显式记录进 Trace。一次完整评测链路(Nexus → MCP Server → 工具)里,除了 traceparent,也该能看到”这一跳用的是哪个协议版本”——否则跨轮评测的可复现性会在版本这一层再破一个洞。
两条都取自官方 release notes 原文(v2.3.0,2026-10-02T22:02:27Z 发布)。同批还有几条行为变更与本章相关,一并记下:空
_meta与params不再发送(见下文);initialize在未配置时不再发experimental;Mcp-Param-*校验改为按名查工具,MCPServer不再为每个tools/call跑一次tools/list,因此过滤或重写tools/list的中间件不再影响校验;Client.call_tool在收到HeaderMismatch(-32020)拒绝后会重新列工具并重试一次;新增max_sse_event_size=(默认仍是每 SSE 事件 1 MiB,可放大或传None)。
生命周期图
Runner 创建 ExecutionContext
│
├─ async with ctx:
│ ├─ _current_context.set(self)
│ │
│ ├─ Task 执行
│ │ ├─ ctx.get_runtime('main') → 创建 Runtime
│ │ ├─ run_agent(...)
│ │ │ ├─ ctx.openai_client → 获取 LLM Client
│ │ │ ├─ tool_executor.call(...)
│ │ │ │ ├─ LOCAL tool → 直接执行
│ │ │ │ └─ RUNTIME tool → runtime.call_tool(...)
│ │ │ └─ ... 迭代 ...
│ │ └─ evaluate(...)
│ │
│ ├─ __aexit__:
│ │ ├─ close_mcp_tools()
│ │ ├─ runtime_provider.release_all()
│ │ └─ _current_context.reset()
│ │
└─ 所有资源已释放