Appearance
第 03 章:多轮对话 — 消息协议与记忆
模型为什么不记得我上一句说了什么?多轮对话到底是怎么实现的?
这一章要解决什么问题?
上两章我们调用了模型,也实现了流式输出,但每次都是"一问一答"就结束了。如果你想接着问"能展开说说第二点吗?",模型会一脸茫然 — 它不知道你之前问了什么。
这一章来搞清楚多轮对话的实现机制:LLM 没有记忆,所谓"记住上下文"是程序每次把完整对话历史传给模型。消息列表(messages)就是模型的"记忆"。
LLM 为什么"没有记忆"?
每次调用 LLM 都是独立的。模型不会自己记住上一轮的对话。看一下这个对比:
第 1 次调用:
Context = { messages: [user("TypeScript 有哪些基础类型?")] }
→ 模型回答了基础类型列表
第 2 次调用(不带历史):
Context = { messages: [user("能展开说说第二点吗?")] }
→ 模型困惑:什么第二点?
第 2 次调用(带历史):
Context = { messages: [
user("TypeScript 有哪些基础类型?"),
assistant("1. string 2. number 3. boolean ..."),
user("能展开说说第二点吗?")
] }
→ 模型知道"第二点"是 number,展开解释秘密就在 messages 数组里。程序每次调用模型时,把之前所有的对话消息都传过去。模型读完整个 messages,才知道当前对话走到了哪一步。
三种消息角色
messages 数组里有三种消息,用 role 字段区分:
UserMessage — 用户说的话
typescript
interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}最常见的形式就是一个字符串。content 也可以是数组,用于传图片(多模态),但大多数场景下就是纯文本。
AssistantMessage — 模型的回复
typescript
interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
stopReason: StopReason;
usage: Usage;
model: string;
timestamp: number;
}注意 content 是一个数组 — 一条 assistant 消息可以同时包含多个内容块。比如模型可能先输出一段文字,然后决定调用一个工具,那 content 里就有一个 TextContent 和一个 ToolCall。
ToolResultMessage — 工具执行结果
typescript
interface ToolResultMessage {
role: "toolResult";
toolCallId: string; // 必须与 toolCall.id 配对
toolName: string;
content: (TextContent | ImageContent)[];
isError: boolean;
timestamp: number;
}这个消息由程序生成,不是模型生成的。每个 toolResult 必须通过 toolCallId 跟对应的 toolCall 配对。
统一的 Message 类型
三种消息用联合类型表示:
typescript
type Message = UserMessage | AssistantMessage | ToolResultMessage;TypeScript 的 discriminated union 让你可以通过 role 字段收窄类型:
typescript
function processMessage(msg: Message) {
switch (msg.role) {
case "user":
// TypeScript 知道 msg 是 UserMessage
console.log(msg.content);
break;
case "assistant":
// TypeScript 知道 msg 是 AssistantMessage
console.log(msg.stopReason);
break;
case "toolResult":
// TypeScript 知道 msg 是 ToolResultMessage
console.log(msg.toolCallId);
break;
}
}content 为什么是数组?
你可能觉得奇怪:为什么 AssistantMessage 的 content 不直接是一个字符串?
因为模型一次回复可能包含多种内容。最典型的场景是工具调用 — 模型可能先说一句话,然后调用工具:
typescript
// 一条 assistant 消息,content 里有两个块
{
role: "assistant",
content: [
{ type: "text", text: "让我先看看这个文件的内容。" },
{ type: "toolCall", id: "tc_001", name: "read_file", arguments: { path: "app.ts" } }
],
stopReason: "toolUse"
}或者模型只回答文本(没有工具调用):
typescript
{
role: "assistant",
content: [
{ type: "text", text: "这是一个用 TypeScript 写的 web 服务器。" }
],
stopReason: "stop"
}content 是数组,让同一条消息能承载不同类型的内容块,不需要为"纯文本回复"和"工具调用回复"设计两种不同的消息结构。
toolCallId 配对的不变量
这是贯穿整个教程的核心规则:每个 toolCall.id 有且只有一个对应的 toolResult.toolCallId。
assistant.content 里有一个 toolCall,id = "tc_001"
↓
messages 里必须有一个 toolResult,toolCallId = "tc_001"漏掉一个 toolResult 会怎样?模型下一轮调用时看到"我请求了一个工具调用,但没有收到结果",会产生困惑。有些 API 甚至会直接报错拒绝请求。
记住这个规则,后续实现 Agent Loop 时会反复用到。
实践:多轮对话 CLI
让我们用 readline 实现一个多轮对话程序,看看 messages 是怎样一轮一轮累积的:
typescript
/**
* 多轮对话 — 演示 messages 的累积
*
* 运行:ANTHROPIC_API_KEY=sk-ant-... npx tsx multi-turn.ts
*/
import * as readline from "node:readline";
import { builtinModels, getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
import type { Context, Message } from "@earendil-works/pi-ai";
const models = builtinModels();
const model = getBuiltinModel("anthropic", "claude-sonnet-4-20250514");
// messages 数组 — 这就是模型的"记忆"
const messages: Message[] = [];
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const question = (prompt: string): Promise<string> =>
new Promise((resolve) => rl.question(prompt, resolve));
console.log("多轮对话(输入 /quit 退出)\n");
while (true) {
const input = await question("> ");
if (input.trim() === "/quit") break;
if (!input.trim()) continue;
// 1. 用户消息加入列表
messages.push({ role: "user", content: input, timestamp: Date.now() });
// 2. 构建 Context — 每次都传完整的 messages
const context: Context = {
systemPrompt: "You are a helpful assistant. Reply in Chinese.",
messages, // ← 完整历史!
};
// 3. 调用模型
const stream = models.streamSimple(model, context);
for await (const event of stream) {
if (event.type === "text_delta") {
process.stdout.write(event.delta);
}
}
console.log("\n");
// 4. 模型回复加入列表
const reply = await stream.result();
messages.push(reply);
// 此时 messages 包含了完整的对话历史
// 下一轮循环,模型能看到所有之前的对话
}
rl.close();messages 累积的过程
运行这个程序,对话三轮后 messages 长这样:
messages[0]: { role: "user", content: "TypeScript 有哪些基础类型?" }
messages[1]: { role: "assistant", content: [{type:"text", text:"1. string..."}] }
messages[2]: { role: "user", content: "展开说说 number" }
messages[3]: { role: "assistant", content: [{type:"text", text:"number 表示..."}] }
messages[4]: { role: "user", content: "它和 bigint 有什么区别?" }
messages[5]: { role: "assistant", content: [{type:"text", text:"number 是 64 位浮点..."}] }第三轮调用模型时,Context.messages 包含 messages[0] 到 messages[4]。模型读完所有五条消息,才知道"它"指的是 number、对话主题是 TypeScript 的数字类型。
每一轮对话都让 messages 变长。这也是为什么后面需要"上下文窗口管理"(第 12 章)— messages 不能无限增长,模型的上下文窗口有大小限制。
小结
LLM 没有记忆,多轮对话靠程序维护一个 messages 数组实现。每次调用模型时传入完整的 messages,模型就能"记住"之前说过什么。messages 里有三种角色的消息:user(用户输入)、assistant(模型回复,content 是数组,可包含文本和 toolCall)、toolResult(程序执行工具后的结果,必须通过 toolCallId 配对)。这套协议是 Agent 通信的基础,后续所有章节都建立在这个消息累积机制之上。
下一章
现在我们的代码写死了 Anthropic 的模型。如果想换成 OpenAI 呢?两家的 API 格式完全不同 — 消息结构不同、请求格式不同、流式协议不同。下一章看 pi-ai 怎样用一个适配层屏蔽这些差异。