Skip to content

3.2 消息、上下文与 token

本章解决什么问题:模型看起来「记得」我上一句说了什么,可它又被称为「无状态的」——到底谁在记?一次请求究竟发出去了哪些东西?为什么聊得越久越慢、越贵,最后干脆报错? 前置知识3.1 LLM 与模型 API2.1 类型入门:基本类型与对象2.3 union、字面量与可辨识联合学习目标:读完本章你能——

  1. 用一句话解释「对话即数组」,并说清楚到底是谁在保存对话历史;
  2. 说出一次模型请求里装了哪三类东西,也就是上下文(Context)的构成;
  3. 解释 token 是什么,为什么不能直接用字符数或单词数代替它;
  4. 算清楚多轮对话的 token 消耗为什么是「越滚越大」的,从而理解为什么必须做上下文压缩;
  5. 看懂「一条消息 = 一串内容块」的结构,为第 3.4 章的工具调用打好基础。

建立直觉:模型没有记忆

先说本书最重要的一个心智模型,它会贯穿后面所有章节:

大语言模型(LLM)本身不保存任何东西。它每次被调用,都是一次从零开始的、独立的请求。

3.1 讲过,调用模型本质上就是发一次 HTTP 请求:你把内容发过去,它把结果算出来返回,连接结束。服务端并不会为你的这次对话建一个「房间」把聊天记录存起来(少数厂商提供的「服务端会话」是额外功能,不是模型本身的能力,本书不依赖它)。

打个比方:你请了一位顾问,他记忆力极好,但只在你待在他办公室的那几分钟内有效——你一出门,他就把你忘得干干净净。想让他接着上次的话题继续,你唯一的办法是:每次进门时,把之前所有的会议纪要重新念一遍给他听

念纪要这件事,不是模型做的,是你的程序做的。这个负责保存历史、每次重新拼装并发送的程序,就是本书的主角——Agent Harness(Agent 运行框架)。

⚠️ 常见误解以为「模型记得我们聊过什么」
聊天界面里,你说「我叫小林」,下一轮问「我叫什么名字」,它答得出来。这很容易让人以为模型「记住」了。真相是:界面背后的程序把「我叫小林」这句话连同它上一轮的回答,一起塞进了第二次请求里。模型是在**当场读到**的,不是**回忆起**的。

一个立刻能验证的推论:如果程序不发送历史,模型就完全答不上来。本章的最小示例会让你亲眼看到这一点。

对话即数组

既然历史要由我们自己保存,那它是什么数据结构?答案朴素得让人意外:一个数组

数组里的每一项叫一条消息(Message)。每条消息至少有两个字段:

  • role:这条消息是谁说的。最基本的两种是 "user"(你)和 "assistant"(模型)。第 3.4 章还会加入第三种 "toolResult"(工具执行结果)。
  • content:说了什么。

一轮对话就是给数组追加两条消息:你的输入(user)和模型的回复(assistant)。下一轮开始时,把整个数组连同系统提示词(System Prompt)原样重发一遍,再在末尾附上你的新输入。

图加载中…

图 3.2-1 每一轮都把完整历史重新发一遍
阅读顺序:从上到下。请重点看两条指向「模型服务」的箭头——第二次请求携带的消息条数是 3,而不是 1,因为第 1 轮的输入和回复都被重新发了一遍。右侧的注记是关键:模型在两次请求之间什么都不记得,所有的「连续感」都来自中间那根柱子,也就是运行在你机器上的历史数组。

由此可以推出几个重要结论,它们在后面的章节会反复出现:

  • 历史是可以被修改的。 数组在你手里,你可以删掉几条、把十条压缩成一条摘要、甚至凭空插入一条「用户」消息。模型无法分辨真假,它只看这次收到了什么。上下文压缩(Context Compaction)就建立在这一点上。
  • 把文件内容塞进历史,模型就「读到」了文件。 Agent 能分析你的代码,不是因为它有神通,而是因为程序把文件内容作为消息发了过去。
  • 对话是可以分叉和回退的。 保存一份历史数组的副本,就等于保存了一个对话分支——这正是第 6.5 章会讲的会话树。

