# 第二章：核心类型系统 -- 对话的基本构建块

> 对应源文件：`packages/ai/src/types.ts`

## 概述

类型系统是整个项目的"词汇表"。所有的 provider 适配器、agent 运行时、编码代理，都使用这套类型来描述"对话"这件事。把这些类型搞懂，后面的一切都顺理成章。

我们从最直观的概念开始，逐层深入。

---

## 1. 消息（Message）-- 对话的最小单元

一次 AI 对话本质上是一系列"消息"的来回。pi-ai 定义了三种消息角色：

### UserMessage -- 用户说的话

```
{
  role: "user",
  content: "帮我写一个排序函数",     // 可以是纯文本字符串
  timestamp: 1714300000000            // 毫秒时间戳
}
```

`content` 也可以是一个数组，包含文本和图片的混合内容：

```
{
  role: "user",
  content: [
    { type: "text", text: "这张图片里有什么？" },
    { type: "image", data: "base64编码的图片数据...", mimeType: "image/png" }
  ],
  timestamp: 1714300000000
}
```

### AssistantMessage -- AI 助手的回复

这是三种消息中最复杂的一个，因为 AI 的回复可能包含多种内容：

```
{
  role: "assistant",
  content: [                                    // 内容块数组
    { type: "thinking", thinking: "让我先分析一下需求..." },  // 思考过程
    { type: "text", text: "这是一个快速排序实现：..." },       // 文本回复
    { type: "toolCall", id: "tc_1", name: "write", arguments: { path: "sort.ts", content: "..." } }  // 工具调用
  ],
  api: "anthropic-messages",       // 使用的 API 协议
  provider: "anthropic",           // 服务商
  model: "claude-sonnet-4",        // 具体模型
  usage: {                         // token 使用统计
    input: 1200,                   // 输入 token 数
    output: 800,                   // 输出 token 数
    cacheRead: 0,                  // 从缓存读取的 token
    cacheWrite: 0,                 // 写入缓存的 token
    totalTokens: 2000,
    cost: { input: 0.0036, output: 0.012, ... }  // 费用计算
  },
  stopReason: "stop",              // 停止原因
  timestamp: 1714300005000
}
```

#### 什么是 content 内容块

AI 的一次回复可能包含多个"块"（block），每个块有不同的类型：

| 块类型 | 名称 | 说明 |
|--------|------|------|
| `TextContent` | 文本 | AI 输出的正文，`{ type: "text", text: "..." }` |
| `ThinkingContent` | 思考 | AI 的内部推理过程（不是所有模型都支持），`{ type: "thinking", thinking: "..." }` |
| `ToolCall` | 工具调用 | AI 决定调用一个工具，`{ type: "toolCall", id: "...", name: "read", arguments: { path: "foo.ts" } }` |

一个 `AssistantMessage` 可以同时包含多个块。比如，AI 可能先思考，然后输出一段文字，接着调用一个工具：`[thinking, text, toolCall]`。

#### 什么是 stopReason（停止原因）

LLM 不会无限生成内容，它会在某个时刻停下来。stopReason 告诉你它为什么停了：

| 值 | 含义 |
|----|------|
| `"stop"` | 正常完成，AI 说完了 |
| `"length"` | 达到了最大 token 限制，被截断了 |
| `"toolUse"` | AI 想调用工具，需要你提供工具结果后继续 |
| `"error"` | 出错了 |
| `"aborted"` | 被用户主动取消了 |

### ToolResultMessage -- 工具执行结果

当 AI 调用了一个工具后，你需要执行那个工具并把结果告诉 AI：

```
{
  role: "toolResult",
  toolCallId: "tc_1",             // 对应哪个 toolCall 的 id
  toolName: "read",               // 工具名
  content: [                      // 结果内容（也可以包含图片）
    { type: "text", text: "文件内容：function sort() { ... }" }
  ],
  isError: false,                 // 执行是否出错
  timestamp: 1714300006000
}
```

### 完整对话示例

一次典型的对话流程如下：

```
Message 1: UserMessage      "读一下 src/main.ts"
Message 2: AssistantMessage  [toolCall: read({ path: "src/main.ts" })]   stopReason: "toolUse"
Message 3: ToolResultMessage  toolCallId: "tc_1", content: "文件内容..."
Message 4: AssistantMessage  [text: "这个文件是入口文件，它做了以下事情..."]  stopReason: "stop"
```

