Yuzhe's Blog

yuzhes

把生成式 Python 关进 WASI:Agent Python Runtime 技术报告

在上一篇 Python-first Agent 里,我提出过一个很简单的分工:模型负责决定,Python 负责执行,authority 留在 Host。

这句话听起来像一种 prompting 风格,但真正实现它,需要回答一组系统问题:

Agent Python Runtime 是我为这些问题写的一个 Host-governed CPython/WASI runtime。它让 Agent framework 提交生成式 Python 和 JSON inputs,但把 capability、credential、resource limits、instance lifecycle、transaction evidence 和 receipts 留在 Go Host。

这篇文章是项目当前阶段的完整技术复盘:从威胁模型、ABI、COW memory 到 scheduler 和 Linux benchmark,也会明确说明哪些结论还不能下。

1. 目标不是“给 Agent 一个更小的容器”

最直接的 Agent code execution 方案,是给模型一个 container、shell、filesystem 和若干 secrets。它很灵活,但 authority surface 也很大:生成代码不仅能表达控制流,还可能继承网络、环境变量、代理配置、文件和进程权限。

这个项目采用另一种边界:

Agent Python Runtime 总体架构:Agent 只提交代码与输入,Go Host 持有权限、资源策略和执行生命周期,CPython 运行在受限 WASI Guest 中

图 1:Python 负责 control flow;capability、credential、policy 与 evidence 留在 Host。点击图可查看原尺寸。

Guest 不是一个通用 Linux 环境。它没有 ambient filesystem、environment、socket、subprocess、package installer 或 credential。WASI 也不应该被简化成“没有 syscall”:更准确的说法是,Guest 只能调用实例化时由 Host 提供的 imports。真正的 authority boundary 是 Host 构造了什么实例,而不是 .wasm 后缀本身。

因此,这个 runtime 的产品边界很窄:执行一次 bounded generated-Python run。它不是 planner、MCP marketplace、package manager、general Linux sandbox,也不是 microVM 级别的 kernel isolation。

2. Python 是 control flow,不是 authority boundary

Python-first 的价值主要来自把确定性机械步骤从 LLM loop 中拿出来。例如分页、filter、join、deduplicate、aggregation 和 bounded retry,可以在一次 run 里完成:

from agent_runtime import tools

items = tools.fetch_many([
    {"request_id": "a", "target": "catalog", "path": "/items/1"},
    {"request_id": "b", "target": "catalog", "path": "/items/2"},
])

rows = [x["body"] for x in items if x["status"] == "ok"]
result = normalize_and_rank(rows)[:5]

这里的 target="catalog" 是 Host-owned alias,不是 URL。Guest 不知道 origin、Authorization header、proxy 或 DNS 结果。Host 负责:

  1. 从 immutable catalog 解析 capability;
  2. 验证参数 schema;
  3. 应用 destination allowlist;
  4. 注入 credential;
  5. 限制 call count、response bytes、timeout 和 concurrency;
  6. 为每个实际执行的 operation 写 Host-authored receipt。

模型生成的 request 只携带 code、inputs、不可信 run label 和可选 output schema。authority-bearing fields 不允许出现在 request 中。未知字段直接拒绝,而不是“尽量猜测”。

这种设计也很适合 stateless MCP:Host 可以把一个经过策略过滤的 tools/list catalog 投影为 Python functions,但 MCP annotation 不会自动变成权限。跨调用状态如果存在,必须显式地作为 server-minted handle 传递。

3. ABI:所有边界都用 bounded JSON 和显式长度

Guest 与 Host 之间采用 versioned core-WASM ABI。请求和返回值都是有上限的 JSON envelope;pointer、length、overflow 和 memory bounds 在 Host copy/decode 之前检查。

一次成功运行大致返回:

{
  "status": "ok",
  "result": {"count": 2, "total": 42},
  "receipts": [],
  "metrics": {
    "guest_time_ms": 1.25,
    "capability_calls": 0,
    "result_bytes": 22
  },
  "error": null
}

错误也必须 bounded。traceback 不能先无限生成、最后再截断;限制必须发生在读取和复制阶段。Guest 返回的“receipt-like JSON”没有证明力,只有 Host 实际中介过的 operation 才能产生 receipt。

项目中还实现并测试了 Host-side effect、transaction、replay、rollback、compensation 和 reconciliation primitives,但公开 V1 产品边界仍然保持保守:默认 read-only,不把 fake provider 和 transaction contract 说成已经接入真实云服务的 production effect plane。