最小示例:亲手重发一次历史

下面这段代码不依赖任何网络和 API Key,模型由一个「假模型」函数扮演——它只会数一数这次收到了多少条消息。请把它存成 context-demo.ts,放进任意一个已经执行过 npm install 的实验目录(例如 labs/typescript-basics/01-hello),然后在该目录下运行 npx tsx context-demo.ts

ts
// 内容块:一条消息的内容不是一整块文本,而是一串「块」
interface TextBlock {
	type: "text";
	text: string;
}
interface ImageBlock {
	type: "image";
	mimeType: string;
	data: string;
}
interface ToolCallBlock {
	type: "toolCall";
	id: string;
	name: string;
	arguments: Record<string, unknown>;
}

// 三种角色的消息
interface UserMessage {
	role: "user";
	content: string | (TextBlock | ImageBlock)[];
}
interface AssistantMessage {
	role: "assistant";
	content: (TextBlock | ToolCallBlock)[];
}
type Message = UserMessage | AssistantMessage;

// 上下文:一次请求要携带的全部内容
interface Context {
	systemPrompt?: string;
	messages: Message[];
}

/** 把任意内容块数组压成纯文本,便于打印 */
function toText(content: string | (TextBlock | ImageBlock | ToolCallBlock)[]): string {
	if (typeof content === "string") return content;
	return content
		.map((block) => {
			if (block.type === "text") return block.text;
			if (block.type === "image") return `[图片 ${block.mimeType}]`;
			return `[调用工具 ${block.name}]`;
		})
		.join("");
}

/** 一个没有任何记忆的「假模型」:它只能看见这一次传进来的 context */
function fakeModel(context: Context): AssistantMessage {
	const userTurns = context.messages.filter((m) => m.role === "user").length;
	const last = context.messages[context.messages.length - 1];
	const lastText = last ? toText(last.content) : "(空)";
	return {
		role: "assistant",
		content: [
			{
				type: "text",
				text: `我这次看到 ${context.messages.length} 条消息,其中 ${userTurns} 条来自你,最后一句是「${lastText}」`,
			},
		],
	};
}

const context: Context = { systemPrompt: "你是一个简洁的中文助手。", messages: [] };

const inputs = ["我叫小林", "帮我记住:项目叫 pipibook", "我叫什么名字?"];

for (const [i, input] of inputs.entries()) {
	context.messages.push({ role: "user", content: input });
	console.log(`--- 第 ${i + 1} 轮请求:systemPrompt 1 段 + messages ${context.messages.length} 条 ---`);
	console.log(`  发出去的历史:${context.messages.map((m) => m.role).join(" → ")}`);
	const reply = fakeModel(context);
	context.messages.push(reply);
	console.log(`  助手:${toText(reply.content)}`);
}

console.log(`\n三轮之后,本地保存的历史共 ${context.messages.length} 条消息`);

预期输出(本书作者实际运行所得):

text
--- 第 1 轮请求:systemPrompt 1 段 + messages 1 条 ---
  发出去的历史:user
  助手:我这次看到 1 条消息,其中 1 条来自你,最后一句是「我叫小林」
--- 第 2 轮请求:systemPrompt 1 段 + messages 3 条 ---
  发出去的历史:user → assistant → user
  助手:我这次看到 3 条消息,其中 2 条来自你,最后一句是「帮我记住:项目叫 pipibook」
--- 第 3 轮请求:systemPrompt 1 段 + messages 5 条 ---
  发出去的历史:user → assistant → user → assistant → user
  助手:我这次看到 5 条消息,其中 3 条来自你,最后一句是「我叫什么名字?」

三轮之后,本地保存的历史共 6 条消息

