AgentZ 技术报告:让模型自己写下一个 cell

  • agentz
  • 技术报告
  • agent

AgentZ 是一个约 900 行的 Python 引擎(agentz/bootstrap.py)。它和常见 Agent 框架的根本区别只有一句话:宿主不写 Agent 的循环,模型写。 宿主只负责在同一个 IPython 进程里执行一个 cell,再执行下一个;下一个 cell 的代码,由模型在当前 cell 里调用 set_next_cell_code 提交。

本文按代码结构逐段说明它是怎么做到的,以及这种设计带来的代价。

与 ReAct Loop 的对比

传统 ReAct Loop 的骨架大致如下:

messages = [{"role": "user", "content": task}]
while True:
    step = llm(messages, tools=TOOLS)
    if step.final_answer:
        break
    observation = TOOLS[step.tool](**step.args)
    messages += [step, observation]

控制流属于宿主,模型每一轮只能从 TOOLS 里选一个,完整的 messages 随每次请求重发。AgentZ 把这几件事全部换掉了:

维度 传统 ReAct Loop AgentZ
谁写循环 开发者,写死在宿主里 模型:每个 cell 写下一个 cell
一步是什么 一次工具调用 一段完整的 Python 程序
能力边界 预先注册的工具清单 两个宿主工具 + 整个解释器
上下文 全部历史逐轮累积 代码决定带什么,历史按需查询
出错时 错误作为观察交回模型 插入恢复 cell,连续三次失败才停止
何时结束 模型给出最终答案 只有用户明确要求才 quit()

表格之外还有一个推论:call_me 会把宿主工具以外的工具调用原样交还给调用它的代码(见下文“续接”),所以 ReAct 并没有被排除:在一个 cell 里写一个 ReAct 循环,只是 AgentZ 能写出的程序之一。

引擎主循环:run()

run() 做的事情可以压缩成这几行:

code = invoke(task + "\nSubmit the first Python cell.")
while True:
    cell = Cell(len(cell_inputs))
    shell.run_cell(code)
    if not cell.next_code:
        raise ValueError("The current cell did not submit a successor")
    code = cell.next_code

真实代码多了三层东西:

  1. 记录。 每个 cell 开始前写入 cell_inputs[n](代码、摘要、生成时间、开始时间、由哪个 cell 生成),结束后写入 cell_outputs[n](状态、捕获的输出、traceback、异常类型、结束时间)。所有字符串都先经过 ui.clean() 脱敏。
  2. Cell 000。 引擎只写过一段代码,就是第一个 cell:response = call_me(input=...) 加一行 print(response.output_text)。之后的每一段代码都来自模型。
  3. 失败处理。 见“恢复”一节。

执行环境是一个常驻的 IPython.InteractiveShell,ast_node_interactivity = "none",所以变量、导入和函数跨 cell 保留,也支持顶层 await。stdout/stderr 被重定向到终端对象,既实时显示,也被捕获进 ui.captured 作为 cell 的输出记录。

Cell 协议

每个 cell 对应一个 Cell 数据对象:

@dataclass
class Cell:
    number: int
    active: bool = True
    next_code: str | None = None
    next_summary: str | None = None
    next_generated_at: float | None = None

引擎在每个 cell 开始前重新注入 call_me 和 quit,二者都经过 only_while_cell_active 包装:

  • 只能在当前 cell 内生效。 cell 结束时 cell.active = False。如果一个模型请求在 cell 结束后才返回并试图提交后继,会得到 Cell N has finished 错误,旧请求无法篡改新 cell。
  • 只能成功提交一次。 set_next_cell_code 第二次调用直接抛出 NextCellAlreadyScheduled,不覆盖第一次的代码。这个异常在 AgentZ.handle() 里被特意重新抛出(其他异常只会作为工具结果回传给模型),因此它会中断当前 cell,而不是被悄悄吞掉。
  • 必须提交。 cell 正常结束却没有 next_code,同样算失败。

