# 02 模型接口学习记录

## 本节目标

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

学完后要能解释：

- `Context` 为什么是 agent 层和 provider 层的边界。
- `streamSimple()` 为什么只是 provider 路由。
- provider 如何把统一 `Context` 转成厂商 payload。
- provider 如何把厂商原始流转成 pi 标准事件。
- `toolCall` 为什么是 assistant message 的内容。
- `toolResult` 为什么必须回填到下一轮上下文。

## 我们怎么学的

1. 先读 `packages/ai/src/types.ts`，确认模型层统一类型。
2. 再读 `packages/ai/src/stream.ts`，确认 `streamSimple()` 的路由职责。
3. 回到 `packages/agent/src/agent-loop.ts`，对照 `streamAssistantResponse()` 如何组装 `Context`。
4. 选 OpenAI Responses provider 粗看实现，因为真实运行输出里使用的是 `api: "openai-responses"`。
5. 读 `processResponsesStream()`，看 OpenAI `response.*` 事件如何翻译成 pi 标准事件。

## 普通 LLM 请求 vs Agent 请求

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

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

Agent 请求多了工具和循环：

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

区别：

- 普通请求通常只需要模型直接回答。
- Agent 请求会把工具 schema 也交给模型。
- 模型返回 `toolCall` 时，只是提出“我要调用这个工具”。
- 本地工具执行完成后，`toolResult` 必须放回下一轮 `Context.messages`。
- 所以 agent 请求的关键不是“一次请求参数更复杂”，而是“请求、工具执行、上下文回填形成循环”。

```mermaid
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

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

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

- `systemPrompt`
- `messages`
- `tools`

这意味着 provider 不直接认识：

- `AgentSession`
- CLI/TUI/RPC
- session 文件
- steering/followUp 队列
- compaction/retry 产品逻辑

### Message

```ts
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
```

模型层只认三种消息：

- 用户消息
- assistant 消息
- 工具结果消息

### AssistantMessage

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

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

- `text`
- `thinking`
- `toolCall`

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

### AssistantMessageEvent

provider 返回的是事件流，事件包括：

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

## Context 是边界

我们的结论：

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

agent 层内部可以使用：

- `AgentMessage[]`
- `AgentTool[]`
- `AgentContext`

但 provider 层只吃：

```ts
{
	systemPrompt,
	messages,
	tools,
}
```

完整边界是：

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

可以用这张图记：

```mermaid
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]
```

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

- `Context`：agent 整理好后交给 provider 的统一请求包。
- `Provider`：把统一请求翻译成厂商 API，又把厂商事件翻译回 pi 事件。
- `AssistantMessageEvent`：provider 回给 agent loop 的标准流式事件。

## streamSimple 的职责

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

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

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

1. 根据 `model.api` 找 provider。
2. 给 provider 补环境变量里的 API key。
3. 调用 provider 的 `streamSimple()`。

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

## 为什么 provider 返回 stream event

provider 不直接返回完整 `AssistantMessage`，而是返回 `AssistantMessageEventStream`。

原因：

- 支持流式显示。
- 支持 thinking/text/toolCall 的结构化增量。
- agent loop 可以维护 partial assistant message。
- 最终仍然可以通过 `response.result()` 获取完整 assistant message。

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

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

## toolCall 为什么有 start/delta/end

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

示意：

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

在 `toolcall_end` 之前不能执行工具，因为：

- JSON 可能还没闭合。
- 参数还不能可靠解析。
- schema validation 还不能做。
- 过早执行会导致半截参数被执行。

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

更完整地说：

- `toolcall_start`：工具调用块开始，通常能知道 tool call id 和工具名。
- `toolcall_delta`：参数正在增量生成，常见是 JSON 字符串片段。
- `toolcall_end`：工具调用块结束，参数才适合解析、校验和交给 agent loop。

```mermaid
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` 时不能急着执行工具：

- JSON 可能还没闭合。
- 参数可能还没通过 schema validation。
- 同一个 assistant message 可能还有其他内容块没有结束。
- agent loop 需要完整 assistant message 来稳定匹配 tool call 和 tool result。

## toolResult 为什么要回填 Context

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

流程必须是：

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

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

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

回填图：

```mermaid
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 输出里出现了：

```json
"api": "openai-responses"
```

### openai-responses.ts

这个文件负责：

- 创建 OpenAI client。
- 把 `Context` 转成 OpenAI Responses params。
- 发起 `client.responses.create(..., stream: true)`。
- 创建一个空的统一 `AssistantMessage output`。
- 调用 `processResponsesStream()`。

关键形态：

```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 标准事件。

事件映射：

```text
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
```

```text
response.output_item.added + message
-> text_start

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

response.output_item.done + message
-> text_end
```

```text
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
```

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

```mermaid
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
```

最后：

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

streamOpenAIResponses()
-> done 或 error
```

## 回到 agent-loop

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

关键流程：

```ts
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 层负责翻译。
- agent-loop 层负责消费事件并推进循环。
- 工具执行发生在完整 assistant message 之后。

## 本节最终结论

provider 层只负责“翻译”：

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

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

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

## 本节完成度

已完成：

- 理解 `Context` 是 agent 和 provider 的边界。
- 理解 `Message` 的三种角色。
- 理解 assistant message 可以包含 `text/thinking/toolCall`。
- 理解 `AssistantMessageEventStream` 的作用。
- 理解 `streamSimple()` 的 provider 路由职责。
- 理解 OpenAI Responses 的事件翻译路径。
- 理解 `toolResult` 必须回填到上下文。

未展开，后续章节继续：

- 不同 provider 的 payload 兼容差异。
- TypeBox 工具 schema 如何转换成 provider tool schema。
- `validateToolArguments()` 如何做参数校验。
- 默认工具如何注册到 `Context.tools`。
- 多 provider 切换和 cross-provider replay 细节。

## 下一节方向

下一节进入 `03 Agent loop：读懂核心循环`。

虽然第一节已经读过 `runLoop()`，但下一节会更细地看：

- `runLoop()` 的内外循环。
- `executeToolCalls()` 的并行/串行执行。
- `beforeToolCall` 和 `afterToolCall` hook。
- `shouldStopAfterTurn` 和 `prepareNextTurn`。
- steering/followUp 队列如何影响下一轮模型调用。
