Yuzhe's Blog

yuzhes

Python-first Agent:代码是临时的,工作流应该可重放

Agent 已经很会调用工具了。问题通常不是“能不能调”,而是一个任务要调多少轮。

假设要读取一个分页 API,直到没有下一页;再从第二个来源补齐信息,过滤失败项,按几个字段 join,最后只保留五条结果。如果每一步都回到模型,执行过程大概是这样:

LLM decides next call
→ tool call
→ full result enters context
→ LLM reads it
→ LLM decides next call
→ ...

并行 tool call 可以压缩一组已知的独立请求,但它解决不了数据相关的控制流:下一页是否存在、失败项是否要重试、哪些记录要 join,往往要看上一步结果。

我最近在实验另一种边界:让模型生成一段临时 Python,把循环、分页、过滤、join 和有限重试留在一次 run 里;外部访问仍然只能经过 Host 提供的 capability。

这里的代码不是要进入长期维护的代码库。它更像本次任务的 executable plan,用完即弃。

Python-first 不等于 Python 拿到所有权限

最容易做错的版本,是直接给 Agent 一个 container、shell 和 secrets,然后说“反正代码更灵活”。这只是把 tool calling 换成了更大的攻击面。

我想要的结构是:

Agent harness
  │ code + inputs + output schema

CPython/WASI guest
  │ typed Host call

Capability broker
  │ aliases + credentials + budgets + policy

External providers

Model 生成的 request 只有四类字段:一个不可信标签、Python code、JSON inputs 和可选 output schema。它不能在 request 里指定 credential、timeout、memory limit、target origin 或 grant。

这些 authority-bearing 配置由 Host 另外提供。Guest 看到的是 catalogweather 一类 opaque alias,不是带 token 的 URL。

一个简化的 workflow 可以写成:

from agent_runtime import tools

items = tools.fetch_many([
    {
        "request_id": "p1",
        "target": "catalog",
        "path": "/items?page=1",
    },
    {
        "request_id": "p2",
        "target": "catalog",
        "path": "/items?page=2",
    },
])

rows = [
    x["body"] for x in items
    if x["status"] == "ok"
]
result = normalize_and_rank(rows)[:5]

Guest 没有 raw socket,也拿不到 Authorization header。Host 将 alias 映射到 origin,注入 credential,检查 path、timeout、call count、response bytes 和 concurrency,然后为每个 operation 写 receipt。

Python 是 control flow,不是 authority boundary。

为什么不全部继续用直接 tool call

简单动作当然应该直接调用工具。查一次天气、读取一个文件、打开一盏灯,都不值得先生成 Python。

Python 更适合这几类任务:

直接 tool loop 的粗略成本是:

k ×(LLM turn + tool serialization + growing context)

Python workflow 更接近:

1 × code generation
+ 1 × bounded runtime
+ m × provider operation

它并没有消灭 provider calls。真正减少的是 LLM round trip 和进入模型上下文的中间数据。

这目前还是经济性假设,不是已经测出的 token-saving 百分比。要证明它,需要用同一任务比较:完成率、模型调用数、input/output tokens、provider calls、wall time,以及失败后的修复成本。只测 runtime latency 没法回答“是否更省”。

还有一个反方向的成本:生成的代码可能有 bug。如果任务只有两个固定调用,让模型写代码、执行、读 traceback、再修一次,反而比直接 tool call 更贵。Python-first 应该是一个由 harness 选择的执行形态,不是所有动作的强制 ABI。

fetch_many 为什么由 Host 并发

Guest 可以生成一批 read request,但并发上限不能由它决定。

当前原型把 requests 按输入顺序分成 bounded waves。每一波最多运行 Host grant 允许的数量;worker 并发抓取,等待整波结束后,再由单线程 reducer 按原始 operation index 处理:

input-order wave
→ concurrent provider calls
→ join
→ ordered byte-budget admission
→ ordered receipts

这样做牺牲了一点最快完成项的即时返回,却换来两个很实用的性质:

  1. completion order 不会改变哪些 response 被 aggregate byte budget 接纳;
  2. receipts 的顺序不受网络抖动影响。

在一个固定 2 ms/provider operation 的 synthetic fixture 中,20 个顺序请求约为 50.2 ms;Host concurrency 8 时约为 8.0 ms,约 6.3×。这不是生产网络 benchmark,但足以说明 concurrency 应由 Host 在一个 call 内批量管理,而不是让模型自己开无界线程。

Python-first 不会把 MCP 架空

Python 是 Agent 侧的 control flow,MCP、REST 和本地 adapter 仍然可以是 Host 侧的 integration backend。

一次简单调用可以继续走 model → MCP tool。当任务需要分页、join 或 bounded retry 时,路径变成 Python guest → capability broker → MCP client adapter。Guest 不持有 MCP server credential,也不直接继承整套 MCP session。

关键是两条入口都从同一个 canonical capability spec 生成:相同的 target alias、argument schema、budget、policy 和 receipt。否则 direct tool 有一套授权,Python binding 又手写一套,很快就会出现行为和审计不一致。

