Wasm Component Model:插件运行时终于有了更像样的边界
古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。
很多插件系统最初都不是作为“平台”设计出来的。它只是一个很顺手的逃生口:用户想在 AI 网关里加一点自己的脱敏逻辑,平台团队不可能为每家公司写一版规则,于是开放一个 Python 脚本入口。脚本收到请求,改一下 prompt 或 payload,再把结果交回主流程。
这条路刚开始很舒服。写 Python 的人多,规则上线快,遇到特殊字段也能马上补。但一年后,宿主开始背上脚本的债:依赖冲突、权限过宽、冷启动、超时不可控、日志里混着敏感数据,还有最麻烦的事:平台团队不敢升级运行环境,因为不知道哪家客户的脚本会突然挂掉。
下面这篇不从 Wasm 概念讲起,而是从一个 AI 网关和数据平台的改造讲起。它把用户自定义脱敏插件从 Python 脚本迁到 Wasm Component Model。案例是虚构的,名称、接口、域名和密钥都不是任何真实内部系统,但它尽量保留工程现场的细节:清单表、WIT 接口、manifest、失败样本、测试办法、发布和回滚。

事故不是从 Wasm 开始的
平台叫 Harbor Gateway。它负责接收企业用户的 LLM 请求,做鉴权、配额、审计、路由和数据脱敏,然后转发给不同模型供应商。早期脱敏能力很简单:手机号、邮箱、身份证号、银行卡号,平台内置正则就够了。
后来用户需求变复杂了。有人要按本公司员工编号脱敏,有人要把某些项目代号替换成内部标签,有人要对 JSON 里的 doctor_note 做不同规则,有人希望模型返回后再做一次还原。平台内置规则追不上,于是开放了 Python 插件:
def redact(request):
text = request["messages"][-1]["content"]
text = text.replace("Project Falcon", "[PROJECT]")
request["messages"][-1]["content"] = text
return request
第一版执行方式很直接:每个租户一个插件目录,平台用受限解释器加载脚本,传入 JSON,返回 JSON。文档里写得很清楚:“插件不要访问文件,不要访问网络,不要记录原文。”但文档不是边界,代码才是。
真正触发改造的是一次看似普通的线上慢请求。某个租户更新了脱敏插件,引入了一个第三方库;这个库第一次加载时扫描本地时区数据,又因为容器镜像里缺文件不断重试。结果不是一个请求失败,而是同一批 worker 的延迟一起抖动。SRE 查到最后,发现 AI 网关主进程、Python 插件、依赖导入和租户规则全缠在一起。
复盘会上列出的不是“Python 不好”,而是这些边界问题:
| 问题 | 具体表现 | 后果 |
|---|---|---|
| 接口靠 JSON 约定 | 字段增删只有文档,没有类型检查 | 错误到运行时才暴露 |
| 权限靠约定 | 插件理论上不该读文件,但运行时没有强制 | 审计和合规说不清 |
| 依赖不可控 | 每个插件带不同包,版本互相影响 | 宿主升级困难 |
| 资源预算模糊 | 超时、内存、输出大小没有统一策略 | 单个插件拖慢网关 |
| 观测太粗 | 只有插件耗时,没有能力使用和错误分类 | 排障靠猜 |
| 发布不可回放 | 用户上传脚本后直接启用 | 无法确认产物来源和可复现构建 |
Wasm Component Model 是在这个背景下被选中的。不是因为它新,也不是因为“浏览器外运行 WebAssembly”这个卖点,而是因为它能把插件和宿主之间的接口、能力、版本和资源预算放到一个更硬的边界里。
先定义不迁移什么
Harbor Gateway 没有把所有 Python 插件一口气迁到 Wasm。团队先做了一张插件清单,把现有脚本按行为分组。
| 插件类型 | 占比 | 行为 | 是否进入第一批 |
|---|---|---|---|
| 纯文本脱敏 | 46% | 输入文本,输出脱敏文本和映射表 | 是 |
| JSON 字段脱敏 | 24% | 按路径改写请求体 | 是 |
| 外部词库查询 | 11% | 访问客户自己的词库服务 | 否,先改成宿主托管词库 |
| 审计旁路 | 8% | 把命中结果发到外部审计系统 | 否,改为宿主事件出口 |
| 自定义路由 | 6% | 根据内容选择模型供应商 | 否,归到策略引擎 |
| 其他脚本 | 5% | 历史实验和半废弃逻辑 | 不迁移,要求下线或重写 |
这个表让项目少走了很多弯路。Wasm 适合边界清楚、输入输出明确、权限可枚举的插件。一个插件如果需要长时间维护连接、访问复杂外部系统、持有大量状态,独立服务或宿主能力可能更自然。把所有扩展都塞进 Wasm,只会把新运行时变成旧问题的新包装。
第一批目标被压到很窄:只迁移“请求进入模型前的脱敏”和“模型返回后的还原/再次脱敏”。插件不能直接联网,不能读任意文件,不能拿宿主密钥。需要词库时,通过宿主提供的 lookup-dictionary 能力访问平台托管词库;需要打指标时,通过宿主提供的 emit-metric 能力,且指标名受限。
这个限制看起来不够自由,但它是运行时能稳定的前提。
接口从 WIT 开始,而不是从 SDK 开始
老 Python 插件的问题之一是 SDK 先行。平台先给了一个 Python helper,后来 JavaScript 用户也想写,Go 用户也想写,SDK 越来越多,但底层契约其实还是一份会漂移的 JSON 文档。
Component Model 逼团队先写接口。第一版 WIT 很小:
package harbor:redaction@1.0.0;
interface types {
record message {
role: string,
content: string,
}
record request-context {
tenant-id: string,
request-id: string,
route: string,
metadata: list<tuple<string, string>>,
}
record redact-input {
context: request-context,
messages: list<message>,
}
record replacement {
token: string,
original-hash: string,
kind: string,
start: u32,
end: u32,
}
record redact-output {
messages: list<message>,
replacements: list<replacement>,
diagnostics: list<string>,
}
variant redact-error {
invalid-input(string),
policy-denied(string),
resource-exhausted(string),
internal(string),
}
}
interface plugin {
use types.{redact-input, redact-output, redact-error};
redact: func(input: redact-input) -> result<redact-output, redact-error>;
}
world redaction-plugin {
export plugin;
}
这个接口故意没有把整个 HTTP 请求暴露给插件。插件拿不到 Authorization header,拿不到供应商路由密钥,也拿不到原始连接信息。context.metadata 只放宿主白名单允许的键,比如业务线、数据区域、策略版本。
replacement.original-hash 也值得一提。插件不允许把原文写进返回值里的诊断信息。需要后续还原时,宿主保存 token 和原文的映射;插件只返回不可逆摘要,方便审计和去重。这个设计一开始让插件作者不太舒服,但后来证明它避免了很多日志污染。
有了 WIT 以后,Rust、TinyGo、JavaScript 生成绑定都围绕同一份接口。平台文档不再说“传一个 JSON,大概长这样”,而是说“你实现 redact,输入输出由接口定义,其他能力需要在 manifest 声明”。
Manifest 是插件的合同
WIT 解决函数接口,manifest 解决插件包和运行策略。Harbor Gateway 设计的 manifest 没有追求通用市场能力,只覆盖第一批脱敏插件。
schema: harbor.plugin.redaction/v1
name: pii-redactor-basic
version: 1.4.2
component: pii_redactor_basic.wasm
wit: harbor:redaction@1.0.0
entrypoint: harbor:redaction/plugin.redact
publisher:
type: tenant
tenant_id: tenant-sandbox-42
contact: security-owner@example.invalid
capabilities:
dictionary:
- name: employee-code
mode: lookup
metrics:
allowed_prefixes:
- redaction.hit
- redaction.latency
network: []
filesystem: []
resources:
max_memory_mb: 32
max_execution_ms: 20
max_output_bytes: 262144
max_host_calls: 20
rollout:
default_mode: shadow
sample_rate: 0.05
compare_with: python-plugin:v17
这份 manifest 的价值在于加载前就能拒绝。插件声明自己需要网络,但策略不允许,宿主不会先运行再报错;插件要求 harbor:redaction@2.0.0,而生产宿主只支持 1.x,加载阶段就失败;插件资源预算超出租户套餐,也不会进入发布流程。
加载错误被设计成开发者能看懂:
plugin pii-redactor-basic@1.4.2 rejected:
capability network is not allowed for redaction plugins.
declared:
network: ["https://dictionary.example.invalid"]
suggestion:
move dictionary data to managed dictionary "employee-code",
or request external-service plugin type.
这类错误信息很啰嗦,但比线上 500 强太多。插件平台如果要收紧边界,就必须把拒绝原因讲清楚。否则用户会觉得新运行时只是更难用。
宿主能力要小,别给一个万能 HTTP
Wasm 沙箱不是自动安全。真正危险的地方往往是宿主函数。你给插件一个 http_request(url, body),它就重新获得了大半个外部世界;你给它一个 read_config(key),它就可能开始试探不该看的配置。
Harbor Gateway 第一版只给了三个宿主能力:
package harbor:redaction-host@1.0.0;
interface dictionary {
lookup: func(name: string, key: string) -> option<string>;
}
interface metrics {
emit-counter: func(name: string, value: u64);
}
interface clock {
now-ms: func() -> u64;
}
这些能力看起来弱,但正因为弱,权限才好审计。dictionary.lookup 只能访问 manifest 里声明过的词库;metrics.emit-counter 只能写允许前缀;clock.now-ms 是为了让插件做本地诊断,不需要系统时间权限。
宿主调用日志会记录能力使用,但不会记录敏感参数原文:
{
"plugin": "pii-redactor-basic",
"version": "1.4.2",
"request_id": "req_7f4c_example",
"host_calls": [
{"capability": "dictionary.lookup", "name": "employee-code", "key_hash": "sha256:7ab..."},
{"capability": "metrics.emit-counter", "name": "redaction.hit.email"}
],
"elapsed_ms": 8,
"memory_peak_mb": 11,
"status": "ok"
}
这让审计问题从“插件有没有偷偷访问外部”变成“插件声明了哪些能力,实际用了哪些能力”。边界清楚以后,安全评审就不再只是读脚本。
第一个迁移插件:员工编号脱敏
第一批试点选择了一个不太复杂但足够真实的场景:某租户会在 prompt 里写内部员工编号,比如 E-102938,模型不能看到真实编号,需要替换成 [EMPLOYEE_CODE_1]。有些编号要查词库确认是否有效,避免把普通文本误伤。
老 Python 插件大概是这样:
import re
def redact(request):
mapping = {}
idx = 1
for msg in request["messages"]:
def repl(match):
nonlocal idx
code = match.group(0)
if not lookup_employee_code(code):
return code
token = f"[EMPLOYEE_CODE_{idx}]"
mapping[token] = code
idx += 1
return token
msg["content"] = re.sub(r"E-[0-9]{6}", repl, msg["content"])
request["_redaction_mapping"] = mapping
return request
迁到 Wasm 后,插件不再自己保存原文映射,也不能直接查外部服务。它只返回 token、位置、类型和原文摘要;真实映射由宿主按租户策略保存到短期安全存储。
伪代码变成这样:
fn redact(input: RedactInput) -> Result<RedactOutput, RedactError> {
let mut messages = input.messages;
let mut replacements = Vec::new();
let mut index = 1;
for message in messages.iter_mut() {
let rewritten = replace_codes(&message.content, |code, start, end| {
if dictionary::lookup("employee-code", code).is_none() {
return None;
}
let token = format!("[EMPLOYEE_CODE_{}]", index);
index += 1;
replacements.push(Replacement {
token: token.clone(),
original_hash: sha256(code),
kind: "employee-code".to_string(),
start,
end,
});
Some(token)
});
message.content = rewritten;
}
Ok(RedactOutput {
messages,
replacements,
diagnostics: vec![],
})
}
这个例子很小,却把新边界都走了一遍:插件只处理被允许的输入;词库访问经过宿主;原文不出现在诊断里;输出大小和 host call 次数受限制;同一个组件可以在本地 runner、预发和生产用同一份接口测试。
影子运行:不要让新插件直接接管请求
迁移最容易犯的错是“编译成功就上线”。Harbor Gateway 没这么干。第一阶段所有 Wasm 插件都跑 shadow mode:生产请求仍走旧 Python 插件,Wasm 插件并行执行,但结果只用于比较,不影响真实转发。
比较器不会保存完整原文,只保存结构化差异:
{
"request_id": "req_shadow_example",
"plugin": "pii-redactor-basic",
"python_version": "v17",
"wasm_version": "1.4.2",
"result": "mismatch",
"diff": {
"message_count_equal": true,
"replacement_count": {"python": 3, "wasm": 2},
"first_mismatch": {
"kind": "employee-code",
"position_bucket": "message[1]:120-160"
}
}
}
他们给 shadow mode 设了明确退出条件:
| 指标 | 进入灰度的门槛 |
|---|---|
| 结果一致率 | 连续 7 天 99.5% 以上 |
| 插件 p95 耗时 | 不高于 Python 版本,且小于 20ms |
| 资源超限 | 连续 7 天 0 次 |
| 未分类错误 | 0 |
| 敏感原文日志扫描 | 0 命中 |
这里的一致率不是越高越好。试点中发现一些“不一致”其实是 Wasm 版本修掉了旧 Python 的误伤。团队没有机械追求 100%,而是把差异分成三类:兼容问题、旧逻辑缺陷、新逻辑缺陷。只有分类之后,才决定是改插件,还是改 golden case,还是给租户发行为变更说明。
失败样本一:正则库差异让中文边界漏了
第一次 shadow 运行,员工编号插件很快出现差异。Python 版本用了一个正则库,\b 在某些中英文混合文本里表现和 Rust 侧不同。例子是:
请把张三E-102938的审批意见发给我
Python 插件匹配到了 E-102938,Wasm 插件没有。平台一开始以为是 bug,后来讨论后决定:这不应该依赖语言正则的边界语义。接口测试里新增了明确样例,插件实现也改成扫描 E- 后接六位数字,再由前后字符规则判断。
新增测试用例长这样:
case: employee-code-adjacent-to-cjk
input:
messages:
- role: user
content: "请把张三E-102938的审批意见发给我"
dictionary:
employee-code:
E-102938: valid
expected:
content: "请把张三[EMPLOYEE_CODE_1]的审批意见发给我"
replacements:
- kind: employee-code
token: "[EMPLOYEE_CODE_1]"
这类问题和 Wasm 本身无关,但迁移会把隐含行为翻出来。原来大家以为“脱敏员工编号”是一个业务规则,实际里面混着语言库语义、Unicode 边界、历史误伤和用户期望。影子运行的价值就在这里:让新旧实现一起跑,用真实流量找到文档里写不出来的边界。
失败样本二:插件没联网,但宿主词库把延迟带进来了
第二个问题更接近运行时设计。插件本身没有网络能力,但它调用宿主 dictionary.lookup。某天词库服务延迟抖动,插件耗时跟着上升。用户只看到 Wasm 插件慢了,差点误判为新运行时性能问题。
复盘后,团队给宿主能力加了两条约束:
| 约束 | 说明 |
|---|---|
| host call 单次超时 | dictionary.lookup 超过 2ms 返回 resource-exhausted |
| 每次调用缓存 | 同一个请求里同一个 key 只查一次 |
manifest 也新增了预算字段:
resources:
max_execution_ms: 20
max_host_calls: 20
host_call_timeout_ms:
dictionary.lookup: 2
fallback:
on_resource_exhausted: use_previous_plugin
这个失败提醒团队:Wasm 沙箱只隔离插件代码,不会自动隔离宿主能力的尾延迟。只要插件能调用宿主,宿主能力就必须像外部依赖一样有超时、缓存、熔断和观测。
测试体系:组件测试之外,还要测拒绝加载
Harbor Gateway 最终把插件测试拆成六类。
| 测试 | 目的 | 样例 |
|---|---|---|
| WIT 兼容测试 | 确认插件实现了正确接口 | wasm-tools component wit 校验 world |
| Golden case | 固定输入输出,防止规则漂移 | 员工编号、邮箱、JSON path 脱敏 |
| Differential test | 新旧插件对同一批样本比较 | Python v17 vs Wasm 1.4.2 |
| Capability test | 未声明能力必须失败 | 插件调用未授权 dictionary 被拒绝 |
| Resource test | 超时、内存、输出大小可控 | 构造大文本和重复 host call |
| Load rejection test | manifest 不合规时加载失败 | 网络能力、签名缺失、版本不兼容 |
本地 runner 是整个体验的关键。插件作者可以在自己机器上跑:
harbor-plugin run \
--manifest plugin.yaml \
--input fixtures/employee-code.json \
--trace \
--limits production
输出不是简单的成功失败,而是把资源和能力都列出来:
status: ok
elapsed: 7.4ms
memory_peak: 10.8MiB
host_calls:
dictionary.lookup: 3 calls, 1 cache hit
output_bytes: 1842
diagnostics: 0
当插件越权时,本地就失败:
status: rejected
reason: capability_denied
detail: plugin called dictionary.lookup("vip-code") but manifest only grants ["employee-code"].
这比“线上策略更严格,本地先随便跑”要好得多。插件平台要长期有人愿意用,必须让限制前置。否则开发者会绕过平台,用最老的脚本入口继续干活。
发布流水线:用户上传的不是 wasm 文件,而是可追踪包
老系统允许用户在控制台贴 Python 脚本。新系统不允许直接上传裸 wasm。插件包必须经过构建、扫描、签名和注册。
包结构很普通:
pii-redactor-basic-1.4.2/
plugin.yaml
pii_redactor_basic.wasm
wit/
harbor-redaction-1.0.0.wit
fixtures/
employee-code.yaml
sbom.json
signature.sig
发布流程是:
| 步骤 | 动作 |
|---|---|
| 构建 | 从源码仓库生成 component,记录编译器和依赖版本 |
| 校验 | 检查 WIT、manifest、资源预算和禁用能力 |
| 测试 | 跑 golden case、资源测试、拒绝加载测试 |
| 签名 | 用租户或平台的发布身份签名 |
| 注册 | 写入内部 plugin registry,生成不可变版本 |
| 灰度 | shadow -> 5% -> 25% -> 100% |
生产宿主只从 registry 加载已签名版本。控制台上看到的是插件版本、签名者、能力声明、资源预算、当前流量比例和最近错误,而不是一个“上传文件”的按钮。
回滚也按版本做:
runtime_policy:
tenant_id: tenant-sandbox-42
plugin: pii-redactor-basic
active_version: 1.4.2
previous_version: python-plugin:v17
mode: canary
traffic_percent: 25
rollback_if:
error_rate_gt: 0.5%
p95_latency_ms_gt: 20
mismatch_rate_gt: 1.0%
触发回滚时,网关不需要重启,不需要删除插件包,只是把租户策略切回旧版本。失败样本会保留,方便后续复盘。
观测:插件调用应该像一次小型请求
Wasm 插件不是黑盒。Harbor Gateway 把每次插件调用都记录成一个 span,字段比老 Python 版本多很多:
| 字段 | 示例 |
|---|---|
plugin.name | pii-redactor-basic |
plugin.version | 1.4.2 |
plugin.runtime | wasm-component |
wit.version | harbor:redaction@1.0.0 |
tenant.id_hash | sha256:... |
input.bytes | 18420 |
output.bytes | 17302 |
elapsed.ms | 8.1 |
memory.peak_mb | 11 |
host_calls.count | 4 |
capabilities.used | dictionary.lookup,metrics.emit-counter |
status | ok/resource_exhausted/policy_denied/internal |
注意这里没有原文、没有完整租户 ID、没有用户 prompt。脱敏插件最讽刺的事故就是把原文写进自己的日志。平台后来还加了一条 CI 检查:插件 diagnostics 里如果出现输入片段,golden case 会失败。
看板也不按“Wasm 总体成功率”这种大口径展示,而是按插件、版本、租户策略、错误类型分开。这样当一个版本出问题时,SRE 可以判断是单个插件、宿主能力、某个租户词库,还是运行时整体回退。
迁移不是消灭 Python,而是把脚本入口降级
Harbor Gateway 最后没有删除 Python 运行时。它把 Python 从默认扩展方式降级成“兼容运行时”,只允许存量插件继续跑,并且不再接受新增高权限能力。新插件默认走 Wasm Component Model;确实需要外部网络或长连接的扩展,改成外部服务插件,通过独立鉴权和配额接入。
最终插件类型变成三层:
| 类型 | 适用场景 | 边界 |
|---|---|---|
| 内置规则 | 常见 PII、平台维护 | 直接在宿主代码里,随平台发布 |
| Wasm 组件 | 短生命周期、输入输出明确、权限可枚举 | WIT + manifest + 沙箱 + 资源预算 |
| 外部服务 | 长任务、复杂依赖、需要网络或状态 | HTTP/gRPC 接口 + 独立部署 + 租户鉴权 |
这个分层比“所有东西都 Wasm 化”更健康。Wasm Component Model 是一个很好的插件边界,但不是所有扩展的唯一答案。它最适合把用户自定义的小逻辑放进一个可审计、可限制、可版本化的盒子里。
最后留下来的工程结论
这次迁移后,团队总结了几条朴素但有用的规则。
接口要比 SDK 更早稳定。SDK 可以换语言、换实现,WIT 契约不能每天变。插件平台一旦有生态,接口演进要像公共 API 一样谨慎。
能力要按业务语义给,不要按系统能力给。lookup-dictionary 比 http_request 更窄,emit-counter 比“写日志”更可控。宿主能力越业务化,越容易审计和限权。
资源预算要进入 manifest,而不是藏在运行时默认值里。插件作者应该知道自己有多少内存、多少时间、多少 host call。预算不是惩罚,是平台契约的一部分。
拒绝加载要早于运行失败。版本不兼容、签名缺失、权限过宽、资源超额,都应该在注册或加载阶段被挡住。含糊地放进生产再报错,才是坏体验。
影子运行比一次性切换可靠。插件迁移经常会挖出历史行为差异,尤其是文本处理、Unicode、JSON path、空值和错误处理。让新旧实现并行一段时间,是把争论变成样本的好办法。
Wasm Component Model 真正打动我的地方,不是“可以用很多语言写插件”。多语言当然有用,但更重要的是它让插件系统终于能认真讨论边界:接口是什么,能力是什么,版本怎么演进,失败怎么解释,谁可以发布,出了问题怎么退。
如果你的平台现在还在直接执行用户脚本,不一定马上要迁到 Wasm。但至少可以先做一件事:把脚本实际用到的输入、输出、权限、依赖、资源和错误列成表。只要这张表写不清楚,问题就不在运行时选型,而在插件边界还没有被真正设计过。