第二阶段 · 结构理解与代码组织

04 · 分层架构

把聊天链路拆成清晰层次,理解 Route、Service、Agent 和 Utils 的职责边界。

课时资源

一、学习目标

本课所在阶段:第二阶段 · 结构理解与代码组织。

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

  • 理解为什么随着功能增加,把所有逻辑写在 route.ts 里会变得难以维护
  • 看懂 Route / Service / Agent / Utils 四层各自负责什么
  • 明白 chat.service.ts 是如何接管原来写在路由里的参数解析和事件组装逻辑的
  • 知道 formatSse 被抽进 app/utils/sse.ts 之后,为什么 route 和 service 的代码都变薄了

二、问题背景

第三课的 route.ts 里已经承担了三件事:解析请求体、生成 SSE 事件流、处理错误。这时候功能还少,放在一起问题不大。但随着课程继续往前走,路由会需要处理工具调用事件、模型事件、附件上传,这些逻辑如果都堆在 route.ts 里,后面几课的改动成本会越来越高。

这一课真正要解决的问题是:在功能还不复杂的时候,先把代码的层次和职责边界定清楚。

如果跳过这一步,后续会出现几个典型问题:

  • 每加一个新能力,都要在一个越来越长的 route.ts 里找位置塞代码
  • 没有 service 层,单元测试很难写,因为业务逻辑和 HTTP 上下文耦合在一起
  • formatSse 这样的工具函数如果写死在 route 里,下一个需要它的地方只能复制粘贴

三、核心概念

这一课最重要的概念是:每一层只负责一件事,层与层之间用清晰的接口沟通。

本课重点讲解的四层

层次文件职责
Route 层app/api/chat/route.ts读 HTTP 请求体,把字节流推出去
Service 层app/services/chat.service.ts解析参数、组装事件、调用 Agent
Agent 层app/agent/chatbot.ts运行 LangGraph 工作流,生成内容
Utils 层app/utils/sse.ts把事件对象编码成 SSE 文本格式

这种分层本质上是"让改动影响范围最小":

  • 改 SSE 格式 → 只动 sse.ts
  • 改参数解析规则 → 只动 chat.service.ts
  • 改 Agent 输出逻辑 → 只动 chatbot.ts
  • 改 HTTP 认证 / 限流 → 只动 route.ts

完整分层架构

本课重点讲解后端的四层架构,但完整项目架构还包括:

层位置职责引入阶段
Component 层前端UI 组件、聊天界面第一阶段
Route 层Service 上方HTTP 边界、认证、限流第二阶段(本课)
Service 层核心层业务逻辑编排第二阶段(本课)
Agent 层Service 下方AI 工作流(可独立运行,可调用 Database)第一阶段
Database 层Service 下方数据持久化第五阶段
Utils 层通用层工具函数第二阶段(本课)

关键设计原则:

  • Agent 层可独立运行,与当前项目无关,只包含 LangChain 相关内容
  • Agent 层可以调用 Database 层
  • Database 层和 Agent 层都位于 Service 层下方

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

维度lesson-03lesson-04
参数解析写在 route.ts 里移到 chat.service.ts parseChatRequest
事件组装写在 route.ts 里移到 chat.service.ts streamChatResponse
SSE 编码formatSse 写在 route.ts抽到 app/utils/sse.ts
route.ts 行数约 66 行约 40 行,职责明显更单一

四、整体流程

五、运行过程

1. Route 层只负责 HTTP 边界

route.ts 现在变得极度精简:读请求体,把它传给 streamChatResponse(body),然后把 service 产出的每一条事件用 formatSse 编码后推进 ReadableStream。它不再知道"怎么解析 message",也不再知道"assistant id 是什么格式"。

2. Service 层统一做参数归一化

parseChatRequest 从 payload 里提取 message,加上安全检查(空值抛错),再把历史消息数组和当前用户消息拼成完整的 messages。这一步的职责是"把 HTTP payload 变成业务层可以直接使用的数据"。

