第三阶段 · Agent 核心能力

06 · 接入真实 API 与记忆系统

从这一课开始接入真实大模型 API,并引入 threadId 和 LangGraph 记忆能力,让聊天应用支持多轮上下文和历史会话切换。

课时资源

关键节点:从这一课开始,课程不再使用教学版固定回复,而是正式接入真实大模型 API。

一、学习目标

本课所在阶段:第三阶段 · Agent 核心能力

学完这一课后,你应该能够:

  • 明确知道课程是从这一课开始接入真实大模型 API 的
  • 理解为什么没有线程记忆,Chat Bot 每轮都会像"全新对话"
  • 看懂 threadId 如何把多轮消息串成同一条线程
  • 明白 MemorySavermemory.service.ts 在这一课分别承担什么职责
  • 知道为什么这一课先用内存型记忆方案,而不是直接接数据库

二、问题背景

前五课已经完成了流式聊天链路、分层结构和消息类型边界,但前面课程还没有把"真实模型调用"正式接进来。到了这里,课程第一次把真实大模型 API 和线程记忆一起放进工作流里。

这意味着第六课同时跨过了两个门槛:

  • 不再只是教学版链路,而是开始连接真实模型
  • 不再只是单轮问答,而是开始具备线程级上下文

如果没有这一课,应用会长期卡在一个很尴尬的状态:

  • 前端看起来像聊天应用,但每次请求都是"单轮问答"
  • 侧边栏即使显示会话,也没有真正的切换意义
  • 后面要做工具调用、多轮推理、数据库持久化时,都会缺少线程这个上层单位

这一课真正要解决的不是"多存几条消息",而是先让系统理解:一段对话不是零散消息集合,而是一条可以持续续写的线程。

三、核心概念

这一课最重要的概念是:记忆不是附着在单条消息上,而是附着在线程上。

当前代码里,记忆能力被拆成了两层:

  1. threadId 这是线程的唯一标识。前端发送消息时带上它,后端就知道这条消息应该接到哪段历史上。

  2. MemorySaver 这是 LangGraph 的 checkpointer。它负责把同一 thread_id 下的消息状态保存在工作流层。

  3. memory.service.ts 它不保存完整聊天内容,而是维护"会话摘要":线程 id、标题、更新时间,用来驱动侧边栏列表。

这一课相对第 05 课的核心增量

维度第 05 课第 06 课
模型调用教学版固定回复从这一课开始接入真实大模型 API
会话标识没有 threadId新增 threadId 并贯穿前后端
消息记忆只依赖本次请求使用 LangGraph MemorySaver 绑定线程状态
侧边栏静态假数据sessions 状态驱动,可切换历史线程
服务层只处理消息流新增线程创建、会话列表、历史恢复
事件流message.start/delta/end额外新增 session.start

这一课和参考项目的关系

参考项目同样是通过 thread_id 驱动多轮上下文,但课程版把"会话摘要"和"完整消息历史"拆得更清楚:

  • 完整消息历史:交给 LangGraph MemorySaver
  • 会话列表摘要:交给 memory.service.ts

这样你会更容易看懂:为什么"记住聊天内容"和"展示会话列表"虽然相关,但不是同一个存储层职责。

四、整体流程

发送消息并绑定线程

切换历史会话

五、运行过程

1. 前端开始维护线程状态

page.tsx 新增了两份关键状态:

  • threadId:当前正在对话的是哪条线程
  • sessions:侧边栏要展示哪些历史会话

这意味着从这一课开始,页面不再只关心"当前消息列表",而是开始关心"当前在哪条会话里"。

2. 服务端决定新建还是续接线程

parseChatRequest 不再只解析 message,还会调用 getOrCreateThread(body.threadId, userMessage)。如果前端传来的 threadId 还有效,就继续这条线程;否则创建新线程。

3. LangGraph 用 thread_id 记住上下文

这一课最关键的变化在 app/agent/chatbot.ts

for await (const event of app.streamEvents(
  { messages: [new HumanMessage(message)] },
  { version: 'v2', configurable: { thread_id: threadId } }
))

这里的重点不是语法本身,而是:从这一行开始,LangGraph 知道"这次输入属于哪条线程"。同一个 thread_id 后续再次进入工作流时,MemorySaver 就能把之前的状态接上。

同时,这一课的 model.invoke(...)app.streamEvents(...) 也意味着课程正式从教学版回复切进了真实模型 API。后面的工具调用、多模型切换,都是建立在这里已经接通真实模型的基础上继续扩展。

4. session.start 先同步线程,message.start 再创建 loading 气泡

streamChatResponse 在真正开始吐 message.delta 之前,会先发一条 session.start 事件,把当前 threadId 和最新 sessions 列表同步给前端;紧接着再发 message.start,让前端先创建 assistant 占位消息。

这样做的好处是:

  1. 第一次聊天时,前端能立刻拿到新线程 id
  2. 侧边栏可以在第一段回复到来前就刷新
  3. assistant 气泡可以先进入 loading,再平滑过渡到真正文本
  4. 后续请求会自动带着正确的 threadId

5. 会话历史通过 GET 恢复

当你点击侧边栏某条历史会话时,前端调用:

fetch(`/api/chat?thread_id=${nextThreadId}`)

服务端不会重新生成回复,而是走 getChatHistory(threadId),从 LangGraph 里取出这个线程当前保存的消息状态,再由 fromLangGraphMessages 转成前端消息格式。

六、环境变量配置

从这一课开始接入真实大模型 API,需要配置相应的环境变量。

