Zhanbo's Blog
Back to home

阅读 Pi Coding Agent:从多 Provider 兼容到结构化工具结果

AI Agent/Learning Notes
2026-08-01
7 min read
0 reads

In brief

Pi 通过 pi-ai、pi-agent-core、pi-tui 和 pi-coding-agent 实现模型协议、Agent 编排、终端界面与 CLI 应用的分层。在多 Provider 支持上,Pi 使用兼容配置统一 OpenAI-compatible API 的字段、reasoning 和 usage 差异;Token 统计则以 Provider 实际返回的数据为准,因此只能做到 best effort。跨 Provider 切换时,Pi 会保留或降级转换 reasoning 等上下文,但无法保证私有签名和超长上下文无损迁移。此外,Pi 支持端到端中止流式请求,并通过结构化工具结果区分模型可见内容与 UI 专用数据,从而保持 Agent 核心的透明、精简与可扩展。

PiCoding AgentAgent HarnessLLMSSETool CallingContext EngineeringTypeScript

最近我开始阅读 Mario Zechner 的文章 What I learned building an opinionated and minimal coding agent,并对照 earendil-works/pi 的源码学习 coding agent harness。

第一轮阅读还没有进入完整的 agent loop,但已经触及几个构建 harness 时绕不开的问题:如何统一不同 Provider 的 API、如何在模型之间移交上下文、如何正确中止流式请求,以及为什么工具结果需要同时服务 LLM 和 UI。这篇文章记录我目前的理解。

从 Pi 的四个组成部分开始

作者开发 Pi 的直接动机,是希望拥有一个简单、稳定、透明的 coding agent:上下文里放了什么应该能够检查,session 格式应该便于后处理,agent core 也不应该和某一种 UI 绑定。

Pi 因此被拆成四个主要部分:

  • pi-ai:统一不同 LLM Provider,处理 streaming、tool calling、reasoning 和 usage。
  • pi-agent-core:运行 agent loop,执行工具并管理状态与事件。
  • pi-tui:提供终端 UI 和差分渲染。
  • pi-coding-agent:把前三层组装成真正可使用的 coding agent CLI。

这个分层很重要。Provider API 的差异属于 pi-ai;“模型—工具—模型”的循环属于 agent core;终端如何显示消息,则属于 TUI。学习 harness 时如果不先划清这些边界,很容易把模型协议、编排逻辑和界面行为混为一谈。

统一 API,不等于假设所有 Provider 都一样

文章提到,覆盖大多数模型只需要理解四种主流 API:OpenAI Completions、OpenAI Responses、Anthropic Messages 和 Google Generative AI。困难不在于 API 数量,而在于大量号称 OpenAI-compatible 的服务只兼容了其中一部分。

例如,不同 Provider 可能存在这些差异:

  • 是否接受 store 字段;
  • system prompt 使用 developer 还是 system role;
  • 输出长度字段叫 max_tokens 还是 max_completion_tokens
  • 是否接受 reasoning_effort
  • reasoning 内容放在 reasoning 还是 reasoning_content
  • streaming usage 是否通过 stream_options.include_usage 返回。

Pi 的做法不是寻找一个所有 Provider 都严格遵守的“最小公分母”,而是在统一的 ModelContext 下面保留兼容层。当前源码会先根据 Provider 和 baseUrl 自动检测,再允许 model.compat 显式覆盖检测结果。

下面这个接口摘自当前项目源码,它把差异变成可声明的能力,而不是散落在业务代码里的判断:

// packages/ai/src/types.ts
export interface OpenAICompletionsCompat {
  supportsStore?: boolean;
  supportsDeveloperRole?: boolean;
  supportsReasoningEffort?: boolean;
  supportsUsageInStreaming?: boolean;
  maxTokensField?: "max_completion_tokens" | "max_tokens";
  // ...其余 Provider 兼容选项
}

构造请求时,适配器再根据这些能力选择字段:

// packages/ai/src/api/openai-completions.ts
if (compat.supportsUsageInStreaming !== false) {
  (params as any).stream_options = { include_usage: true };
}

if (compat.supportsStore) {
  params.store = false;
}

if (options?.maxTokens) {
  if (compat.maxTokensField === "max_tokens") {
    (params as any).max_tokens = options.maxTokens;
  } else {
    params.max_completion_tokens = options.maxTokens;
  }
}

我从这里得到的第一个认识是:好的统一层并不是消灭差异,而是把差异集中、命名并约束起来。抽象仍然会泄漏,但泄漏的位置是可见的。

Token 统计为什么只能是 best effort

Provider 对 usage 的报告时机并不统一。有的会在 SSE 开始时报告输入 token,有的只在最后一个 chunk 中返回完整 usage。如果用户提前 abort,最后一个 chunk 根本不会到达,客户端也就无法知道准确消耗。

Pi 会尽可能请求并解析 Provider 报告的数据。在 OpenAI-compatible streaming 中,它优先读取 chunk.usage,同时兼容把 usage 错放在 choice.usage 的服务。随后再统一为:

input + output + cacheRead + cacheWrite + reasoning + cost

Anthropic 的情况更适合中途统计:message_start 就可能包含输入和 cache token,后续 message_delta 再更新输出 token。因此即使中途 abort,Pi 仍可能保留一部分可靠统计。