4. 三种执行策略,以及最重要的不变量

Runtime 提供三个 execution strategy:

Strategy准备边界Served instance 是否复用
fresh每次请求重新 instantiate 和 initialize否
single-use-preinitializedcheckout 前完成可信初始化否
cow-ready-single-use从 sealed Linux COW image 恢复 never-served slot否

最重要的不变量是:任何已经执行过请求的 instance 都不会回到 ready pool。

实际生命周期是:

Single-use COW 生命周期:sealed baseline 生成 never-served ready slot,请求只执行一次,随后销毁并由后台补充干净 replacement

图 2:COW 用来构造 clean replacement,不把已经服务过的脏实例重新放回池中。

这个选择牺牲了一些理论上的极限吞吐,但显著缩小了 reset claim:项目不需要声称自己已经完整恢复 Python globals、module cache、random state、WASM mutable globals/tables、WASI resources 和 Host handles。

COW 在这里是 clean replacement 的构造技术,不是 served-instance reuse,也不是 stateful session。

5. Linux COW memory 是怎样接进 wazero 的

项目没有 fork wazero。它使用 wazero 公开的 experimental memory allocator 接口,把固定大小的 Guest linear memory 接到 Linux COW image。

核心流程可以概括为:

可信初始化后的 canonical linear memory
→ 写入 memfd
→ 校验 identity / size
→ 加 seals
→ 每个 slot 建立独立 private mapping
→ request 写入产生 private dirty pages
→ served slot 关闭并 munmap
→ replacement 从 sealed image 获得 clean mapping

这里要区分四个经常被混用的量:

一个 slot 可以保留很大的 virtual address range,但只在页被触碰后形成物理工作集。MAP_PRIVATE 让 immutable baseline 可以共享;request-local writes 进入该 slot 的 private pages。

项目还验证了 reclaim 后 named mapping 消失、Private_Dirty 和 Anonymous 回到零。trap、cancellation、memory-shape drift、unsupported import、refill failure 或任何不确定状态都会 close/discard,不能以“可能没问题”为理由继续复用。

6. 为什么 ready inventory 不等于并发能力

COW pool 至少有三种不同的容量概念:logical/static capacity、materialized ready inventory,以及正在执行或等待 checkout 的 active consumers。

Pool容量边界:32768个静态slot、补充后29897个ready slot、64个consumer,与真正有用的active执行区域并不是同一个数字

图 3:static slots、ready supply、waiting consumers 与 useful simultaneous execution 必须分开报告。

在一轮 extreme density 实验里,系统成功表示了 32,768 个 static COW-ready slots,对应约 4 TiB virtual reservation 和约 23.2 GiB physical Host memory。当时只有 64 个 active consumers;120 秒 refill 后 ready inventory 为 29,897,仍有 2,871 的 deficit。

这个结果证明的是 pool density,不是“可以同时跑 32,768 个 Python”。当时估算的 Host/wazero metadata 约为 738 KiB/static slot,而真实 mixed workload 的有效 active 区域仍在几十个 consumers。

在 replacement lifecycle 的 aggregate breakdown 中:

Instantiate Guest  ≈ 47.31 ms / replacement,86.1%
COW restore        ≈  7.62 ms / replacement,13.9%
Initialize         ≈  0.04 ms / replacement, 0.1%

47.31 ms 是特定 artifact、host、contention 和 32K setup 下的平均值,不是 wazero 每个 instance 都固定支付的常数。但它清楚地指出了优化优先级:先处理 replacement instantiation,再谈更激进的 pool scale。

7. Scheduler:控制的是 active、memory credit 和 refill,不是一个线程数

Scheduler 将 task、attempt、profile、reservation、ready inventory 和 live memory 分开建模。

一次 profile-aware admission 的关键路径是:

lock ProfileStore
→ 读取最新profile estimate
→ lock Scheduler
→ 选择queued task
→ reserve memory credit
→ register attempt

这个锁序保证 Greed Controller 更新 reservation quantile 时,不会插入 estimate 和 admission 之间,造成“用旧estimate注册新attempt”的竞态。

Retry 还有 reservation floor:发生过 eviction 后,新的估计值不能把 retry reservation 降到已经观察到的 footprint 以下。Guaranteed attempt 也不能低于 existing floor。已经 Admitted 或 Running 的 attempt 不会被后台 profile 更新偷偷改写 reservation。