这三条规则合起来,保证了“一次只有一条控制流”:并行调用模型是允许的,但后继只有一个。

call_me:请求改写

call_me(input=..., **params) 的接口和 OpenAI SDK 的 client.responses.create 基本一致,返回原生 Response。区别在于 AgentZ.call() 会在发出前改写请求体,把定义“你自己”的四部分强制写回去(简化后):

effort = reasoning.get("effort")
if effort in (None, "none", "minimal", "low"):
    effort = "medium"
body.update(
    model=self.model,
    instructions=self.INSTRUCTIONS,
    background=False,
    timeout=1800,
    max_output_tokens=128000,
    reasoning=reasoning,
    service_tier="fast",
    tools=[t for t in body.get("tools", []) if t.get("name") not in HOST_TOOL_NAMES] + HOST_TOOLS,
)

几个细节:

  • 思考强度只能更高。 none、minimal、low 或未指定都会被提升到 medium;high 等更高的值原样保留。
  • 宿主工具不可替换。 调用方传入的同名工具会被移除,再追加宿主自己的定义。
  • extra_body 不是后门。 SDK 会最后合并 extra_body,所以 model、instructions、tools、reasoning、stream、timeout 等字段会从 extra_body 中剔除。
  • 总是流式。 内部请求一律 stream=True,这样才能实时估算 token 并在 response.completed 时处理宿主工具。调用方没要求流式时,call() 自己消费事件流,只返回最终的 Response。

指令(INSTRUCTIONS)把指令本身、模型、不低于 medium 的思考强度、宿主工具这四部分定义为 Agent 不可变的身份,其余一切都允许模型自行调整。

宿主工具与续接

宿主只提供两个工具:

  • set_next_cell_code(code, summary):strict 模式。登记当前 cell 结束后执行的完整代码,摘要不超过 256 字符。只登记,不执行。
  • get_history_cells(start, end):非 strict 模式,由宿主自行校验参数必须是整数或 null。按 Python 切片 history[start:end] 返回记录;空参数只返回 total。

模型调用宿主工具发生在 call_me 内部,调用方不需要处理。AgentZ.events() 是一个循环:

  1. 发出请求,边读流边估算 token。
  2. 收到 response.completed 后,handle() 执行其中的宿主工具调用,结果按 response.id 存进 self.results。
  3. 如果这一轮只有宿主工具调用,就把原始输入、完整的模型输出(包括 reasoning;function_call 条目去掉仅属于输出的 status 字段)和工具结果拼成续接输入,立刻发出下一次请求。这一轮的终止事件被 continue 吞掉,调用方看不到中间状态。
  4. 如果还有外部工具调用(调用方自己传入的函数工具,或 custom_tool_call、computer_call、local_shell_call、apply_patch_call),就把响应交还给调用方。

调用方执行完外部工具后续接时,call() 会根据 previous_response_id 或输入中的 function_call 条目,自动补齐宿主工具的结果,并替换掉调用方可能自行填写的同 call_id 输出。指令里也要求模型不要把宿主工具和外部工具放在同一批并行调用中,以减少这种交错。

正是第 4 步,让“在 cell 里自己写一个 ReAct 循环”成为可能。

历史与状态

模型的上下文不会自动增长。引擎在 IPython 命名空间里放了这些对象,但不会自动把它们传进 call_me:

  • CELL_INPUTS / CELL_OUTPUTS:按 cell 编号索引的记录,编号从 0 开始。
  • get_last_error():最近一次失败 cell 的完整记录,没有则为 None。
  • ENGINE_SOURCE:引擎自身的源码文本。
  • openai_docs:同目录下 OPENAI_DOCS.md 的内容,文件不存在时为空字符串。
  • RUNTIME_INFO:宿主提供的运行环境信息。
  • input:被重载的输入函数(见“终端界面”)。

需要回顾时,模型通过 get_history_cells 按切片取回。read_history() 对 sorted(cell_inputs) 做切片,天然支持负索引和越界截断;正在运行的 cell 也包含在内,状态标为 running,输出是 ui.captured 的实时快照。

