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-03 | lesson-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) {
...
}
}代码解析:
- 这个
route.ts现在只有一件事:把 HTTP 请求体交给 service,把 service 产出的事件逐条编码后推进流。它不知道message里有什么,不知道assistant-xxx是怎么生成的,也不知道 SSE 事件是三种还是五种。 for await (const item of streamChatResponse(body))这一行是分层架构的关键接缝:route 消费的是 service 的异步生成器,service 消费的是 agent 的异步生成器,每一层只依赖下面那一层暴露的接口,不跨层直接调用。- 如果之后要加 HTTP 认证(比如
withAuth中间件)、限流、或者在请求开始/结束时打日志,只需要改 route 层,service 和 agent 完全不受影响。如果把认证逻辑写在 service 里,它就会和 HTTP 上下文耦合,无法独立测试。 - 这里
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' } };
}代码解析:
parseChatRequest是纯函数:给定 payload,返回归一化后的消息数组,没有副作用。这让它非常容易单独测试——只需要构造不同的body输入,验证抛错时机和messages拼接结果是否正确,不需要模拟 HTTP 请求对象。streamChatResponse是异步生成器,它的职责是"按什么顺序把什么事件推给调用者"。调用者(route 层)不需要知道生成器内部是先解析参数还是先算 id,只负责把每个item编码后入流。- 把
assistantId的生成放在 service 层而不是 route 层,是因为 id 是这次会话回复的业务标识,不是 HTTP 协议细节。如果以后 id 改成来自数据库或 UUID,只需要改 service,route 不感知。 - 注意
parseChatRequest在streamChatResponse内部调用,而不是让 route 先调用它再传结果进来。这样 service 的接口就是"给我一个原始 payload",而不是"给我一个已解析的对象",接口更简单,调用更自然。
关键代码 3:utils 层的 formatSse 为什么值得单独抽出来
export function formatSse(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}代码解析:
- 这只有一行实现,但把"SSE 格式是什么"的知识点收归到一处。如果以后需要在事件里加入
id:或retry:字段(SSE 规范支持这些),只改这一处就覆盖所有调用点。 - 之前在 lesson-03 里,这个格式是直接写在 route 的
formatSse内部函数里的,只有 route 能用。抽出来之后,service 层同样可以引用它(比如未来直接从 service 构造完整的 SSE 字符串),不需要复制粘贴。 - 参数类型
data: unknown而不是data: object或data: Record<string, any>,是故意设计的。JSON.stringify能处理数字、字符串、布尔值,不限制成 object 让这个函数更通用。 - 如果这个函数在多个地方各自手写,迟早会出现一处漏掉第二个
\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 层几乎不需要动,这正是分层带来的收益。
八、练习题
- 解释 Route / Service / Agent / Utils 四层各自的职责,并说明为什么这一课 Agent 层没有任何改动。
- 如果要在每次请求开始时打一条服务端日志(记录时间戳和 message 内容),你会把这段代码加在哪一层?为什么?
parseChatRequest是纯函数。写出两个测试用例:一个验证message为空时抛错,一个验证messages不是数组时能安全回退为空数组。- 对比这一课的
route.ts和 lesson-03 的route.ts,列出被移走的内容以及移到了哪里。这个对比有助于你理解"分层"在代码上的实际体现。
九、总结
这一课真正建立的是:一个可持续演进的后端分层结构。
只要你已经能说清楚 Route / Service / Agent / Utils 各自负责什么、改一个功能点时改动影响哪一层,这一课就学到位了。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。