逐段看:

  • fakeModel 是一个纯函数:它的全部输入就是参数 context,函数体里没有任何外部变量。这正是真实模型的行为——它看不到 context 以外的世界。你可以试着把 fakeModel(context) 改成 fakeModel({ messages: [context.messages.at(-1)!] }),只发最后一条,模型立刻就「失忆」了。
  • 循环体的顺序是固定的三步:追加用户消息 → 带着完整历史请求 → 把回复追加回历史。第 3.5 章的 Agent Loop(Agent 循环)就是在这三步之间插入工具执行环节。
  • 发送的消息条数是 1、3、5,每轮增加 2。数组只增不减——这是下一节所有麻烦的根源。

上下文(Context):一次请求携带的全部内容

📘 概念上下文(Context)
一次模型请求携带的**全部**内容。它通常由三部分组成:
  1. 系统提示词(System Prompt):设定模型身份、规则、可用信息的一段说明,每次请求都在最前面;
  2. 消息历史:从对话开始到现在的完整 Message[],包括这一轮的新输入;
  3. 工具定义:模型这次可以调用哪些工具(第 3.4 章展开)。

关键点:上下文是每次请求现场拼装出来的,而不是在服务端累积的。上一次发过什么,跟这一次没有关系;这一次没发的东西,模型就当它不存在。

日常口语里「上下文」常常被当成「历史记录」的同义词,但在 Agent 的语境里,它比历史更大:系统提示词和工具定义都占位置、都要花钱。后面你会看到,Pi 里几乎所有和「省钱、防溢出」相关的机制,处理的对象都是这个上下文整体。

上下文的拼装方式还解释了一件很多人困惑的事:同一个 Agent,不同时刻的「能力」是不一样的。因为工具列表、系统提示词、可见的文件内容都可能随着这一次的拼装而变化。

token:模型眼中的计量单位

你可能已经注意到,前面一直在说「上下文很大会有问题」,却没说大小怎么量。答案是 token。

📘 概念token(词元)(token)
模型不是按字符、也不是按单词处理文本的,而是按 **token** ——由分词器(tokenizer)切出来的一小段文本片段。一个 token 可能是一个完整的英文单词、一个词根、一个标点、几个字节,也可能是一个汉字或半个汉字。

常用的量级直觉(不同模型的分词器不同,只能当估算):

  • 英文:1 个常见单词 ≈ 1 个 token,或者说 4 个字符 ≈ 1 个 token;
  • 中文:1 个汉字 ≈ 1~2 个 token;
  • 代码:符号多、缩进多,token 数通常比同长度的自然语言更多。

token 是模型世界的通用计价单位:计费按 token、限速按 token、上下文窗口的容量也按 token

🌱 初学者提示为什么不直接按字符或单词算
模型内部把文本转成一串数字(每个 token 对应词表里的一个编号)才能计算。词表是训练时固定下来的,包含几万到几十万个条目:常见单词整体占一个条目,罕见词被拆成几段。所以「token 数」取决于文本内容和这张词表,无法用字符数精确换算。

这带来两个实际后果:其一,同样长度的中文和英文,token 数可能差一倍以上;其二,任何本地估算都只能是估算,真实数字必须以模型返回的用量数据为准。

下面这段代码演示估算方法,同时把「历史越滚越大」的账算给你看。同样存进那个实验目录,用 npx tsx tokens-demo.ts 运行:

ts
/**
 * 粗略估算 token 数:中文字符按 1.5 个 token 算,其它字符按 4 个字符 1 个 token 算。
 * 这只是量级直觉,真实分词由模型自己的分词器决定,真实数字以响应里的用量字段为准。
 */
function estimateTokens(text: string): number {
	let cjk = 0;
	let other = 0;
	for (const ch of text) {
		if (/[一-鿿 -〿＀-￯]/.test(ch)) cjk++;
		else other++;
	}
	return Math.ceil(cjk * 1.5 + other / 4);
}

for (const sample of ["hello", "你好", "你好,世界!", "Agent Harness 是什么?"]) {
	console.log(`「${sample}」 字符数 ${[...sample].length},估算 ${estimateTokens(sample)} token`);
}

console.log("\n--- 10 轮对话,每轮都把完整历史重发一遍 ---");

const CONTEXT_WINDOW = 1000; // 假设这个模型的上下文窗口只有 1000 token
const systemPrompt = "你是一个中文助手,回答要简洁。".repeat(3);

