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

对应源文件: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 字段,类型是字符串
  • 可选有 startLineendLine 字段,类型是数字

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 时可以传入的可选参数:

参数类型说明
temperaturenumber控制随机性。0 = 确定性输出,1 = 更有创造性
maxTokensnumber最大输出 token 数
signalAbortSignal用于取消请求(类似浏览器中取消 fetch)
apiKeystringAPI 密钥
cacheRetention"none"|"short"|"long"缓存保留策略
sessionIdstring会话 ID,用于 provider 的缓存路由
onPayloadfunction调试用回调,可以查看发给 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)

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