最近我开始阅读 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还是systemrole; - 输出长度字段叫
max_tokens还是max_completion_tokens; - 是否接受
reasoning_effort; - reasoning 内容放在
reasoning还是reasoning_content; - streaming usage 是否通过
stream_options.include_usage返回。
Pi 的做法不是寻找一个所有 Provider 都严格遵守的“最小公分母”,而是在统一的 Model 和 Context 下面保留兼容层。当前源码会先根据 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 + costAnthropic 的情况更适合中途统计: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 connectionSSE 已经传来的 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 状态之间的边界对象。