对外 policy surface 被压缩成三个 bounded knobs:

scheduler.ProductionPolicy{
    MaxMemoryBytes: 16 << 30,
    MaxCPU:         12,
    Greed:          50,
}

Greed 只在硬边界内编译出 watermarks、MaxActive、p90–p100 reservation quantile、eviction budget、poll interval 和 retry policy;它不会提高 MaxMemoryBytes 或 CPU quota,也不会自动把 cgroup 限制应用到机器。

派生策略Greed 0Greed 50Greed 100
target/high/critical memory80/88/95%85/91/96.5%90/94/98%
MaxActive / MaxCPU1×2.5×4×
initial reservationp100p95p90
target eviction budget0 PPM25,000 PPM50,000 PPM
control interval200 ms125 ms50 ms

这仍然只是 policy compiler 和 scheduler library,不等于 production overcommit service。deployment 还必须实际 apply/read-back cgroup,并把 effect-safe eviction 接到 executor。

8. Snapshot shell:不 fork wazero,也减少重复 active-data 工作

profiling 之后,一个明显问题是:replacement InstantiateModule 会重复处理非常大的 active data payload。当前 CPython artifact 大小为 52,585,175 bytes,其中:

active data segments       10,000
active payload bytes   47,841,558
active i32.const offsets    10,000 / 10,000

最初的想法是构造一个 replacement-only shell:保留 module structure,但清空 active data payload,因为 canonical COW memory 已经包含初始化后的数据。

真正安全的实现比“decode 后重新 encode 一次”更严格。实验中发现,整 module 经 wabin 重编码会连 Element section 也改变,baseline initialization 最终触发 invalid table access。因此最终实现只重写原始 Data section,其他 section 逐字节保留。

单编译版本的流程是:

parse full artifact
→ 验证fixed local memory、无start section、active offset均为i32.const
→ 从active payload生成temporary memory seed
→ 只把Data section中的active payload长度改为0
→ compile derived shell once
→ instantiate shell
→ 在_initialize之前attach seed
→ runtime_init / trusted warmup
→ seal ordinary canonical COW image
→ 释放shell bytes、seed和segment payload

它是显式 opt-in:

{
  "prepared_capacity": 256,
  "execution_strategy": "cow-ready-single-use",
  "cow_snapshot_shell": true
}

有 imported/growable memory、非 constant active offset、WASM start section 或越界 segment 时直接 fail closed。默认路径保持原来的完整 module。

9. Benchmark 方法:先固定证据,再解释数字

性能实验在 Linux x86-64 Slurm node 上运行,主重复矩阵使用 Linux 6.17、Go 1.26、cgroup v2、20 GiB allocation / 16 GiB runtime limit / 4 GiB reserve。Host source revision、Guest source、artifact SHA-256、manifest、schema 和 result checksums 都绑定在 evidence 中。

主 CPython artifact SHA-256 为:

e32b9ff8ebb60f6e970b0b25d19299e13e066b38fa499ee7a4980e47a9257bef

接受一个结果,不只看 Slurm COMPLETED 或 process exit code,还检查:

三次重复点报告 median 和完整 min–max range;它们不是 confidence interval,也不证明 cross-node variance。Synthetic timer、fake provider 和 real network workload 始终分开描述。

10. Benchmark 结果

Benchmark生命周期边界:adaptive refill以CPU换吞吐,snapshot shell优化replacement instantiation,NumPy-ready把import移出请求路径

图 4:三组结果分别回答调度策略、replacement preparation 和 prepared request latency,不能合并成一个“Python加速倍数”。

10.1 Refill worker scaling

固定 1,024 ready slots、64 closed-loop consumers、30 秒 deterministic CPU workload,三次重复:

WorkersThroughput medianp99CPU coresDrain相对16-worker peak
173.00 req/s1593 ms1.3324.14 s31.47%
2108.07 req/s911 ms2.4115.01 s46.59%
4149.78 req/s586 ms4.109.55 s64.57%
8211.76 req/s393 ms6.776.42 s91.29%
12227.91 req/s371 ms7.905.59 s98.24%
16231.98 req/s371 ms8.115.29 s100%

8 workers 已经取得 16-worker peak 的 91.29%;12→16 只增加 1.79%。但 library 默认仍保持 fixed 4,因为自动提高 worker 数会改变 idle CPU 和 refill pressure。

