Wasm Component Model:插件运行时终于有了更像样的边界
原创 · 约 40 分钟阅读 · 阅读 --
Last updated on

Wasm Component Model:插件运行时终于有了更像样的边界

作者: Alex Xiang


古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。

很多插件系统最初都不是作为“平台”设计出来的。它只是一个很顺手的逃生口:用户想在 AI 网关里加一点自己的脱敏逻辑,平台团队不可能为每家公司写一版规则,于是开放一个 Python 脚本入口。脚本收到请求,改一下 prompt 或 payload,再把结果交回主流程。

这条路刚开始很舒服。写 Python 的人多,规则上线快,遇到特殊字段也能马上补。但一年后,宿主开始背上脚本的债:依赖冲突、权限过宽、冷启动、超时不可控、日志里混着敏感数据,还有最麻烦的事:平台团队不敢升级运行环境,因为不知道哪家客户的脚本会突然挂掉。

下面这篇不从 Wasm 概念讲起,而是从一个 AI 网关和数据平台的改造讲起。它把用户自定义脱敏插件从 Python 脚本迁到 Wasm Component Model。案例是虚构的,名称、接口、域名和密钥都不是任何真实内部系统,但它尽量保留工程现场的细节:清单表、WIT 接口、manifest、失败样本、测试办法、发布和回滚。

Wasm Component Model 插件运行时结构图

事故不是从 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 testmanifest 不合规时加载失败网络能力、签名缺失、版本不兼容

本地 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.namepii-redactor-basic
plugin.version1.4.2
plugin.runtimewasm-component
wit.versionharbor:redaction@1.0.0
tenant.id_hashsha256:...
input.bytes18420
output.bytes17302
elapsed.ms8.1
memory.peak_mb11
host_calls.count4
capabilities.useddictionary.lookup,metrics.emit-counter
statusok/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-dictionaryhttp_request 更窄,emit-counter 比“写日志”更可控。宿主能力越业务化,越容易审计和限权。

资源预算要进入 manifest,而不是藏在运行时默认值里。插件作者应该知道自己有多少内存、多少时间、多少 host call。预算不是惩罚,是平台契约的一部分。

拒绝加载要早于运行失败。版本不兼容、签名缺失、权限过宽、资源超额,都应该在注册或加载阶段被挡住。含糊地放进生产再报错,才是坏体验。

影子运行比一次性切换可靠。插件迁移经常会挖出历史行为差异,尤其是文本处理、Unicode、JSON path、空值和错误处理。让新旧实现并行一段时间,是把争论变成样本的好办法。

Wasm Component Model 真正打动我的地方,不是“可以用很多语言写插件”。多语言当然有用,但更重要的是它让插件系统终于能认真讨论边界:接口是什么,能力是什么,版本怎么演进,失败怎么解释,谁可以发布,出了问题怎么退。

如果你的平台现在还在直接执行用户脚本,不一定马上要迁到 Wasm。但至少可以先做一件事:把脚本实际用到的输入、输出、权限、依赖、资源和错误列成表。只要这张表写不清楚,问题就不在运行时选型,而在插件边界还没有被真正设计过。