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

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.deltamessage.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. 注意 parseChatRequeststreamChatResponse 内部调用,而不是让 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: objectdata: 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