AI Agent 工程实践(22):AI Agent 项目应该如何分层
系列:AI Agent 工程实践
上一篇:第 21 篇《为什么 Demo 永远变不成生产系统》
下一篇:第 23 篇《Provider 抽象层设计》
一、开场:一个"分层很专业"的灾难项目
我去年帮人做代码评审,看到过一个目录结构,第一眼很感动:
models/ controllers/ services/ utils/ dtos/ middleware/
十二个文件夹,命名规范,看起来像大厂出品。结果新人要改一个"发送邮件"的工具,要同时碰 services/ 里的业务逻辑、models/ 里的数据结构、utils/ 里的辅助函数、还有 controllers/ 里的一个调用点——四处改完,还不敢提交,因为没人说得清"还有谁依赖这个发送逻辑"。
这就是典型的:分了文件夹,但没分层。 文件夹是物理切片,分层是逻辑契约——层与层之间必须有"边界"和"接口",否则只是把一团乱麻切成十二团更小的乱麻。
上篇(21)讲了"为什么 Demo 变不成生产",结论是生产系统靠"可替换 / 可观测 / 可演进"三支柱。这一篇落地到最具体的一步:那棵目录树到底怎么画,每一层凭什么存在,它向上暴露什么、向下依赖什么。
二、问题背景:分文件夹 ≠ 分层
先看清两种"看起来都分层"的结构,差别到底在哪。
A. 按技术类型切(假分层):
models/ # 所有数据模型
services/ # 所有业务逻辑
utils/ # 所有工具函数
controllers/ # 所有接口
问题:它按"代码长什么样"切,不按"它会怎么变"切。一个业务功能(发邮件)的代码被拆进四个文件夹,改一处要跨四处在脑子里拼图。关注点(发邮件这件事)没有内聚,类型(模型/服务/工具)反而内聚了——本末倒置。
B. 按变化原因切(真分层):
app/
api/ # 对外接口契约
agent/ # Agent 定义(提示词 + 工具清单 + 行为边界)
runtime/ # 一次执行的完整生命周期
memory/ # 记忆读写抽象
providers/ # 模型抽象层
tools/ # 具体工具 + 权限 + schema
models/ # 数据模型 / 持久化
config/ # 配置(环境变量、模型路由表)
infra/ # 基础设施连接(DB / 队列 / 缓存)
真分层的核心不是文件夹名字好听,而是三条铁律:
- 依赖单向:上层依赖下层,下层绝不反向依赖上层。
- 接口显式:层与层之间只通过明确定义的函数 / 类通信,不互相 import 内部变量。
- 边界内聚:一层只懂"自己负责的那点变化原因",别的层怎么变它不关心。
一句话:分层 = 依赖方向 + 接口契约 + 变化内聚。 缺任意一条,都是假分层。这也是为什么(21)里"按技术类型分 models/utils/services"会翻车——它只满足"文件夹变多",三条铁律一条都没满足。
三、错误尝试:三种把层分错的姿势
错误 1:循环依赖——A 依赖 B,B 又依赖 A
团队想"解耦",于是 providers/ 里放模型调用,runtime/ 里放编排。但 runtime 需要知道"这次用的模型叫什么"来记日志,于是 import 了 providers 的某个常量;providers 想在执行前后打点,又 import 了 runtime 的 trace 函数。
结果:改 providers 一处,runtime 跟着崩;改 runtime 一处,providers 跟着崩。两个"独立"的层其实绑死了。根因是没守住"依赖单向"——下层为了图方便反向依赖了上层。
错误 2:跨层调用——api 绕过 runtime 直接调 tools
为了"省事",controllers(api 层)里直接 from tools.email import send_email 发邮件,绕过了 agent 和 runtime。
短期爽:少写一层转发。长期坑:发邮件这个动作绕过了 Agent 的权限校验和审计埋点,哪天要加"敏感操作需人工确认",得回过头把每个跨层调用点都改一遍。跨层调用等于在墙上开了个后门,后门越多,边界越不存在。
错误 3:配置散落各层——换模型要 grep 全项目
模型名写在 providers 里、超时时间写在 runtime 里、重试次数写在 tools 里、API Key 写在三个不同的 .env 引用处。
来个需求"所有 DeepSeek 调用都加 30 秒超时",你只能全项目 grep "deepseek" 挨个改。配置不收口,等于没有配置层——每一层都在偷偷当自己的运维。
四、关键观察:层不是文件夹,是"会独立变化的理由"
把三个错误归纳,真正的分层原则只有一句:每一层,对应一个"会独立变化的原因"(axis of change)。
- 换模型(DeepSeek→Claude)→ 只有 providers/ 变,其余不动 → 这是"模型供应商"这个变化轴。
- 加一个新工具(发短信)→ 只有 tools/ 变,agent 只是把工具名加进清单 → 这是"能力"变化轴。
- 改接口协议(REST→gRPC)→ 只有 api/ 变 → 这是"对外契约"变化轴。
- 换数据库(Postgres→Qdrant)→ 只有 infra/ + memory/ 的存储实现变 → 这是"基础设施"变化轴。
目录树只是这些"变化轴"的物理投影。 你看到 providers/,背后是"模型供应商会换";看到 config/,背后是"运行参数会调"。所以判断"该不该分一层",就问一句:它有没有一个别的层都不拥有的、独立的变化原因? 有,就值得单独成层;没有,合并进最近的层。
这也是上篇(21)三支柱的落地:可替换 → 每层封装一个变化轴;可观测 → monitor 旁路贯穿;可演进 → 新需求在既有边界里插一块。分层不是形式主义,是把"坏假设"按变化原因分门别类收进各自的抽屉。
五、最终方案:完整目录树 + 每层边界与接口
下面是可直接落地的项目骨架(以一个中等规模 Agent 服务为例):
your_project/
├── app/
│ ├── api/ # 对外 HTTP 接口(FastAPI/路由)
│ │ ├── chat.py # POST /chat 入口,只做协议转换
│ │ └── schemas.py # 请求/响应 Pydantic 模型(接口契约)
│ ├── agent/ # Agent 定义(不写编排逻辑)
│ │ ├── customer_agent.py # 系统提示 + 工具清单 + 行为边界
│ │ └── registry.py # 有哪些 Agent,按名字取
│ ├── runtime/ # 编排一次执行的完整生命周期
│ │ ├── orchestrator.py # 调 LLM、循环、状态机
│ │ └── loop.py # ReAct / Plan-Execute 循环
│ ├── memory/ # 记忆读写抽象
│ │ ├── store.py # 短期/长期记忆接口
│ │ └── backends/ # redis / qdrant / sqlite 实现
│ ├── providers/ # 模型抽象层
│ │ ├── base.py # LLMProvider 抽象基类
│ │ ├── deepseek.py # DeepSeek 实现
│ │ └── claude.py # Claude 实现
│ ├── tools/ # 具体工具 + 权限 + schema
│ │ ├── registry.py # 工具注册表
│ │ ├── email.py # 发邮件(带权限校验)
│ │ └── schemas.py # 每个工具的 input schema
│ ├── models/ # 数据模型 / ORM
│ │ └── session.py # 会话、消息等持久化结构
│ ├── config/ # 配置收口
│ │ ├── settings.py # 读环境变量
│ │ └── model_routing.yaml # 默认模型、fallback、超时
│ └── infra/ # 基础设施连接
│ ├── db.py # 数据库连接池
│ └── queue.py # 异步任务队列
├── tests/ # 测试(对应 30 篇)
├── deployments/ # Docker / K8s(对应 31 篇)
└── pyproject.toml
逐层讲清:它向上暴露什么接口、向下依赖什么下层。
| 层 | 向上暴露的接口 | 向下依赖 | 它封装的"变化轴" |
|---|---|---|---|
api/ |
POST /chat(req) -> resp(schemas 定义契约) |
agent / runtime |
对外协议(REST/gRPC) |
agent/ |
get_agent(name) -> AgentSpec(提示+工具清单) |
tools、memory 的引用 |
Agent 的"人设与能力边界" |
runtime/ |
run_agent(input) -> output(一次执行) |
providers、tools、memory、config |
执行策略(循环/状态机) |
memory/ |
recall(session) / remember(msg) |
infra 的存储后端 |
记忆后端(Redis/Qdrant…) |
providers/ |
get_provider(name) -> LLMProvider.generate() |
config(密钥/端点) |
模型供应商(DeepSeek/Claude) |
tools/ |
ToolRegistry.list() / call(name, args) |
config(权限)、infra |
能力集合(加工具不动别的层) |
models/ |
数据类 / ORM 映射 | 无(纯结构) | 数据形状 |
config/ |
settings.xxx、routing 表 |
环境变量 / 文件 | 运行参数(模型/超时/密钥) |
infra/ |
连接池 / 队列句柄 | 外部系统(DB/队列) | 基础设施(换库不动上层) |
注意 config/ 的位置——它几乎被所有层依赖,但谁都不依赖它"怎么读"(只读 settings.xxx)。 这就是"配置收口":变化轴集中在一处,换模型、调超时、改密钥都只动 config/,不用 grep 全项目(正好治了错误 3)。
怎么验证你分对了?看 api/ 这一层:它只 import runtime,完全不认识 providers 和 tools 的具体实现。这意味着"对外接口"和"内部能力"被彻底隔开——换工具、换模型,api 一行不动。边界成立。
六、一张图看依赖方向(Mermaid)

