tc9011

多轮对话不是记忆:你其实在替模型搬运上下文

14 min

做过客服 Copilot、IDE 内嵌助手或者运营问答机器人的人,很快都会遇到一个现实问题:用户不会每一轮都把背景重说一遍。第一句说“帮我查一下订单 23019”,第二句说“为什么它还没发货”,第三句又补一句“收件地址已经改过了”。如果系统每轮都像第一次请求那样重新开始,这个产品根本没法用。

于是很多人自然会得出一个结论:聊天模型已经有记忆了。这个说法不算全错,但很容易把工程重点说歪。更准确的描述是:你的应用在替模型维护会话历史,并在每一轮调用时把这段历史重新带回去。

也就是说,聊天界面的连续感,不是模型突然拥有了长期状态,而是系统在不断搬运上下文。

为什么要把“连续聊天”这件事拆开看

因为只要把“多轮对话”误会成“模型已经记住了”,你后面就会在几个关键地方吃亏:

  • 不知道 token 为什么越来越贵
  • 不知道为什么会话一长,早期约束开始失效
  • 不知道为什么进程重启后,所谓的记忆突然消失
  • 不知道为什么多用户接进来后,状态隔离会变成系统设计问题

这些都不是模型能力问题,而是会话状态管理问题。

下面这个示例用 Gemini SDK 的 startChat() 搭一个最小 CLI,把多轮对话背后的机械结构完全展开。

为什么这种实现方式重要

它让你第一次真正看清“聊天”背后的工作流:

  1. 应用保存历史
  2. 用户发来新输入
  3. 应用把历史 + 当前输入一起发给模型
  4. 模型给出回答
  5. 应用把这轮回答继续追加到历史里

如果你后面要做会话持久化、上下文裁剪、摘要压缩、跨设备同步,都是沿着这条链路往外扩展,而不是另起一套完全不同的体系。

完整代码

// 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 数组和重组消息。底层工作流依然是:

  1. 取已有 history
  2. 拼上本轮输入
  3. 一起发给模型
  4. 把模型输出再追加回 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:

  1. 接收用户输入
  2. SDK 把新输入追加到 history
  3. 整段上下文一起送给模型
  4. 模型生成回答
  5. 回答再次进入 history
  6. 进入下一轮

所以“多轮对话到底怎么来的”,最准确的回答不是“模型会记”,而是:

应用在维护一段不断增长的上下文,并在每一轮继续重放。

责任边界:模型、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 要解决的分界点。

  • 本文作者: tc9011
  • 本文链接: https://tc9011.com/posts/2026/02-多轮对话不是记忆-而是你在替模型搬上下文/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!