π Pi Agent 源码学习课程 阶段二 · 4/14

阶段二 · 模型层 · pi-ai(上) · 建议时长 3~4 小时

pi-ai:统一 LLM API消息、流式与模型目录 —— Messages · EventStream · Catalog

把「消息格式、流式事件、模型目录、Token 计量」抽象成与厂商无关的一层类型:上层 Agent 只面对一套协议,底层适配器负责翻译成各家云端 API。本讲读完,pi-agent-core 的输入输出你就全认识了。

packages/ai本讲源码包
10种协议 API
43个内置 Provider
4种消息角色
统一类型层(pi-ai) Message[] · ToolCall · Usage · AssistantMessageEvent anthropic-messagesSSE 内容块 openai-responsesResponses 事件 google-generative-aigenerateContent EventStream 回传统一 上层只写一份代码;换模型 = 换一对 (provider, model) 字符串
pi-ai 的核心承诺:请求向下翻译为厂商协议,响应向上归一为统一事件流。
  1. 说清 Provider 与 Api 两层解耦的动机:厂商维度与协议维度为什么不能合并成一层。
  2. 读懂 Model / Models / Provider 的真实类型签名,知道 createModels()、setProvider()、getModel()、streamSimple() 各自负责什么。
  3. 掌握 Message 类型体系:四种消息角色、四类内容块、stopReason 七种取值的语义。
  4. 会用拉模式迭代器消费 AssistantMessageEvent,并说出它与 agent 层事件的边界。
  5. 理解模型目录「构建期生成 + 运行时叠加」双机制,以及 pi update --models 背后发生了什么。
  6. 理解 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/*”——核心只含类型与注册表,不带任何厂商副作用:

packages/ai/src/ —— 关键文件职责目录速览(依据真实文件)
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 是对「一个可用模型」的完整描述——它属于哪个厂商、走哪种协议、能力如何、窗口多大、什么价格。先看真实签名(节选,注释有删减):

packages/ai/src/types.ts节选
/** 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()),并把请求委托给底层协议实现:

packages/ai/src/models.ts节选
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 包装到一起:

packages/ai/src/providers/anthropic.ts节选:一个真实的厂商工厂
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
  });
}
L0 · 调用方(只见统一类型) agent loop(pi-agent-core) coding-agent(CLI / 会话) 你的脚本(npm 包直接使用) L1 · Models 注册表(src/models.ts) createModels() → MutableModels setProvider · getModel · streamSimple · calculateCost 目录数据(models.generated.ts + models-store 运行时叠加) L2 · Provider 层 = 厂商维度(src/providers/<id>.ts) anthropic kimi-coding deepseek groq xai google…共43家 每家:认证 auth + baseUrl + 目录 + 价格修正;不含协议解析 L3 · Api 层 = 协议维度(src/api/<api-id>.ts,共 10 种) anthropic-messages消息转换 + 事件解析 openai-completionsSSE 分片 → 统一事件 google-generative-aigenerateContent L4 · 传输 fetch:HTTP / SSE / WebSocket(Transport: "sse" | "websocket" | "websocket-cached" | "auto") ↑ AssistantMessageEventStream(统一事件向上回传) ↓ TranscriptContext(规范化消息向下分发)
两层解耦的关键在 L2 与 L3 之间的多对多连线:deepseek、groq、xai 等厂商共享同一个 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") 类型守卫收窄。

JAVA 视角 ①

这套结构几乎就是 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 标记失败。

真实签名(节选,字段注释有删减):

packages/ai/src/types.ts节选:Message 类型体系
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 队列加等待者队列:没有消费者等待时事件入队,有等待者则直接唤醒。完成判定与结果提取通过构造器注入的两个回调完成:

packages/ai/src/utils/event-stream.ts节选:核心骨架
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_endcontentIndex 定位块;delta 是增量字符串;text_end 携带权威全文。
thinking_start / thinking_delta / thinking_end推理内容流;被涂黑的 thinking 可能在 start 即完整、无 delta。
toolcall_start / toolcall_delta / toolcall_end参数 JSON 分片流式到达;toolcall_end 携带解析完成的 ToolCall。
成功终止donereason 只能取 "stop" | "length" | "toolUse" | "deferred";message 是最终消息。
失败终止errorreason 只能取 "aborted" | "error";error 字段是带 errorMessage 的 AssistantMessage。

协议不变量(类型注释原文):成功流先发 start 再发增量、以 done 结束;请求组装阶段就失败可以不经 start 直接 error;start 之后任何失败也以 error 收尾——错误永远被编码成流内事件,而不是异常。类型定义节选:

packages/ai/src/types.ts节选
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 partial 就绪 thinking thinking_start thinking_delta ×n thinking_end text text_start text_delta ×n(delta 为增量字符串) text_end toolCall toolcall_start toolcall_delta(参数 JSON 分片) toolcall_end done reason="toolUse" error(任何时刻) reason="error"|"aborted" 每个事件都带 partial(累积到此刻的 AssistantMessage);done/error 终止流并可用 result() 取最终消息
事件顺序并非固定为「思考→文本→工具」——不同模型可能交错甚至只发其中一类;但 start 必先于一切增量,done/error 必为终态。

消费代码:README 官方示例(节选)

packages/ai/README.md — Quick Start节选
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" 则进入工具执行分支,循环继续。两套协议各管一段,互不越界。

JAVA 视角 ②

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 都能自动补全:

packages/ai/src/providers/anthropic.models.ts全文(自动生成文件的标准形状)
// 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 实现):

packages/ai/src/models-store.ts节选
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>;
}
packages/coding-agent/src/core/remote-catalog-provider.ts节选:叠加与合并核心
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 持久化 */ },
  };
}
构建期(npm run generate-models · catalog/generate.ts) catalog/sources/ models.dev v2 · OpenRouter Vercel · NVIDIA · Radius providers/<id>.ts 映射 + 修正价格/limits families/ 共享规则 providers/<id>.models.ts models.generated.ts(提交入库) providers/data/<id>.json(gitignored) npm 发布 → 随包内置(离线可用的基线目录) 运行期(ModelRuntime · withRemoteCatalog) 内置目录(基线) provider.getModels() GET pi.dev/api/models /providers/<id>?types=… ETag / 304 · 4h 节流 FileModelsStore models-store.json models·lastModified·etag mergeModels() 叠加 键 = type \0 id,后写覆盖 Models 注册表 → /model 基线 拉取 恢复缓存 pi update --models = 强制刷新;离线 / allowNetwork=false = 仅缓存
双机制各取所长:包内目录保证「开箱即用 + 离线可用」,pi.dev 叠加保证「新模型当天可见」,ModelsStore 的 ETag/时间戳让叠加既省流量又可回退到基线。
JAVA 视角 ③