但“保留已经收到的数据”不等于“估算出 Provider 没有报告的数据”。如果某个 Provider 只在结束时报告 usage,Pi 不会凭空获得精确账单。这就是作者所说的 best effort:适合个人成本观察,却不能直接当作面向客户的精确计费系统。

浏览器接入与 CORS

pi-ai 可以运行在浏览器中,不代表浏览器能够直接请求所有 Provider。只有开放了合适 CORS 策略的服务才允许直连,而且把 API key 放在浏览器中通常也不安全。

更通用的结构是:

Browser Agent
    ↓ Pi 的统一流式协议
Own Backend /api/stream
    ↓ Provider 原生 API
OpenAI / Anthropic / Other Provider

浏览器通过 streamProxy() 请求自己的后端,Provider 凭据保留在服务端。后端调用真实模型,并将统一事件流转发给客户端。这样同时解决 CORS、密钥保护和服务端认证问题。

Context handoff:移交的是语义,不保证原生状态无损

跨 Provider 切换不仅是把历史文本重新发送一次。不同 Provider 会在 reasoning 和 tool call 事件中加入签名或加密的 opaque blob。这些 blob 不是 SSE chunk 本身,而是某些 chunk 携带的数据。客户端通常不能解释它们,却需要在后续请求中原样回传。

在相同或兼容的 Provider 之间继续对话时,Pi 会尽量保留原生 thinking 和 signature。切换到另一个无法验证该签名的 Provider 时,只能进行有损转换。例如 Anthropic reasoning 可以被降级为普通 assistant 内容:

<thinking>
原模型生成的 reasoning 内容
</thinking>

目标模型能够读到这段历史,但不会把它当成自己生成并签名的原生 reasoning。因此 seamless context handoff 更准确的含义是“对话能够继续”,而不是“所有 Provider 私有状态都能无损迁移”。

这里还有一个容易忽略的限制:如果从 1M context window 的模型切换到 256K 模型,格式转换并不能解决容量问题。Harness 仍然需要 compact、总结或裁剪历史。pi-ai 负责协议兼容,context window 管理则属于上层 harness。

自定义 Model 与端到端 abort

模型目录可以提供类型提示、能力、context window 和价格,但目录永远不可能及时收录全部模型。Pi 因此允许直接构造 Model,描述一个本地 Ollama 模型或尚未注册的 OpenAI-compatible endpoint。

这不是把模型写回全局目录,而是为当前调用提供一份完整的模型描述。它仍然必须使用 Pi 已经支持的 API 风格;Model 接口不会自动发明一种新的传输协议。

这一节随后讨论的 abort,批评对象主要是统一 AI SDK,而不只是底层 Provider。很多 Provider 的 HTTP/SSE 请求本来可以取消,但上层 SDK 可能没有暴露 AbortSignal,没有将信号传到底层,或者取消后直接丢弃已经收到的内容。

Pi 强调端到端取消:

AbortController
  → Pi stream API
  → Provider SDK / fetch
  → SSE connection

SSE 已经传来的 text_delta、thinking 或部分 tool call 参数会被累计,因此中止后仍可得到 stopReason: "aborted" 的部分 response。需要再次强调:部分 response 可以保留,不代表最后才报告的 token usage 也能保留。

Structured split tool results

工具结果同时面对两个消费者:LLM 和 UI。它们需要的信息并不相同。

天气工具可以向模型返回一句简短文本,同时向 UI 返回结构化数据:

return {
  output: `Temperature in ${args.city}: ${temp}°C`,
  details: { temp }
};

模型需要知道温度是多少,UI 则可能需要 { temp: 24 } 来绘制卡片或仪表盘。如果只有一段文本,UI 就不得不重新解析模型可读字符串;如果把全部 UI 数据都交给模型,又会浪费 context token。

因此可以把结果理解为:

ToolResult
├─ output/content → 进入模型上下文,也可以显示
└─ details        → 供 UI 和应用逻辑使用,不必进入模型上下文

工具的 content 还可以包含图片。图片不仅是 UI 附件,也可以在目标模型支持 vision 时转换为 Provider 的原生图片输入,参与下一轮推理。details 和图片附件因此不是同一概念:前者是应用数据,后者属于模型可消费的内容块。

文章写作时,Pi 已经支持模型通过 SSE 流式生成 tool call 参数,并对未完成的 JSON 做增量解析。这让 UI 可以提前展示正在形成的调用。但当时尚未支持工具执行结果自身的 streaming,例如实时显示 bash 不断产生的 ANSI 输出。

所以这一设计的重点并不是“通过 SSE 获取工具结果”,而是三个更明确的能力:

  • 增量解析模型流式生成的 tool call 参数;
  • 将模型可见内容与 UI 专用数据分开;
  • 让工具结果携带文本、图片等统一内容块。

当前阶段的总结

读完文章前半部分后,我对 harness 的理解开始从“调用模型的循环”转向“管理边界的系统”:

  • Provider 抽象要统一调用方式,但必须承认兼容性差异;
  • usage 统计受 Provider 事件协议限制,只能对已报告数据负责;
  • context handoff 解决格式与语义移交,不保证私有状态和超长上下文无损迁移;
  • abort 必须贯穿整个网络与事件链路,部分响应才不会丢失;
  • 工具结果是模型上下文和 UI 状态之间的边界对象。

参考资料

ZB

Zhanbo Chen

Java Backend & AI Agent Developer

Back to home
Comments