后端为什么要分层:把问答 Bot 长成一个完整的 Agent 项目
前两篇里,我们已经让一个问答 Bot 接住了 HTTP 请求,也给请求和响应规定了形状。第一篇关心 Python 函数怎样成为 API,第二篇关心客户端送来的数据怎样通过校验,最后才进入模型调用。到了上一篇末尾,/sessions/{session_id}/chat 还会检查会话是否存在、是不是属于当前调用方。
这些代码写在一个 main.py 里,我们能顺着读完。但如果这个 Bot 开始记住之前说过的话,又能查学习笔记,路由里就要接着写查历史、拼上下文、调用工具、保存消息。等到加入额度、日志和失败处理,chat() 很快会变成一个什么都要知道的函数,这样的耦合会让系统后续的开发变得很痛苦。
这一篇我想把这个项目的主干搭起来。我们仍然从同一个会话接口出发,只是让 Bot 多一点 Agent 的能力:模型可以决定是否检索本地笔记,程序执行工具,把结果交回模型,最后把答案留在会话里。我们来看看如何把这样一个复杂系统的代码写清楚。
一个会话接口需要处理很多问题
假设客户端向 42 号会话发送问题:“Router 和 Service 怎样分工?”后端至少要知道调用方是谁,确认会话归属,读出历史,把问题交给 Agent,再把这一轮问题和答案保存下来。客户端得到答案后,下一次还能沿着同一个会话继续问。
这里有几件事的变化原因很不一样。把 URL 从 /sessions/42/chat 改成别的路径,是接口路径的安排;规定只有会话的拥有者才能发消息,是业务规则;从内存改成数据库存历史,是存储实现;调整提示词或换一个检索工具,是 Agent 自己的执行逻辑。现在它们挤在同一个函数里,将来任何一项变化都会碰到这个入口。
随着这些需求增加,分层能让不同职责的代码更容易维护。Router 接住 HTTP,Service 组织一次业务动作,Repository 提供数据访问能力。 Agent 则单独保留自己的执行循环。对于这个项目,我们可以先画成下面这样:
flowchart TD
request["HTTP 请求"] --> fastapi["FastAPI
匹配路由、解析依赖、校验参数"]
fastapi --> router["Router
把参数交给 Service"]
router --> service["AgentService
检查会话、读取历史、运行 Agent、保存问答"]
service -->|查询与保存| repository["ConversationRepository
本例使用内存存储"]
repository -.->|会话与历史| service
service -->|运行| agent["KnowledgeAgent
模型调用与检索工具循环"]
agent -.->|答案与使用量| service
service -->|ChatResult| response["Router
构造 ChatResponse"]
response --> output["HTTP 响应"]
这几层仍在同一个 Python 进程里,通过普通的函数和方法调用协作,这样的分层可以换来更加清晰的文件结构。
完整项目示例
我们的 Agent 是一个后端学习助手,有三条本地笔记和一个 search_notes 工具。检索用关键词匹配,模型调用使用真实的 Responses API;模型 ID 从 CHAT_MODEL 读取。工具是否被调用,由模型根据问题和提示决定,代码也允许模型直接回答。
这个项目有三个入口:POST /sessions 创建会话,POST /sessions/{session_id}/chat 发起一轮问答,GET /sessions/{session_id}/messages 查看历史。继续沿用上一篇的演示约定:有效 API Key 对应 demo-client,42 号会话归它所有,43 号会话属于另一个调用方。这样我们既能自己创建新会话,也能直接验证 403 和 404 的路径。
项目的文件不多,暂时按职责拆成平铺的模块。只有一个路由和一个业务流程时,没必要为每个文件再建一层文件夹:
1 | backend-layered-agent/ |
下面是完整应用代码,其中 # app/xxx.py 标记各个文件的分界,使用时分别保存。
1 | # requirements.txt(单独保存;app/__init__.py 是空文件) |
domain.py 用 dataclass 表示内部记录,例如 ChatResult 传递业务结果,schemas.py 的 ChatResponse 则规定 HTTP 响应结构。Protocol 只声明所需方法,... 表示此处不写实现,具体工作由后面的 Repository 和 Agent 完成。
顺着一次请求,把代码重新读一遍
我们先给 42 号会话发一条消息。客户端送来的内容仍然是上一篇认识的四个位置:路径里的 ID、查询里的生成上限、请求头里的 Key,以及请求体里的 question。
1 | POST /sessions/42/chat?max_output_tokens=512 |
FastAPI 匹配到 router.py 中的 chat(),解析路径与查询参数,把请求体校验成 ChatRequest,并解析声明的依赖。identify_client() 核对 Key,返回 demo-client。路由使用的 Service 已经在组装应用时传给 create_router();这些入口步骤完成后,就能调用它处理这次消息。
接下来,路由把 session_id、client_id、request.question 和 max_output_tokens 交给 service.chat()。Service 查到会话并确认归属,拿着当前历史调用 Agent。Agent 可以多次请求模型,执行检索工具,再得到最终答案。成功以后,Service 要求 Repository 保存一整轮问答,最后返回 ChatResult;Router 把这个内部结果转换成 ChatResponse,FastAPI 再发回 JSON。
成功响应的结构示意如下:
1 | { |
Router 留下的,是 HTTP 这一侧的约定
回到完整代码的 router.py,最值得看的其实是 chat() 已经短到能一次读完。它声明接收哪些参数,调用 Service,再按 ChatResponse 返回。方法是 POST、路径包含 {session_id}、查询参数的范围是 16 到 1024,这些都决定了客户端怎样使用接口,因此留在 Router 很合适。
APIRouter 和上一篇的 FastAPI() 应用对象也可以对上。create_router(service) 先创建 Router、登记三个入口,再返回这个 Router;main.py 的 app.include_router(...) 把它们注册到应用里。prefix="/sessions" 是这组入口共用的路径前缀。
这几个入口都要认识调用方,所以参数里写了 client_id: Client;Client 是 Annotated[str, Depends(identify_client)] 的别名,告诉 FastAPI 先识别调用方,再把结果传给参数。create_session()、get_history() 和 chat() 都定义在 create_router() 内部,通过闭包保留对外层 service 的引用,注册完成后仍能使用同一个对象。
返回时,asdict(result) 把 dataclass 转成字典,** 再把字典中的字段展开为构造参数,得到 ChatResponse。
原来的 401 仍由鉴权依赖抛出。会话不存在和无权访问,则变成 Service 抛出的内部异常;Router 用 try / except 接住它们,通过 to_http_error() 转成 404、403 等 HTTP 错误。
Service 组织的是一次完整的业务动作
service.py 里的 AgentService.chat() 按顺序完成四件事:取得有权限访问的会话,运行 Agent,保存结果,返回本次业务结果。它不需要接收 Request,也不用知道 JSON 字段从请求体还是查询字符串来。对于它而言,client_id 和 question 已经是 Python 参数。
会话归属判断为什么放在这里?因为“只有拥有者才能访问自己的会话”同样适用于聊天和读历史,也适用于未来从别的入口执行这些动作。get_session() 把存在性与归属一起检查,chat() 和查询历史共用它。
Service 也决定了一个容易藏在代码顺序里的选择:Agent 成功以后,才保存这一轮问题与答案。 因此模型出错、答案为空、工具参数不合法或执行超过上限时,这个版本都不向历史写入半轮对话。对于我们的学习助手,这是一个容易验证的规则。
所以 Service 的复杂度,往往来自步骤之间的关系。以后检查额度是在调用模型之前,记录使用量是在调用之后,知识库范围要依据会话权限决定,这些规则都要有人组织。它和我们写 Agent workflow 时有相似之处,但这里关注的是后端如何完成“发送一条会话消息”这项业务。
Repository 把“保存一轮问答”落实到数据上
完整代码中只有一个 InMemoryConversationRepository,同时保存会话信息和消息。这个例子把一次会话作为一个整体,暂时没有独立查询某条消息的需求,所以先把相关操作放在一起。
Service 使用的是 create()、get() 和 append_turn() 这些能力;内存字典怎么组织、ID 怎么分配,以及怎样替换会话对象,都放在 Repository 内部。我们特意给 append_turn() 一个完整的问答对,让“保存一轮”成为一次明确的数据操作。在这个内存实现里,两条消息通过一次赋值一起写入。
Conversation.messages 使用 tuple,Conversation 和 Message 都是冻结的 dataclass。get() 返回的因此是一份不会随之后保存动作一起改变的会话快照。Service 可以把它交给 Agent,当作本次运行开始时看到的历史;Repository 保存新消息时使用 replace() 建立新的会话对象。否则,如果把内部可变列表直接传出去,外面的代码就可能不经过 Repository 改掉历史。
以后换成 SQLAlchemy 或其他数据库实现,Service 可以继续表达“查会话、运行 Agent、保存一轮”,查询语句则进入新的 Repository。这就是分层的核心价值。
Agent 自己,还有一段执行流程
agent.py 是这个项目与普通 CRUD 后端最明显的区别。KnowledgeAgent.run() 接收问题、历史和生成上限,把历史转换为模型输入,追加本次问题,再进入一个有上限的循环。它不查会话属于谁,也不直接往会话历史中写消息。
每轮模型请求有两种结果。模型直接给出文本时,我们得到最终答案;模型返回 function_call 时,程序检查工具名和参数,执行 search_notes(),再把结果作为 function_call_output 放回输入,继续请求模型。模型只提出工具调用,真正检索笔记的是我们写的 Python 函数。
代码保留了 response.output 中的全部 item,再追加工具结果,不能只留下函数名和参数。某些模型的输出还包含 reasoning item,也要一起传回。call_id 则把一份工具结果对应到模型刚才提出的那次调用。这里使用 store=False,由本次 run() 自己传递完整的中间输入,没有依靠上一轮响应 ID 在服务端接续状态。
工具 schema 描述参数形状,执行前的代码仍然检查工具名、参数类型和查询长度。最多四次模型请求是这个例子的执行上限。前三次允许工具调用,第四次用 tool_choice="none" 要求进入回答阶段,返回不完整结果或空答案时仍按失败处理。会话历史只保存用户问题与最终答案,工具调用与检索结果只在当前 run() 内保留。下一轮问答会重新检索。
main.py 只负责把对象接起来
现在看完整代码最后的 main.py,主体只有六条语句。先创建模型客户端、Repository 和 Agent,再把后两个对象交给 AgentService;最后创建 FastAPI 应用,把使用这个 Service 的 Router 注册进去。
AgentService(repository=repository, agent=agent) 可以从普通的 Python 调用理解。等号左侧是构造函数的参数名,右侧是前面创建的对象;构造函数再把它们保存到 self._repository 和 self._agent。我们没有在 Service 内部重新创建仓库或 Agent,而是把它需要的能力从外面传进去。
app.include_router(create_router(service)) 则有内外两次调用。Python 先执行里面的 create_router(service),得到已经登记好接口的 APIRouter,再把返回的对象交给 app.include_router(...)。
这里的 main 是模块名,没有额外定义一个 main() 函数。Uvicorn 加载 app.main:app 时,会执行模块顶层的这些语句,找到创建好的 app。OpenAI() 在这里创建客户端,真正发出模型请求要等到 KnowledgeAgent.run();FastAPI() 创建应用对象,启动 Uvicorn 后由它监听端口,并把收到的请求交给应用。
项目以后怎样长大,要看新增的需求
现在把一个文件拆成这些模块,并不代表项目已经完成。它已经能创建会话、运行带工具的 Agent、继续对话和查询历史,主干也已经有了各自的位置。接下来如果新增用户、知识库和额度管理,可以先按职责扩展,再根据模块数量决定要不要调整目录。
| 新需求 | 主要从哪里扩展 | 还要考虑什么 |
|---|---|---|
| 把会话与消息保存到数据库 | Repository 与应用组装 | 连接生命周期、事务与版本检查 |
| 换模型或增加工具 | Agent 与工具实现 | 执行上限、工具参数和上下文 |
| 规定谁能使用哪份知识库 | Service 与检索能力 | 权限范围如何传到工具 |
| 按调用方检查额度 | Service | 并发额度预留、失败与实际消耗 |
| 增加流式输出 | Router 与 Agent 的返回接口 | 最终保存和中途断开时的语义 |
| 接入长任务队列 | 提交入口、任务状态与 Worker | 恢复、重试和重复执行 |
分层以后,改动仍然可能跨越多个模块。比如从“等完整答案再返回”改成流式输出,就要同时考虑 Agent 怎样产出片段、Router 怎样传输,以及 Service 在什么时刻保存完成状态。现在我们至少能沿着这些职责讨论影响,而不是把所有问题都当作 chat() 的局部补丁。
当路由变多,把 router.py 扩成 routers/chat.py、routers/sessions.py 就很自然;数据访问变多,可以建立 repositories/,数据库模型放进 models/。如果用户、聊天、计费各自形成一套代码,也可以按业务模块组织成 features/chat/、features/billing/,模块内部再放 Router、Service 和 Repository。按技术层分目录和按业务分目录,都能表达我们今天建立的职责关系。
我们现在可以理解一个完整项目,去解释“这段代码为什么在这里”。客户端怎样发请求,由 Router 和 schema 说明;发送消息有哪些业务步骤,由 Service 组织;会话怎样读取和保存,由 Repository 落实;模型怎样使用工具并得到答案,由 Agent 自己完成。main.py 把这些对象接成一个能运行的应用。
在 AI Coding 可以很快生成这些文件的情况下,这是很有价值的内容,我们能够理解为什么一个项目要被架构成这个样子,以及在业务问题变得复杂以后,怎么调整架构,适应新的内容。
- 标题: 后端为什么要分层:把问答 Bot 长成一个完整的 Agent 项目
- 作者: Hyacehila
- 创建于 : 2026-10-01 04:00:00
- 链接: https://hyacehila.github.io//blog/2026/10/01/router-service-repository-agent-backend-architecture/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。