阶段 1:一次模型调用(模型面)
对应代码:stage01_model.py |
学习目标
理解"模型服务端只产生事件流,不循环"这件事。把多次请求串成闭环的永远是本地代码------这是理解整个 Agent 架构的第一块基石。
源码锚点
| 概念 | 位置 | 说明 |
|---|---|---|
| 客户端会话 | codex-rs/core/src/client.rs(ModelClientSession) |
一个 Turn 内复用连接;sticky routing/WebSocket 是优化,先忽略 |
| SSE 事件归约 | codex-rs/codex-api/src/sse/responses.rs:387-528 |
response.failed 错误分类、response.completed 判定、流提前 EOF → Stream 错误 |
| 流空闲超时 | codex-api/src/sse/responses.rs:505 |
timeout(idle_timeout, stream.next()),默认 5 分钟 |
| provider 常量 | model-provider-info/src/lib.rs:26-31 |
各类超时/重试的家 |
一条物理流的生命周期(分析文档 7.12 节的"物理响应流状态机"):
text
Created -> OutputItemAdded -> (delta)* -> OutputItemDone* -> Completed
四层包含关系先记住形状:
text
一个公开 Turn
└─ 1..N 个模型轮次
└─ 1..M 个物理流尝试
└─ 0..K 个完成的 ResponseItem
代码走读
Item:对应ResponseItem的教学子集(message / function_call / function_call_output / reasoning 四种形态)。FakeModel.stream():剧本模型。每个剧本元素吐output_item.done事件若干个 + 一个response.completed。之后每个阶段都用它离线测试 ------这正是 codex 自己core/suite集成测试的做法。ResponsesClient.stream():真实客户端。SSE 一行一个data: <json>,对应responses.rs的解析。需要OPENAI_API_KEY,可选CODEX_BASE_URL/CODEX_MODEL。
运行与预期输出
bash
python stage01_model.py
# 三行:一个 function_call 的 output_item.done、一个 assistant message、一个 response.completed
练习
- 给
FakeModel加reasoning类型的 item(type="reasoning"),并让stream吐出来。 - 给
ResponsesClient加"流提前断开"模拟:在 completed 前抛RuntimeError("stream closed before response.completed")------这就是真实代码里CodexErr::Stream的由来(protocol/src/error.rs注释明确标注它可自动重试)。 - 观察:如果剧本用完了会发生什么?(
FakeModel会返回 "(no script left)"------真实系统里这是流异常,不是正常完成。)
与真实实现的差距
- 真实流的 delta 是细粒度的(
output_text.delta),本阶段只保留output_item.done------delta 属于展示层(分析文档 9.1 节),不影响闭环。
代码
python
"""积木 2:Agent Loop。
真实对应物(codex-rs/core/src/session/turn.rs):
- run_turn() 主循环(L272-547):每轮 drain -> 采样 -> follow-up 判定
- needs_follow_up = model_needs_follow_up or has_pending_input(分析文档 7.7 节)
- 外层 Task 循环在 tasks/regular.rs:77-92(本阶段不做,阶段 6 补)
核心认知:Agent Loop 是"模型-工具-模型"的外部编排循环,不是一个 while 里调模型。
退出条件不是"模型说了话",而是 needs_follow_up == false。
运行:python stage02_loop.py
"""
from __future__ import annotations
import asyncio
import json
from typing import Awaitable, Callable
from stage01_model import FakeModel, Item, SampleResult, ToolSpec
ToolHandler = Callable[[str, dict], Awaitable[str]] # (name, args) -> output text
async def sample(model, history: list[Item], tools: list[ToolSpec]) -> SampleResult:
"""一次逻辑采样。对应 try_run_sampling_request() 的归约部分:
收集完成的 item,识别工具调用,计算 last_agent_message / needs_follow_up。"""
result = SampleResult(items=[])
async for ev in model.stream(history, tools):
if ev["type"] == "output_item.done":
item: Item = ev["item"]
result.items.append(item)
if item.type == "function_call":
result.needs_follow_up = True # 模型侧 follow-up(7.7 节)
elif item.type == "message" and item.text:
result.last_agent_message = item.text
elif ev["type"] == "response.completed":
if ev.get("end_turn") is False:
result.needs_follow_up = True
return result
async def execute_tool(call: Item, handler: ToolHandler) -> Item:
"""对应 stream_events_utils.rs handle_output_item_done 的 Ok(Some(call)) 分支
+ parallel.rs 的结果转换:普通失败也包装成 output 回给模型(8.1 节),
只有 fatal 才终止采样。"""
try:
out = await handler(call.name, json.loads(call.arguments or "{}"))
except Exception as e: # 普通错误不终止采样
out = f"tool error: {e}"
return Item(type="function_call_output", call_id=call.call_id, output=out)
async def run_turn(model, user_text: str, tools: list[ToolSpec],
handler: ToolHandler) -> str | None:
"""对应 run_turn():输入用户消息,返回最终 agent message。
不变量:call -> output 配对按模型发出调用的顺序进入历史(7.6 节)。
"""
history: list[Item] = [Item(type="message", role="user", text=user_text)]
while True:
result = await sample(model, history, tools)
history.extend(result.items) # 先记调用(事务语义)
if not result.needs_follow_up: # follow-up 判定是唯一的退出条件
return result.last_agent_message
calls = [i for i in result.items if i.type == "function_call"]
outputs = await asyncio.gather(*(execute_tool(c, handler) for c in calls))
history.extend(outputs) # 工具结果 -> 下一轮 Prompt 的新事实
# ---- 演示:一个只会 echo 的工具 ----
async def shell_handler(name: str, args: dict) -> str:
print(f" [tool] {name} {args}")
return "hi"
async def demo() -> None:
model = FakeModel(
[
{"tool_calls": [{"call_id": "c1", "name": "shell", "arguments": {"cmd": "echo hi"}}]},
{"text": "命令输出了 hi"},
]
)
tools = [
ToolSpec(
"shell",
"run a command",
{"type": "object", "properties": {"cmd": {"type": "string"}}},
)
]
final = await run_turn(model, "跑一下 echo", tools, shell_handler)
print("final:", final)
if __name__ == "__main__":
asyncio.run(demo())
原文为: https://github.com/3127651234jk-creator/learn-to-codex/blob/main/stage01-model.md