多轮对话不是记忆:你其实在替模型搬运上下文
做过客服 Copilot、IDE 内嵌助手或者运营问答机器人的人,很快都会遇到一个现实问题:用户不会每一轮都把背景重说一遍。第一句说“帮我查一下订单 23019”,第二句说“为什么它还没发货”,第三句又补一句“收件地址已经改过了”。如果系统每轮都像第一次请求那样重新开始,这个产品根本没法用。
于是很多人自然会得出一个结论:聊天模型已经有记忆了。这个说法不算全错,但很容易把工程重点说歪。更准确的描述是:你的应用在替模型维护会话历史,并在每一轮调用时把这段历史重新带回去。
也就是说,聊天界面的连续感,不是模型突然拥有了长期状态,而是系统在不断搬运上下文。
为什么要把“连续聊天”这件事拆开看
因为只要把“多轮对话”误会成“模型已经记住了”,你后面就会在几个关键地方吃亏:
- 不知道 token 为什么越来越贵
- 不知道为什么会话一长,早期约束开始失效
- 不知道为什么进程重启后,所谓的记忆突然消失
- 不知道为什么多用户接进来后,状态隔离会变成系统设计问题
这些都不是模型能力问题,而是会话状态管理问题。
下面这个示例用 Gemini SDK 的 startChat() 搭一个最小 CLI,把多轮对话背后的机械结构完全展开。
为什么这种实现方式重要
它让你第一次真正看清“聊天”背后的工作流:
- 应用保存历史
- 用户发来新输入
- 应用把历史 + 当前输入一起发给模型
- 模型给出回答
- 应用把这轮回答继续追加到历史里
如果你后面要做会话持久化、上下文裁剪、摘要压缩、跨设备同步,都是沿着这条链路往外扩展,而不是另起一套完全不同的体系。
完整代码
// 02-memory-loop-gemini.js
// 目标:手动实现“记忆” (Memory)
// 原理:使用 GoogleGenerativeAI 的 `startChat` 模式
// 它会帮我们把 history 维护在内存里 (类似我们手动 push array)
import { GoogleGenerativeAI } from '@google/generative-ai';
import readline from 'readline';
import dotenv from 'dotenv';
dotenv.config();
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
// 🧠 Gemini SDK 提供了 `startChat`,简化了手动维护数组的过程
// 但底层逻辑是一样的:每次发送 prompt 时,其实都在带上之前的所有历史。
const model = genAI.getGenerativeModel({ model: "gemini-flash-latest" });
const chat = model.startChat({
history: [
{
role: "user",
parts: [{ text: "System: 你是一个名叫 Jarvis 的 AI 助手。你说话幽默风趣。" }],
},
{
role: "model",
parts: [{ text: "Jarvis: 明白了,我会尽力做一个有趣又靠谱的管家。" }],
},
],
generationConfig: {
maxOutputTokens: 1000,
},
});
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log("🤖 Jarvis (Gemini) 在线。输入 'exit' 退出。");
function ask() {
rl.question('\nUser: ', async (input) => {
if (input.toLowerCase() === 'exit') {
rl.close();
return;
}
try {
// 发送消息,chat 对象会自动 append history
const result = await chat.sendMessage(input);
const response = await result.response;
const text = response.text();
console.log(`Jarvis: ${text}`);
// 我们可以看看现在的 history 有多长
// (Gemini SDK 把这部分藏起来了,但在实际 API call 里,它还是要把全量 token 发过去)
// 注意:Gemini 的 Context Window 很大 (1M+ tokens),比 OpenAI 更耐造
} catch (error) {
console.error("Error:", error.message);
}
ask(); // 继续下一轮对话
});
}
ask();先看这段代码真正解决了什么
如果只从效果看,它像一个会聊天的小 CLI;但从工程角度看,它第一次把“连续对话”拆成了几个清晰的责任点:
- 初始规则放在哪里
- 会话历史由谁保存
- 新消息怎么并入上下文
- 每轮 token 为什么会持续增长
- 为什么这种记忆只能算 session memory,而不是长期记忆
所以这篇的重点不是“做一个聊天壳”,而是看清多轮能力其实是应用层的状态管理。
先看一个真实运行结果
这节课更适合你真的在终端里跑起来看。当前版本运行 node 02-memory-loop.js 后,第一次输入一句普通问候,通常会得到类似下面的结果:
🤖 Jarvis (Gemini) 在线。输入 'exit' 退出。
User: 你好
Jarvis: 你好!很高兴为您服务。我是 Jarvis。这里真正值得注意的不是它会自称 Jarvis,而是这种角色连续性来自一开始注入的 history。也就是说,系统并不是“唤醒了一个本来就记得自己叫 Jarvis 的模型”,而是每一轮都把这段上下文继续带了回去。
按实现流拆代码
1. 模型初始化没变,变的是调用方式
import { GoogleGenerativeAI } from '@google/generative-ai';
import readline from 'readline';
import dotenv from 'dotenv';
dotenv.config();
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-flash-latest" });这里和上一篇几乎一样,这反而说明问题:
多轮体验不是因为模型换了,而是因为调用侧开始管理会话状态。
模型本身依旧是无状态推理引擎。变化发生在应用层——你不再只发一条 prompt,而是开始维护一整段消息历史。
2. startChat() 的价值是把 history 管理封装起来
const chat = model.startChat({
history: [...],
generationConfig: {
maxOutputTokens: 1000,
},
});这里最容易产生的误会是:startChat() 好像在模型服务端“开了一个会话”。
更靠谱的理解是:SDK 帮你维护一份 history 数据结构,让你后面调用 sendMessage() 时,不用手动自己 push 数组和重组消息。底层工作流依然是:
- 取已有 history
- 拼上本轮输入
- 一起发给模型
- 把模型输出再追加回 history
也就是说,这是一层状态管理封装,不是底层原理改变。
3. 初始 history 本质上是在定义工作上下文
history: [
{
role: "user",
parts: [{ text: "System: 你是一个名叫 Jarvis 的 AI 助手。你说话幽默风趣。" }],
},
{
role: "model",
parts: [{ text: "Jarvis: 明白了,我会尽力做一个有趣又靠谱的管家。" }],
},
]这段很值得细看,因为它提醒你一件关键的事:
人格、规则、任务边界,本质上也只是上下文的一部分。
这里没有专门的 system 字段,而是把角色约束伪装成历史消息直接塞进来。工程上你后面当然可以把 system prompt、developer instruction、memory snippet 分层管理,但它们底层仍然都在回答同一个问题:
哪些信息必须在每一轮继续存在。
4. readline 只是交互壳,真正关键的是 loop
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});这段只是 Node CLI 的标准输入输出包装,本身没什么神秘感。它的作用是给这个示例套上一个最小可运行外壳,让你更容易观察“多轮输入 → 历史累积 → 回答变化”这条链路。
5. ask() 才是连续对话的真实工作流
function ask() {
rl.question('\nUser: ', async (input) => {
if (input.toLowerCase() === 'exit') {
rl.close();
return;
}
try {
const result = await chat.sendMessage(input);
const response = await result.response;
const text = response.text();
console.log(`Jarvis: ${text}`);
} catch (error) {
console.error("Error:", error.message);
}
ask();
});
}这里表面上是递归提问,实际是在跑一个最小 chat loop:
- 接收用户输入
- SDK 把新输入追加到 history
- 整段上下文一起送给模型
- 模型生成回答
- 回答再次进入 history
- 进入下一轮
所以“多轮对话到底怎么来的”,最准确的回答不是“模型会记”,而是:
应用在维护一段不断增长的上下文,并在每一轮继续重放。
责任边界:模型、SDK、应用各自负责什么
模型负责
- 在当前上下文内推理
- 延续语气、角色和话题
- 基于当前输入和历史回答问题
SDK 负责
- 帮你包装 history 数据结构
- 简化 message append 和调用流程
- 提供更接近聊天的开发体验
应用负责
- 决定 history 初始内容
- 决定 history 保存在哪里
- 决定何时裁剪、摘要、持久化
- 决定多用户状态如何隔离
这三层别混。很多人以为“用了 chat SDK,所以记忆问题解决了”,结果一上线就发现:进程重启、会话过长、多端同步、用户隔离,全都是自己的系统问题。
这类“记忆”为什么只算短期会话记忆
这个示例的边界其实很明确:
- 进程一停,内存里的
chat就没了 - 会话越长,token 成本越高
- 早期规则可能被后面的大量消息稀释
- 没有持久化,就不存在跨天、跨设备、跨入口的连续性
所以它更准确的名字应该是 session memory,不是“长期记忆系统”。
如果一个产品今天能记得,明天就不记得,那它其实只是会话连续,不是用户记忆。
真实工程里很快会遇到的几个问题
1. history 会无限膨胀
一开始你会本能地觉得“那就全带上”。但只要会话一长,马上会遇到:
- token 成本增加
- 延迟变高
- 噪声信息挤占关键上下文
2. 早期约束会被冲淡
如果最关键的规则只放在最前面,随着对话拉长,模型对这些约束的遵循度可能下降。
3. 多用户系统需要 session 隔离
本地 demo 里一个 chat 对象就够了;上到服务端产品,你必须处理用户级会话存储,不然就会串上下文。
4. 崩溃恢复与持久化迟早会成为需求
只要系统想上线,就得回答:历史存哪里?多久过期?如何回放?如何压缩?
如果把这一步做对,后面会自然过渡到什么
当你真的接受“连续对话 = 应用维护 history 并每轮重放”这个事实后,后面很多概念都会自动对齐:
- system prompt 为什么要稳态注入
- 工具调用结果为什么也要回填
- 摘要压缩为什么是上下文治理,不是锦上添花
- RAG 为什么是在补充当前上下文,而不是给模型洗脑
收尾
一个能连续聊天的系统,并没有改变模型默认无状态的事实;它只是把状态显式移到了应用层。多轮体验的本质,不是模型突然有记忆,而是你在一轮一轮替它搬运上下文。
这一步特别重要,因为它把聊天产品最容易被神化的一部分,重新还原成了工程机制。接下来再往前走,问题就不再只是“怎么持续聊”,而是“除了聊天,它什么时候能开始真正去做事”。这也是 Tool Calling 要解决的分界点。

