把生成式 Python 关进 WASI:Agent Python Runtime 技术报告
在上一篇 Python-first Agent 里,我提出过一个很简单的分工:模型负责决定,Python 负责执行,authority 留在 Host。
这句话听起来像一种 prompting 风格,但真正实现它,需要回答一组系统问题:
- 模型生成的 Python 能看到什么?
- credential、network destination 和 effect policy 放在哪里?
- CPython 初始化太慢时,怎样把准备成本移出请求路径?
- prepared instance 如何避免跨请求污染?
- 一台机器能表示多少 ready slot,又真正能同时运行多少请求?
- memory pressure 出现时,谁负责 admission、reservation、reclaim 和 retry?
- benchmark 测到的到底是 Python 计算速度、生命周期迁移,还是并发下的排队行为?
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 也很大:生成代码不仅能表达控制流,还可能继承网络、环境变量、代理配置、文件和进程权限。
这个项目采用另一种边界:
图 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 负责:
- 从 immutable catalog 解析 capability;
- 验证参数 schema;
- 应用 destination allowlist;
- 注入 credential;
- 限制 call count、response bytes、timeout 和 concurrency;
- 为每个实际执行的 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-preinitialized | checkout 前完成可信初始化 | 否 |
cow-ready-single-use | 从 sealed Linux COW image 恢复 never-served slot | 否 |
最重要的不变量是:任何已经执行过请求的 instance 都不会回到 ready pool。
实际生命周期是:
图 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
这里要区分四个经常被混用的量:
- Guest logical memory;
- mapping RSS;
- process RSS/PSS;
- cgroup usage。
一个 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。
图 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 0 | Greed 50 | Greed 100 |
|---|---|---|---|
| target/high/critical memory | 80/88/95% | 85/91/96.5% | 90/94/98% |
| MaxActive / MaxCPU | 1× | 2.5× | 4× |
| initial reservation | p100 | p95 | p90 |
| target eviction budget | 0 PPM | 25,000 PPM | 50,000 PPM |
| control interval | 200 ms | 125 ms | 50 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,还检查:
- payload 和 result checksum;
- exact source bundle 和 clean checkout;
- artifact/manifest identity;
- JSON schema;
- completed/failed/timeout counts;
- ready inventory 是否恢复;
- request semantic result;
- cgroup OOM events;
- stdout/stderr 和 wrapper exit。
三次重复点报告 median 和完整 min–max range;它们不是 confidence interval,也不证明 cross-node variance。Synthetic timer、fake provider 和 real network workload 始终分开描述。
10. Benchmark 结果
图 4:三组结果分别回答调度策略、replacement preparation 和 prepared request latency,不能合并成一个“Python加速倍数”。
10.1 Refill worker scaling
固定 1,024 ready slots、64 closed-loop consumers、30 秒 deterministic CPU workload,三次重复:
| Workers | Throughput median | p99 | CPU cores | Drain | 相对16-worker peak |
|---|---|---|---|---|---|
| 1 | 73.00 req/s | 1593 ms | 1.33 | 24.14 s | 31.47% |
| 2 | 108.07 req/s | 911 ms | 2.41 | 15.01 s | 46.59% |
| 4 | 149.78 req/s | 586 ms | 4.10 | 9.55 s | 64.57% |
| 8 | 211.76 req/s | 393 ms | 6.77 | 6.42 s | 91.29% |
| 12 | 227.91 req/s | 371 ms | 7.90 | 5.59 s | 98.24% |
| 16 | 231.98 req/s | 371 ms | 8.11 | 5.29 s | 100% |
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:
| Policy | Throughput | p50 | p99 | CPU cores | req/s/core | Drain |
|---|---|---|---|---|---|---|
| Fixed 4 | 154.72 | 512.31 ms | 570.34 ms | 4.10 | 37.77 | 9.28 s |
| Adaptive 4/8/12 | 235.80 | 303.47 ms | 362.05 ms | 8.14 | 28.98 | 7.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
| Profile | Consumers | Throughput | p50 | p99 | CPU cores |
|---|---|---|---|---|---|
| Mixed | 16 | 78.37 req/s | 11.28 ms | 2030.66 ms | 2.99 |
| Mixed | 32 | 146.32 req/s | 19.72 ms | 2043.08 ms | 5.75 |
| Heavy-tail | 16 | 132.19 req/s | 8.96 ms | 2013.56 ms | 5.13 |
| Heavy-tail | 32 | 163.97 req/s | 60.79 ms | 2076.45 ms | 6.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/request | Throughput | p99 | Active Anonymous | Process PSS |
|---|---|---|---|---|
| 16 MiB | 7.76 req/s | 2091 ms | 270.1 MiB | 640.2 MiB |
| 32 MiB | 7.61 req/s | 2139 ms | 525.4 MiB | 896.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秒一次性释放更多消费者:
| Burst | Peak consumers | Burst-window throughput | Overall p50 | p99 | Waiting median |
|---|---|---|---|---|---|
| 2× | 32 | 188.97 req/s | 16.03 ms | 2038.95 ms | 0 |
| 4× | 64 | 220.99 req/s | 127.08 ms | 2208.23 ms | 30 |
| 8× | 128 | 213.19 req/s | 511.47 ms | 2565.23 ms | 95 |
从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 stage | Full module | Snapshot shell | Change |
|---|---|---|---|
| Factory wall | 11,371.995 ms | 9,382.347 ms | −17.50% |
| Compile | 3,348.513 ms | 3,346.866 ms | −0.05% |
Replacement InstantiateModule/slot | 29.062 ms | 0.585 ms | −97.99% |
| COW restore/slot | 2.773 ms | 0.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
请求路径对比:
| Path | Request E2E | Request-path import |
|---|---|---|
| Native fresh process | 324.067 ms | 146.887 ms |
| WASI fresh | 11,728.494 ms | 5,995.095 ms |
| WASI single-use CPython-ready | 6,029.889 ms | 6,028.541 ms |
| WASI COW CPython-ready | 6,218.871 ms | 6,216.111 ms |
| WASI COW NumPy-ready | 3.863 ms | 0 |
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. 当前没有证明什么
这个项目目前没有证明:
- production deployment readiness;
- microVM级别的kernel isolation;
- real HTTP、cloud、database或filesystem I/O性能;
- open-loop arrival下的排队稳定性;
- cross-node / cross-architecture confidence interval;
- arbitrary Python package兼容性;
- served-instance restore/reuse;
- unbounded optimistic overcommit;
- 任意workload都能收敛到目标eviction budget。
完整 /proc/self/smaps sampling 也会扰动dirty/mixed measurement。Synthetic timer只能帮助分离调度行为,不能代表真实provider latency。
13. 下一步
接下来最重要的工作不再是“再造一个更大的静态pool”,而是把已经验证的机制接成一个完整的product control loop。
13.1 Production actuator
- apply并read-back memory cgroup与CPU quota;
- bounded live-memory polling;
- pressure出现时停止admission;
- insufficient victim时不做partial transition,而是重新采样;
- 把effect-safe eviction真正接到executor;
- 明确stop/error/cancellation语义。
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,然后希望它别出事”更接近可部署的方向。