const history: string[] = [];
let cumulative = 0;

for (let turn = 1; turn <= 10; turn++) {
	history.push(`用户第 ${turn} 轮的问题,大概两三行字,这里用一句话代替。`.repeat(2));
	history.push(`助手第 ${turn} 轮的回答,通常比问题长一些,这里也用几句话代替。`.repeat(4));

	const requestTokens = estimateTokens(systemPrompt) + history.reduce((sum, m) => sum + estimateTokens(m), 0);
	cumulative += requestTokens;
	const flag = requestTokens > CONTEXT_WINDOW ? "  ← 超出上下文窗口,请求会被拒绝" : "";
	console.log(
		`第 ${String(turn).padStart(2)} 轮:历史 ${String(history.length).padStart(2)} 条,本次请求约 ${String(requestTokens).padStart(4)} token,累计已发送约 ${String(cumulative).padStart(5)} token${flag}`,
	);
}

预期输出(本书作者实际运行所得):

text
「hello」 字符数 5,估算 2 token
「你好」 字符数 2,估算 3 token
「你好,世界!」 字符数 6,估算 9 token
「Agent Harness 是什么?」 字符数 18,估算 10 token

--- 10 轮对话,每轮都把完整历史重发一遍 ---
第  1 轮:历史  2 条,本次请求约  307 token,累计已发送约   307 token
第  2 轮:历史  4 条,本次请求约  546 token,累计已发送约   853 token
第  3 轮:历史  6 条,本次请求约  785 token,累计已发送约  1638 token
第  4 轮:历史  8 条,本次请求约 1024 token,累计已发送约  2662 token  ← 超出上下文窗口,请求会被拒绝
第  5 轮:历史 10 条,本次请求约 1263 token,累计已发送约  3925 token  ← 超出上下文窗口,请求会被拒绝
第  6 轮:历史 12 条,本次请求约 1502 token,累计已发送约  5427 token  ← 超出上下文窗口,请求会被拒绝
第  7 轮:历史 14 条,本次请求约 1741 token,累计已发送约  7168 token  ← 超出上下文窗口,请求会被拒绝
第  8 轮:历史 16 条,本次请求约 1980 token,累计已发送约  9148 token  ← 超出上下文窗口,请求会被拒绝
第  9 轮:历史 18 条,本次请求约 2219 token,累计已发送约 11367 token  ← 超出上下文窗口,请求会被拒绝
第 10 轮:历史 20 条,本次请求约 2459 token,累计已发送约 13826 token  ← 超出上下文窗口,请求会被拒绝

注意第一组输出:hello 5 个字符只算 2 个 token,你好 2 个字才 2 个字符却算 3 个 token。这就是「字符数不等于 token 数」的直观体现。

⚠️ 常见误解把这个估算函数当成真实的 token 计数
`estimateTokens` 只是拍脑袋的比例换算,误差可以很大,尤其是代码、URL、表情符号。它的用途是建立量级直觉,以及在没有网络的教学实验里做演示。真实场景中,请求的真实 token 数由服务端返回——Pi 把它记录在每条 assistant 消息的用量字段里(见本章「Pi 中哪里用到了它」)。

上下文窗口:为什么必须做压缩

每个模型都有一个上下文窗口(Context Window):单次请求允许携带的 token 上限。超过这个上限,请求直接失败,而不是「自动忘掉最早的几句」。

看上面输出的两列数字,它们说明了两件不同的事:

  • 本次请求的 token(第一列)线性增长:每多聊一轮,下一次请求就多背 2 条消息。上例第 4 轮就撞上了 1000 token 的窗口上限。
  • 累计消耗(第二列)以更快的速度增长:因为第 N 轮要重发前面 N-1 轮的全部内容,总消耗大致正比于轮数的平方。10 轮下来累计约 13826 token,而全部对话文本本身只有约 2459 token——同一段历史被反复发送了很多遍
