阶段二 · 模型层 · pi-ai(上) · 建议时长 3~4 小时
pi-ai:统一 LLM API消息、流式与模型目录 —— Messages · EventStream · Catalog
把「消息格式、流式事件、模型目录、Token 计量」抽象成与厂商无关的一层类型:上层 Agent 只面对一套协议,底层适配器负责翻译成各家云端 API。本讲读完,pi-agent-core 的输入输出你就全认识了。
- 说清
Provider与Api两层解耦的动机:厂商维度与协议维度为什么不能合并成一层。 - 读懂
Model/Models/Provider的真实类型签名,知道createModels()、setProvider()、getModel()、streamSimple()各自负责什么。 - 掌握
Message类型体系:四种消息角色、四类内容块、stopReason七种取值的语义。 - 会用拉模式迭代器消费
AssistantMessageEvent,并说出它与 agent 层事件的边界。 - 理解模型目录「构建期生成 + 运行时叠加」双机制,以及
pi update --models背后发生了什么。 - 理解
TranscriptContext的品牌类型设计,以及 agent 层StreamFn契约与 pi-ai 异常约定的差异。
§4.1为什么需要一层「统一 LLM API」
一句话类比:pi-ai 之于各家 LLM 云服务,就像 JDBC 之于各家数据库——它定义统一的「请求类型、响应类型、流式事件、错误与计量」,再由各适配器翻译成厂商私有协议。
这件事的必要性来自现实:Anthropic 的 Messages API 用内容块数组与 message_delta 流式事件;OpenAI 有 Chat Completions 与 Responses 两套协议;Google 的 generateContent 又是另一种分片格式。工具调用的字段名(tool_use / function_call / functionCall)、思考块(thinking / reasoning)的表示、甚至「停止原因」的枚举都互不相同。如果 agent 内核直接面对这些差异,每接一家厂商都要改一遍循环逻辑。
pi-ai(包名 @earendil-works/pi-ai)把差异全部压到 src/api/ 目录的协议实现里,向上只暴露四样东西:
- 统一消息与上下文类型:
Context/Message/Tool(§4.3); - 统一流式协议:
AssistantMessageEventStream与 13 种事件(§4.4); - 统一模型目录:
Model元数据、上下文窗口、价格与能力(§4.2、§4.5); - 统一计量:
Usage的 token 与成本字段(§4.6)。
从目录结构也能看出这条分层线。入口 src/index.ts 的注释写得很明确:“Core only, side-effect free: no generated catalogs, no provider factories…Provider factories live under providers/*, API implementations under api/*”——核心只含类型与注册表,不带任何厂商副作用:
src/
├── types.ts # 全部核心类型:Model / Provider / Message / Usage / 事件
├── models.ts # Models 注册表、Provider 接口、createModels / createProvider
├── model-catalog.ts # 目录类型工具:ChatModelCatalog 等 flatten 函数
├── models-store.ts # 目录持久化接口 ModelsStore + InMemoryModelsStore
├── models.generated.ts # 【自动生成】注册全部内置厂商目录
├── api/ # 协议实现,每模块导出 stream / streamSimple
├── providers/ # 厂商工厂:目录 + 认证 + 懒加载 api
├── catalog/ # 构建期目录生成脚本(npm run generate-models)
└── utils/ # event-stream.ts / transcript.ts 等
§4.2Models / Model / Provider:注册表与两层解耦
Model:一条目录记录,不只是一个 id
Model 是对「一个可用模型」的完整描述——它属于哪个厂商、走哪种协议、能力如何、窗口多大、什么价格。先看真实签名(节选,注释有删减):
/** Fields shared by every catalog entry. */
export interface BaseModel<TApi extends string> {
id: string;
name: string;
api: TApi;
provider: ProviderId;
baseUrl: string;
input: ModelInputModality[];
inputLimits?: ModelInputLimits;
cost: ModelCost;
headers?: Record<string, string>;
}
/** Chat model: usable with `stream()` and friends. */
export interface Model<TApi extends Api> extends BaseModel<TApi> {
type?: "chat"; // 可省略:缺省即 chat 模型
reasoning: boolean; // 是否支持思考/推理输出
thinkingLevelMap?: ThinkingLevelMap;
promptCache?: ModelPromptCache; // 各档缓存保留时长
contextWindow: number;
maxTokens: number;
samplingParams?: SamplingParams;
compat?: …; // 按协议分支的兼容性覆盖
}
模型按用途分三种类型:chat(流式对话,type 可省略)、image(生图,type: "image")、classifier(结构化分类)。混排列表不要直接比较 type,用工具函数 isModelType(model, "image") 收窄,或 getModelType(model) 读有效类型——因为 chat 模型的 type 可能是 undefined。
值得注意的细节:README 明确说 “The chat catalog only includes models that support tool calling”——聊天目录只收录支持工具调用的模型,因为工具调用对 Agent 工作流是必需能力。
Provider:厂商抽象,与 Api 两层解耦
Provider 代表「一家厂商的接入点」:身份(id/name)、入口(baseUrl)、认证(auth)、模型目录(getModels()),并把请求委托给底层协议实现:
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly baseUrl?: string;
readonly headers?: ProviderHeaders;
readonly auth: ProviderAuth; // apiKey / oauth 语义,getAuth() 据此判定可用性
getModels(): readonly Model<TApi>[]; // 同步读 last-known 目录
getAllModels?(): readonly ProviderModel<TApi>[]; // 含 image / classifier 全类型
refreshModels?(context: RefreshModelsContext): Promise<void>; // 动态目录
filterModels?(models, credential): readonly Model<TApi>[]; // 按凭据过滤
stream<T extends TApi>(model, context: TranscriptContext,
options?: ApiStreamOptions<T>): AssistantMessageEventStream;
streamSimple(model, context: TranscriptContext,
options?: SimpleStreamOptions): AssistantMessageEventStream;
fetchDeferred?(model, handle, options?): AssistantMessageEventStream;
cancelDeferred?(model, handle, options?): Promise<void>;
generateImages?(model, context, options?): Promise<AssistantImages>;
classify?(model, context, options?): Promise<ClassifierResult>;
}
两层解耦是本讲最重要的架构决策:Provider 是「厂商」维度(谁家、怎么认证、卖什么价),Api 是「协议」维度(请求怎么拼、响应怎么解析)。二者是多对多关系:
- 一个
api被多家厂商复用:KnownApi一共只有 10 个值——openai-completions、openai-responses、azure-openai-responses、openai-codex-responses、anthropic-messages、mistral-conversations、bedrock-converse-stream、google-generative-ai、google-vertex、pi-messages。DeepSeek、Groq、Together、xAI、Fireworks、Ollama、vLLM……全都走openai-completions兼容协议;Kimi For Coding 走anthropic-messages。新接入一个「OpenAI 兼容」厂商不需要写任何协议代码。 - 一家厂商可以同时暴露多种 api 的模型(如 OpenRouter 上同时有 chat 模型与
openrouter-images生图模型)。
协议实现模块(src/api/*.ts)统一导出 stream 与 streamSimple(能力更强的还有 deferred 系列),这个「模块即接口」的形状由 ProviderStreams 类型描述;厂商工厂用 createProvider() 把目录、认证与懒加载的 api 包装到一起:
export function anthropicProvider(): Provider<"anthropic-messages"> {
return createProvider({
id: "anthropic",
name: "Anthropic",
baseUrl: "https://api.anthropic.com",
auth: {
apiKey: anthropicApiKeyAuth(),
oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)",
isSubscription: true, load: loadAnthropicOAuth }),
},
models: Object.values(ANTHROPIC_MODELS), // 生成目录(见 §4.5)
api: anthropicMessagesApi(), // 懒加载协议实现,不提前引入 SDK
});
}
openai-completions 协议实现,新增「OpenAI 兼容」厂商时 L3 一行不改。Models:运行时注册表
Models 是「Provider 集合 + 认证解析 + 请求便捷方法」的运行时注册表。createModels(options?) 返回 MutableModels,用 setProvider() 注册(按 provider.id upsert,另有 deleteProvider() / clearProviders())。读取方法分三套:不带后缀的 getModels()/getModel() 只返回 chat 模型;getModelsOfType("image")/getModelOfType(...) 按类型取;getAllModels() 返回全部类型的 AnyModel。带 Available 的异步方法会先确认该厂商认证已配置;refresh(options?) 触发动态目录刷新。
请求侧入口:stream(model, context, options?) 与 streamSimple(...)(外加 complete()/completeSimple() 一步取最终消息,本质是 stream().result())。动态列出的模型类型被放宽为 Model<Api>,需要按协议定制选项时用 hasApi(model, "anthropic-messages") 类型守卫收窄。
这套结构几乎就是 JDBC 的翻版:Models ≈ DriverManager/DataSource 注册表;Provider ≈ 厂商 Driver(负责 URL、认证、方言修正);Api ≈ 线上协议编解码层;getModel() ≈ 按 JDBC URL 取连接前的元数据查询。
区别在于 Java 生态通常把「驱动 + 协议」捆在一个 jar 里,而 pi-ai 显式拆成两层——因为 LLM 行业的现实是「协议比厂商少得多」:40 余家厂商、10 种协议,多数厂商是 OpenAI 兼容的「贴牌」。两层拆开后,接新厂商常常只是写一个 30 行的工厂加一段目录定义。
hasApi() 类型守卫则相当于向下转型前的 instanceof 检查,只是发生在 TypeScript 编译期。
§4.3Message 类型体系:四种角色、内容块与 stopReason
一句话类比:Message 数组就是 Agent 的「数据库表」——无状态的 LLM API 每次都全量重放这张表,「会话记忆」只是把历史重新发一遍。四种角色各司其职:
| 角色 | 定位 | 关键点 |
|---|---|---|
SystemMessage | 系统指令 + 工具声明的「变更事件」 | 首条是基础 prompt;后续条目是增量:sections 按名替换/删除、toolsAdded/toolsRemoved 调整工具集。按序重放全部 system 消息得到当前 prompt 与工具集。 |
UserMessage | 用户输入 | content 为字符串或 TextContent | ImageContent 数组(支持图片输入)。 |
AssistantMessage | 模型输出(元数据最重的一条) | 内容块可为 text/thinking/toolCall 混排;携带 api/provider/model/usage/stopReason/durationMs 等。 |
ToolResultMessage | 工具执行结果回填 | toolCallId 对应 ToolCall.id;content 支持 text + image;isError 标记失败。 |
真实签名(节选,字段注释有删减):
export interface SystemMessage {
role: "system";
content: string | TextContent[]; // 首条 = 基础 prompt;后续 = 追加指令
sections?: Record<string, string | null>; // 命名 prompt 段:按名替换,null 删除
toolsAdded?: Tool[]; // 此刻起可用的工具完整定义
toolsRemoved?: ToolReference[];
timestamp: number;
}
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api; provider: ProviderId; model: string;
responseModel?: string; responseId?: string;
thinkingLevel?: ModelThinkingLevel;
usage: Usage;
stopReason: StopReason;
deferred?: DeferredHandle;
errorMessage?: string;
endTurn?: boolean;
timestamp: number;
durationMs?: number; // 由 AssistantMessageEventStream 用单调时钟计时
}
export type ToolResultMessage<TDetails = JsonValue> = … {
role: "toolResult";
toolCallId: string; toolName: string;
content: (TextContent | ImageContent)[]; // 支持文本与图片
details?: JsonRepresentation<TDetails>; // 结构化明细(不发给模型正文)
usage?: Usage; // 工具自身消耗;不计入主 LLM 上下文计量
nestedCalls?: NestedToolCalls; // 工具内嵌套调用记录(codemode)
isError: boolean;
timestamp: number; durationMs?: number;
};
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;
内容块:四种积木
| 内容块 | 字段 | 出现位置 |
|---|---|---|
TextContent | { type: "text", text, textSignature? } | system / user / assistant / toolResult |
ImageContent | { type: "image", data(base64), mimeType } | user / toolResult |
ThinkingContent | { type: "thinking", thinking, thinkingSignature?, redacted? } | 仅 assistant |
ToolCall | { type: "toolCall", id, name, arguments(JsonObject), namespace?, argumentsError? } | 仅 assistant |
两个容易被忽略的字段:argumentsError 在模型产出的参数不是合法 JSON 对象时记录原因(此时 arguments 为 {},校验阶段会失败并带上原文);thinkingSignature 保存厂商不透明的推理回放数据,被安全过滤涂黑的 thinking 会置 redacted: true 并把密文放在签名里,以便多轮续传。
stopReason:模型为什么停
StopReason 是 agent loop 的控制信号,源码定义为七值联合:
"pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred"
| 取值 | 语义 | agent loop 的典型反应 |
|---|---|---|
stop | 自然结束 | turn 结束 |
toolUse | 回复中有待执行的工具调用 | 执行工具、回填 toolResult、再次请求 |
length | 达到 maxTokens 被截断 | 提示用户 / 触发续写策略 |
error | 请求或模型错误(errorMessage 有值) | 按重试策略处理 |
aborted | 被取消(AbortSignal) | 终止循环 |
deferred | 厂商延迟响应,返回 DeferredHandle 稍后取回 | 挂起等待 fetchDeferred() |
pending | 流式过程中的中间态 | 仅在 partial 上出现 |
对照提醒:大纲速记里写的 toolCalls 在源码中实际是 "toolUse"(沿用 Anthropic 命名);写扩展或对照日志时请以 types.ts 为准。
SystemMessage 为什么做成「事件流」
把后续 system 消息设计成增量变更(sections patch、工具增删)而不是「每次全量重发 prompt」,直接动机是 prompt caching:各家缓存都按「请求前缀不变」命中,若每轮都重写首条 system 消息,前缀立刻失效。按序重放的语义由 utils/transcript.ts 的 getCurrentSystemPrompt() / getCurrentTools() 等辅助函数实现;支持中途 system 消息的模型(compat 里 supportsMidConvoSystemMessages)原位发送以保住缓存前缀,不支持的模型则调用 collapseSystemMessages() 折叠回单条首部消息。
§4.4EventStream:拉模式流式与 AssistantMessageEvent
一句话类比:EventStream 是一个「带完成语义的异步迭代器」——生产者 push() 事件,消费者 for await...of 拉取,result() 再给一个「最终 AssistantMessage」的 Promise。
基础类 EventStream<T, R>(src/utils/event-stream.ts)内部是一个 FIFO 队列加等待者队列:没有消费者等待时事件入队,有等待者则直接唤醒。完成判定与结果提取通过构造器注入的两个回调完成:
export class EventStream<T, R = T> implements AsyncIterable<T> {
push(event: T): void; // 完成事件到达时置 done 并 resolve 最终结果
end(result?: R): void; // 无终止事件时直接收尾
async *[Symbol.asyncIterator](): AsyncIterator<T> { … }
result(): Promise<R>; // 最终结果(不消费事件也可 await)
}
export class AssistantMessageEventStream
extends EventStream<AssistantMessageEvent, AssistantMessage> {
// done/error 事件 → 取出最终 AssistantMessage 并用单调时钟补 durationMs
}
13 种事件的完整清单
AssistantMessageEvent 是判别联合(discriminated union),按 type 区分。注意每个事件都携带 partial——当前累积状态的 AssistantMessage,因此任意时刻 UI 都能直接渲染「到目前为止的完整回复」:
| 类别 | 事件 | 说明 |
|---|---|---|
| 响应开始 | start | 必发首事件;此时 partial 已含 api/provider/model 等元信息。 |
| 内容增量 | text_start / text_delta / text_end | contentIndex 定位块;delta 是增量字符串;text_end 携带权威全文。 |
thinking_start / thinking_delta / thinking_end | 推理内容流;被涂黑的 thinking 可能在 start 即完整、无 delta。 | |
toolcall_start / toolcall_delta / toolcall_end | 参数 JSON 分片流式到达;toolcall_end 携带解析完成的 ToolCall。 | |
| 成功终止 | done | reason 只能取 "stop" | "length" | "toolUse" | "deferred";message 是最终消息。 |
| 失败终止 | error | reason 只能取 "aborted" | "error";error 字段是带 errorMessage 的 AssistantMessage。 |
协议不变量(类型注释原文):成功流先发 start 再发增量、以 done 结束;请求组装阶段就失败可以不经 start 直接 error;start 之后任何失败也以 error 收尾——错误永远被编码成流内事件,而不是异常。类型定义节选:
export type AssistantMessageEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse" | "deferred">;
message: AssistantMessage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">;
error: AssistantMessage };
start 必先于一切增量,done/error 必为终态。消费代码:README 官方示例(节选)
const s = models.stream(model, context);
for await (const event of s) {
switch (event.type) {
case 'start': console.log(`Starting with ${event.partial.model}`); break;
case 'text_delta': process.stdout.write(event.delta); break;
case 'toolcall_end': console.log(`Tool called: ${event.toolCall.name}`); break;
case 'done': console.log(`Finished: ${event.reason}`); break;
case 'error': console.error(`Error: ${event.error.errorMessage}`); break;
}
}
const finalMessage = await s.result(); // 最终 AssistantMessage(含 usage / stopReason)
context.messages.push(finalMessage);
与 agent 层事件的区别与衔接
这是初学者最容易混淆的一条边界:pi-ai 的事件只描述「一次模型响应内部」(一个 AssistantMessage 从生长到终结);而第 6 讲将看到的 pi-agent-core 事件流描述的是「一个回合(turn)」——turn 开始/结束、assistant 消息事件、工具执行事件等。衔接点在 agent loop:它把 AssistantMessageEventStream 的增量转发为自己的 assistant 事件,把 done 得到的最终消息 push 回 transcript;若 stopReason === "toolUse" 则进入工具执行分支,循环继续。两套协议各管一段,互不越界。
EventStream 可以按两个熟悉的概念叠加理解:Reactive Streams 的 Flow.Publisher(背压语义上退化为单消费者拉模式)+ BlockingQueue 的 hand-off(内部正是 FIFO 队列与等待者队列的配合)。for await...of 相当于 Iterator 循环,result() 相当于 CompletableFuture<AssistantMessage>。
与 Flux 的显著差异:这里没有操作符(map/flatMap),也刻意不做多播——流是一次性的、终态唯一。「错误进流不进异常」的约定,则与 Flux.error(...) 终止信号同构。
§4.5模型目录:构建期生成 + 运行时叠加
模型目录要回答的问题是:某个 (provider, id) 是什么模型、窗口多大、支持什么能力、怎么计价。Pi 的答案是一套双机制:发布时把目录「编译」进包里保证离线可用,运行时再从 pi.dev 叠加更新的数据。
构建期:generate-models
npm run generate-models(catalog/generate.ts)在构建时拉取上游目录——主要数据源是 models.dev v2,OpenRouter、Vercel AI Gateway、NVIDIA NIM、Radius 有各自的抓取模块——经过 catalog/providers/<id>.ts 里的厂商定义(修正 limits/价格、补充上游没有的模型、处理能力标记)后写出三类产物:
src/providers/data/<provider>.json:按 API 分组的模型数据(gitignored,不入库);src/providers/<provider>.models.ts与src/models.generated.ts:提交入库的类型化包装(43 家厂商全部在此注册);--json-output:pi.dev 对外服务的公共目录 JSON。
类型工具在 model-catalog.ts:flattenChatModelCatalog("anthropic", values) 把 JSON 的键转成字面量类型,得到 ChatModelCatalog<typeof values, "anthropic">——于是 getBuiltinModel('openai', 'gpt-4o-mini') 能返回精确的 Model<'openai-responses'>,厂商 id 与模型 id 都能自动补全:
// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/anthropic.json" with { type: "json" };
import { flattenChatModelCatalog, … } from "../model-catalog.ts";
export const ANTHROPIC_MODELS: ChatModelCatalog<typeof values, "anthropic"> =
flattenChatModelCatalog("anthropic", values);
export const ANTHROPIC_IMAGE_MODELS: ImageModelCatalog<typeof values, "anthropic"> =
flattenImageModelCatalog("anthropic", values);
export const ANTHROPIC_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "anthropic"> =
flattenClassifierModelCatalog("anthropic", values);
运行时:pi.dev 目录叠加
包内目录再新,也追不上模型上新速度——这就是「生成 + 叠加」要解决的矛盾。coding-agent 的 ModelRuntime(packages/coding-agent/src/core/model-runtime.ts)在启动时把每个内置 provider 包一层 withRemoteCatalog()(remote-catalog-provider.ts):同步读仍走内置目录;refresh() 时向 https://pi.dev/api/models/providers/<id>?types=chat,image,classifier 发请求,用 If-None-Match 携带上次 ETag 协商缓存(304 则只移动新鲜度窗口),成功后把目录持久化到本地 models-store.json,并只叠加比本地生成时间戳更新的条目,按 "${type}\0${id}" 键合并覆盖。离线时缓存目录照常可用;pi update --models 强制刷新。OpenRouter 是特例,走 withPersonalizedCatalog(),按用户 key 拉取「这把钥匙实际可用、实际计价」的个性化目录并整体替换。
持久化契约由 pi-ai 的 ModelsStore 接口定义(coding-agent 提供 FileModelsStore 实现):
export interface ModelsStoreEntry {
models: readonly AnyModel[]; // 持久化的全类型模型列表
lastModified?: number; // 远端目录 Last-Modified 的时间戳
checkedAt?: number; // 上次完成远端检查的时间
etag?: string; // ETag 原样保存(含引号),回传作 If-None-Match
}
/** Persistent model catalogs keyed by provider ID. */
export interface ModelsStore {
read(providerId: string, options?): Promise<ModelsStoreEntry | undefined>;
write(providerId: string, entry: ModelsStoreEntry, options?): Promise<void>;
delete(providerId: string, options?): Promise<void>;
}
function mergeModels<TModel extends AnyModel>(baseline, dynamic): TModel[] {
const merged = new Map<string, TModel>();
for (const model of [...baseline, ...dynamic])
merged.set(`${getModelType(model)}\0${model.id}`, model); // 后写覆盖 = 叠加
return [...merged.values()];
}
export function withRemoteCatalog(provider, catalogBaseUrl = "https://pi.dev",
localGeneratedAt?) {
let dynamicModels: readonly AnyModel[] = [];
return {
...provider,
getModels: () => mergeModels(provider.getModels(),
dynamicModels.filter((m) => isModelType(m, "chat"))),
refreshModelsAuth: "configured", // pi.dev 服务目录,过期登录不得阻塞刷新
refreshModels: async (context) => { /* 恢复缓存 → 协商请求 → publish 持久化 */ },
};
}
ModelsStore 的 ETag/时间戳让叠加既省流量又可回退到基线。这套「生成 + 叠加」可以按两个老概念叠加理解:打进 jar 的版本化字典(如 ICU 的区域数据包,保证离线基线)+ 配置中心的热更新 overlay(如 Nacos/Apollo 下发覆盖,运行时合并生效)。ETag/If-None-Match 与 304 协商缓存,就是你在网关层用过的那套 HTTP 缓存机制原样落地。
工程上的取舍点在于:models.generated.ts 提交入库(类型精确、可 code review、可回滚),而 data/*.json 不入库(体积大、可再生)——类似「把 schema 编译进产物、把数据当作构建产物」的分离。
§4.6Usage 计量:token 与成本的统一账本
每次响应的 AssistantMessage.usage 是统一账本,字段全部来自真实类型:
| 字段 | 语义 |
|---|---|
input / output | 输入/输出 token 数。 |
cacheRead / cacheWrite | 提示缓存命中读取 / 写入的 token。 |
cacheWrite1h? | 1 小时档缓存写入量,仅 Anthropic 上报(计费为基础 input 价的 2 倍)。 |
reasoning? | 推理 token 数,是 output 的子集;厂商给出拆分时置值(可为 0),不报则留空。 |
totalTokens | 总 token。 |
cost.{input,output,cacheRead,cacheWrite} | 按目录价(每百万 token 单价)估算的分项成本。 |
cost.total / cost.totalFromProvider? | 最优总成本:优先厂商回报的实付金额(如 OpenRouter),否则用目录估算;布尔标记来源。 |
成本计算函数 calculateCost(model, usage)(models.ts 导出)支持分层阶梯价(model.cost.tiers,按本次输入 token 总量选档),并内置了 Anthropic 1h 缓存写入 2 倍计价的特例。会话层面的累计(coding-agent 底部状态栏与 /session)只是把这些 Usage 逐条加总;ToolResultMessage.usage 记录的是「工具自身」的消耗(如 codemode 里发起的生图/分类调用),明确不计入主 LLM 上下文计量,但会随工具结果计入会话成本。
§4.7TranscriptContext 与 StreamFn 契约
最后一块拼图是请求上下文的规范化。公开 API 接受的是 Context:systemPrompt 与 tools 是「便捷速记」。进入 provider 层之前,normalizeContext() 会把它们折叠成一条首部 SystemMessage,产出 TranscriptContext:
export interface Context { // 公开入口的请求形状
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}
declare const transcriptContextBrand: unique symbol;
/**
* Normalized request context passed to providers and API implementations.
* …Only `normalizeContext()` produces this type, so a raw `Context`
* cannot reach provider code by accident.
*/
export type TranscriptContext = {
messages: Message[];
readonly [transcriptContextBrand]: true;
};
export function normalizeContext(context: Context): TranscriptContext {
const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);
const messages = initialMessage
? [initialMessage, ...context.messages] : context.messages;
return { messages } as TranscriptContext;
}
那个 unique symbol 品牌字段(brand)是纯编译期技巧:运行时不存在,但 TypeScript 会拒绝把普通对象当 TranscriptContext 使用——只有 normalizeContext() 这一个入口能生产该类型,从类型系统上杜绝「未折叠 systemPrompt/tools 的 Context 混进 provider 层」。这好比 Java 中用包装类型(wrapper class)区分「已校验的请求对象」与原始 DTO:构造器私有 + 工厂方法唯一。
在这个规范化形状之上,定义了两份「流函数契约」。pi-ai 侧的 StreamFunction(types.ts):接收规范化 transcript(system prompt 与工具只存在于首部 system 消息中),必须返回事件流;请求认证缺失可以直接同步抛异常,但一旦返回了流,后续失败必须编码为流内 error 事件(最终消息 stopReason 为 "error" 或 "aborted")。agent 层的 StreamFn(packages/agent/src/types.ts)在此基础上收紧:
/**
* Contract:
* - Must not throw or return a rejected promise for request/model/runtime failures.
* - Must return an AssistantMessageEventStream.
* - Failures must be encoded in the returned stream via protocol events and a
* final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
*/
export type StreamFn = (
model: Model<Api>,
context: TranscriptContext,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
注意差异:agent 层连同步异常也不允许——因为 agent loop 要用统一的错误事件通道处理一切失败(重试、用户提示、事件日志),异常路径会绕开事件流破坏这个不变量。两者之间的粘合剂是 setDefaultStreamFn()(packages/agent/src/stream-fn.ts):宿主应用(如 coding-agent)在启动时把「查目录、解析认证后调用 models.streamSimple()」的闭包装进去,而 pi-agent-core 本身不依赖 pi-ai 的任何目录或兼容层——这是教科书式的依赖倒置:内核定义接口,外层注入实现。
§4.8动手实践
- 建最小脚本:新建
probe.ts,createModels()后setProvider(anthropicProvider())(或任一你有 key 的厂商工厂),设置对应环境变量(如ANTHROPIC_API_KEY),用getModel('anthropic', …)取模型。 - 打印全部事件类型:构造带一个简单工具的
Context,for await消费models.streamSimple(model, context),用Map<string, number>统计各event.type出现次数;最后打印(await s.result()).usage与stopReason。 - 换厂商验证一致性:改用 OpenAI(或 DeepSeek 等 OpenAI 兼容厂商)的模型再跑一遍,对比两份事件统计——顺序与次数会不同(是否有 thinking、是否真的调用工具),但事件类型集合与终止语义必须一致,这就是统一抽象的价值。
- 观察错误路径:故意清空环境变量重跑,观察认证缺失时
streamSimple的同步异常;再在流中途AbortController.abort(),确认收到error事件且reason === "aborted"、最终消息带errorMessage。
§4.9常见误区
把速记名当成源码事件名
教程与大纲里常见的 message_start / message_end / tool_call_delta 在 pi-ai 源码中分别是 start / done / toolcall_delta;stopReason 的取值是 "toolUse" 而非 "toolCalls"。写 switch 分支前先对照 types.ts 的 AssistantMessageEvent 与 StopReason。
以为 getModel() 会联网刷新目录
getModels()/getModel() 是同步的 last-known 读取,首次 refresh 前动态 provider 返回空列表。要拿最新目录必须显式 await models.refresh();内置静态 provider 对 refresh 是 no-op,叠加由 withRemoteCatalog 的 refreshModels 参与。
以为 prompt caching 与消息顺序无关
各家缓存按「请求前缀逐字节不变」命中。任何对首部 system 消息的重写、工具列表的顶层变更都会使前缀失效。Pi 的对策正是 §4.3 的增量 SystemMessage 设计 + §4.5 目录里的 compat 标记(如 supportsMidConvoSystemMessages、Anthropic 的 inline-tools),尽量让变更发生在「前缀之后」。
把 Context 直接传给 provider 实现
provider 与 api 层只接受 TranscriptContext——prompt 与工具声明必须已折叠进首部 system 消息。brand 类型让原始 Context 在编译期就无法通过;自定义 provider 时同样应从 context.messages 用 getCurrentTools()/getInitialSystemMessage() 读取,而不是期待 context.systemPrompt 字段(那一层不存在这个字段)。
§4.10自测问答
Provider 与 Api 为什么要拆成两层?合并成一层「Provider = 厂商协议」行不行?
因为厂商维度与协议维度是多对多关系:行业里协议远少于厂商——10 种 KnownApi 服务 40 余家 provider,大量厂商(DeepSeek、Groq、Together、xAI、Ollama、vLLM……)复用 openai-completions 兼容协议,Kimi For Coding 复用 anthropic-messages。合并成一层意味着每家厂商都要复制一份协议代码,接「OpenAI 兼容」新厂商从 30 行工厂膨胀成全套解析器;拆开后 Provider 只管认证、baseUrl、目录与价格修正,Api 模块(src/api/*.ts,统一导出 stream/streamSimple)只管消息转换与事件解析,可被任意组合复用。
prompt caching 对消息顺序提出了什么要求?pi-ai 的 SystemMessage 设计如何回应?
各家缓存按请求前缀不变命中:system prompt、工具声明、历史消息中任何一处早期内容被改写,缓存前缀即失效,重新全价计费。pi-ai 把首条 SystemMessage 定为基础 prompt,后续 system 消息设计成增量变更事件(sections 按名替换、toolsAdded/toolsRemoved),按序重放得到当前状态;对支持中途 system 消息的模型原位发送以保住前缀,不支持的模型用 collapseSystemMessages() 折叠。工具增删同样有 compat 级别的协议(如 Anthropic inline-tools)避免顶层工具列表变化打断前缀。
模型数据「生成 + 运行时叠加」解决了什么矛盾?
矛盾是发版节奏追不上模型上新节奏:npm 包一旦发布,内置目录就冻结,但厂商天天上下架模型、调价。解法:构建期 npm run generate-models 把上游目录(models.dev v2 等)编译成提交入库的类型化包装(models.generated.ts,保证离线可用的基线与精确字面量类型);运行时 withRemoteCatalog() 从 pi.dev 按厂商拉取更新,经 ModelsStore(ETag/304 协商、4 小时节流、本地 models-store.json 缓存)持久化,再按 type+id 键 mergeModels() 叠加——只有比本地生成时间新的远端数据才生效,pi update --models 可强制刷新。
AssistantMessageEvent 有哪些类别?done 与 error 的 reason 取值各受什么约束?
四类:响应开始(start)、内容增量(text_start/delta/end、thinking_start/delta/end、toolcall_start/delta/end,均带 contentIndex 与累积 partial)、成功终止 done、失败终止 error。done.reason 只能取 "stop" | "length" | "toolUse" | "deferred";error.reason 只能取 "aborted" | "error"。不变量:start 必先于一切增量与终止事件;请求组装失败可不经 start 直接 error;错误永远以流内事件而非异常表达,且终止事件携带带 errorMessage 与 stopReason 的最终 AssistantMessage。
StopReason 有哪七种取值?agent loop 最关心哪一个?
"pending"(流中中间态)、"stop"(自然结束)、"length"(达到 maxTokens 截断)、"toolUse"(有待执行的工具调用)、"error"、"aborted"(被取消)、"deferred"(厂商延迟响应,附 DeferredHandle)。agent loop 最关心 toolUse:它是「请求 → 执行工具 → 回填 toolResult → 再请求」循环的继续信号;stop 则结束当前 turn。
TranscriptContext 的 brand 字段起什么作用?agent 层 StreamFn 与 pi-ai StreamFunction 的异常契约有何差异?
transcriptContextBrand 是编译期唯一符号:运行时不存在,但只有 normalizeContext() 能产出带该标记的类型,从类型层面保证「未折叠 systemPrompt/tools 的原始 Context」无法混入 provider 层——等价于 Java 里「私有构造器 + 唯一工厂」的包装类型。契约差异:pi-ai 的 StreamFunction 允许在请求认证缺失时同步抛异常,流开始后的失败才编码为流内事件;agent 层 StreamFn 更严——任何请求/模型/运行时失败都不得抛异常或返回 rejected promise,必须全部编码进返回的事件流(最终 stopReason: "error" | "aborted"),让 agent loop 用统一错误通道处理重试与提示。