读图要点:所有箭头朝下,没有一条回头。 这就是"依赖单向"的可视化——infra 在最底,谁都依赖它,它不依赖任何人;api 在最顶,只被用户依赖。任何一条向上箭头,都是循环依赖的苗头(对应错误 1)。当你不确定某层该放哪,就把候选依赖画出来:只要出现回边,说明这两层至少有一个该下沉或该抽接口。
七、代码对比:跨层调用(坏)vs 接口调用(好)
错误写法(api 跨层直调 tools):
# app/api/chat.py —— 绕过 runtime,直接碰工具实现
from tools.email import send_email # ① 跨层:api 直接依赖 tools 内部
def chat(req):
if "发邮件" in req.text:
send_email(req.to, req.body) # ② 绕过 Agent 的权限校验与审计
return {"ok": True}
问题:发邮件绕过了 Agent 的权限与审计(错误 2),且 api 层因此绑定了具体工具实现,tools 一改 api 跟着改。
正确写法(只通过接口,由 runtime 编排):
# app/api/chat.py —— 只做协议转换,把活交给 runtime
from runtime.orchestrator import run_agent
def chat(req: ChatRequest) -> ChatResponse:
output = run_agent(req.text) # ① 只依赖 runtime 的接口
return ChatResponse(content=output)
# app/runtime/orchestrator.py
from agent.registry import get_agent
from providers import get_provider
from tools.registry import ToolRegistry
from monitor.trace import trace
def run_agent(user_input: str) -> str:
agent = get_agent("customer") # 取 Agent 定义(提示+工具清单)
provider = get_provider("default") # 取模型(可替换)
tools = ToolRegistry() # 取工具(带权限)
with trace("agent_run"):
return provider.generate(
system=agent.system_prompt,
tools=tools.list(),
user=user_input,
)
关键差异:api 只认识 run_agent 这一个接口;模型、工具、记忆怎么组装,全在 runtime 内部。换模型改 config/model_routing.yaml,加工具改 tools/,api 一行不动。 这就是分层带来的"可演进"——新能力在既有边界里插一块,而不是推倒重写。
八、设计权衡:层该多细,什么时候不该分
反过度工程必须补一句:层不是越多越好。
| 决策 | 建议 | 理由 |
|---|---|---|
| 单人玩具 / 原型 | chat.py 一个文件 |
没有"独立变化轴"需要隔离,分层是负债 |
| 1–3 人小项目,功能稳 | api + runtime + providers + tools + config 五层足够 |
变化轴有限,过度切分反而内耗 |
| 多人对接口 / 长期维护 / 多模型多工具 | 上完整九层 | 每个变化轴都有人动,边界能防踩脚 |
| 层该不该再拆 | 问"它有没有独立变化轴" | 没有就合并,有就单分(错误 3 的反面) |
判断标准就一条:数一数你项目里"会独立变化的原因"有几个,就有几层。 强行把"只有一个变化原因"的东西拆成三层,等于为了整齐而整齐——和"为了显得专业照搬大厂目录"(21 篇错误 3)是同一类病。
落地自检清单(三个问题,答"能"即通过):
- 不改 runtime,能否换模型? 能 → providers 真正独立。
- 不改 api,能否加一个工具? 能 → tools 真正独立。
- 不改业务代码,能否调超时 / 换密钥? 能 → config 真正收口。
三问全"能",分层基本合格;任一"不能",说明那一层没真正独立,回去检查它是不是既被上层依赖、又偷偷依赖了上层(回边)。
九、总结
- ✅ 分文件夹 ≠ 分层。分层 = 依赖单向 + 接口显式 + 变化内聚,缺一条都是假分层。
- ✅ 三种分错姿势:循环依赖(下层反向依赖上层)、跨层调用(开后门绕过边界)、配置散落(没有收口层)。
- ✅ 核心原则:每一层对应一个"会独立变化的原因"。换模型→providers,加工具→tools,改协议→api,换库→infra。
- ✅ 完整九层骨架(api/agent/runtime/memory/providers/tools/models/config/infra),每层只向上暴露接口、只向下依赖,config 收口所有运行参数。
- ✅ 反过度工程:层數 = 独立变化轴的数量,不是越多越好;单人原型一个
chat.py就是最优解。三问自检(换模型 / 加工具 / 调配置)能验证分层是否真独立。
下一篇,我们钻进九层里最该先写的一层——Provider 抽象层:为什么企业从不 OpenAI() 写死,而是 Provider → *Provider。(23)
参考资料(带用途说明)
- 本系列(21)为什么 Demo 永远变不成生产系统:本文是(21)"三支柱"的工程落地——可替换/可观测/可演进如何映射成具体目录层与依赖方向。
- 本系列(20)过渡篇:本文回答(20)提出的"把零件翻译成目录结构"的第二讲(第一讲是 21)。
- 《Clean Architecture》(Robert C. Martin):依赖倒置与边界(boundary)思想的来源,对应本文"依赖单向 + 接口显式"。
- 12-Factor Agents(Chroma 团队,公开技术文章):Agent 应拆成可替换步骤、每层独立演进,对应本文"变化轴"视角。
- DeepSeek 开放平台文档(platform.deepseek.com):本文
providers/deepseek.py真实对接的模型方之一,说明"模型供应商"这一变化轴在企业里如何被隔离。
本文是 AI Agent 工程实践系列的第 22 篇(第四阶段第二篇)。
系列导航
上一篇:第 21 篇《为什么 Demo 永远变不成生产系统》
下一篇:第 23 篇《Provider 抽象层设计》
更多推荐


所有评论(0)