青雲的博客

第二部:模型请求不是一次 fetch

从 AgentMessage 到 provider 请求与统一事件流,拆开模型目录、凭据解析、Context 变换、协议归一化,以及重试、溢出和中止的实际归属层。

消息、模型目录、凭据和 Provider 流式协议共同组成的模型请求边界。
展开阅读路线与实验入口

第一部停在 createAgentSession() 返回。此时 Pi 已经有模型选择、工具和消息历史,却还没有发出网络请求。接下来那段距离不长,层次却很多:Agent 保存的消息先缩到模型能理解的 Message[]Models 再找到 provider 并解析凭据,具体 API 适配器才会组装 payload、建立流并把厂商事件压回统一协议。

flowchart LR
  accTitle: 一次模型请求的分层路径
  accDescr: Agent 状态经过消息转换、运行时 provider 与凭据解析、协议适配和统一事件流,最后回到 Agent 与会话策略层
  AM["AgentMessage[]"] --> TC["transformContext"]
  TC --> CL["convertToLlm"]
  CL --> CX["pi-ai Context"]
  CX --> MR["Models / ModelRuntime"]
  MR --> AU["运行时凭据解析"]
  AU --> PA["Provider API 适配器"]
  PA --> ES["AssistantMessageEventStream"]
  ES --> AL["Agent loop"]
  AL --> SS["AgentSession 策略"]
  CAT["生成的模型目录"] -. 静态输入 .-> MR
  REG["Provider 注册配置"] -. 静态输入 .-> MR
  SS -. 溢出压缩与可见重试 .-> AM

图里的实线是一轮请求实际经过的主要步骤,虚线是目录、注册配置与会话策略对运行时的影响。它不是所有 provider 的网络时序图:不同 API 可能走 HTTP 或 WebSocket,也可能在自己的适配器中增加缓存、推理参数和供应商 SDK。但它们对上仍要交出同一种事件流。

agent-loop.ts 给出了最窄的模型边界:可选的 transformContext 先处理 Agent 上下文,convertToLlm 再产出 Message[],随后才构造 pi-aiContext 并调用流函数。第 6 章从这里开始。

Models 的工作发生在请求时:按 model.provider 找到 provider,解析认证材料,再把带有请求级 options 的模型与 Context 交给 streamstreamSimple。模型目录只是输入,不会自己发起请求。

向上的统一点是 AssistantMessageEventStream。它既可异步迭代增量事件,也能通过 result() 得到终态消息;doneerror 都会结算结果。到第 10 章再看原始供应商事件怎样进入它。

本部还会刻意保留一条更高层的边界:请求适配器可以做短暂、不可见的 transport retry;上下文溢出后的压缩与再次发起一轮 Agent 请求,则由 AgentSession 收口。把两者都简称“重试”,会看不清用户为什么有时能看到倒计时,有时只能看到同一轮请求继续。

下面各章的实验默认只读取固定 tag 下的源码;涉及测试的命令会明确标注依赖前提,不调用真实模型,也不读取本机的 auth.json

深入浅出 Pi 第二部:模型请求不是一次 fetch 第 06 章

AgentMessage 为什么不能直接发给模型

沿 Agent 状态、transformContext 与 convertToLlm 的调用顺序,解释 Pi 为什么允许内部消息扩展,却只在模型边界生成受限的 Message 数组。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

上一章留下了一个待解释的类型:Agent 保存 AgentMessage[],provider 接受的却是 Context.messages: Message[]。两者不能直接画等号。AgentMessage 先包含 pi-ai 的标准 Message,又通过 TypeScript 声明合并接纳上层包定义的消息。coding-agent 的 bash 执行、压缩摘要或自定义通知可以因此留在会话状态里,不必伪装成 user、assistant 或 tool result。

状态里的消息比模型输入更宽

这是一条静态扩展边界,只说明哪些对象能进入 Agent 状态,不保证模型认识它们。真正运行一轮时,顺序是固定的:先对带有 system prompt、全部 AgentMessage 和工具的 AgentContext 执行 transformContext,再调用 convertToLlm,随后用转换结果建立 pi-ai Context

flowchart LR
  accTitle: AgentMessage 到模型 Message 的边界
  accDescr: 上层消息保留在 Agent 状态中,完整上下文先变换,再投影为 provider 可消费的标准消息
  S["Agent state: AgentMessage[]"] --> T["transformContext(AgentMessage[])"]
  T --> C["convertToLlm(AgentMessage[])"]
  C --> M["Context.messages: Message[]"]
  S -. "自定义消息仍可持久化或展示" .-> U["coding-agent / extension"]

顺序并非实现细节。若压缩、裁剪或扩展钩子要参考某种内部消息,它必须在投影前看见;若某条消息只服务于 UI 或会话恢复,转换器可以过滤它。默认转换器只保留角色为 userassistanttoolResult 的消息。自定义产品也可以提供自己的转换函数,但返回值仍受 Message[] 约束。

这也划清了两类兼容性责任。上层新增一种 AgentMessage 时,只要它不应被模型看见,现有 provider 无需跟着修改;但负责安装该消息的产品必须确认转换器会过滤或翻译它。反过来,若直接把内部对象强制断言成 Message,错误会推迟到更深的 API adapter:那里通常按 role 和 content block 分支处理,既不了解扩展字段,也无法判断一条 UI 通知应被删除还是改写成 user 文本。

AgentContext 本身也比持久化 session 窄。它只聚合当前 system prompt、消息和 tools,Agent state 还保存 model、thinking level、streaming 状态与错误等运行信息。transformContext 接收的是消息数组,而非整份可任意重写的 AgentState;这阻止一次请求前的裁剪顺手改掉模型选择或运行标志。

响应方向没有对称的自定义转换:provider 适配器已经生成标准 AssistantMessage,agent loop 把它追加或替换到 AgentMessage[] 中。于是模型输出天然属于 Agent 状态;上层私有消息如何进入模型输入,始终由向外的 converter 单独控制。这条非对称设计让会话可以积累更多事实类型,又不扩大每个 provider 的输入联合。

还要注意,converter 的返回值只服务于这一轮模型调用,不会自动回写 Agent 的历史。若要永久压缩或删除消息,必须由拥有会话状态的上层显式完成;把临时投影误当成持久化变更,会导致界面历史与下一轮 Context 再次分叉。

仓库测试专门放入一条 notification 自定义消息,并断言 converter 能看见完整输入而模型 Context 看不见该通知;相邻测试还锁定 transform 先于 convert。它验证的是内存边界,不需要网络或凭据。

只读重建这条边界

可以先用只读命令重建调用顺序:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/src/agent-loop.ts |
  nl -ba | sed -n '277,312p'
git -C "$repo" show v0.83.0:packages/agent/src/types.ts |
  nl -ba | sed -n '300,319p'

若该 checkout 已按仓库说明安装依赖,可在 packages/agent 下运行 node ../../node_modules/vitest/dist/cli.js --run test/agent-loop.test.ts。这条测试命令不调用真实模型;这里只提供复现入口,不把未执行结果写成事实。

模型边界至此只解决了“什么能发”。下一章继续追问:拿到标准 Context 后,模型条目怎样找到真正会执行请求的 provider。