第 8 章 第二部分:架构原理

ExecutionContext:依赖注入的核心

ContextVar 传播、async with 生命周期、多 Runtime 懒加载、Worker Venv 自动创建、MCP 动态加载

Nexus ExecutionContext 依赖注入 ContextVar 生命周期

问题:如何在 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 边界:

  1. asyncio.create_task() 默认不继承上下文:需要 context=copy_context() 显式传递。Nexus 当前所有 Task → Agent → Tool 调用都在同一个协程链内,不涉及 create_task(),但未来如果引入并行 Agent 执行,需要显式传递 context。

  2. run_in_executor 中 ContextVar 不自动传递到子线程:Nexus 的 RUNTIME Tool 在沙箱进程中执行,require_context() 在那边拿不到值。这正是 _is_inside_runtime_tool_function() 检查存在的原因——不是”你不应该用”,而是”用了会拿到 Host 侧的值,这是 bug 源头”。正确做法是使用 ToolContext 参数。

  3. 子 Task 的 set() 不影响父 Task:ExecutionContext 的修改是 Task 局部的——子协程修改了 context,父协程看不到变化。这是特性而非 bug,保证了 Task 间的隔离性。

  4. 纯同步代码路径:如果一段代码完全不涉及 await,用 threading.local 更轻量。Nexus 统一用 ContextVar 是因为混合场景(sync 函数被 async 调用)必须统一用 contextvars。

为什么不直接传参

一个常见的质疑:为什么不把 ctx 作为函数参数传递?

原因有三:

  1. 签名约束:Agent/Task/Tool 的函数签名是严格约定的(恰好一个业务参数)。如果加一个 ctx 参数,签名就不统一了。
  2. 深度嵌套:Tool 可以调用 Runtime,Runtime 内部还需要 context。层层传参会让接口臃肿。
  3. 框架代码 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 SDK mcp 为 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 的精简包”,两者版本号当前一致但依赖树不同,引用或锁定版本时要分清。

发版节奏值得记一笔:mcp 2.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 区间,升到 mcp 2.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-header annotation fails at registration. @mcp.tool(), add_tool and Tool.from_function raise InvalidSignature, 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 on 2025-11-25 when 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()
  │   │
  └─ 所有资源已释放