3. Service 层组装事件流

streamChatResponse 在拿到解析后的 messages 之后,负责按顺序产出三类事件:message.start、多个 message.delta、message.end。它不直接操作 HTTP 响应,只是 yield 结构化的事件对象。

4. Utils 层处理格式细节

formatSse(event, data) 把事件对象编码成 SSE 协议要求的文本,交给 route 层写入流。这个函数只有一行,但把"SSE 格式是什么"这个知识点集中在一处,避免多处维护。

5. Agent 层保持不动

chatbot.ts 和第三课完全相同,这说明"分层重构不影响 Agent 内部逻辑"——这正是分层架构的核心价值所在。

六、关键代码解析

app/api/chat/route.ts - 职责被大幅收窄,只做"把请求体传给 service、把 service 的事件推进流"。

app/services/chat.service.ts - 新增的核心文件,接管原来在 route 里的参数解析和事件生成逻辑。

app/utils/sse.ts - 新增的工具文件,把 SSE 文本格式编码抽成独立函数。

app/agent/chatbot.ts - 没有变化,体现分层架构价值。

关键代码 1:route.ts 收窄后长什么样

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const encoder = new TextEncoder();

    const stream = new ReadableStream({
      async start(controller) {
        for await (const item of streamChatResponse(body)) {
          controller.enqueue(encoder.encode(formatSse(item.event, item.data)));
        }
        controller.close();
      },
    });

    return new Response(stream, { headers: { ... } });
  } catch (error) {
    ...
  }
}

代码解析:

  1. 这个 route.ts 现在只有一件事:把 HTTP 请求体交给 service,把 service 产出的事件逐条编码后推进流。它不知道 message 里有什么,不知道 assistant-xxx 是怎么生成的,也不知道 SSE 事件是三种还是五种。
  2. for await (const item of streamChatResponse(body)) 这一行是分层架构的关键接缝:route 消费的是 service 的异步生成器,service 消费的是 agent 的异步生成器,每一层只依赖下面那一层暴露的接口,不跨层直接调用。
  3. 如果之后要加 HTTP 认证(比如 withAuth 中间件)、限流、或者在请求开始/结束时打日志,只需要改 route 层,service 和 agent 完全不受影响。如果把认证逻辑写在 service 里,它就会和 HTTP 上下文耦合,无法独立测试。
  4. 这里 body 的类型是 any(来自 request.json()),参数归一化放在 parseChatRequest 里做,而不是在 route 里用 as 断言强转,这样 service 层可以对参数做完整校验,不会让非法输入悄悄进入业务逻辑。

关键代码 2:service 层是如何把参数解析和事件组装分成两段的

export function parseChatRequest(body: ChatRequestPayload) {
  const userMessage = body.message?.trim();
  if (!userMessage) throw new Error('message 不能为空');

  const history = Array.isArray(body.messages) ? body.messages : [];
  const messages = [...history, { id: `user-${Date.now()}`, role: 'user' as const, content: userMessage }];

  return { userMessage, history, messages };
}

export async function* streamChatResponse(payload: ChatRequestPayload) {
  const { messages } = parseChatRequest(payload);
  const assistantId = `assistant-${Date.now()}`;

  yield { event: 'message.start', data: { id: assistantId } };
  for await (const chunk of streamChat(messages)) {
    yield { event: 'message.delta', data: { id: assistantId, delta: chunk } };
  }
  yield { event: 'message.end', data: { id: assistantId, role: 'assistant' } };
}

