Appearance
1.1 统一的会话消息模型
任何 LLM 对话,本质上都是一串消息的来回。pi 把"消息"抽象成一套类型,让上层(Agent 运行时)不用关心背后是 OpenAI 还是 Anthropic。这一节我们从消息模型讲起 —— 它是整个 pi 的地基。
三种基础消息角色
在 packages/ai/src/types.ts 中,Message 是三种消息的联合类型:
ts
export type Message = UserMessage | AssistantMessage | ToolResultMessage;| 角色 | 含义 | 关键字段 |
|---|---|---|
UserMessage | 用户输入 | content、timestamp |
AssistantMessage | 模型回复 | content、usage、stopReason |
ToolResultMessage | 工具执行结果 | toolCallId、toolName、content、isError |
先看一份真实的对话长什么样
为了不抽象,先给你看一段"用户 → 助手(要调工具)→ 工具结果 → 助手(最终回答)"的真实 JSON。这是 Agent 循环里最典型的一段会话:
jsonc
[
// 1. 用户提问
{ "role": "user",
"content": [{ "type": "text", "text": "帮我算 3 + 4" }],
"timestamp": 1700000000000 },
// 2. 助手回复:要求调用工具(注意 stopReason = "toolUse")
{ "role": "assistant",
"content": [{ "type": "toolCall", "id": "tc1", "name": "calculator", "arguments": { "a": 3, "b": 4 } }],
"api": "openai-completions", "provider": "openai", "model": "gpt-4o",
"stopReason": "toolUse", "usage": { "input": 50, "output": 8, "totalTokens": 58 },
"timestamp": 1700000001000 },
// 3. 工具结果:回填给模型(用 toolCallId 指回第 2 条)
{ "role": "toolResult", "toolCallId": "tc1", "toolName": "calculator",
"content": [{ "type": "text", "text": "7" }],
"isError": false, "timestamp": 1700000002000 },
// 4. 助手最终回答:stopReason = "stop"
{ "role": "assistant",
"content": [{ "type": "text", "text": "3 + 4 = 7" }],
"api": "openai-completions", "provider": "openai", "model": "gpt-4o",
"stopReason": "stop", "usage": { "input": 60, "output": 12, "totalTokens": 72 },
"timestamp": 1700000003000 }
]上面这 4 条消息,就是 Agent 一次"算数"的完整足迹。记住这个例子,后面讲循环、讲工具、讲协议时都会回到它。
三种角色逐个看
UserMessage
ts
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}注意 content 既可以是字符串,也可以是内容块数组。数组形式支持图文混排。
AssistantMessage
ts
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: ProviderId;
model: string;
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
timestamp: number;
}AssistantMessage 是三者中最复杂的:
usage记录 token 消耗与成本(Usage)。stopReason说明模型为什么停下来。api/provider/model记录这条消息由谁产生 —— 这是"统一多家厂商"的关键:每条消息都自报家门。
stopReason 的含义
ts
export type StopReason =
| "pending" // 流式中途
| "stop" // 正常结束
| "length" // 触达输出 token 上限
| "toolUse" // 模型要求调用工具
| "error" // 出错
| "aborted" // 被取消
| "deferred"; // 异步任务,稍后取结果toolUse 是 Agent 循环的"燃料"—— 模型看到 stopReason = "toolUse" 就知道应该去执行工具(见 2.1 核心循环)。
ToolResultMessage
ts
export interface ToolResultMessage {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: TDetails;
isError: boolean;
timestamp: number;
}它通过 toolCallId 精确回填到模型发出的那条 toolCall,让模型知道"你刚才调用的这个工具,结果是这个"。
四种内容块(Content Block)
content 数组里的每一项称为内容块,pi 用 type 字段区分:
ts
TextContent // { type: "text", text: "..." }
ThinkingContent // { type: "thinking", thinking: "..." }
ImageContent // { type: "image", data, mimeType } // base64
ToolCall // { type: "toolCall", id, name, arguments }ToolCall 是工具调用的载体,arguments 是符合工具参数 Schema 的 JSON 对象:
ts
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
}上下文(Context)
一次完整的模型请求,除了消息,还包含系统提示与可用工具:
ts
export interface Context {
systemPrompt?: string; // 例如 "你是…编码助手"
messages: Message[]; // 上面的消息
tools?: Tool[]; // 告诉模型可以调用哪些工具
}Context 是 StreamFunction 的入参(见 1.4)。
记账:Usage 与 cost
ts
export interface Usage {
input: number; // 输入 token
output: number; // 输出 token
cacheRead: number; // 命中缓存的输入
cacheWrite: number; // 写入缓存
totalTokens: number;
cost: { input; output; cacheRead; cacheWrite; total }; // 估算成本
}Usage 让上层可以统计成本、估算上下文占用(见 6.2 上下文压缩)。
从 Agent 看消息:AgentMessage
上层 @pi/agent-core 在 Message 之上又包了一层 AgentMessage,允许应用插入自定义消息类型(如 UI 通知),同时保持与 LLM 消息的兼容:
ts
// packages/agent/src/types.ts
export interface CustomAgentMessages { /* 应用可用声明合并扩展 */ }
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];AgentMessage 与 Message 之间通过 convertToLlm 相互转换(见 2.1)。
小结
- 统一消息模型 =
role(user/assistant/toolResult)+ 内容块 + 元数据。 stopReason = "toolUse"是触发 Agent 循环的关键信号。- 每条回复都携带
api/provider/model,实现多厂商统一。 Context把 "系统提示 + 消息 + 工具" 打包,作为一次模型请求的输入。
真实源码位置
- 三种消息、内容块、
Context:packages/ai/src/types.ts:403-502 StopReason:packages/ai/src/types.ts:387AgentMessage扩展:packages/agent/src/types.ts:319
面试角度:为什么这样设计消息模型
Q1:为什么 role 只有 user / assistant / toolResult 三种? 因为 LLM 的对话本质就是"这三类说话人"的轮换:用户问、模型答、工具回报。角色决定了这条消息在协议里怎么被编码、怎么进上下文。多加角色(如 system)会让各家厂商的协议转换更复杂,所以 pi 把 system 收敛进 Context.systemPrompt,而不是当作一种 role。
Q2:为什么每条消息都要带 timestamp? 因为会话是可持久化、可恢复的(见 6.2)。恢复时要知道消息的先后顺序和发生时刻,timestamp 提供这个信息。没有它,追加式日志就无法还原时序。
Q3:为什么每条 AssistantMessage 都要自带 api / provider / model? 这就是"多厂商统一"的关键。同一份 transcript 里可能混着不同模型的消息(中途切了模型),每条消息自报家门,上层才能知道"这句话是谁说的、花了多少钱、用的哪套协议"。这也是成本核算和恢复会话时选回原模型的基础。
Q4:为什么要区分 stopReason = "toolUse" 和 "stop"? 这是 Agent 循环的"发动机开关":toolUse 告诉循环"去执行工具再回来",stop 告诉循环"可以停了"。没有这个区分,循环就不知道何时该继续、何时该结束 —— 也就无法实现 2.1 的 ReAct 循环。
Q5:为什么 content 用"内容块数组"而不是纯字符串? 因为一条消息可能要图文混排、还可能包含工具调用(ToolCall 块)。纯字符串只能表达文本;内容块数组能表达"文本 + 图片 + 工具调用"的组合,且每种块用 type 区分,扩展新类型(如 thinking)时不用改消息结构。
下一步:1.2 事件流机制 —— 模型回复如何被流式消费。