概述
本文聚焦 MAF 的 CodeAct 能力,以 MAF 1.6.1(含 Microsoft.Agents.AI.Hyperlight 1.6.1-preview.260514.1)为基线,说明该模块的职责边界、核心调用链、扩展方式与生产落地策略。文档目标不是复述示例代码,而是帮助你理解 CodeAct 作为“可执行编排面”的设计意义和工程取舍。
之前写了一篇文章,分享了我对Harness Engineering的使用探索:
Harness Engineering 实战 —— 把 Harness Engineering 做成一个一键安装的项目
开源项目:https://github.com/shuaihuadu/inkwell
Microsoft Agent Framework 官方项目地址:https://github.com/microsoft/agent-framework
Microsoft Agent Framework 正在定义下一代 AI 应用的标准——你的技术栈该刷新了!我建了个交流群,欢迎加入,一起学习,不掉队。
CodeAct 是什么
CodeAct 是 MAF 把"多步工具编排"从模型多轮往返压成"一次代码执行"的范式。模型不再被诱导成"一步一个 function call",而是把要做的事写成一段可执行代码,由受控沙箱代为运行,把结构化结果回传给模型再做表达层处理。
它真正解决的问题不是"让模型会写代码",而是三件长期困扰 Agent 工程化落地的事:
工具往返成本。传统函数调用(function-calling)的代价不是一次工具调用,而是"工具调用 × 模型轮次"的叉乘。CodeAct 把整段编排合并成一次执行,模型上下文里只新增一段标准输出(stdout),不会被多轮 tool/role 信息撑爆。
执行边界模糊。模型生成的代码如果直接跑在宿主进程,相当于把宿主能力面整体暴露给一段不可信文本。CodeAct 把执行下沉到 Hyperlight 微型虚拟机,宿主能力只通过显式注册的
call_tool桥接进入,能力面是"白名单 + 入口集中"。工具治理碎片化。直接把十几个
AIFunction挂到模型上,会出现接口描述体积膨胀、模型挑错工具、审批界面被拆碎等一连串次生问题。CodeAct 把模型可见面收敛到一个execute_code,治理点天然集中。
一句话总结:CodeAct 不是"多一个工具",而是把"工具的使用面"从模型外移到了沙箱内。
模块组成与职责拆分
CodeAct 在 MAF 中由六个职责分明的内部组件协作完成,可以按从上到下的层次理解:
上下文注入层:
HyperlightCodeActProvider(AIContextProvider)。负责在每次运行前向 agent 注入execute_code工具与一段 CodeAct 指令,是 Provider 路径的入口。直连工具层:
HyperlightExecuteCodeFunction(AIFunction)。把execute_code直接作为AIFunction暴露,跳过AIContextProvider生命周期,适合配置稳定的场景。配置与能力描述层:
HyperlightCodeActProviderOptions、CodeActApprovalMode、FileMount、AllowedDomain。决定后端(SandboxBackend.Wasm或SandboxBackend.JavaScript,Wasm 下的沙箱端语言取决于加载的模块,示例里用的是官方 Python 模块)、沙箱端模块、堆栈大小、工具集、挂载、出站白名单、审批模式。运行快照层:
SandboxExecutor.RunSnapshot。每次运行把当时的工具、挂载、允许名单、输入目录冻结成一份不可变快照,避免运行中增删改动作污染当次执行。沙箱生命周期层:
SandboxExecutor。持有底层Sandbox的构建、预热、快照与恢复、按指纹重建等核心机制。跨沙箱边界桥接层:
ToolBridge。把AIFunction注册到沙箱端,并在沙箱端与宿主之间建立 "请求 JSON 入 / 结果 JSON 出" 的稳定契约。
这六层中,开发者直接面对的只有前三层;后三层是 CodeAct 真正"贵"的地方,也是文档需要讲清楚的地方。
模型可见面与沙箱可见面
CodeAct 故意制造了两套"工具可见面",它们的边界对工程落地非常重要:
模型可见面:只看到一个工具
execute_code。该工具的描述由InstructionBuilder.BuildExecuteCodeDescription动态拼出,列出当前可用call_tool名字、文件挂载提示、出站白名单等。沙箱可见面:沙箱端代码可以通过
call_tool("<name>", ...)调用宿主AIFunction,但这些工具不在模型的工具列表里,也不会出现在tools接口描述中。
这种切分带来三个直接好处:
模型看到的工具接口描述体积只跟"是否启用 CodeAct"有关,不随宿主工具数量线性膨胀。
宿主工具的命名空间不会污染模型的函数调用决策。
审批、配额、日志等横切策略可以只盯一个入口
execute_code,不需要为每个宿主工具重写一遍。
这也是为什么文档默认不建议"同时把同一批工具既挂到tools又挂到 CodeAct"——HyperlightExecuteCodeFunction.BuildInstructions(toolsVisibleToModel: true)提供的简化指令就是给真要这么做的人留的口子,但绝大多数业务不需要。
调用链与执行时序
一次完整的 CodeAct 执行由"agent 框架 + provider + executor + sandbox + 模型"五方协作完成,关键路径如下:
Agent 接到用户消息,遍历
AIContextProviders调用ProvideAIContextAsync,CodeActProvider 当场冻结一份RunSnapshot,根据快照算approvalRequired,构造本次运行专用的ExecuteCodeFunction注入到AIContext.Tools。ChatClient 把这份工具列表拼进请求发给模型;模型决定调用
execute_code({code: "..."})。框架判断
execute_code是否被ApprovalRequiredAIFunction包裹,是则不直接执行,而是把ToolApprovalRequestContent作为响应返回,等待回路确认。审批通过后框架真正调用
ExecuteCodeFunction.InvokeAsync,由SandboxExecutor.ExecuteAsync接管。SandboxExecutor检查当前Sandbox实例的指纹是否与本次快照一致:一致就Restore(warmSnapshot)复用,不一致就重建沙箱、重新注册工具、做一次预热并捕获新的预热快照。代码在沙箱内执行;遇到
call_tool("name", args)时进入ToolBridge.InvokeAsync,按 JSON 反序列化为AIFunctionArguments,调用宿主AIFunction.InvokeAsync,结果通过AIJsonUtilities序列化回沙箱。执行结束后框架把
execute_code返回的结构化 JSON 当作工具结果回灌给模型,模型据此生成自然语言回答。
这条链路里有四个一旦理解就能避免大量坑的细节:
RunSnapshot一旦生成,本次运行内的所有AddTools/RemoveTools/AddFileMounts都不会影响这次执行——这是线程安全的设计选择,也是为什么并发场景下增删改操作不会"跑一半被改半"。ApprovalRequired在每次运行都重新计算,因此你可以通过中途插拔ApprovalRequiredAIFunction来在不重启 agent 的情况下切换审批策略。沙箱不是每次运行都创建。多次指纹相同的运行复用同一个
Sandbox,只是每次Restore到预热快照拿干净状态。这是 CodeAct 性能可用的真正原因。同一个
SandboxExecutor内部用SemaphoreSlim(1, 1)串行执行。同一个 CodeAct provider 上的多个并发execute_code调用会单飞排队,不会真并行执行;需要高吞吐时需要在上层按会话 / 租户维度拆出多个 provider 实例。call_tool调用宿主工具时,任何异常都会被ToolBridge捕获并序列化成{ "error": "<message>" }回给沙箱端,而不是把异常穿透到沙箱外部。沙箱端代码必须把call_tool的返回值当成"可能成功也可能是错误对象"处理。
execute_code的返回契约
execute_code永远返回一个结构化 JSON 字符串,字段固定为四个:
stdout:沙箱端标准输出,包括print(...)内容、被 print 出来的call_tool结果。stderr:沙箱端标准错误或框架兜底的异常信息。exit_code:沙箱端进程退出码,正常情况0,框架兜底失败时为-1。success:当且仅当exit_code == 0时为true。
这个契约的工程意义在于:你可以围绕execute_code建一层稳定的可观测性,而无需关心沙箱端用的是 Python 还是 JavaScript、模型这次生成了什么代码。把这四个字段直接接到日志 / 链路追踪 / 指标上,你就拿到了与模型行为无关的执行面服务级别指标(SLI)。
快照与预热:性能可用的关键
CodeAct 的性能不是"沙箱启得快",而是"沙箱不必每次都重新启"。SandboxExecutor用三件事撑住这一点:
配置指纹(
ConfigFingerprint)。把工具名集合、挂载、出站白名单、输入目录拼成稳定字符串,作为沙箱复用键。任何一项变了,下一次运行就会重建沙箱;都没变,就直接复用。预热快照(Warm Snapshot)。首次构建沙箱之后会做一次与后端相关的空操作(
None或void 0;)来强制沙箱运行时完成延迟初始化,然后捕获一份干净快照留作复用。运行前恢复(Restore-before-Run)。后续每次运行都先
Restore(warmSnapshot)回到这份干净状态再执行,确保沙箱端没有跨运行的隐式状态泄漏。
这就是为什么文档前面强调"execute_code 调用之间默认不持久化状态"——这不是建议,是机制层面的保证。如果你想跨调用共享数据,必须显式让模型把上次结果传进新一次execute_code的源码里。
Hyperlight 沙箱执行的能力面与限制
CodeAct 的执行底座是 hyperlight-dev/hyperlight-wasm,按官方 README 的原文定位是"a component that enables Wasm Modules to be run inside lightweight Virtual Machine backed Sandbox … to enable applications to safely run untrusted or third party Wasm code within a VM with very low latency/overhead",构建在更底层的 hyperlight 项目之上。注意上游 README 同时声明:This is experimental code. It is not considered production-grade by its developers, neither is it "supported" software.生产投放需要自己承担风险与版本固定策略。
运行时硬性前置条件
直接来自官方 README,没有这些条件 Hyperlight 起不来:
Windows:需要启用 Windows Hypervisor Platform,PowerShell 执行
Enable-WindowsOptionalFeature -Online -FeatureName HyperVisorPlatform。Linux:需要 KVM 或
/dev/mshv,运行前可用kvm-ok验证。容器/虚拟机内运行需要宿主开启嵌套虚拟化。
默认能力面(全部需要显式开通)
Hyperlight 沙箱默认是"零能力面":不能访问宿主进程、不能联网、不能读文件系统。所有能力都必须显式声明。MAF 这层把声明面收敛在HyperlightCodeActProviderOptions:
宿主能力:只能通过
Options.Tools注册的AIFunction,由沙箱端用call_tool("name", ...)调用。没有 syscall、没有 P/Invoke、没有访问宿主进程内存的通道。网络:默认完全禁止;要联网必须
Options.AllowedDomains.Add(new AllowedDomain("api.example.com", ...)),按 target + 可选 method 精确匹配,不在名单里的目标直接拒绝。文件系统:默认看不到任何宿主文件;
HostInputDirectory是当前唯一真实挂载的路径,以/input暴露给沙箱端;FileMount当前主要参与ConfigFingerprint与触发WithTempOutput(),没有按条映射 API(详见上一节"模块的扩展性"对应说明)。资源:
HeapSize与StackSize控制沙箱端堆栈上限,越界会让沙箱兜底为{exit_code: -1, success: false}。
没有执行时长强制截断
MAF 这层把CancellationToken一直传到SandboxExecutor.ExecuteAsync,但底层sandbox.Run(code)是同步阻塞调用——cancellationToken实际生效在SemaphoreSlim的排队阶段;一旦沙箱真正开始跑,就只能等它结束。生成的代码里不要写死循环或无界流式拉取,没有强制截断的机制。
并发也不是真并行:同一个SandboxExecutor用SemaphoreSlim(1, 1)串行执行(已在"调用链与执行时序"节展开)。
Python 沙箱端的语言天花板
如果用 Wasm 后端 + 官方 Python guest(也是 MAF 示例的默认选择),实际可写的代码受 Python guest 模块本身限制:
标准库只覆盖 CPython 的一个子集(具体覆盖范围由 guest 模块发布版本决定),常见的
math/statistics/json/re/datetime/ 字符串处理通常可用。**没有
pip install**:guest 是预编译模块,运行期不能装第三方包。不能起进程:没有
subprocess/os.system/fork/exec——沙箱内根本没有"宿主进程"概念。原生 C 扩展不可加载:
numpy、pandas这类带二进制 wheel 的库默认不可用(除非换一个把它们静态编译进去的自定义 guest 模块,需要自己用 hyperlight-dev/hyperlight-sandbox 构建)。多线程 /
asyncio:取决于 guest 实现,不要假设可用;模型用单线程同步代码最稳。网络相关库(
socket/urllib/requests等)默认不可用,要联网走AllowedDomains白名单且取决于 guest 是否暴露对应能力。
JavaScript 后端走的是另一条 guest(默认构造即SandboxBackend.JavaScript),其语言能力面由该 guest 模块决定,约束维度类似但具体可用 API 不同。
错误兜底
沙箱端代码异常:traceback 进
stderr,exit_code != 0,success = false,不向宿主抛异常。沙箱本身崩溃(OOM、爆栈、guest panic):
SandboxExecutor兜成{ stdout: "", stderr: ex.Message, exit_code: -1, success: false }。call_tool调宿主工具异常:ToolBridge捕获并序列化成{ "error": "<message>" }回沙箱端,由沙箱端代码决定怎么处理。
审批模型:bundled approval 的判定与影响
CodeAct 的审批语义来自HyperlightCodeActProvider.ComputeApprovalRequired,判定条件只有两个:
ApprovalMode设为AlwaysRequire;当前
RunSnapshot.Tools中任意工具的GetService<ApprovalRequiredAIFunction>()不为 null,即被ApprovalRequiredAIFunction包装过。
任一成立,本次运行暴露给模型的execute_code就会再被ApprovalRequiredAIFunction包一层。这意味着:
审批粒度是
execute_code整次调用,不是某个call_tool子调用。这就是 bundled approval。哪怕模型这次生成的代码完全没调审批必需的工具,也会触发审批。它的语义是"代码块本身需要被批准",而不是"代码块里某条特定调用需要被批准"。
审批次数等于模型把任务拆成几个
execute_code块,不是固定 1 次也不是按call_tool数算。
对HyperlightExecuteCodeFunction(直连 Tool 路径)来说判定同样成立,只是它通过GetService(typeof(ApprovalRequiredAIFunction))把自身延迟包装成审批代理暴露给框架,机制等价。
工程上必须配合的一件事是:客户端要有一段稳定的审批回路,从响应中筛ToolApprovalRequestContent,调CreateResponse(approved)包成ChatMessage,通过同一个AgentSession回传RunAsync。漏掉这段回路最直观的现象是"RunAsync跑完但响应里没有文本"——因为框架返回的是审批请求本身,不是终答。
Provider 与直连 Tool:何时选哪一种
两种集成路径的差异不在"能不能用",而在"生命周期由谁掌控"和"运行期是否允许配置变动"。
维度 | HyperlightCodeActProvider | HyperlightExecuteCodeFunction |
|---|---|---|
注入方式 | 进 | 直接作为 |
配置时机 | 每次运行现取 | 构造时一次性快照,整个 agent 寿命共用 |
审批发现 | 直接用 | 通过 |
多 provider 组合 | 单 agent 只能挂一个( | 不占 |
适用场景 | 工具集会动态变、需要按租户/会话切策略 | 配置在部署时已经定死、追求最小运行期开销 |
一个常见判断:如果你的"工具集合 + 出站白名单 + 挂载"一旦上线就基本不变,用直连 Tool;如果你需要按会话/租户/权限动态调整 CodeAct 的能力面,用 Provider。
模块的扩展性
CodeAct 的可扩展面分四层,对应到不同的源码入口:
工具扩展层:通过
AIFunctionFactory.Create(...)把任意 C# 委托包成AIFunction,挂到Options.Tools或运行期AddTools(...)。工具的Name即沙箱端call_tool的第一个参数;Description直接进入execute_code的描述让模型读到。执行策略层:通过
ApprovalMode和按工具包ApprovalRequiredAIFunction控制审批面;通过AllowedDomains控制出站;通过HeapSize/StackSize控制资源;通过后端切换决定沙箱端语言。数据输入层:
HostInputDirectory真实挂载到沙箱端/input;FileMount参与ConfigFingerprint计算且会触发WithTempOutput(),但当前版本没有按条映射的 API——运行期不会按每条FileMount实际映射路径,主要作用是让模型从execute_code的描述里看到这些路径存在,等 SDK 暴露更完整挂载 API 后会落到真实运行时能力上。集成形态层:Provider 与直连 Tool 二选一;如果未来需要嵌入到
DelegatingChatClient或FunctionInvokingChatClient,可以参考HyperlightExecuteCodeFunction的GetService模式自行扩展。
一个容易被忽略的扩展点:InstructionBuilder.BuildContextInstructions(toolsVisibleToModel)决定模型读到的 CodeAct 指令措辞。如果你自定义系统提示词又想保留 CodeAct 的语义提示,建议直接在自己的指令里拼接这段,而不是覆盖掉它。
运行准备:NuGet 包与 guest 模块
CodeAct 落地只需要两个 NuGet 包:Microsoft.Agents.AI.Hyperlight是 MAF 这层的封装(提供HyperlightCodeActProvider/HyperlightExecuteCodeFunction等类型),Hyperlight.HyperlightSandbox.Guest.Python是官方预编译的 Python guest,包内带runtimes/{win-x64,linux-x64}/native/python-sandbox.aot。
实际运行 Hyperlight 时,你需要把这份.aot文件的绝对路径传给HyperlightCodeActProviderOptions.CreateForWasm(...)——MAF 官方示例统一通过HYPERLIGHT_PYTHON_GUEST_PATH环境变量读取(这是示例的约定,不是 SDK 强制名)。这个包默认不会把.aot自动拷贝到bin/,所以路径要么指到 NuGet 缓存里的原始位置(dotnet nuget locals global-packages --list可查实际路径),要么在 csproj 里自己加<None Include="...">拷贝到输出目录。
宿主机层面还需要满足 Hyperlight 的硬件虚拟化前置条件(Windows 启用 WHP、Linux 准备好 KVM 或/dev/mshv),详见上一节"Hyperlight 沙箱执行的能力面与限制"。
使用方式与落地策略
建议把 CodeAct 上线拆成三段,每段都对应一个清晰的验证目标:
纯解释器形态。不挂任何宿主工具,验证"模型愿意稳定生成可执行代码 + 沙箱跑得通"这件事本身。这一段失败大多不是 CodeAct 问题,而是沙箱端模块路径、运行权限、模型系统提示词不到位。
只读工具桥接。挂 1 到 2 个只读宿主工具,让模型在
execute_code里调用call_tool,重点观察:参数类型是否一致、错误分支是否被处理、沙箱端标准输出是否能稳定承载结构化结果。审批 + 审计。引入
ApprovalRequiredAIFunction触发 bundled approval,验证审批回路(ToolApprovalRequestContent→CreateResponse→ 同一 sessionRunAsync)端到端走得通;同时把execute_code维度的stdout/stderr/exit_code接入审计与告警。
三段之间不要急着合并,每段都要在生产链路(含网关、可观测性、回滚预案)跑过再进入下一段。CodeAct 真正容易出问题的不是"写不出代码",而是"审批漏接、错误吞了、stderr没人看"。
系统提示词建议长期保留三条约束:
优先把相关步骤合并在单个
execute_code块中,减少模型往返。每次
call_tool必须检查错误分支(结果可能是{ "error": "..." }这种结构),失败时显式短路。不要假设
execute_code调用之间有状态:跨调用要传值的,由生成的代码自己显式带上。
示例
CodeAct 的示例可以按"能力成熟度"理解,而不是按代码文件顺序理解:
解释器形态。验证生成 → 执行 → 回传主链路;典型场景是数值计算、统计分析、临时数据处理。
ToolEnabled 形态。验证跨沙箱边界的工具桥接和 bundled approval;典型场景是"查文档 + 查数据 + 总结"的复合任务,能直观看出
call_tool与ApprovalRequiredAIFunction联动的现象级表现。ManualWiring 形态。验证不走 Provider、直接把
HyperlightExecuteCodeFunction当AIFunction挂的集成方式;典型场景是已有AIContextProvider集合不希望被打乱、或配置稳定希望最小化运行期开销。
三种形态共同回答的是同一个问题:CodeAct 的核心价值不是"更多工具",而是"执行面收敛后可控性更强"。
总结
CodeAct 在 MAF 中提供了一条同时兼顾效率、隔离和治理的执行路径。它把多步工具编排合并到一次代码执行,把宿主能力以call_tool形式受控桥接进入沙箱,把审批 / 审计 / 可观测性收敛到execute_code这一个入口。Provider 与直连 Tool 两种形态对应不同生命周期偏好,快照与预热 + 指纹复用机制保证了多次执行的低开销。
适用场景:
需要串联检索、查询、聚合、总结等多步流程的复合任务。
对执行隔离、审批和审计有明确要求的企业内部 Agent。
希望在不重写既有工具实现的前提下,把 Agent 从单纯的问答升级到"可执行编排"。
最佳实践策略:
先用纯解释器形态打通主链路,再逐步引入工具与审批,每一步在生产链路独立验证。
对所有写操作或外部副作用工具统一
ApprovalRequiredAIFunction,并实现稳定的审批回路。围绕
execute_code的stdout/stderr/exit_code建立日志、指标、采样回放,使 CodeAct 维度可观测。把 CodeAct 视为独立能力面做版本治理(工具集、出站白名单、挂载、审批模式各自演进),避免与业务工具实现、系统提示词层强耦合。
当工具集会跨租户 / 会话变化时优先 Provider 路径;当配置在部署时就已经定死、追求最小运行期开销时优先直连
HyperlightExecuteCodeFunction。