06 · 接入真实 API 与记忆系统
从这一课开始接入真实大模型 API,并引入 threadId 和 LangGraph 记忆能力,让聊天应用支持多轮上下文和历史会话切换。
课时资源
关键节点:从这一课开始,课程不再使用教学版固定回复,而是正式接入真实大模型 API。
一、学习目标
本课所在阶段:第三阶段 · Agent 核心能力。
学完这一课后,你应该能够:
- 明确知道课程是从这一课开始接入真实大模型 API 的
- 理解为什么没有线程记忆,Chat Bot 每轮都会像"全新对话"
- 看懂
threadId如何把多轮消息串成同一条线程 - 明白
MemorySaver和memory.service.ts在这一课分别承担什么职责 - 知道为什么这一课先用内存型记忆方案,而不是直接接数据库
二、问题背景
前五课已经完成了流式聊天链路、分层结构和消息类型边界,但前面课程还没有把"真实模型调用"正式接进来。到了这里,课程第一次把真实大模型 API 和线程记忆一起放进工作流里。
这意味着第六课同时跨过了两个门槛:
- 不再只是教学版链路,而是开始连接真实模型
- 不再只是单轮问答,而是开始具备线程级上下文
如果没有这一课,应用会长期卡在一个很尴尬的状态:
- 前端看起来像聊天应用,但每次请求都是"单轮问答"
- 侧边栏即使显示会话,也没有真正的切换意义
- 后面要做工具调用、多轮推理、数据库持久化时,都会缺少线程这个上层单位
这一课真正要解决的不是"多存几条消息",而是先让系统理解:一段对话不是零散消息集合,而是一条可以持续续写的线程。
三、核心概念
这一课最重要的概念是:记忆不是附着在单条消息上,而是附着在线程上。
当前代码里,记忆能力被拆成了两层:
-
threadId这是线程的唯一标识。前端发送消息时带上它,后端就知道这条消息应该接到哪段历史上。 -
MemorySaver这是 LangGraph 的 checkpointer。它负责把同一thread_id下的消息状态保存在工作流层。 -
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 占位消息。
这样做的好处是:
- 第一次聊天时,前端能立刻拿到新线程 id
- 侧边栏可以在第一段回复到来前就刷新
- assistant 气泡可以先进入 loading,再平滑过渡到真正文本
- 后续请求会自动带着正确的
threadId
5. 会话历史通过 GET 恢复
当你点击侧边栏某条历史会话时,前端调用:
fetch(`/api/chat?thread_id=${nextThreadId}`)服务端不会重新生成回复,而是走 getChatHistory(threadId),从 LangGraph 里取出这个线程当前保存的消息状态,再由 fromLangGraphMessages 转成前端消息格式。
六、环境变量配置
从这一课开始接入真实大模型 API,需要配置相应的环境变量。
请参考 环境变量配置指南 完成配置:
- 获取 OpenAI 兼容的 API Key(阿里云百炼 / OpenAI 官方)
- 在项目根目录创建
.env文件 - 配置
OPENAI_API_KEY、OPENAI_BASE_URL、OPENAI_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 - 维护线程摘要信息
维护线程摘要信息,提供 getOrCreateThread、touchThread、listThreads 等能力,给侧边栏提供真实会话数据。
关键代码:
interface ThreadSummaryRecord {
threadId: string;
title: string;
updatedAt: string;
}
const records = new Map<string, ThreadSummaryRecord>();代码解析:这段代码很重要,因为它说明了这一课的真实分层:
memory.service.ts不是聊天内容数据库- 它只负责侧边栏所需的摘要信息
- 真正的消息历史仍然由 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 - 前端线程状态管理
前端这一课最重要的入口。新增 threadId、sessions、loadThread,并处理 session.start、message.start、message.delta、message.end 这组事件流。
关键代码:
if (eventName === 'message.start' && payload.id) {
const messageId = payload.id;
setMessages((current) => ensureAssistantMessage(current, messageId));
}代码解析:这一步是第六课当前代码里一个很值得注意的 UI 细节:
session.start只负责同步线程,不负责创建消息气泡- 真正的 assistant 占位消息是在
message.start时创建 - 收到第一段
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 读当前线程状态。这说明:
- 记忆的真正来源是 LangGraph 工作流
thread_id既用于写入,也用于读取- 后面的数据库课,本质上是把这种线程状态持久化到更稳定的存储层
八、常见问题
为什么这一课还要保留 memory.service.ts,不能只用 MemorySaver?
因为 MemorySaver 解决的是"工作流状态怎么记住",它不负责给你整理一个适合侧边栏展示的会话列表。memory.service.ts 在这里承担的是摘要和排序职责。
为什么前端还需要保存 threadId?
因为服务端不会凭空知道你下一条消息要接到哪条线程里。前端必须把当前会话 id 带回去,后端才能续接上下文。
这一课是不是已经完成真正持久化了?
还没有。MemorySaver 和进程内 Map 都是内存级方案,开发时很方便,但服务重启后仍然会丢数据。真正的持久化要到后面的数据库阶段才完成。
为什么这一课先用内存方案,而不是直接接数据库?
因为这一课要先让你看懂"线程 id 如何流动"以及"工作流如何按线程恢复状态"。如果一开始就引入数据库,你会同时面对连接、表结构、鉴权和线程状态这些问题,理解成本太高。
九、练习题
- 解释
threadId在前端请求、服务层解析、Agent 调用、历史恢复这四个位置分别扮演什么角色。 - 比较
MemorySaver和memory.service.ts的职责,说明为什么这一课要同时保留两者。 - 修改
touchThread的调用时机,观察如果在流式过程中频繁更新,会话列表会发生什么变化。 - 发起两轮对话后,点击历史会话重新加载,确认消息是否还能恢复;然后重启服务,再观察这些历史是否仍然存在。
十、总结
这一课真正建立的是:以线程为单位的对话记忆能力。
只要你已经能说清楚 threadId 是怎么在前后端流动的、LangGraph 为什么能按线程恢复状态、以及 memory.service.ts 为什么只维护会话摘要而不重复存完整消息,这一课就掌握了。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。