02 · 核心对话流
打通用户输入到 Agent 回复的最小链路,建立真正可运行的 Chat Bot 闭环。
课时资源
一、学习目标
本课所在阶段:第一阶段 · 最小可运行闭环。
学完这一课后,你应该能够:
- 理解最小 Chat Bot 为什么必须先打通"输入到回复"的完整链路
- 看懂
app/page.tsx、app/api/chat/route.ts、app/agent/chatbot.ts之间的职责分工 - 明白为什么这一课先使用同步 JSON 返回,而不是直接上流式输出
- 知道前端消息是怎样从输入框进入 Agent,再回到页面中的
二、问题背景
第一课只是把界面骨架搭出来,但它还不能真正聊天。下一步最自然的问题就是:这个 Chat Bot 什么时候才能真的收到问题并返回回答?
如果这条链路还没打通,后面几节课会很难学:
- 你看不到前端状态变化和后端响应之间的关系
- 你还没弄清最小闭环,就会被 SSE、记忆、工具调用这些后续概念打断
- 页面上的输入区和消息区都只是摆设,无法建立"代码正在工作"的感觉
所以这一课的重点不是把体验做到最好,而是先让"用户输入 → API → Agent → 页面显示"这条最小链路真正跑起来。
三、核心概念
这一课最重要的概念是:先建立最小闭环,再追求更复杂的交互体验。
当前实现有三个关键边界:
- 前端负责收集输入、发起请求、更新页面状态
- API 路由负责校验请求并把消息交给 Agent
- Agent 负责基于当前消息生成一段可返回的 assistant 内容
这一课仍然使用一次性 JSON 返回,因此它解决的是"能不能聊",不是"聊得像不像真实产品"。第三课会在这条链路上继续升级成流式输出。
这一课和 lesson-01 的关键差异
| 维度 | lesson-01 | lesson-02 |
|---|---|---|
page.tsx | 纯静态组合,无状态 | 开始管理 messages 和 isLoading |
| 输入组件 | StaticChatInput(readOnly) | ChatInput(可输入、可提交) |
| 消息展示 | EmptyState 固定展示 | MessageList 动态渲染 |
| API 层 | 只有占位 README | 新增 app/api/chat/route.ts |
| Agent 层 | 只有占位 README | 新增 app/agent/chatbot.ts |
| 类型定义 | 无 | 新增 app/types/chat.ts |
这一课和参考项目的差异
参考项目(langgraphjs-chat-app)的聊天链路使用了真实 LLM(ChatGoogleGenerativeAI)和 LangGraph 的 streamEvents API,回复内容来自大模型的真实推理。这一课则使用教学版固定文本回复(buildResponse),目的是让你只关注"链路是否打通",而不是模型效果本身。
四、整体流程
这张图对应的是这一课真实已经打通的最小主线。后面几课虽然会继续加事件流、分层和记忆,但骨架仍然是这条路径。
五、运行过程
1. 页面先收集用户输入
app/page.tsx 中的 sendMessage 会先创建一条用户消息,把它放进本地 messages 状态,再调用 /api/chat。这意味着用户按下回车后,页面上会立刻出现自己发送的消息,不需要等后端响应。
2. 路由把请求变成可处理的数据
app/api/chat/route.ts 会从请求体里拿到 message 和 messages。如果 message 为空,就直接返回 400。否则它会把当前提问补进历史消息数组,组成 Agent 需要的完整输入。
3. Agent 把消息送进 LangGraph
app/agent/chatbot.ts 中的 runChat(messages) 会先通过 toLangChainMessages 把前端的 ChatMessage[] 转换成 LangChain 的 BaseMessage[],然后交给一个最小的 StateGraph 处理。
4. Agent 生成一条教学版回复
当前版本的 StateGraph 只有一个 chatbot 节点,它调用 buildResponse 生成固定格式的教学文本。这一课不追求模型能力本身,而是强调"当前闭环已经跑通"。
5. 页面把返回结果渲染出来
前端拿到 JSON 后,会把 assistant 消息接到现有 messages 后面,再由 MessageList 和 MessageBubble 显示到页面中。
六、关键代码解析
app/page.tsx - 前端主入口,管理 messages 和 isLoading,在 sendMessage 里调用 /api/chat。
app/api/chat/route.ts - 最小 API 入口,读取请求体、校验 message、拼接历史消息,调用 Agent。
app/agent/chatbot.ts - 最小 Agent,将前端消息转成 LangChain 消息,通过 LangGraph 工作流生成教学版回复。
app/components/ChatInput.tsx - 替代静态输入框,真正触发发送动作。
app/components/MessageList.tsx - 将用户消息和 assistant 消息渲染到页面里。
app/types/chat.ts - 前后端共享的最小消息结构定义。
关键代码 1:API 路由如何拼出最小消息链路
const history = Array.isArray(body.messages) ? body.messages : [];
const messages = [
...history,
{ id: `user-${Date.now()}`, role: 'user' as const, content: userMessage },
];
const response = await runChat(messages);代码解析:
history代表前端已知的旧消息。路由先做了类型检查,如果body.messages不是数组就回退为空数组,避免运行时崩溃。- 当前用户问题会被补到历史后面,形成新的完整输入。这一步在路由层完成而不是要求前端提前拼好完整数组,是为了让
/api/chat成为这条链路里真正可靠的入口。 runChat(messages)是这一课最关键的一步——消息从 HTTP 层正式进入 Agent 层。- 如果没有这段拼装,最容易出现两个问题:前端和后端各自维护一套"当前消息如何补进历史"的规则,逻辑开始分叉;Agent 拿到的输入到底包不包含本轮提问会变得不稳定,调试时很难判断问题出在页面还是接口。
关键代码 2:页面如何把一次请求变成一次聊天
async function sendMessage(content: string) {
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content,
};
const nextMessages = [...messages, userMessage];
setMessages(nextMessages);
setIsLoading(true);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: content, messages }),
});
if (!response.ok) throw new Error('聊天请求失败');
const data = (await response.json()) as { message: ChatMessage };
setMessages([...nextMessages, data.message]);
} catch (error) {
const fallback: ChatMessage = {
id: `assistant-error-${Date.now()}`,
role: 'assistant',
content: error instanceof Error ? `请求失败:${error.message}` : '请求失败:未知错误',
};
setMessages([...nextMessages, fallback]);
} finally {
setIsLoading(false);
}
}代码解析:
- 用户消息先立即加进
messages状态(乐观更新),这样页面不会在等待后端时出现"空白期"。这是聊天 UI 的常见模式:先让用户看到自己说的话,再等回复出现。 message和messages同时存在于请求体中,不是重复设计。message强调"本轮输入",messages强调"已有上下文",这样接口语义更清楚。- 错误兜底里创建了一条
assistant角色的错误消息并直接追加到列表里,而不是弹窗提示。这让错误信息仍然以聊天的形式出现在对话流里,体验更一致。 - 页面负责把当前可见上下文送进接口,但并不负责直接调用 Agent。这意味着后面不管是加校验、埋点、鉴权还是事件流,扩展点都还能留在 API 层。
关键代码 3:Agent 为什么先用教学版回复而不是真实 LLM
const workflow = new StateGraph(MessagesAnnotation)
.addNode('chatbot', async (state) => {
const history = state.messages;
const lastHuman = [...history].reverse().find((m) => m._getType() === 'human');
const prompt = typeof lastHuman?.content === 'string' ? lastHuman.content : '你好';
const response = buildResponse(prompt, history.length);
return { messages: [new AIMessage(response)] };
})
.addEdge(START, 'chatbot')
.addEdge('chatbot', END)
.compile();代码解析:
- 这是一个最小的 LangGraph
StateGraph,只有一个节点chatbot,从START进、到END出。虽然简单,但它已经是一个合法的 LangGraph 工作流,后面加工具节点、条件路由时会在这个基础上扩展。 buildResponse生成的是教学版文本,不是模型推理结果。这样做的好处是:这一课不需要配置任何 API Key 就能跑通,你可以专注理解链路本身。- 注意
[...history].reverse().find(...)这种写法:它不会修改原数组,只是从后往前找最近一条HumanMessage。如果直接history.reverse(),会把 LangGraph 内部的消息数组原地翻转,导致后续节点拿到的消息顺序全部颠倒。 - 如果这里直接接真实 LLM,你会同时面对两个问题:"链路有没有通"和"模型为什么返回这段内容"。这一课先消除第二个干扰项,让你只验证链路本身。
七、常见问题
为什么这一课不直接做流式输出?
因为你首先需要确认"请求真的从页面到了后端,再回到了页面"。如果最小闭环还没看懂,SSE 只会让链路更难理解。第三课会在完全不改动 Agent 内核的前提下,把同一条链路升级到流式输出。
为什么 Agent 回复看起来像课程说明?
因为这一课的目标是教学闭环,不是模型效果评测。当前回复文本是为了让你看清链路,而不是为了模拟生产环境输出。参考项目中同样位置使用的是 ChatGoogleGenerativeAI 真实模型调用,后面的课程会逐步接近那个完整实现。
为什么前端已经传 messages,却还不能算记忆系统?
因为这里的消息只存在当前页面状态里,刷新页面就全部丢失。真正的记忆系统需要 threadId 来标识会话、需要后端保存消息历史、需要侧边栏能切换和恢复对话。那是第六课要解决的问题。
这一课的 ChatMessage 和参考项目的消息有什么不同?
这一课的 ChatMessage 只有三个字段:id、role、content。参考项目的消息结构要复杂得多——包含 tool_calls、toolCallResults、isStreaming、附件信息等。后续课程会逐步往 ChatMessage 上加字段。
八、练习题
- 画出这一课从输入框到 assistant 回复的最小链路,并标出前端、API、Agent 三层的边界。
- 说明
app/page.tsx和app/api/chat/route.ts各自负责什么,为什么页面不直接调用runChat。 - 如果
message为空,为什么应该在路由层直接返回错误,而不是交给 Agent 再处理? - 尝试修改
app/agent/chatbot.ts里的buildResponse函数,让它返回一条不同格式的文本。验证修改后页面是否能正确显示新的回复内容——这能帮你确认自己理解了消息从 Agent 回到页面的完整路径。
九、总结
这一课真正建立的是:一个最小可运行的 Chat Bot 闭环。
只要你已经能说清楚输入是怎样进入 /api/chat、Agent 怎样返回内容、页面怎样把返回结果显示出来,这一课就已经学到位了。
和参考项目相比,这一课刻意精简了三件事:没有真实 LLM、没有流式输出、没有线程记忆。第三课到第六课会依次把这些能力补上。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。