---

## 2. 上下文（Context）-- 发给 LLM 的完整信息

`Context` 是你发给 LLM 的所有信息的打包：

```
{
  systemPrompt: "你是一个编码助手，可以读写文件...",   // 系统提示词
  messages: [ ... ],                                    // 上面的消息数组
  tools: [ ... ]                                        // 可用工具列表
}
```

### 什么是 systemPrompt（系统提示词）

System prompt 是给 AI 的"角色设定"和"行为规则"。它告诉 AI "你是谁"、"你能做什么"、"有哪些规则要遵守"。用户看不到这个提示词，但它深刻影响 AI 的行为。

比如 pi 的默认系统提示会告诉 AI：你是一个编码助手，你有 read、write、edit、bash 这些工具可以用，你应该先读文件再修改...

---

## 3. 工具（Tool）-- 让 AI 能"做事"

纯文字聊天的 AI 只能"说"，不能"做"。工具机制让 AI 可以调用你预定义的函数来执行实际操作。

### 工具的定义

```
{
  name: "read",                              // 工具名称，AI 用这个名字来调用
  description: "读取文件内容",                // 描述，帮助 AI 理解何时该用这个工具
  parameters: Type.Object({                  // 参数的 schema（模式定义）
    path: Type.String({ description: "文件路径" }),
    startLine: Type.Optional(Type.Number()),
    endLine: Type.Optional(Type.Number())
  })
}
```

#### 什么是 Schema

Schema 是"数据结构的描述"。它定义了数据应该长什么样。比如上面的 parameters schema 说的是：

- 必须是一个对象
- 必须有一个 `path` 字段，类型是字符串
- 可选有 `startLine` 和 `endLine` 字段，类型是数字

pi-ai 使用 **TypeBox** 库来定义 schema。TypeBox 的好处是：
1. 在 TypeScript 中有完整的类型提示
2. 可以自动验证 AI 返回的参数是否符合格式
3. 可以序列化为 JSON（因为 schema 需要发送给 LLM）

### 工具调用的流程

```
1. 你在 Context.tools 中告诉 LLM 有哪些工具可用
2. LLM 分析用户请求，决定要调用哪个工具
3. LLM 返回一个 ToolCall 块：{ name: "read", arguments: { path: "foo.ts" } }
4. 你的代码执行这个工具，得到结果
5. 你把结果包装成 ToolResultMessage 放回 messages
6. 再次调用 LLM，让它根据工具结果继续回答
```

这就是所谓的 **tool-call loop（工具调用循环）**，是 AI Agent 的核心机制。

---

## 4. 模型（Model）-- 描述一个具体的 AI 模型

每个可用的 AI 模型被描述为一个 `Model` 对象：

```
{
  id: "claude-sonnet-4-20250514",    // 模型的唯一标识
  name: "Claude Sonnet 4",           // 人类可读名称
  api: "anthropic-messages",         // 使用哪种 API 协议
  provider: "anthropic",             // 服务商名称
  baseUrl: "https://api.anthropic.com",  // API 地址
  reasoning: true,                   // 是否支持推理/思考功能
  input: ["text", "image"],          // 支持的输入类型
  cost: {                            // 定价（每百万 token 美元）
    input: 3,
    output: 15,
    cacheRead: 0.3,
    cacheWrite: 3.75
  },
  contextWindow: 200000,             // 上下文窗口大小（token）
  maxTokens: 16384                   // 单次最大输出 token
}
```

### api 和 provider 的区别

这是一个容易混淆的概念：

- **Provider（服务商）**：谁提供这个模型。比如 `"anthropic"`, `"openai"`, `"xai"`
- **Api（API 协议）**：模型使用哪种通信协议。比如 `"anthropic-messages"`, `"openai-completions"`

它们不是一一对应的。很多服务商（xAI、Groq、Cerebras 等）虽然是不同的公司，但它们的 API 都兼容 OpenAI 的 `openai-completions` 协议。所以：

```
OpenAI    → openai-responses (自家协议)
Anthropic → anthropic-messages (自家协议)
Google    → google-generative-ai (自家协议)
xAI       → openai-completions (兼容 OpenAI)
Groq      → openai-completions (兼容 OpenAI)
Cerebras  → openai-completions (兼容 OpenAI)
Mistral   → mistral-conversations (自家协议)
```

