考虑下面这个小程序:
a = tool("lookup", key="book")
b = tool("lookup", key="shipping")
result = a + b
普通调用要先拿到完整程序,然后依次查价格、查运费、相加。流式生成时,第一条读取可能早已写完,后面几行还在传输。Spine 利用这段时间启动允许提前执行的 Host 读取。Python 变量的赋值和最终程序执行仍由同一个 Guest 完成。
本文依据 Spine 的 190ea2e 版本。最后会分别列出直接观察 Prefix 对象的实验和真实 Wasm 测试,方便区分两种证据。
1. 先把参与者放在一张图里
源码生产方 Go Runner
│ chunks: <-chan string │
└───────────────────────── receiveSource
│ prefix_feed(片段)
▼
Guest 的 Prefix 对象
source / seen / ready / claimed
│ prepare(request)
▼
Go 的 runState / Future
│ 启动工具 goroutine
▼
response + done
源码通道关闭
└─ 同一个 Guest 执行完整 source
└─ 原调用或 PLM helper 领取 handle
└─ resolve 等待并交付 response
这里有两条进度:一条是源码接收到哪里,另一条是已经启动的工具做到哪里。源码接收阶段会运行 Python 的解析和辅助代码,但不会执行用户程序的赋值、循环或分支。提前启动的实际工作发生在 Go 工具函数中。
2. RunPrefix 只创建一轮执行状态
prefix.go · L11–L23 · 打开完整源码
// RunPrefix receives append-only UTF-8 source fragments. Closing chunks commits
// the source for one execution in the same Guest; ctx cancels an unfinished stream.
func (r *Runner) RunPrefix(ctx context.Context, chunks <-chan string, inputs any) (Output, error) {
if chunks == nil {
return Output{}, errors.New("nil source stream")
}
for _, name := range []string{"prefix_begin", "prefix_feed"} {
if r.code.ExportedFunctions()[name] == nil {
return Output{}, fmt.Errorf("missing prefix Guest export: %s", name)
}
}
return r.run(ctx, "", inputs, true, chunks)
}
chunks 是接收方向的 channel。它的关闭表示本轮源码传输完成。RunPrefix 首先检查 channel 和 Guest 导出函数,然后进入统一的 r.run。
此时传给 run 的 source 是空字符串,真实源码随后从 channel 接收。plm=true 为本轮启用允许提前读取的机制。
runner.go · L95–L116 · 打开完整源码
func (r *Runner) Close(ctx context.Context) error {
r.image = nil
err := r.runtime.Close(ctx)
if r.cow != nil {
err = errors.Join(err, r.cow.close())
r.cow = nil
}
return err
}
// Run owns the instance, I/O and all Guest allocations until it returns.
func (r *Runner) Run(ctx context.Context, source string, inputs any) (Output, error) {
return r.run(ctx, source, inputs, false, nil)
}
// RunPLM enables the built-in Guest AST pass, only for explicitly allowed snapshot reads.
func (r *Runner) RunPLM(ctx context.Context, source string, inputs any) (Output, error) {
return r.run(ctx, source, inputs, true, nil)
}
func (r *Runner) run(ctx context.Context, source string, inputs any, plm bool, chunks <-chan string) (Output, error) {
一轮执行会创建:
- 一个
runState,持有本轮 context、Future 表和 worker 计数; - 一个 Guest 实例,持有本轮的线性内存和 Python 状态;
- 一对输出缓冲区。
这些对象会覆盖源码接收和最终执行两个阶段。源码到齐时,系统不再另建一个 Guest 去执行,否则刚才积累的 Python Prefix 状态就无法直接使用。
3. 接收循环怎样把片段送进 Guest
prefix.go · L25–L57 · 打开完整源码
func receiveSource(ctx context.Context, m api.Module, initial []byte, chunks <-chan string) error {
status, err := callWithBytes(ctx, m, "prefix_begin", initial)
if err != nil {
return err
}
if status[0] != 0 {
return errors.New("prefix initialization failed: ")
}
received := len(initial)
for {
select {
case <-ctx.Done():
return ctx.Err()
case chunk, ok := <-chunks:
if !ok {
return ctx.Err()
}
received += len(chunk)
if received > maxMessage {
return errors.New("prefix input exceeds 1 MiB")
}
if chunk == "" {
continue
}
status, err = callWithBytes(ctx, m, "prefix_feed", []byte(chunk))
if err != nil {
return err
}
if status[0] != 0 {
return errors.New("prefix feed failed: ")
}
}
}
先调用一次 prefix_begin,传入本轮输入。之后每个非空片段通过 prefix_feed 送进去。C 包装最终调用缓存的 Python 函数,并把临时调用结果的引用归还。
guest/bootstrap.py · L37–L44 · 打开完整源码
return 0
def resolve_call(handle, name, **args):
return decode(resolve(handle, json.dumps({"tool": name, "args": args})))
def prefix_begin(request):
_prefix 是当前 Guest 中的 Python 模块变量。每轮使用新实例,所以另一次 Run 有自己的 _prefix。
接收循环还有三个值得注意的判断:
ctx.Done()让未结束的源码流可以被取消。- 空片段不会触发一次新的
prefix_feed。 - 初始 JSON 字节数加上后续片段字节数,共同受
maxMessage约束。目前上限为 1 MiB,这是仓库的演示策略。
chunk, ok := <-chunks 中,ok=false 表示通道已关闭。此时函数返回 ctx.Err():正常关闭时它为 nil;已经取消时它带回取消原因。因此,同一个返回语句能区分正常收流完成和取消。
实际接入流式服务时,还要保证每个片段是有效 UTF-8。C 的 Python 调用会逐段把字符串解码;如果上游把一个多字节字符切成两段,应先在接收适配器中拼成有效文本。当前 demo 按换行拆分文件,避免了这个问题。
4. source、seen、ready、claimed 各记什么
guest/prefix.py · L9–L16 · 打开完整源码
class Prefix:
def __init__(self, inputs, manifest, prepare):
self.inputs = inputs
self.manifest = manifest
self.early_names = {spec["name"] for spec in manifest if spec["allow_early_read"]}
self.prepare = prepare
self.source = ""
self.seen = 0
source:到目前为止收到的全部文本,包括尾部未完成部分。seen:前面连续候选语句中,已经处理到的位置。它用于防止下一次解析重复准备同一条语句。ready:成功取得的(request, handle)列表。这里没有工具的返回值。claimed:最终执行已经领取到列表中的哪个位置。
例如 ready 中有句柄 1,最多说明 Host 接受了一项提前读取。该任务可能仍在运行。完成状态和响应内容保存在 Go 的 Future 中。
5. “完整前缀”是怎样定义的
guest/prefix.py · L18–L25 · 打开完整源码
self.claimed = 0
def feed(self, chunk):
self.source += chunk
# A trailing partial line is not a complete received statement.
complete = self.source[:self.source.rfind("\n") + 1]
try:
tree = ast.parse(complete, filename="<spine>")
每次 feed 都先追加文本,然后只拿到最后一个已收到的换行符为止。尾部没有换行的部分暂时留下。
再把这段文本整体交给 ast.parse。这一顺序带来两个效果:
- 一个调用即使括号已经闭合,只要本段尚未收到末尾换行,也暂时不参与提前准备。
- 有换行也未必能解析。例如括号内部换行、未闭合的三引号字符串,都可能让整段解析失败。
发生 SyntaxError 时,本次 feed 直接返回。最终完整程序的编译会负责报告错误,接收过程不会替用户挑选另一种解释。
片段的组合方式会影响提前启动的时机
假设一次收到:
a = tool("lookup", key="book")
b = (
虽然第一行完整,截取到最后一个换行后的整段文本仍无法解析。因此这一次不会先从里面取出第一行启动。
若第一行先单独到达,系统已经为它做过准备;之后才收到 b = (,则已启动的工作仍存在。这两种分片方式可能产生不同的提前启动进度。最终程序执行的结果仍由完整源码决定。
这说明当前实现采用的是“累计文本整体解析”的简洁策略。它没有维护一套支持错误恢复的增量 Python parser。
6. 哪些语句能进入准备队列
guest/prefix.py · L42–L64 · 打开完整源码
self.ready.append((request, handle))
def candidate(self, statement):
if not (isinstance(statement, ast.Assign) and len(statement.targets) == 1
and isinstance(statement.targets[0], ast.Name)
and statement.targets[0].id != "inputs"
and statement.targets[0].id not in self.early_names):
return False
call = statement.value
return (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
and call.func.id in self.early_names and not call.args
and all(k.arg is not None and self.literal_or_input(k.value) for k in call.keywords))
@staticmethod
def literal_or_input(node):
if isinstance(node, ast.Constant):
return isinstance(node.value, (str, int, float, bool, type(None)))
return (isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name)
and node.value.id == "inputs" and isinstance(node.slice, ast.Constant)
and isinstance(node.slice.value, (str, int)))
def argument(self, node):
if isinstance(node, ast.Constant):
候选需要满足几个条件:
- 顶层语句是单目标赋值,目标为一个普通名字。
- 目标不能覆盖
inputs或tool。 - 右侧直接调用
tool,工具名是字面字符串。 - 参数形式受限:简单字面值,或
inputs的固定字符串/整数下标。
下面几种情况会得到不同处理:
# 可以准备:参数直接来自已经接收的输入
price = tool("lookup", key=inputs["item"])
# 依赖先前计算的变量,留给最终执行
price = tool("lookup", key=chosen_item)
# 分支需要等待 Python 选择
if inputs["discount"]:
price = tool("lookup", key="discounted")
# 普通计算也会结束前面的候选区域
quantity = inputs["quantity"]
这个限制让接收阶段能从现有输入直接构造请求,无需先执行用户代码来获取参数。
扫描只覆盖最前面的连续区域
guest/prefix.py · L26–L40 · 打开完整源码
except SyntaxError:
return # Incomplete/invalid source is diagnosed at final compile.
for index, statement in enumerate(tree.body):
if not self.candidate(statement):
break # Never pass a branch, dependency, mutation, import, or other code.
if index < self.seen:
continue
self.seen = index + 1
call = statement.value
try:
args = {k.arg: self.argument(k.value) for k in call.keywords}
request = json.dumps({"tool": call.func.id, "args": args})
except Exception:
continue # Argument errors are still raised at the actual Python call.
handle = self.prepare(request)
循环遇到第一条不符合条件的语句就 break。例如:
key = tool("lookup", key="choose")
a = tool("lookup", key=key)
b = tool("lookup", key="shipping")
接收阶段可以准备第一条。第二条依赖 key,因此扫描到此停止,第三条也不会越过它提前准备。后续完整程序的 PLM 变换有自己的依赖分析,见完整 AST 变换篇。
seen 在参数取值和 Host 准备之前更新。这意味着同一条已处理的候选不会在每次新片段到来时反复重试。参数提取失败时会留到原调用处报告;Host 返回句柄 0 时则不加入 ready。
7. Host 如何决定是否真正启动
future.go · L41–L59 · 打开完整源码
func (s *runState) prepare(request callRequest) uint32 {
// 64 pending reads is a demo resource policy, not a Wasm limit.
spec, ok := s.runner.manifest[request.Tool]
if !s.enabled || !ok || !spec.AllowEarlyRead || len(s.futures) >= 64 {
return 0 // Explicitly not prepared; original call will execute normally.
}
ctx, cancel := context.WithCancel(s.ctx)
f := &future{request: request, done: make(chan struct{}), cancel: cancel}
s.next++
id := s.next
s.futures[id] = f
s.workers.Add(1)
go func() {
defer s.workers.Done()
defer cancel()
defer close(f.done)
value, err := spec.Call(ctx, request.Args)
f.response = encodeResponse(value, err)
}()
Python 识别出候选,只完成了语法方面的筛选。Host 还会检查:
- 本轮是否启用了提前读取;
- 工具是否列入
earlyReads; - 待处理 Future 数量是否达到当前上限。
通过后,Host 创建一个 Future,把请求、响应缓冲和完成通知关联起来,然后启动工具 goroutine。工具返回后,响应先写入 f.response,随后关闭 f.done。
这里真正提前执行的是工具调用,所以工具必须满足注册时的只读快照约定。最终程序可能编译失败,也可能在执行到这条调用之前出错。已经发出的写操作无法靠“用户最后没运行到这里”自动撤销。
8. 一次实际状态观察
可以在仓库根目录运行下面的短实验。prepare 在实验中只记录请求并返回编号,便于观察 Prefix 状态;它不执行 Go 工具。
from guest.prefix import Prefix
import json
requests = []
def prepare(request):
requests.append(json.loads(request))
return len(requests)
p = Prefix({}, prepare)
chunks = [
'a = tool("lookup", key="bo',
'ok")\n',
'b = tool("lookup", key="shipping")\n',
'result = a + b',
]
for chunk in chunks:
p.feed(chunk)
print(p.seen, len(p.ready), len(requests))
本次直接调用真实 guest/prefix.py 得到的三个计数依次为:
0 0 0
1 1 1
2 2 2
2 2 2
第一段只累积文本。第二段完成第一行,启动一次准备。第三段增加第二条候选。第四段没有换行,且是结果计算,准备队列保持原样。最终 source 已经能正常编译。
9. 收流结束后如何继续执行
runner.go · L120–L127 · 打开完整源码
request, err := json.Marshal(struct {
Source string `json:"source"`
Inputs any `json:"inputs"`
PLM bool `json:"plm"`
Manifest []guestToolSpec `json:"manifest"`
}{source, inputs, plm, r.guestManifest})
if err != nil {
return Output{}, err
源码和输入已经保存在 Guest 中,Go 最后只传一个 {"prefix":true} 标记。这个小请求用于进入最终执行阶段,避免再次复制整段 source 和 inputs。
guest/bootstrap.py · L47–L64 · 打开完整源码
request = json.loads(request)
_prefix = Prefix(request["inputs"], request["manifest"], prepare)
def prefix_feed(chunk):
_prefix.feed(chunk)
def execute(request):
transformed = ""
try:
request = json.loads(request)
if request.get("prefix"):
request = {
"source": _prefix.source,
"inputs": _prefix.inputs,
"manifest": _prefix.manifest,
"plm": True,
execute 把标记展开为 _prefix.source 和 _prefix.inputs,再对完整源码执行 PLM 分析、编译和执行。
完整源码可以没有末尾换行。换行要求只用于接收期间的提前准备筛选,不妨碍最终的 Python 编译。
10. Prefix 与完整 AST 变换如何接在一起
最终执行可能走两条路径:
- AST 变换接受程序,原调用被改写成
prepare_call与resolve_call。 - 程序保留原 AST,仍然直接调用
tool。
这两条路径都能领取 Prefix 提前准备的句柄:
guest/bootstrap.py · L16–L34 · 打开完整源码
def invoke_tool(name, args):
request = json.dumps({"tool": name, "args": args})
handle = _prefix.claim(request) if _prefix is not None else None
return decode(call(request) if handle is None else resolve(handle, request))
def make_tool(name):
def proxy(**args):
return invoke_tool(name, args)
proxy.__name__ = name
return proxy
def prepare_call(thunk):
try:
name, args = thunk()
request = json.dumps({"tool": name, "args": args})
handle = _prefix.claim(request) if _prefix is not None else None
return prepare(request) if handle is None else handle
tool 会先尝试领取,然后选择普通调用或 resolve。prepare_call 也先尝试领取,只有没领到时才请求新的准备。这样 Prefix 的准备结果可以在最终 PLM helper 中继续使用。
prepare_call 对参数提取或 JSON 编码异常返回 0。原调用位置上的参数仍会再次按 Python 规则求值;该位置会报告相应错误,或者通过 resolve(0, ...) 执行普通调用。
11. claim 为什么只看队首
guest/prefix.py · L66–L73 · 打开完整源码
return self.inputs[node.slice.value]
def claim(self, request):
# Leading calls execute in source order even if the full program is not optimized.
# Match the actual request too; never search ahead or reuse a different read.
if self.claimed < len(self.ready) and self.ready[self.claimed][0] == request:
handle = self.ready[self.claimed][1]
self.claimed += 1
匹配要求实际请求字符串与当前队首完全一致。成功后游标前进一次;失败时游标不动,也不往后搜索。
延续前面的实验,先尝试领取 shipping,再领取 book、shipping、shipping,本次观察到:
None, 1, 2, None
这让句柄与前面已识别的源码调用顺序保持联系。同样的请求在源码中出现两次,也会对应两次准备及不同句柄。它们可能返回相同值,但承担的是两次调用。
Go 的 resolve 还会再次核对工具名与参数字节,并删除该句柄:
future.go · L62–L83 · 打开完整源码
func (s *runState) resolve(id uint32, request callRequest) []byte {
if id == 0 {
return s.runner.invoke(s.ctx, request)
}
f := s.futures[id]
if f == nil {
return encodeResponse(nil, errors.New("unknown or consumed future"))
}
delete(s.futures, id) // Single-use handle, not a static source-site slot.
defer f.cancel()
if f.request.Tool != request.Tool || !bytes.Equal(f.request.Args, request.Args) {
f.cancel()
// Only registered snapshot reads were started early. Never reuse mismatched data.
return s.runner.invoke(s.ctx, request)
}
select {
case <-f.done:
return f.response // Includes tool errors: do not retry a failed operation.
case <-s.ctx.Done():
return encodeResponse(nil, s.ctx.Err())
}
匹配后等待 done,交回保存的响应,包括工具错误。错误不会触发一次自动重试。请求不匹配时放弃这份结果,按本次真实请求普通执行。
12. 编译失败、取消和没人领取的工作
源码传输可能被取消,完整源码可能有语法错误,用户代码也可能在中途抛出异常。这时仍要收掉已启动的 Host 工作。
统一 run 一开始就登记了 state.close()。它取消派生 context,然后等待 worker 退出:
future.go · L31–L39 · 打开完整源码
func newRun(ctx context.Context, r *Runner, enabled bool) *runState {
ctx, cancel := context.WithCancel(ctx)
return &runState{runner: r, ctx: ctx, cancel: cancel, enabled: enabled, futures: make(map[uint32]*future)}
}
func (s *runState) close() {
s.cancel()
s.workers.Wait() // Cooperative tools finish before Run returns, including discarded reads.
}
工具需要观察 context 并协作返回。调用方最终收到 Run 的结果或错误时,本轮工具工作已经完成收尾。
Guest 模块和 Host worker 属于不同的资源。当前代码的逆序 defer 会先释放返回缓冲、关闭 Guest,再执行 state.close。提前读取使用 Go 拥有的请求参数和响应,不依赖已关闭的 Guest 内存。完整顺序见所有权与失败收尾篇。
源码生产方则是另一个参与者。RunPrefix 接收 channel,但不拥有任意外部 producer goroutine。实际接入生成服务时,调用方需要把同一个取消信号传给 producer,并安排它自己的等待和关闭。
13. 本次验证了哪些部分
除了上面的 Python 状态观察,本次还在实际 Spine Wasm Guest 上运行了五个定向测试:
- 流关闭前启动读取,并且只领取一次;
- 半句不会执行;
- 依赖等待最终执行;
- 未选中分支不提前准备;
- 接收阶段取消后等待工作退出。
五项均通过。测试来源为 prefix_test.go。它们使用握手信号观察执行关系,本文没有给出性能倍数或虚构的时间线刻度。
14. 读源码时可以用这四个问题自检
- 新片段到达后,哪些文本进入了
ast.parse? - 扫描为何停在这一条语句,后面还有哪些调用没有被准备?
ready中存的是哪一份请求和哪一个句柄,工具是否已经完成?- 最终执行由哪个函数领取这份句柄,错误和收尾会发生在哪里?
把这四个问题顺着源码回答一遍,便能区分“收到源码”“接受候选”“工具完成”和“结果交付”四个时刻。