我也不会在临时 runtime 里开放任意 MCP sampling。那会让一段看似确定性的 Python 在内部再次调用模型,隐藏新的 token budget 和递归 Agent loop。需要语义判断时,应该显式回到 harness。

Playback 不是“再跑一遍外部世界”

如果代码是临时 workflow,它反而很适合被记录。一个 run 可以绑定:

exact generated code + digest
input payload + digest
runtime / artifact identity
capability request sequence
bounded recorded observations
Host receipts
final output digest

我希望的 deterministic playback 是:重新执行同一段 Python,但 Host capability 不再请求真实 provider,而是按照 journal 返回当时记录的 observations。这样可以复现“Agent 为什么得出这个结果”,也可以在修 runtime、parser 或 output schema 后做回归测试。

只有 digest 不够。Digest 可以确认身份,却不能还原 response body。要做真正的 playback,journal 必须保存经过 policy 裁剪的 observation,或保存一个 content-addressed object 引用;两者都要单独处理 retention、敏感字段和访问权限。当前 receipt 只承担 bounded evidence,不等于完整 replay log。

这里的 deterministic 有边界。它不是承诺 CPython 在所有机器上逐字节一致,也不是把今天的 API 状态伪装成新鲜数据。

当前 read-only runtime 已经有 Host-authored receipts,但 durable journal 和 playback mode 仍是设计工作,不能因为存在 digest 就说“已经可重放”。

写操作不能靠 playback 假装回滚

读取旧天气数据最多是过期;重复发送邮件、重复付款或重复发布消息会产生真实后果。

因此未来如果加入 write capability,Python call 不应该直接等于“操作成功”。更合理的返回值是一个 immutable intent:

Python proposes action
→ Host canonicalizes arguments
→ journal stores intent + digest
→ policy chooses DENY / COMMIT / APPROVE
→ Effect Kernel calls provider
→ Host stores receipt or reconciliation state

COMMIT 也不能和 proposal 出现在同一次 Python run 里,否则 generated code 可以立即批准自己。第一轮只生成 staged intent;之后由新的 Agent turn 或可信用户对同一个 digest作决定。

Playback 对 applied effect 只返回原 receipt,绝不能再次调用 provider。如果 provider timeout 发生在“对方可能已经接受、Host 还没收到响应”的位置,状态应该是 reconciliation_required,而不是盲目 retry。

这一整层目前都不是 V1 功能。当前实现只有 read-only fetch_many。我把它写进设计,是因为确定性回放一旦涉及 side effect,问题就从“沙箱”变成了 request identity、idempotency 和 durable state machine。

Fresh 不等于每次都从冷启动开始

把 Python 放进 WASI 后,还有一个很现实的问题:CPython 初始化不便宜。上一篇写了 evaluator runtime 如何处理 post-import snapshot;Agent runtime 使用的 artifact 状态不同,不能直接照搬。

最保守的实现是每个 run 创建 fresh instance,执行后直接销毁。对当前实验 artifact 做结构审计时,我发现只复制 linear memory 不足以安全复用:模块还有 unexported mutable global,wazero 也没有完整 clone/restore module state 的公开 API。

所以我没有硬做 snapshot。默认配置仍然是每个 run fresh instantiate,再在结束后丢弃;只有显式把 pool capacity 设为大于零,才启用一个更笨但边界清楚的可选优化:single-use preinitialised instance

background:
instantiate → _initialize → runtime_init → ready queue

request:
checkout never-served instance
→ attach fresh Host broker
→ trusted request prepare
→ execute once
→ close forever

它不是 persistent notebook。执行过一次的 interpreter 永远不会交给下一个 Agent。

同一套 Linux synthetic fixture 中:

2484× 的 ratio 很醒目,但不能只摘这一行。初始化只是移到了 readiness 和 background refill;capacity 4 单是 guest memory 就会保留 512 MiB,还没算 compiled code、Go heap 和 WASI state。Pool miss 仍回退到 fresh path。

这个可选优化换的是 request latency,不是免费计算。

我最后把它拆成三层

Python-first 对我来说不是“Agent 会写代码,所以以后都写代码”。我更想要的是一条清楚的分工:

LLM
  负责语义判断和 workflow 生成
  处理真正需要推理的异常

Python
  负责本次任务里的循环与数据变换
  执行确定性控制流

Host
  负责 authority、credentials 和 budget
  管理 identity、receipts 与 effects

简单动作继续直接 tool call。需要人类判断的事情继续停下来问。临时代码只接管那些模型不必反复思考、却会反复付 token 和 latency 的部分。

如果这套经济性最后成立,原因不会是 Python 比 LLM “更智能”。正相反:很多 workflow 根本不需要每一步都智能。让模型只出现在需要判断的位置,剩下的交给一段可丢弃、可约束、以后还能 playback 的普通代码,可能才是更便宜也更容易解释的 Agent runtime。