这样设计的好处是：同一个 API 协议的适配器代码只需要写一次，所有使用该协议的 provider 都能复用。

### 什么是 Context Window（上下文窗口）

LLM 不能处理无限长的文本。每个模型有一个"上下文窗口"大小，通常用 token 数衡量。所有输入（system prompt + 历史消息 + 工具定义）加上输出，总和不能超过这个窗口。

Token 与自然语言的换算比例因 tokenizer（分词器）和语言而异，不同 provider 使用不同的 tokenizer，无法给出精确统一的数字。粗略参考：

- **英文**：1 token 约 4 个字符，约 0.75 个单词。200,000 token 约 15 万个英文单词
- **中文**：现代 tokenizer（如 OpenAI 的 cl100k_base、Anthropic 的 tokenizer）通常 1 个汉字消耗 1-2 个 token。200,000 token 约 10-15 万个汉字

注意：早期 tokenizer 对中文效率更低（1 个汉字可能消耗 2-4 个 token），而某些针对中文优化的模型（如 DeepSeek）可以做到接近 1:1。实际使用中应以各 provider 的 tokenizer 工具为准。

当对话太长超过窗口时，就需要"压缩"（compaction）-- 这是后面 coding-agent 包要解决的问题。

---

## 5. Usage（使用量统计）

每次 LLM 调用都会返回 token 使用量和费用：

```
usage: {
  input: 1200,           // 发送给 LLM 的 token 数
  output: 800,           // LLM 生成的 token 数
  cacheRead: 5000,       // 从 prompt cache 读取的 token（节省费用）
  cacheWrite: 1200,      // 写入 prompt cache 的 token
  totalTokens: 8200,     // 总计
  cost: {
    input: 0.0036,       // 美元
    output: 0.012,
    cacheRead: 0.0015,
    cacheWrite: 0.0045,
    total: 0.0216
  }
}
```

### 什么是 Token

Token 是 LLM 处理文本的基本单位。它不完全等于"字"或"词"，而是一种中间粒度的分词单位。大致估算：
- 英文：1 个 token 约 4 个字符（约 3/4 个单词）
- 中文：1 个汉字通常消耗 1-2 个 token（因 tokenizer 而异，见上文说明）

LLM 的收费和能力限制都以 token 为单位。

### 什么是 Prompt Cache（提示缓存）

当你多次调用 LLM 且前半部分的内容相同时（比如 system prompt 和早期对话不变），服务商可以缓存这些内容，下次调用时直接复用，不需要重新处理。这样能显著降低费用和延迟。

`cacheRead` 表示复用了多少 token，`cacheWrite` 表示新写入缓存了多少 token。通常 cache 读取的费用比正常输入便宜很多。

---

## 6. StreamOptions -- 调用选项

调用 LLM 时可以传入的可选参数：

| 参数 | 类型 | 说明 |
|------|------|------|
| `temperature` | number | 控制随机性。0 = 确定性输出，1 = 更有创造性 |
| `maxTokens` | number | 最大输出 token 数 |
| `signal` | AbortSignal | 用于取消请求（类似浏览器中取消 fetch） |
| `apiKey` | string | API 密钥 |
| `cacheRetention` | "none"\|"short"\|"long" | 缓存保留策略 |
| `sessionId` | string | 会话 ID，用于 provider 的缓存路由 |
| `onPayload` | function | 调试用回调，可以查看发给 provider 的原始请求 |

`SimpleStreamOptions` 在此基础上增加了 `reasoning` 字段，用统一的方式控制"思考力度"：

```
{ reasoning: "medium" }   // "minimal" | "low" | "medium" | "high" | "xhigh"
```

不同 provider 对"思考"的实现方式不同（OpenAI 用 reasoning_effort，Anthropic 用 thinking budget），但通过 SimpleStreamOptions 你只需要说"中等思考"就够了。

---

## 小结

这一章的核心概念用一句话概括：

> **一次 LLM 调用 = 把一个 `Context`（包含系统提示、消息历史、工具列表）发给一个 `Model`，得到一个 `AssistantMessage`（包含文本、思考、工具调用、使用量统计）。**

```
Context ──────────────► Model ──────────────► AssistantMessage
(systemPrompt,           (id, api,             (content blocks,
 messages,                provider,              usage,
 tools)                   cost, ...)             stopReason)
```

下一章我们会看这个调用过程是如何流式进行的。
