第一阶段 · 最小可运行闭环

02 · 核心对话流

打通用户输入到 Agent 回复的最小链路,建立真正可运行的 Chat Bot 闭环。

课时资源

一、学习目标

本课所在阶段:第一阶段 · 最小可运行闭环

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

  • 理解最小 Chat Bot 为什么必须先打通"输入到回复"的完整链路
  • 看懂 app/page.tsxapp/api/chat/route.tsapp/agent/chatbot.ts 之间的职责分工
  • 明白为什么这一课先使用同步 JSON 返回,而不是直接上流式输出
  • 知道前端消息是怎样从输入框进入 Agent,再回到页面中的

二、问题背景

第一课只是把界面骨架搭出来,但它还不能真正聊天。下一步最自然的问题就是:这个 Chat Bot 什么时候才能真的收到问题并返回回答?

如果这条链路还没打通,后面几节课会很难学:

  • 你看不到前端状态变化和后端响应之间的关系
  • 你还没弄清最小闭环,就会被 SSE、记忆、工具调用这些后续概念打断
  • 页面上的输入区和消息区都只是摆设,无法建立"代码正在工作"的感觉

所以这一课的重点不是把体验做到最好,而是先让"用户输入 → API → Agent → 页面显示"这条最小链路真正跑起来。

三、核心概念

这一课最重要的概念是:先建立最小闭环,再追求更复杂的交互体验。

当前实现有三个关键边界:

  1. 前端负责收集输入、发起请求、更新页面状态
  2. API 路由负责校验请求并把消息交给 Agent
  3. Agent 负责基于当前消息生成一段可返回的 assistant 内容

这一课仍然使用一次性 JSON 返回,因此它解决的是"能不能聊",不是"聊得像不像真实产品"。第三课会在这条链路上继续升级成流式输出。

这一课和 lesson-01 的关键差异

维度lesson-01lesson-02
page.tsx纯静态组合,无状态开始管理 messagesisLoading
输入组件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 会从请求体里拿到 messagemessages。如果 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 后面,再由 MessageListMessageBubble 显示到页面中。

六、关键代码解析

app/page.tsx - 前端主入口,管理 messagesisLoading,在 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);

代码解析:

  1. history 代表前端已知的旧消息。路由先做了类型检查,如果 body.messages 不是数组就回退为空数组,避免运行时崩溃。
  2. 当前用户问题会被补到历史后面,形成新的完整输入。这一步在路由层完成而不是要求前端提前拼好完整数组,是为了让 /api/chat 成为这条链路里真正可靠的入口。
  3. runChat(messages) 是这一课最关键的一步——消息从 HTTP 层正式进入 Agent 层。
  4. 如果没有这段拼装,最容易出现两个问题:前端和后端各自维护一套"当前消息如何补进历史"的规则,逻辑开始分叉;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);
  }
}

代码解析:

  1. 用户消息先立即加进 messages 状态(乐观更新),这样页面不会在等待后端时出现"空白期"。这是聊天 UI 的常见模式:先让用户看到自己说的话,再等回复出现。
  2. messagemessages 同时存在于请求体中,不是重复设计。message 强调"本轮输入",messages 强调"已有上下文",这样接口语义更清楚。
  3. 错误兜底里创建了一条 assistant 角色的错误消息并直接追加到列表里,而不是弹窗提示。这让错误信息仍然以聊天的形式出现在对话流里,体验更一致。
  4. 页面负责把当前可见上下文送进接口,但并不负责直接调用 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();

代码解析:

  1. 这是一个最小的 LangGraph StateGraph,只有一个节点 chatbot,从 START 进、到 END 出。虽然简单,但它已经是一个合法的 LangGraph 工作流,后面加工具节点、条件路由时会在这个基础上扩展。
  2. buildResponse 生成的是教学版文本,不是模型推理结果。这样做的好处是:这一课不需要配置任何 API Key 就能跑通,你可以专注理解链路本身。
  3. 注意 [...history].reverse().find(...) 这种写法:它不会修改原数组,只是从后往前找最近一条 HumanMessage。如果直接 history.reverse(),会把 LangGraph 内部的消息数组原地翻转,导致后续节点拿到的消息顺序全部颠倒。
  4. 如果这里直接接真实 LLM,你会同时面对两个问题:"链路有没有通"和"模型为什么返回这段内容"。这一课先消除第二个干扰项,让你只验证链路本身。

七、常见问题

为什么这一课不直接做流式输出?

因为你首先需要确认"请求真的从页面到了后端,再回到了页面"。如果最小闭环还没看懂,SSE 只会让链路更难理解。第三课会在完全不改动 Agent 内核的前提下,把同一条链路升级到流式输出。

为什么 Agent 回复看起来像课程说明?

因为这一课的目标是教学闭环,不是模型效果评测。当前回复文本是为了让你看清链路,而不是为了模拟生产环境输出。参考项目中同样位置使用的是 ChatGoogleGenerativeAI 真实模型调用,后面的课程会逐步接近那个完整实现。

为什么前端已经传 messages,却还不能算记忆系统?

因为这里的消息只存在当前页面状态里,刷新页面就全部丢失。真正的记忆系统需要 threadId 来标识会话、需要后端保存消息历史、需要侧边栏能切换和恢复对话。那是第六课要解决的问题。

这一课的 ChatMessage 和参考项目的消息有什么不同?

这一课的 ChatMessage 只有三个字段:idrolecontent。参考项目的消息结构要复杂得多——包含 tool_callstoolCallResultsisStreaming、附件信息等。后续课程会逐步往 ChatMessage 上加字段。

八、练习题

  1. 画出这一课从输入框到 assistant 回复的最小链路,并标出前端、API、Agent 三层的边界。
  2. 说明 app/page.tsxapp/api/chat/route.ts 各自负责什么,为什么页面不直接调用 runChat
  3. 如果 message 为空,为什么应该在路由层直接返回错误,而不是交给 Agent 再处理?
  4. 尝试修改 app/agent/chatbot.ts 里的 buildResponse 函数,让它返回一条不同格式的文本。验证修改后页面是否能正确显示新的回复内容——这能帮你确认自己理解了消息从 Agent 回到页面的完整路径。

九、总结

这一课真正建立的是:一个最小可运行的 Chat Bot 闭环。

只要你已经能说清楚输入是怎样进入 /api/chat、Agent 怎样返回内容、页面怎样把返回结果显示出来,这一课就已经学到位了。

和参考项目相比,这一课刻意精简了三件事:没有真实 LLM、没有流式输出、没有线程记忆。第三课到第六课会依次把这些能力补上。

登录以继续阅读

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

立即登录

On this page