请参考 环境变量配置指南 完成配置:

  1. 获取 OpenAI 兼容的 API Key(阿里云百炼 / OpenAI 官方)
  2. 在项目根目录创建 .env 文件
  3. 配置 OPENAI_API_KEYOPENAI_BASE_URLOPENAI_MODEL

七、关键代码解析

app/agent/chatbot.ts - 引入 MemorySaver 并绑定线程

本课最关键的 Agent 文件。这里引入了 MemorySaver,并在 streamChat 里通过 configurable: { thread_id } 把流式输出绑定到特定线程。

关键代码:

for await (const event of app.streamEvents(
  { messages: [new HumanMessage(message)] },
  { version: 'v2', configurable: { thread_id: threadId } }
))

代码解析:从这一行开始,LangGraph 知道"这次输入属于哪条线程"。同一个 thread_id 后续再次进入工作流时,MemorySaver 就能把之前的状态接上。同时,这一课的 model.invoke(...)app.streamEvents(...) 也意味着课程正式从教学版回复切进了真实模型 API。


app/services/memory.service.ts - 维护线程摘要信息

维护线程摘要信息,提供 getOrCreateThreadtouchThreadlistThreads 等能力,给侧边栏提供真实会话数据。

关键代码:

interface ThreadSummaryRecord {
  threadId: string;
  title: string;
  updatedAt: string;
}

const records = new Map<string, ThreadSummaryRecord>();

代码解析:这段代码很重要,因为它说明了这一课的真实分层:

  1. memory.service.ts 不是聊天内容数据库
  2. 它只负责侧边栏所需的摘要信息
  3. 真正的消息历史仍然由 LangGraph MemorySaver 维护

如果把完整消息也塞进这个 Map,逻辑会和 LangGraph 的工作流状态重复,后面迁移数据库时也会更混乱。


app/services/chat.service.ts - 解析线程并发出事件

负责解析 threadId、发出 session.start 事件、触发聊天流,以及通过 getChatHistory 恢复指定线程历史。

关键代码:

for await (const chunk of streamChat(userMessage, threadId)) {
  yield { event: 'message.delta', data: { id: assistantId, delta: chunk } };
}

touchThread(threadId, userMessage);

代码解析:这里保留"先流式输出,再刷新线程摘要"的顺序,是为了让 updatedAt 和会话排序反映真实完成时间,而不是请求刚开始的时间。


app/page.tsx - 前端线程状态管理

前端这一课最重要的入口。新增 threadIdsessionsloadThread,并处理 session.startmessage.startmessage.deltamessage.end 这组事件流。

关键代码:

if (eventName === 'message.start' && payload.id) {
  const messageId = payload.id;
  setMessages((current) => ensureAssistantMessage(current, messageId));
}

代码解析:这一步是第六课当前代码里一个很值得注意的 UI 细节:

  1. session.start 只负责同步线程,不负责创建消息气泡
  2. 真正的 assistant 占位消息是在 message.start 时创建
  3. 收到第一段 message.delta 后,这个占位气泡会从 loading 状态切到正常文本

这样事件语义会更清楚:线程建立和消息开始是两件不同的事。


app/components/SessionSidebar.tsx - 从静态侧边栏升级成受控组件

从静态侧边栏升级成受控组件,真正承接"新建对话"和"切换历史会话"。


app/types/chat.ts - 新增 ChatSession 类型

新增 ChatSession,并提供 fromLangGraphMessages,把 LangGraph 恢复出来的消息重新变成前端可渲染的结构。

关键代码:

const state = await getChatApp().getState({
  configurable: { thread_id: threadId },
});

代码解析:这是这一课最容易忽略的一点。恢复历史不是从 memory.service.ts 读出来的,而是直接从 LangGraph checkpointer 读当前线程状态。这说明:

  1. 记忆的真正来源是 LangGraph 工作流
  2. thread_id 既用于写入,也用于读取
  3. 后面的数据库课,本质上是把这种线程状态持久化到更稳定的存储层

八、常见问题

为什么这一课还要保留 memory.service.ts,不能只用 MemorySaver

因为 MemorySaver 解决的是"工作流状态怎么记住",它不负责给你整理一个适合侧边栏展示的会话列表。memory.service.ts 在这里承担的是摘要和排序职责。

为什么前端还需要保存 threadId

因为服务端不会凭空知道你下一条消息要接到哪条线程里。前端必须把当前会话 id 带回去,后端才能续接上下文。

这一课是不是已经完成真正持久化了?

还没有。MemorySaver 和进程内 Map 都是内存级方案,开发时很方便,但服务重启后仍然会丢数据。真正的持久化要到后面的数据库阶段才完成。

为什么这一课先用内存方案,而不是直接接数据库?

因为这一课要先让你看懂"线程 id 如何流动"以及"工作流如何按线程恢复状态"。如果一开始就引入数据库,你会同时面对连接、表结构、鉴权和线程状态这些问题,理解成本太高。

九、练习题

  1. 解释 threadId 在前端请求、服务层解析、Agent 调用、历史恢复这四个位置分别扮演什么角色。
  2. 比较 MemorySavermemory.service.ts 的职责,说明为什么这一课要同时保留两者。
  3. 修改 touchThread 的调用时机,观察如果在流式过程中频繁更新,会话列表会发生什么变化。
  4. 发起两轮对话后,点击历史会话重新加载,确认消息是否还能恢复;然后重启服务,再观察这些历史是否仍然存在。

十、总结

这一课真正建立的是:以线程为单位的对话记忆能力。

只要你已经能说清楚 threadId 是怎么在前后端流动的、LangGraph 为什么能按线程恢复状态、以及 memory.service.ts 为什么只维护会话摘要而不重复存完整消息,这一课就掌握了。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page