🌱 初学者提示那不是白花钱吗
主流厂商都提供「提示词缓存」之类的机制:把重复发送的前缀标记出来,命中缓存时按更低的价格计费。这能大幅降低重发的成本,但并不改变结论——上下文窗口的上限依然存在,历史终究会撑爆它。(Pi 的用量记录里就区分了普通输入与缓存读写,本章末尾会指出位置。)

于是每个 Agent Harness 都必须回答同一个问题:历史迟早会超出窗口,那时怎么办? 常见的三种做法:

  1. 直接截断:丢掉最早的若干条消息。实现最简单,代价是模型会突然「忘记」开头的约定。
  2. 摘要压缩:让模型把早期历史总结成一段简短的摘要,用摘要替换掉原始消息。这就是上下文压缩(Context Compaction)。
  3. 外部存储:把内容写进文件或数据库,需要时再由工具读回来。这样上下文里只留下「索引」,不留全文。

Pi 三种都用到了。第 3.7 Compaction、Extension 与 Skill 会讲清楚压缩的触发时机与取舍,第 6.6 Context 构造与 Compaction 会深入到源码实现。现在你只需要记住因果链:模型无记忆 → 每轮重发历史 → 历史单调增长 → 窗口有限 → 必须压缩

⚠️ 常见误解以为超出窗口会自动丢弃旧消息
不会。超限时服务端返回的是错误,整个请求失败。「保留哪些、丢弃哪些」是客户端程序的职责,也就是 Harness 必须自己实现的逻辑。写自己的 Agent 时,这一步不做就等于埋了一颗定时炸弹。

一条消息不是一段文本,而是一串内容块

回头看最小示例里的类型定义,你会发现 content 不只是 string

ts
interface UserMessage {
	role: "user";
	content: string | (TextBlock | ImageBlock)[];
}

为什么要搞得这么复杂?因为一条消息里可能同时包含好几种东西,而且顺序有意义

  • 用户可以「发一段文字 + 贴一张截图 + 再补一句话」;
  • 模型的一条回复可能是「先说一段话,再发起一次工具调用」;
  • 有些模型还会先输出一段推理过程,再输出正式回答。

如果 content 只能是字符串,这些结构就没法表达。所以主流模型 API 统一采用内容块(content block)数组:每个块带一个 type 字段说明自己是什么,程序靠 type 分辨——这正是第 2.3 章讲的可辨识联合(Discriminated Union)在真实项目里最重要的应用场景。

图加载中…

图 3.2-2 三种角色的消息与它们允许的内容块
阅读顺序:从左到右。三行分别是三种 `role`。请注意每种角色**允许的块类型并不相同**:只有 assistant 消息能包含工具调用块,只有它能包含思考块;图片块则出现在 user 和 toolResult 一侧。这张图右侧的每个方框,在 Pi 源码里都是一个真实的 interface,下一节就能对上号。`toolResult` 这一行会在第 3.4 章正式讲解,这里先建立印象。

Pi 中哪里用到了它

上面这些不是本书编出来的教学模型,而是 Pi 真实类型定义的简化版。Pi 把消息与上下文的类型集中放在 packages/ai/src/types.ts(第 6.1 章会完整介绍这个 package)。

earendil-works/pi@c13ffe1第 393–433 行在 GitHub 查看 ↗
三种角色的消息定义,以及把它们合成一个可辨识联合的 `Message` 类型。
ts
export interface UserMessage {
	role: "user";
	content: string | (TextContent | ImageContent)[];
	timestamp: number; // Unix timestamp in milliseconds
}

export interface AssistantMessage {
	role: "assistant";
	content: (TextContent | ThinkingContent | ToolCall)[];
	// …(省略:api / provider / model / usage 等字段)
	stopReason: StopReason;
	timestamp: number; // Unix timestamp in milliseconds
}

export interface ToolResultMessage<TDetails = any> {
	role: "toolResult";
	toolCallId: string;
	toolName: string;
	content: (TextContent | ImageContent)[]; // Supports text and images
	// …(省略:details / usage / addedToolNames 等字段)
	isError: boolean;
	timestamp: number; // Unix timestamp in milliseconds
}

export type Message = UserMessage | AssistantMessage | ToolResultMessage;

