3.2 消息、上下文与 token
本章解决什么问题:模型看起来「记得」我上一句说了什么,可它又被称为「无状态的」——到底谁在记?一次请求究竟发出去了哪些东西?为什么聊得越久越慢、越贵,最后干脆报错? 前置知识:3.1 LLM 与模型 API、2.1 类型入门:基本类型与对象、2.3 union、字面量与可辨识联合。 学习目标:读完本章你能——
- 用一句话解释「对话即数组」,并说清楚到底是谁在保存对话历史;
- 说出一次模型请求里装了哪三类东西,也就是上下文(Context)的构成;
- 解释 token 是什么,为什么不能直接用字符数或单词数代替它;
- 算清楚多轮对话的 token 消耗为什么是「越滚越大」的,从而理解为什么必须做上下文压缩;
- 看懂「一条消息 = 一串内容块」的结构,为第 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。
// 内容块:一条消息的内容不是一整块文本,而是一串「块」
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} 条消息`);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
预期输出(本书作者实际运行所得):
--- 第 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 条消息2
3
4
5
6
7
8
9
10
11
逐段看:
fakeModel是一个纯函数:它的全部输入就是参数context,函数体里没有任何外部变量。这正是真实模型的行为——它看不到context以外的世界。你可以试着把fakeModel(context)改成fakeModel({ messages: [context.messages.at(-1)!] }),只发最后一条,模型立刻就「失忆」了。- 循环体的顺序是固定的三步:追加用户消息 → 带着完整历史请求 → 把回复追加回历史。第 3.5 章的 Agent Loop(Agent 循环)就是在这三步之间插入工具执行环节。
- 发送的消息条数是 1、3、5,每轮增加 2。数组只增不减——这是下一节所有麻烦的根源。
上下文(Context):一次请求携带的全部内容
- 系统提示词(System Prompt):设定模型身份、规则、可用信息的一段说明,每次请求都在最前面;
- 消息历史:从对话开始到现在的完整
Message[],包括这一轮的新输入; - 工具定义:模型这次可以调用哪些工具(第 3.4 章展开)。
关键点:上下文是每次请求现场拼装出来的,而不是在服务端累积的。上一次发过什么,跟这一次没有关系;这一次没发的东西,模型就当它不存在。
日常口语里「上下文」常常被当成「历史记录」的同义词,但在 Agent 的语境里,它比历史更大:系统提示词和工具定义都占位置、都要花钱。后面你会看到,Pi 里几乎所有和「省钱、防溢出」相关的机制,处理的对象都是这个上下文整体。
上下文的拼装方式还解释了一件很多人困惑的事:同一个 Agent,不同时刻的「能力」是不一样的。因为工具列表、系统提示词、可见的文件内容都可能随着这一次的拼装而变化。
token:模型眼中的计量单位
你可能已经注意到,前面一直在说「上下文很大会有问题」,却没说大小怎么量。答案是 token。
常用的量级直觉(不同模型的分词器不同,只能当估算):
- 英文:1 个常见单词 ≈ 1 个 token,或者说 4 个字符 ≈ 1 个 token;
- 中文:1 个汉字 ≈ 1~2 个 token;
- 代码:符号多、缩进多,token 数通常比同长度的自然语言更多。
token 是模型世界的通用计价单位:计费按 token、限速按 token、上下文窗口的容量也按 token。
这带来两个实际后果:其一,同样长度的中文和英文,token 数可能差一倍以上;其二,任何本地估算都只能是估算,真实数字必须以模型返回的用量数据为准。
下面这段代码演示估算方法,同时把「历史越滚越大」的账算给你看。同样存进那个实验目录,用 npx tsx tokens-demo.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}`,
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
预期输出(本书作者实际运行所得):
「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 ← 超出上下文窗口,请求会被拒绝2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
注意第一组输出:hello 5 个字符只算 2 个 token,你好 2 个字才 2 个字符却算 3 个 token。这就是「字符数不等于 token 数」的直观体现。
上下文窗口:为什么必须做压缩
每个模型都有一个上下文窗口(Context Window):单次请求允许携带的 token 上限。超过这个上限,请求直接失败,而不是「自动忘掉最早的几句」。
看上面输出的两列数字,它们说明了两件不同的事:
- 本次请求的 token(第一列)线性增长:每多聊一轮,下一次请求就多背 2 条消息。上例第 4 轮就撞上了 1000 token 的窗口上限。
- 累计消耗(第二列)以更快的速度增长:因为第 N 轮要重发前面 N-1 轮的全部内容,总消耗大致正比于轮数的平方。10 轮下来累计约 13826 token,而全部对话文本本身只有约 2459 token——同一段历史被反复发送了很多遍。
于是每个 Agent Harness 都必须回答同一个问题:历史迟早会超出窗口,那时怎么办? 常见的三种做法:
- 直接截断:丢掉最早的若干条消息。实现最简单,代价是模型会突然「忘记」开头的约定。
- 摘要压缩:让模型把早期历史总结成一段简短的摘要,用摘要替换掉原始消息。这就是上下文压缩(Context Compaction)。
- 外部存储:把内容写进文件或数据库,需要时再由工具读回来。这样上下文里只留下「索引」,不留全文。
Pi 三种都用到了。第 3.7 Compaction、Extension 与 Skill 会讲清楚压缩的触发时机与取舍,第 6.6 Context 构造与 Compaction 会深入到源码实现。现在你只需要记住因果链:模型无记忆 → 每轮重发历史 → 历史单调增长 → 窗口有限 → 必须压缩。
一条消息不是一段文本,而是一串内容块
回头看最小示例里的类型定义,你会发现 content 不只是 string:
interface UserMessage {
role: "user";
content: string | (TextBlock | ImageBlock)[];
}2
3
4
为什么要搞得这么复杂?因为一条消息里可能同时包含好几种东西,而且顺序有意义:
- 用户可以「发一段文字 + 贴一张截图 + 再补一句话」;
- 模型的一条回复可能是「先说一段话,再发起一次工具调用」;
- 有些模型还会先输出一段推理过程,再输出正式回答。
如果 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)。
Messageexport 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;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这是源码事实,和图 3.2-2 完全对得上:UserMessage.content 允许字符串或「文本块 + 图片块」数组;AssistantMessage.content 允许文本块、思考块和工具调用块;工具结果自成一种角色。这些内容块本身也定义在同一个文件里(TextContent 第 338–342 行、ImageContent 第 354–358 行、ToolCall 第 360–366 行)。
AssistantMessage 上还挂着一个 usage 字段,类型是同文件第 368–389 行的 Usage,里面记录了本次请求真实的输入 / 输出 token 数、缓存读写 token 数和折算成本——这就是前面说的「真实数字以服务端返回为准」的落点。
再看上下文本身:
Contextexport interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}2
3
4
5
五行代码,把本章前面讲的三部分构成原封不动地写了出来。从源码结构看,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 条。
步骤
- 进入实验目录
labs/agent-concepts/01-fake-model,先按它的 README 跑通原来的程序(npm install后npm start)。 - 新建
src/dump-history.ts,写入下面的代码。它可以独立运行,先单独跑一遍看看效果:npx tsx src/dump-history.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} 条消息。` }] });
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
这一步的预期输出(本书作者实际运行所得):
[历史] 本次请求携带 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 我叫什么名字?2
3
4
5
6
7
8
9
10
11
12
- 把文件末尾的「演示」部分删掉,只留下
summarize与dumpHistory(dumpHistory已经export)。 - 回到实验的主程序,
import { dumpHistory } from "./dump-history.ts";,然后在调用假模型之前插入一行dumpHistory(messages);(如果实验里保存历史的变量不叫messages,按 README 里的名字改)。 - 运行
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 行。
自测问题
- 你在聊天界面里问了 5 轮,第 5 次请求发送了多少条消息?如果程序只发最后一条会怎样?
- 一段 1000 字的中文文档,大致相当于多少 token?为什么只能说「大致」?
- 累计 token 消耗为什么比「对话文本总长度」大得多?这对费用意味着什么?
- 为什么
AssistantMessage.content必须是数组而不能是一个字符串?举一个字符串表达不了的例子。
下一章预告:3.3 流式输出(Streaming)。本章的假模型是「算完一次性返回」,而真实模型回答一个问题可能要几十秒。下一章讲模型如何一边生成一边把内容吐出来,以及程序怎样用第 2.6 章的异步迭代器(Async Iterator)接住这些片段——它同时也是 Pi 事件系统的地基。