π Pi Agent Study

02 模型接口学习记录

本节目标

本节目标是理解 packages/ai 这一层的职责:它不是 agent loop,也不是产品会话层,而是统一模型协议和 provider 适配层。

学完后要能解释:

我们怎么学的

普通 LLM 请求 vs Agent 请求

普通 LLM 请求可以先理解成:

用户消息 + 历史
-> 模型
-> 文本回答

Agent 请求多了工具和循环:

Context(systemPrompt, messages, tools)
-> provider
-> assistant text 或 toolCall
-> agent loop 执行 toolCall
-> toolResult 回填 messages
-> 下一轮 provider 请求

区别:

flowchart LR
  U1[用户输入] --> M1[普通 LLM 请求]
  M1 --> A1[文本回答]

  U2[用户输入] --> C[Agent Context]
  C --> P[Provider]
  P --> TC{assistant 输出}
  TC -->|text| A2[文本回答]
  TC -->|toolCall| T[本地工具执行]
  T --> R[toolResult]
  R --> C

核心类型

packages/ai/src/types.ts 里最重要的是这几个类型。

Context

export interface Context {
	systemPrompt?: string;
	messages: Message[];
	tools?: Tool[];
}

Context 是 provider 层接收的统一输入。它只包含:

这意味着 provider 不直接认识:

Message

export type Message = UserMessage | AssistantMessage | ToolResultMessage;

模型层只认三种消息:

AssistantMessage

export interface AssistantMessage {
	role: "assistant";
	content: (TextContent | ThinkingContent | ToolCall)[];
	usage: Usage;
	stopReason: StopReason;
}

assistant message 不是只有文本。它的内容可以是:

这解释了为什么 tool calling 是模型输出的一部分,而不是 agent loop 凭空造出来的。

AssistantMessageEvent

provider 返回的是事件流,事件包括:

start
text_start / text_delta / text_end
thinking_start / thinking_delta / thinking_end
toolcall_start / toolcall_delta / toolcall_end
done / error

Context 是边界

我们的结论:

Context 是 agent 内部状态被整理后,交给 provider 的统一输入格式。

agent 层内部可以使用:

但 provider 层只吃:

{
	systemPrompt,
	messages,
	tools,
}

完整边界是:

Agent 内部结构
AgentMessage / AgentTool / AgentContext
        |
        | convertToLlm + create llmContext
        v
pi-ai 统一协议
Context / Message / Tool
        |
        | provider.streamSimple()
        v
真实 API payload
OpenAI / Anthropic / Google / Bedrock

可以用这张图记:

flowchart TD
  A[Agent 内部状态] --> B[Context]
  B --> C[Provider adapter]
  C --> D[OpenAI/Anthropic/Google payload]
  D --> E[厂商 stream events]
  E --> F[Pi AssistantMessageEvent]
  F --> G[Agent loop]

新手第一遍只需要抓住三个词:

streamSimple 的职责

packages/ai/src/stream.ts 里的 streamSimple() 很薄:

export function streamSimple(model, context, options) {
	const provider = resolveApiProvider(model.api);
	return provider.streamSimple(model, context, withEnvApiKey(model, options));
}

它不懂 OpenAI、Anthropic、Google 的 payload。它只负责:

所以 streamSimple() 是 provider 路由,不是模型执行逻辑本身。

为什么 provider 返回 stream event

provider 不直接返回完整 AssistantMessage,而是返回 AssistantMessageEventStream

原因:

所以它同时提供两个能力:

for await (event of response)  // 消费过程事件
await response.result()        // 获取最终 AssistantMessage

toolCall 为什么有 start/delta/end

工具调用也是流式生成的,尤其是 JSON 参数可能分多段返回。

示意:

toolcall_start
toolcall_delta: {"path":
toolcall_delta: "README.md"
toolcall_delta: }
toolcall_end

toolcall_end 之前不能执行工具,因为:

所以 provider 负责组装完整 toolCall,agent loop 在完整 assistant message 出来后再执行工具。

更完整地说:

sequenceDiagram
  participant Provider
  participant Loop as Agent loop
  participant Tool
  Provider-->>Loop: toolcall_start(read)
  Provider-->>Loop: toolcall_delta({"path":)
  Provider-->>Loop: toolcall_delta("README.md"})
  Provider-->>Loop: toolcall_end
  Loop->>Loop: validate complete arguments
  Loop->>Tool: execute read({ path: "README.md" })
  Tool-->>Loop: toolResult

这也是为什么看到 toolcall_delta 时不能急着执行工具:

toolResult 为什么要回填 Context

模型发出 tool call 后,它自己还不知道本地工具执行结果。

流程必须是:

assistant message: toolCall(read README.md)
-> pi 本地执行 read
-> toolResult message: README.md 内容
-> 放回 Context.messages
-> 下一次 streamAssistantResponse()
-> 模型基于 toolResult 继续回答

如果不把 ToolResultMessage 放回 Context.messages,下一轮模型调用就看不到工具输出。

因此 ToolResultMessage 不是日志,也不是纯 UI 展示数据。它是下一次模型调用的上下文输入。

回填图:

flowchart TD
  A[assistant message: toolCall] --> B[agent loop 执行本地工具]
  B --> C[ToolResultMessage]
  C --> D[append 到 Context.messages]
  D --> E[下一次 provider 请求]
  E --> F[模型看到工具结果并继续回答]

OpenAI Responses provider

我们选 openai-responses 粗看,因为实际 JSON 输出里出现了:

"api": "openai-responses"

openai-responses.ts

这个文件负责:

关键形态:

const stream = new AssistantMessageEventStream();
const output: AssistantMessage = {
	role: "assistant",
	content: [],
	...
};

let params = buildParams(model, context, options);
const { data: openaiStream } = await client.responses.create(params, requestOptions).withResponse();

stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model);
stream.push({ type: "done", reason: output.stopReason, message: output });

processResponsesStream

这个函数维护同一个 output: AssistantMessage,边读 OpenAI 的原始事件,边更新 output.content,再发出 pi 标准事件。

事件映射:

response.output_item.added + reasoning
-> thinking_start

response.reasoning_text.delta
response.reasoning_summary_text.delta
-> thinking_delta

response.output_item.done + reasoning
-> thinking_end
response.output_item.added + message
-> text_start

response.output_text.delta
response.refusal.delta
-> text_delta

response.output_item.done + message
-> text_end
response.output_item.added + function_call
-> toolcall_start

response.function_call_arguments.delta
-> toolcall_delta

response.function_call_arguments.done
-> 补全 arguments

response.output_item.done + function_call
-> toolcall_end

映射关系也可以压成一张图:

flowchart LR
  O[OpenAI response.* events] --> R[processResponsesStream]
  R --> T[thinking_start/delta/end]
  R --> X[text_start/delta/end]
  R --> C[toolcall_start/delta/end]
  T --> P[Pi AssistantMessageEvent]
  X --> P
  C --> P

最后:

response.completed
-> 更新 usage / cost / stopReason

streamOpenAIResponses()
-> done 或 error

回到 agent-loop

packages/agent/src/agent-loop.tsstreamAssistantResponse() 消费 provider 返回的统一事件。

关键流程:

const llmMessages = await config.convertToLlm(messages);

const llmContext: Context = {
	systemPrompt: context.systemPrompt,
	messages: llmMessages,
	tools: context.tools,
};

const response = await streamFunction(config.model, llmContext, ...);

for await (const event of response) {
	// start -> message_start
	// text/thinking/toolcall -> message_update
	// done/error -> message_end
}

这说明:

本节最终结论

provider 层只负责“翻译”:

Context 翻译成厂商请求
厂商事件翻译成 pi 标准事件

agent-loop 层才负责“执行”:

发现完整 toolCall
执行工具
把 toolResult 回填上下文
决定是否继续下一轮

本节完成度

已完成:

未展开,后续章节继续:

下一节方向

下一节进入 03 Agent loop:读懂核心循环

虽然第一节已经读过 runLoop(),但下一节会更细地看:

Source: 02-model-interface-notes.md