这是源码事实,和图 3.2-2 完全对得上:UserMessage.content 允许字符串或「文本块 + 图片块」数组;AssistantMessage.content 允许文本块、思考块和工具调用块;工具结果自成一种角色。这些内容块本身也定义在同一个文件里(TextContent 第 338–342 行、ImageContent 第 354–358 行、ToolCall 第 360–366 行)。

AssistantMessage 上还挂着一个 usage 字段,类型是同文件第 368–389 行的 Usage,里面记录了本次请求真实的输入 / 输出 token 数、缓存读写 token 数和折算成本——这就是前面说的「真实数字以服务端返回为准」的落点。

再看上下文本身:

earendil-works/pi@c13ffe1第 487–491 行在 GitHub 查看 ↗
Pi 对「一次请求携带什么」的完整定义:系统提示词 + 消息历史 + 工具列表。
ts
export interface Context {
	systemPrompt?: string;
	messages: Message[];
	tools?: Tool[];
}

五行代码,把本章前面讲的三部分构成原封不动地写了出来。从源码结构看,Pi 里所有 Provider(模型服务提供方)的调用入口都接收这样一个 Context:同文件第 236–239 行的 ProviderStreams 接口规定,每个 API 实现模块都必须导出 stream(model, context, options)。也就是说,「把完整上下文交出去」这件事,在 Pi 里被固化成了类型约束:调用模型时不存在「只发增量」的选项。

至于这个 Context 是怎么从会话(Session)里拼装出来、历史太长时如何压缩,属于第 5.2 章和第 6.6 章的内容。

实践任务

🛠 实践任务把消息历史打印出来,看着它长大labs/agent-concepts/01-fake-model

目标:在 3.1 的实验 labs/agent-concepts/01-fake-model 里加一个「历史打印器」,亲眼确认每一轮请求携带的消息条数是 1、3、5……而不是永远 1 条。

步骤

  1. 进入实验目录 labs/agent-concepts/01-fake-model,先按它的 README 跑通原来的程序(npm installnpm start)。
  2. 新建 src/dump-history.ts,写入下面的代码。它可以独立运行,先单独跑一遍看看效果:npx tsx src/dump-history.ts
ts
interface TextBlock {
	type: "text";
	text: string;
}
interface UserMessage {
	role: "user";
	content: string | TextBlock[];
}
interface AssistantMessage {
	role: "assistant";
	content: TextBlock[];
}
type Message = UserMessage | AssistantMessage;

/** 把一条消息压成一行摘要,超过 30 个字符就截断 */
function summarize(content: string | TextBlock[]): string {
	const text = typeof content === "string" ? content : content.map((b) => b.text).join("");
	const oneLine = text.replace(/\s+/g, " ");
	return oneLine.length > 30 ? `${oneLine.slice(0, 30)}…` : oneLine;
}

/** 打印当前这次请求会带上的完整历史 */
export function dumpHistory(messages: Message[]): void {
	console.log(`[历史] 本次请求携带 ${messages.length} 条消息`);
	messages.forEach((m, i) => {
		console.log(`  #${i} ${m.role.padEnd(9)} ${summarize(m.content)}`);
	});
}

// 演示:三轮对话后历史长成什么样
const messages: Message[] = [];
for (const input of ["我叫小林", "帮我记住:项目叫 pipibook", "我叫什么名字?"]) {
	messages.push({ role: "user", content: input });
	dumpHistory(messages);
	messages.push({ role: "assistant", content: [{ type: "text", text: `收到,我看到了 ${messages.length} 条消息。` }] });
}

这一步的预期输出(本书作者实际运行所得):

text
[历史] 本次请求携带 1 条消息
  #0 user      我叫小林
[历史] 本次请求携带 3 条消息
  #0 user      我叫小林
  #1 assistant 收到,我看到了 1 条消息。
  #2 user      帮我记住:项目叫 pipibook