恢复

cell 抛出异常或没有提交后继时,状态记为 error,引擎插入一个恢复 cell:

code = invoke(
    "Repair the failed cell and continue the original task. Existing side effects are not rolled back.\n"
    + json.dumps({"task": task, **record}, ensure_ascii=False)
)

恢复 cell 的摘要固定为“分析上一个 cell 的错误并生成修复代码。”,generated_by 为 None,表示由引擎生成。连续三次失败(恢复 cell 本身失败也计入)时,引擎抛出 RuntimeError 停止;任何一次成功都会把计数清零。

两类异常不走恢复:KeyboardInterrupt/SystemExit 记为 interrupted 后直接向上抛出;AgentQuit 继承自 BaseException,由 quit() 触发,记为 exited 并正常结束循环。指令要求模型只在用户明确要求时才调用 quit();任务完成、暂时无事或等待回复都不算。

终端界面

Terminal 负责两件事:输出记录,和一条常驻的 token 状态栏。

  • 状态栏。 通过 \x1b[1;{height-1}r 把滚动区域限制在倒数第二行以上,最后一行留给状态栏,普通输出在上方滚动。一个守护线程每 0.1 秒刷新 spinner。
  • token 统计。 流式过程中按 UTF-8 字节数除以 4 估算,数字前带 ≈;响应完成后换成 usage 里的真实值。状态栏同时显示当前请求和累计总量。
  • 输入。 被重载的 input() 在交互式终端下使用 prompt_toolkit,状态栏变成底部工具栏;读取期间所有后台输出经过 StdoutProxy,写在输入框上方,不会冲掉正在输入的内容。非交互环境退回普通 input()。
  • 脱敏。 clean() 把 OPENAI_API_KEY 的值替换为 [REDACTED],作用于终端输出、cell 记录和工具结果。

-v/--verbose 会显示每个 cell 的摘要、带行号的代码和 traceback;默认只显示输出和状态行。

运行环境信息

runtime_info() 默认只暴露 provider、platform 和 workspace。部署方可以通过 AGENTZ_RUNTIME_INFO 传入一个 JSON 对象,但只有白名单里的键(如 session_id、memory_gib、public_origin、user_timezone)会被采用,引擎从不枚举环境变量。provider 不是 local 时,指令会额外说明:Agent 运行在远端 Linux 容器里,用户通过浏览器终端连接,可以用 cloudflared 分享网页,并注意隧道链接是公开的。

安全边界与已知限制

这份设计把大量信任交给了模型,部署前需要清楚以下几点:

  • 权限是最大化的。 指令明确要求 Agent 直接读写文件、访问网络、运行命令、安装依赖,并主动使用环境变量里的 API key,不等待用户批准。它应该运行在隔离的容器或虚拟机里,而不是开发者的主力机器上。
  • 脱敏只覆盖一个密钥。 clean() 只替换 OPENAI_API_KEY。环境里的其他密钥如果被打印,不会被遮盖;目前只能依赖指令里“不要打印或记录密钥”的约束。
  • 没有硬执行限制。 代码注释写明:同进程的 IPython 无法强制超时,真正的执行上限需要独立的 worker 进程。一个永远不结束的 cell 会让引擎停在那里。
  • 副作用不回滚。 恢复 cell 面对的是失败之后的真实世界状态,写到一半的文件、已经发出的请求都不会撤销。
  • token 数是估算。 流式期间的数字只用于显示,不能用于计费。
  • 部分参数写死。 默认模型 gpt-6-astra、service_tier="fast"、1800 秒超时和 128,000 的输出上限都在代码里固定,调用方无法覆盖。

运行

uv sync
echo 'OPENAI_API_KEY=...' > .env
uv run agentz "你的任务"
uv run agentz -v --model gpt-6-astra "你的任务"   # 显示 cell 代码与 traceback

main() 会从当前目录的 .env 读取配置(不覆盖已有环境变量)。Ctrl-C 以退出码 130 结束。