10.2 Adaptive refill:吞吐更高,但不是免费的

同一 node/job 的 A/B 中,adaptive policy 在 normal/low/critical inventory 下使用 4/8/12 的 bounded worker limit:

PolicyThroughputp50p99CPU coresreq/s/coreDrain
Fixed 4154.72512.31 ms570.34 ms4.1037.779.28 s
Adaptive 4/8/12235.80303.47 ms362.05 ms8.1428.987.60 s

Adaptive 带来:

throughput       +52.40%
p50              -40.76%
p99              -36.52%
drain            -18.06%
CPU              +98.62%
req/s per core   -23.27%

所以它被定位为显式开启的 burst-recovery / latency policy,而不是默认性能优化。

10.3 Mixed 与 heavy-tail:并发拐点取决于profile

mixed-v1 的确定性周期是:

60% tiny CPU
25% 50 ms timer
10% 4 MiB dirty + 500 ms hold
 5% 16 MiB dirty + 2 s hold
ProfileConsumersThroughputp50p99CPU cores
Mixed1678.37 req/s11.28 ms2030.66 ms2.99
Mixed32146.32 req/s19.72 ms2043.08 ms5.75
Heavy-tail16132.19 req/s8.96 ms2013.56 ms5.13
Heavy-tail32163.97 req/s60.79 ms2076.45 ms6.04

Mixed 16→32 的吞吐提升 86.71%,而 heavy-tail 只提升 24.04%,p50 却扩大 6.79 倍。active = CPU cores 或一个全局固定并发值都不是好的模型。

10.4 Dirty working set:memory 是流量,不只是容量

16 active consumers、固定 2 秒 hold:

Dirty/requestThroughputp99Active AnonymousProcess PSS
16 MiB7.76 req/s2091 ms270.1 MiB640.2 MiB
32 MiB7.61 req/s2139 ms525.4 MiB896.4 MiB

dirty bytes 加倍后,Active Anonymous 增加到 1.95 倍,PSS 增加 256.2 MiB,而吞吐基本不变。这个 workload 的瓶颈是 hold 和 memory-system work,不是 pool 里还有多少 logical slots。

64 MiB/request 的尝试触发了 Guest MemoryError,因此只保留为 boundary observation,没有包装成一个成功数据点。

10.5 Correlated burst:64是短时上界,128已经过饱和

实验从16个closed-loop consumers开始,在第5秒一次性释放更多消费者:

BurstPeak consumersBurst-window throughputOverall p50p99Waiting median
2×32188.97 req/s16.03 ms2038.95 ms0
4×64220.99 req/s127.08 ms2208.23 ms30
8×128213.19 req/s511.47 ms2565.23 ms95

从64增加到128没有提高吞吐,反而让p50和等待数显著恶化。对这个mixed profile,32是高效区,64是短时上界,128已经过饱和。

10.6 Snapshot shell:优化的是replacement lifecycle

最终 source-bound A/B 使用 256 ready slots、fixed 4 refill workers,顺序为 full → shell → full → shell:

Lifecycle stageFull moduleSnapshot shellChange
Factory wall11,371.995 ms9,382.347 ms−17.50%
Compile3,348.513 ms3,346.866 ms−0.05%
Replacement InstantiateModule/slot29.062 ms0.585 ms−97.99%
COW restore/slot2.773 ms0.038 ms−98.61%

每个 trial 都返回 {"ok":true,"value":45},ready inventory 恢复到256,失败数为0。

最重要的解释边界是:这不是“所有 wazero instance 都快98%”,也不是 Python kernel speedup。它说明在这个含47.8 MB active payload 的 artifact 上,replacement preparation 的大部分重复工作可以移到一次性 seed/baseline 阶段。

10.7 NumPy:prepared latency 不是native compute speed

NumPy lifecycle 用三个量表示最清楚:

A = CPython/WASI COW shard readiness
B = pre-COW import NumPy
C = ready slot checkout + request + result

同一份 NumPy-ready evidence 中:

A         12,234.896 ms
B          6,204.099 ms
A+B       18,438.994 ms
C              3.863 ms
A+B+C     18,442.857 ms

请求路径对比:

