Skip to content

1.1 统一的会话消息模型

任何 LLM 对话,本质上都是一串消息的来回。pi 把"消息"抽象成一套类型,让上层(Agent 运行时)不用关心背后是 OpenAI 还是 Anthropic。这一节我们从消息模型讲起 —— 它是整个 pi 的地基。

三种基础消息角色

packages/ai/src/types.ts 中,Message 是三种消息的联合类型:

ts
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
角色含义关键字段
UserMessage用户输入contenttimestamp
AssistantMessage模型回复contentusagestopReason
ToolResultMessage工具执行结果toolCallIdtoolNamecontentisError

先看一份真实的对话长什么样

为了不抽象,先给你看一段"用户 → 助手(要调工具)→ 工具结果 → 助手(最终回答)"的真实 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[];          // 告诉模型可以调用哪些工具
}

ContextStreamFunction 的入参(见 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-coreMessage 之上又包了一层 AgentMessage,允许应用插入自定义消息类型(如 UI 通知),同时保持与 LLM 消息的兼容:

ts
// packages/agent/src/types.ts
export interface CustomAgentMessages { /* 应用可用声明合并扩展 */ }
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];

AgentMessageMessage 之间通过 convertToLlm 相互转换(见 2.1)。

小结

  • 统一消息模型 = role(user/assistant/toolResult)+ 内容块 + 元数据。
  • stopReason = "toolUse" 是触发 Agent 循环的关键信号。
  • 每条回复都携带 api/provider/model,实现多厂商统一。
  • Context 把 "系统提示 + 消息 + 工具" 打包,作为一次模型请求的输入。
真实源码位置
  • 三种消息、内容块、Contextpackages/ai/src/types.ts:403-502
  • StopReasonpackages/ai/src/types.ts:387
  • AgentMessage 扩展: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 事件流机制 —— 模型回复如何被流式消费。