[历史] 本次请求携带 5 条消息
  #0 user      我叫小林
  #1 assistant 收到,我看到了 1 条消息。
  #2 user      帮我记住:项目叫 pipibook
  #3 assistant 收到,我看到了 3 条消息。
  #4 user      我叫什么名字?
  1. 把文件末尾的「演示」部分删掉,只留下 summarizedumpHistorydumpHistory 已经 export)。
  2. 回到实验的主程序,import { dumpHistory } from "./dump-history.ts";,然后在调用假模型之前插入一行 dumpHistory(messages);(如果实验里保存历史的变量不叫 messages,按 README 里的名字改)。
  3. 运行 npm start,连续输入三句话,观察每次打印的条数。

预期现象:三次打印的条数分别是 1、3、5;每一次都完整重复了之前所有内容,只是末尾多了新的一轮。assistant 那几行的具体文字取决于实验里假模型的规则,与本页不同是正常的。

如何判断成功:条数是 1、3、5 这样的奇数递增,且第三次打印里仍然能看到第一句「我叫小林」。如果你看到的永远是 1 条,说明主程序每轮都新建了数组,历史根本没有被保存。

进阶(可选):把本章的 estimateTokens 也复制过去,在 dumpHistory 里多打印一行「本次请求约 N token」,观察这个数字如何随轮次增长。

常见错误

  • dumpHistory(messages) 放在了追加回复之后:这样打印出来的条数会是 2、4、6,反映的是「这一轮结束后」的状态,而不是「这次请求发出去」的内容。
  • 直接 console.log(messages):内容块是嵌套对象,Node.js 默认只展开两层,你会看到 [Object] 而看不到文本,所以才需要 summarize
  • import 路径漏了 .ts 后缀:本书实验使用 ESM 与 tsx 运行,相对导入需要写全后缀(见 1.4 模块系统:import 与 export)。

相关源码packages/ai/src/types.ts 第 393–433 行(Message)、第 487–491 行(Context)。

本章小结

  • 模型没有记忆。 每次调用都是独立的一次请求,服务端不为你保存对话。所有「连续感」都由客户端程序制造。
  • 对话即数组。 历史是一个 Message[],每轮追加两条(有工具调用时更多),下一轮把整个数组重发。
  • 上下文 = 系统提示词 + 完整历史 + 工具定义,每次请求现场拼装。历史掌握在你手里,因此可以裁剪、摘要、伪造。
  • token 是模型的计量单位,与字符数不成固定比例;中文大致 1 个字 1~2 个 token。计费、限速、窗口上限都按它算。
  • 上下文窗口有限,历史单调增长,累计消耗随轮数以平方级放大,超限时请求直接失败而不是自动遗忘——所以必须有截断或压缩策略。
  • 一条消息是一串内容块,靠 type 字段区分文本、图片、思考、工具调用,这是可辨识联合的典型应用,也是第 3.4 章工具调用的结构基础。

关键术语:消息(Message)、角色(role: user / assistant / toolResult)、上下文(Context)、系统提示词(System Prompt)、token(词元)、上下文窗口(Context Window)、上下文压缩(Context Compaction)、内容块(content block)。

关键源码索引packages/ai/src/types.ts —— Message 第 393–433 行、Context 第 487–491 行、内容块 TextContent / ImageContent / ToolCall 第 338–366 行、Usage 第 368–389 行、ProviderStreams 第 236–239 行。

自测问题

  1. 你在聊天界面里问了 5 轮,第 5 次请求发送了多少条消息?如果程序只发最后一条会怎样?
  2. 一段 1000 字的中文文档,大致相当于多少 token?为什么只能说「大致」?
  3. 累计 token 消耗为什么比「对话文本总长度」大得多?这对费用意味着什么?
  4. 为什么 AssistantMessage.content 必须是数组而不能是一个字符串?举一个字符串表达不了的例子。

下一章预告3.3 流式输出(Streaming)。本章的假模型是「算完一次性返回」,而真实模型回答一个问题可能要几十秒。下一章讲模型如何一边生成一边把内容吐出来,以及程序怎样用第 2.6 章的异步迭代器(Async Iterator)接住这些片段——它同时也是 Pi 事件系统的地基。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)