代码解析:

  1. parseChatRequest 是纯函数:给定 payload,返回归一化后的消息数组,没有副作用。这让它非常容易单独测试——只需要构造不同的 body 输入,验证抛错时机和 messages 拼接结果是否正确,不需要模拟 HTTP 请求对象。
  2. streamChatResponse 是异步生成器,它的职责是"按什么顺序把什么事件推给调用者"。调用者(route 层)不需要知道生成器内部是先解析参数还是先算 id,只负责把每个 item 编码后入流。
  3. 把 assistantId 的生成放在 service 层而不是 route 层,是因为 id 是这次会话回复的业务标识,不是 HTTP 协议细节。如果以后 id 改成来自数据库或 UUID,只需要改 service,route 不感知。
  4. 注意 parseChatRequest 在 streamChatResponse 内部调用,而不是让 route 先调用它再传结果进来。这样 service 的接口就是"给我一个原始 payload",而不是"给我一个已解析的对象",接口更简单,调用更自然。

关键代码 3:utils 层的 formatSse 为什么值得单独抽出来

export function formatSse(event: string, data: unknown) {
  return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}

代码解析:

  1. 这只有一行实现,但把"SSE 格式是什么"的知识点收归到一处。如果以后需要在事件里加入 id: 或 retry: 字段(SSE 规范支持这些),只改这一处就覆盖所有调用点。
  2. 之前在 lesson-03 里,这个格式是直接写在 route 的 formatSse 内部函数里的,只有 route 能用。抽出来之后,service 层同样可以引用它(比如未来直接从 service 构造完整的 SSE 字符串),不需要复制粘贴。
  3. 参数类型 data: unknown 而不是 data: object 或 data: Record<string, any>,是故意设计的。JSON.stringify 能处理数字、字符串、布尔值,不限制成 object 让这个函数更通用。
  4. 如果这个函数在多个地方各自手写,迟早会出现一处漏掉第二个 \n(SSE 事件必须以 \n\n 结尾),或者某处用了 JSON.stringify(data, null, 2) 加了缩进。集中在一个地方保证了所有推出去的 SSE 格式一致。

七、常见问题

为什么不直接继续把逻辑写在 route.ts?

因为一旦功能变多,路由文件会同时掺杂 HTTP 协议细节、业务逻辑和格式化代码,后续维护成本会迅速上升。

这一课 page.tsx 有没有改动?

几乎没有。这正是分层架构的价值:改动只发生在后端分层里,前端不受影响。你可以对比 lesson-03 和 lesson-04 的 page.tsx,差异极小。

为什么 service 层用函数而不是类?

这一课保持最小实现,用模块函数就够了。参考项目的 ChatService 是类,是因为它需要管理实例状态(比如进行中的会话 map)、支持依赖注入、以及便于写 Jest 的 mock。课程版在有需要的课次(比如记忆系统课)会引入类的方式,但提前引入反而会增加不必要的认知负担。

parseChatRequest 为什么要在 service 层抛错,而不是在 route 层检查?

因为"message 不能为空"是业务规则,不是 HTTP 协议规则。把它放在 service 层意味着:不管将来从 HTTP 路由、WebSocket 还是 CLI 调用 streamChatResponse,都会执行同一套参数验证,不会因为调用入口不同而漏检。

以后要加工具调用,改哪一层?

主要是 Agent 层(chatbot.ts)和 Service 层(chat.service.ts)。Agent 层负责在工具被调用时 yield 工具结果,Service 层负责把工具事件组装成正确的 SSE 格式。Route 层几乎不需要动,这正是分层带来的收益。

八、练习题

  1. 解释 Route / Service / Agent / Utils 四层各自的职责,并说明为什么这一课 Agent 层没有任何改动。
  2. 如果要在每次请求开始时打一条服务端日志(记录时间戳和 message 内容),你会把这段代码加在哪一层?为什么?
  3. parseChatRequest 是纯函数。写出两个测试用例:一个验证 message 为空时抛错,一个验证 messages 不是数组时能安全回退为空数组。
  4. 对比这一课的 route.ts 和 lesson-03 的 route.ts,列出被移走的内容以及移到了哪里。这个对比有助于你理解"分层"在代码上的实际体现。

九、总结

这一课真正建立的是:一个可持续演进的后端分层结构。

只要你已经能说清楚 Route / Service / Agent / Utils 各自负责什么、改一个功能点时改动影响哪一层,这一课就学到位了。

On this page