先读 whole-program PLM。本章不增加第二套 Future,也没有启动第二个 Python 实例。变化是:同一个 Guest 先接收源码片段、尝试准备简单读取,收齐后才执行 Python。
1. 输入从 string 变成 channel
Go 新入口是:
out, err := runner.RunPrefix(ctx, chunks, inputs)
chunks 是 <-chan string,提供按顺序追加的 UTF-8 字符串片段。发送片段不代表执行那段 Python;关闭 channel 才提交最终源码,调用者取消 context 则中止接收。
同一个 Run / 同一个 Guest
prefix_begin(inputs)
prefix_feed("price = tool(...") 半句,不启动
prefix_feed(")\n") 完整顶层调用,可 prepare
prefix_feed("shipping = tool(...)\n") 可 prepare
... channel 关闭 ...
execute compile / exec / resolve
Close 取消并等待未领取任务
两个 Host 读取可以发生在最后一段源码收到之前。Python 的赋值、分支及普通语句仍等到最终 compile/exec 才执行。
接口只允许追加。 没有“替换前面某段源码”或“再传一份不同最终源码”的操作,最终程序就是收到的片段拼接结果;因此不需要再加一套 source identity/hash/审批。
2. 先运行例子
go run ./cmd/demo -source examples/order.py -prefix -show-transformed
仍是价格加运费,结果为 total 47。CLI 的 -prefix 把读入的文件按行片段发送,是一个小型回放 producer,不是已经接入 LLM provider 的实时生成演示。真正的接收接口可以等待外部 producer 逐段发送,测试使用受控 channel 验证这一点。
-show-transformed 显示最终 whole-program AST。它本身不能证明工具在收齐源码前启动;第 7 节的信号握手测试才检查这个事实。
3. prefix.go:复用既有执行生命周期
RunPrefix拒绝 nil channel,检查当前 artifact 的prefix_begin/prefix_feed导出,然后进入共用的run。没有创建另一套 Host runtime。receiveSource在已经初始化的 Guest 中调用 begin,再 select 接收 channel 或 context 取消。每段调用 feed,关闭 channel 后返回。callWithBytes负责临时 Guest 请求分配、写入、调用和释放;现在普通 execute 也用它。它不拥有长期实例或 Future。run仍只创建一次实例和一次 runState。prefix 输入已归 Guest 所有后,最终只发送{"prefix":true},不再把同一份 inputs 和整段源码来回搬一次。
Go channel 可以让接收端等待生产者。这里等待源码时,已经启动的 Host goroutine 仍能推进;它们不需要借用 Guest 内存,也不需要在另一份 CPython 中运行用户表达式。
当前累计预算是初始请求 JSON 字节数加收到的源代码片段字节数,不超过 1 MiB。空片段忽略。片段应各自是有效 UTF-8;这里不是任意字节流的跨片段 UTF-8 解码器。
4. Prefix 对象只做语法读取,不执行用户代码
读 guest/prefix.py。这是一个小数据对象,字段分别是 inputs、源码、已看过的候选数、待领取的请求/handle 列表及领取游标。
feed
每次追加源码后,只取到最后一个换行符为止的文本尝试 ast.parse。尾部半行不是完整收到的语句。当前完整前缀仍有 SyntaxError 时先不准备;最终 compile 再决定程序是否真的有语法错误。
这里只处理最前面连续的顶层简单工具赋值。遇到普通赋值、依赖变量、if、import、方法调用等就停止向后发现候选,不去执行这些语句寻找下一个机会。
已经处理的候选用 seen 计数,后续片段触发重新 parse 时不会重复 prepare。这个版本选择简单的累计解析,不实现增量 parser,也没有预测未收到的 token。
candidate / literal_or_input / argument
候选要求:
- 赋给一个普通变量,不能重绑定
tool或inputs; - 直接调用
tool("固定名称", ...),不接受别名和参数展开; - 参数只能是简单常量,或
inputs["固定字段"]/inputs[固定整数]。
argument 直接从初始 JSON 数据读值,没有 eval/exec,也不会执行任意 AST 表达式。key=a 这类依赖 Python 赋值的参数留到最终 whole-program 路径处理。
缺少输入字段或编码失败时,不在 prefix 阶段抛用户参数错误;实际 Python 执行到那一处时仍会自然失败。Host prepare 返回 0 时没有任务,因此不保留这个请求的缓存副本。
为什么比 whole-program 更窄?
whole-program 阶段已经在执行 Python,可以等一个 resolve 完成、得到变量后再准备依赖它的工具。prefix 阶段没有执行任何 Python 赋值,不能因为变量将来会有值就先猜一个值。
同样,源码开头的 if inputs["yes"]: 也不由 prefix 执行。即使你觉得条件显然为真,本版也不提前进入分支。
5. 怎么领取,为什么不会再调用一次?
Prefix 缓存的只是已经准备好的请求文本与本轮 handle;真正的值/错误还在既有 Go Future 中。
claim(request) 只比较队首请求,匹配才移动游标并返回 handle;不向后搜索另一个同名工具,不重新创建 Future。重复的相同请求是不同队列条目、不同动态 handle,分别领取。
最终有两条路径:
- 整个程序被 PLM pass 接受:生成的
prepare_call先尝试 claim。已有 prefix handle 就直接复用,不再发起 Host prepare。随后原位置的 resolve 领取它。 - 后面的语法不支持 PLM:完整程序普通执行,
tool先尝试 claim,再通过相同 Host resolve 领取。没有缓存才普通 call。因此追加一个 import 不会让前面已准备的读取重复执行。
为什么 FIFO 合适?因为 prefix 只准备最前面连续的独立顶层调用,最终程序又只能追加;它们在正常执行中的顺序不会被新片段改写。分支或其他代码之前不会被跳过去准备。如果将来扩大 prefix 的支持范围,必须重新讨论这个前提,不能把 FIFO 当成通用候选匹配器。
Guest 的请求匹配是缓存查找,Go resolve 仍检查本轮 handle 对应的 tool/args;不会因 Guest 声称“这是同一调用”就采用不同参数的旧结果。未领取的对象由原来的 runState.close 取消和等待。
6. Bootstrap、C 和打包新增了什么?
_prefix默认 None,属于该 Guest;普通 Run 不创建 Prefix。prefix_begin从初始请求建立 Prefix,保存输入快照。prefix_feed将片段交给 Prefix。tool/prepare_call多一次已准备 handle 的领取尝试,结果交付仍由既有 decode/resolve 负责。execute在 prefix 模式从 Prefix 取得最终源码及同一份 inputs,走已有 whole-program/普通路径。
init一次性取得 bootstrap 的两个新函数;prefix_step把字符串传给对应 Python 函数、释放临时 Python 返回对象,并报告调用失败;- 两个导出
prefix_begin/prefix_feed是薄包装,没有第二份 Python 初始化。
构建脚本打包 prefix.py,远程重建 helper 同步这份新源码。Go 会拒绝用缺少 prefix 导出的旧 Wasm 运行新模式,不能靠改本机 Python 源码假装 Guest 已升级。
7. 验证的具体问题
prefix_test.go 的 10 个真实 Guest 子测试覆盖:
- producer 发送第一条完整调用后,必须等真正的 Host 工具启动信号,才发送剩余源码并关闭 channel。若实现等 EOF 才启动工具,会卡住而失败;同时检查工具只调用一次。
- 最终追加 import,whole-program pass 不优化时仍复用已准备读取。
- 最终只有半条语句时没有工具调用,返回 SyntaxError。
- 两个相同请求分别准备和领取,不合并成一次调用。
- 依赖另一个工具结果的调用留到最终执行。
- 不准备未选择分支中的工具。
- 接收中途取消,已启动 worker 退出后 Run 才返回。
- 最终源码有语法错误,仍取消/等待之前启动的读取。
- 累计输入超限报错。
- 空 stream 正常执行空程序,结果为 null。
guest/test_prefix.py 另有 5 个本机 parser/queue 单测,检查拆句、不重复 prepare、不执行分支/任意表达式、参数错误及严格队首领取;它们不替代真实 Wasm。
go test ./... -count=1
go test -race -run TestPrefixGuest -count=1
PYTHONPATH=guest python3 -m unittest guest/test_prefix.py
没有测速度,没有伪造模型输出,也不声称所有可用 prefix 都会被优化。可选的 prepared full-copy 已接入同一个 newGuest 入口,没有复制 Future 或流式状态。本版不实现真实 Linux COW。
8. 留给你的判断
- 为什么末尾未换行的最后一条调用仍可以正常执行,却不一定在 EOF 前 prepare?
- 如果产品需要修改已经收到的源码,而不只是追加,FIFO 的前提哪里失效?
- 如果想跨过
key = ...准备后面的调用,会引入哪些提前执行 Python 的风险? - 为什么不能把已有 prefix handles 的运行中 Guest 直接复制成所有实例的初始化基线?