这套「生成 + 叠加」可以按两个老概念叠加理解:打进 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:

packages/ai/src/utils/transcript.ts节选
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)在此基础上收紧:

packages/agent/src/types.ts节选:agent 层契约
/**
 * 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动手实践

  1. 建最小脚本:新建 probe.ts,createModels() 后 setProvider(anthropicProvider())(或任一你有 key 的厂商工厂),设置对应环境变量(如 ANTHROPIC_API_KEY),用 getModel('anthropic', …) 取模型。
  2. 打印全部事件类型:构造带一个简单工具的 Context,for await 消费 models.streamSimple(model, context),用 Map<string, number> 统计各 event.type 出现次数;最后打印 (await s.result()).usage 与 stopReason。
  3. 换厂商验证一致性:改用 OpenAI(或 DeepSeek 等 OpenAI 兼容厂商)的模型再跑一遍,对比两份事件统计——顺序与次数会不同(是否有 thinking、是否真的调用工具),但事件类型集合与终止语义必须一致,这就是统一抽象的价值。
  4. 观察错误路径:故意清空环境变量重跑,观察认证缺失时 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 用统一错误通道处理重试与提示。

Pi Agent 源码学习课程 · 第 4 讲 · 源码基线 C:\pi_agent_study\pi-main 类型与事件名以 packages/ai/src/types.ts 为准