PathRequest E2ERequest-path import
Native fresh process324.067 ms146.887 ms
WASI fresh11,728.494 ms5,995.095 ms
WASI single-use CPython-ready6,029.889 ms6,028.541 ms
WASI COW CPython-ready6,218.871 ms6,216.111 ms
WASI COW NumPy-ready3.863 ms0

COW CPython-ready 仍然要支付约6.2秒的 NumPy import;真正把 import 移出请求路径的是 numpy-ready-v1 warmup profile。

324.067 / 3.863 = 83.90× 比较的是 fresh native lifecycle 和 prepared COW hit,不是说 WASM NumPy kernel 比 native 快83.9倍。A+B 是 readiness 前的一次性 factory work,不能加到每个 steady request,也不能从系统成本里假装消失。

11. 我从这些实验里学到的几件事

11.1 Freshness 是状态机,不是一个 reset 函数

只恢复 linear memory 不代表恢复了 globals、tables、WASI resources、Host handles 和外部 effects。与其早早宣称“实例可重用”,不如先坚持 served instance single-use,把 clean replacement 做快。

11.2 Prepared pool 的关键指标是补给,不是静态容量

32K slots 很适合证明 representation density,但生产问题更接近:active consumers 消耗ready inventory的速度,refill能否追上,以及dirty pages以多快速度进入和离开cgroup。

11.3 并发是profile参数

CPU-heavy、timer-heavy、dirty-held 和 heavy-tail workload 的拐点不同。scheduler需要profile estimate和实时pressure,而不是把CPU数量直接当作最佳active count。

11.4 优化必须标明生命周期边界

“请求3.863 ms”“Instantiate减少97.99%”“32K ready slots”分别描述不同对象。把这些数字合并成“Python很快”会丢失最重要的信息。

11.5 Fail closed 会让优化更慢,但也更可信

Snapshot shell 的两次失败很有价值:一次暴露整module重编码改变Element section,另一次暴露seed直到runtime_init后才attach。最终方案增加了raw section preservation、start-section rejection和明确attach顺序。性能路径只有在语义约束成立时才开启。

12. 当前没有证明什么

这个项目目前没有证明:

完整 /proc/self/smaps sampling 也会扰动dirty/mixed measurement。Synthetic timer只能帮助分离调度行为,不能代表真实provider latency。

13. 下一步

接下来最重要的工作不再是“再造一个更大的静态pool”,而是把已经验证的机制接成一个完整的product control loop。

13.1 Production actuator

13.2 更真实的memory evidence

当前mixed load主要使用scheduler-level dirty mappings。还需要Guest-COW内部的mixed workload、不同Python allocation pattern、长时间steady state和multi-node repeat。

13.3 Open-loop与产品入口

Closed-loop consumers适合定位容量拐点,但真实服务还需要open-loop arrival、backpressure、deadline、admission rejection和SLO分析。apyrun目前是单请求CLI;production需要multi-request product contract和fail-closed startup verification。

13.4 Artifact profiles

NumPy只是一个selected profile。未来应增加更多可复现package matrix、mutation/freshness corpus、warmup generation binding和artifact-specific compatibility checks,而不是承诺任意pip install。

13.5 Typed tool bridge

继续把MCP/JSON Schema投影为function-shaped Python SDK,但只接受无歧义、可bounded validation的schema;effect、approval、rollback、reconciliation和receipt仍由Host拥有。

13.6 暂时不做的事

我暂时不会fork wazero去实现lazy globals/tables、ModuleInstance template或内部engine clone。公开allocator和artifact-level snapshot shell已经证明了一条更小、可验证、风险更低的路线。只有在CPU/heap attribution表明剩余收益足够大时,才值得讨论upstream API或fork。

14. 结语

Agent Python Runtime 想解决的不是“怎样让模型拥有更多工具”,而是“怎样让模型生成的程序只拥有完成任务所需的最小authority”。

CPython/WASI提供了语言和实例边界;Go Host持有capability、credentials、policy和evidence;single-use lifecycle避免把不完整reset包装成隔离;COW把可信准备成本移出请求路径;scheduler则把ready inventory、active attempts、memory credit和pressure放进同一个可审计状态机。

最终得到的并不是一个万能sandbox,而是一组更窄的、可以被测试和反驳的claim:

code first
→ tools as functions
→ authority stays in Host
→ prepared instances serve once
→ memory and concurrency remain bounded
→ every performance number names its lifecycle boundary

对我来说,这比“给Agent一个shell,然后希望它别出事”更接近可部署的方向。