4-项目结构与基础服务配置管理
4 - 电商问数:项目结构与基础服务配置管理
本章课程目标:
- 先建立对
shopkeeper-agent项目结构的整体认识,知道代码、配置、提示词和脚本分别放在哪里。 - 理解当前项目默认依赖哪些服务端口,以及这些端口与后续基础服务之间的关系。
- 掌握本项目的配置参数管理方式,理解 YAML 配置、
dataclass结构定义和OmegaConf加载链路。
学习建议: 这篇先看项目骨架,再看配置对象如何被创建和复用。重点不是记住每个目录名,而是理解 MySQL、Qdrant、ES、Embedding 为什么都应该从同一套配置体系取参数。读完后再看各类客户端代码时,可以顺手追一下:它们拿到的 host、port、key 到底从哪里来。
对应代码分支: 04-structure-config
这一章开始,我们正式进入「电商问数」的基础服务搭建部分。
这里的“基础服务”可以看作三类和业务无关、但项目运行离不开的内容:
- 各类外部服务客户端
例如MySQL、Elasticsearch、Qdrant、Embedding服务的客户端 - 配置与日志
例如配置文件加载、日志输出、通用基础能力 - 项目结构约定
也就是代码应该放在哪、脚本应该放在哪、提示词应该放在哪
如果再往整个项目的全局看,可以把后续开发内容大致分成三大块:
- 基础服务搭建
- 元数据知识库构建
- 问数智能体工作流搭建
本章先处理第一块,也就是先把后端项目运行所需的工程骨架和配置入口准备好。后面的 MySQL、Qdrant、Elasticsearch、Embedding、日志、脚本等基础服务能力,最终都是为后端主链路服务的。
1、项目目录结构
在开始写代码之前,先明确一件很重要的事:把项目结构规划清楚。
当前仓库中,后端项目 shopkeeper-agent 的核心结构如下:
1 | shopkeeper-agent/ |
这里需要特别注意一个设计原则:
所有源码统一放在
app包下,而配置文件、提示词这类“非源码内容”尽量放在源码目录之外。
这样做的目的,是把“代码”和“配置 / 资源”明确分开,避免后面项目越写越乱。
如果你以前做过 Java 或 Spring Boot 开发,也可以这样类比:
api有点像controllerservices有点像servicerepositories有点像mapper或repository
先建立一个整体印象:
- 源码放在
app - 配置放在
conf - 提示词放在
prompts - 脚本放在
app/scripts
这一节先记住: app 放源码,conf 放配置,prompts 放静态提示词;后面如果看到客户端、仓储、服务、脚本这些概念,先回到这张结构图里找它们的位置。
2、配置参数管理
这一章虽然属于基础服务部分,但不会先展开某个具体服务的接入,而是先把这些服务共同依赖的配置参数管理理顺。
原因很简单:这个项目里有很多组件都要依赖配置,例如:
- MySQL 的地址、用户名、密码
- Elasticsearch 的地址
- Qdrant 的地址
- Embedding 服务地址
- 大模型的
API Key
这些内容如果直接写死在源码里,会有两个明显问题:一旦环境变化,代码里就要到处改;配置和逻辑耦合在一起,不方便维护。
所以更合理的做法是:
- 把配置单独写到 YAML 文件里
- 再通过工具把 YAML 配置加载为 Python 对象
- 后续代码统一通过对象属性访问配置项
2.1 配置文件
本项目采用 YAML 文件管理配置参数。你也可以根据以下路径在对应文件夹下创建对应文件。
项目对应文件路径:shopkeeper-agent/conf/app_config.yaml
以下是项目当前使用的核心配置:
1 | logging: |
从这份配置里,也可以先快速对照每个配置分组的默认地址与作用:
| 配置分组 | 默认地址 | 作用 |
|---|---|---|
logging |
—(无对外服务端口) |
控制日志输出到文件还是控制台,以及日志级别、轮转策略 |
db_meta |
localhost:3306(库 meta) |
元数据库连接信息 |
db_dw |
localhost:3306(库 dw) |
数据仓库模拟库连接信息 |
qdrant |
localhost:6333 |
向量数据库连接信息与向量维度 |
embedding |
localhost:8081 |
Embedding 推理服务地址与模型名 |
es |
localhost:9200 |
Elasticsearch 地址与索引名 |
llm |
—(远程 API,地址见 llm.base_url,无本机固定端口) |
大模型名称、密钥与服务地址 |
| 后端 API(运行端口) | localhost:8000 |
问数后端 HTTP 服务(由启动方式决定,一般不在 app_config.yaml 顶层单独成组) |
浏览器访问地址:
| 服务 | 浏览器访问地址 | 说明 |
|---|---|---|
qdrant |
http://localhost:6333/dashboard#/collections | Qdrant 自带的 Dashboard,可直接查看 collection |
embedding |
http://localhost:8081/docs | Embedding 服务的接口文档页,可用来确认服务是否启动成功 |
es |
http://localhost:5601/app/elasticsearch/elasticsearch | Kibana调试 Elasticsearch 的控制台入口 |
像 db_meta、db_dw 这类 MySQL 服务,本身并没有浏览器管理页面,通常还是通过 Navicat、DBeaver、命令行客户端等方式连接查看。
2.2 YAML 快速入门
YAML 本质上也是一种结构化数据格式,它和 JSON 很像,都可以表达:
- 对象
- 数组
- 多层嵌套结构
只是 YAML 的写法通常更简洁,更适合人手维护配置文件。
YAML 与 JSON 在线转换工具:https://tool.ip138.com/yamljson/
例如,在 JSON 里,如果我们要表示一个对象,通常会写成这样:
1 | { |
换成 YAML,可以写成:
1 | name: zhangsan |
可以看到,YAML 不需要像 JSON 那样写大量花括号、引号和逗号,看起来会更清爽。
如果要表示数组,在 JSON 里常见的是:
1 | ["a", "b", "c"] |
而在 YAML 里,最常见的写法是:
1 | - a |
当然,YAML 也支持更像 JSON 的方括号写法,只不过在配置文件场景里,更常见的还是这种用横杠表示数组元素的形式。
再进一步,如果是“对象数组”这种更复杂的结构,YAML 依然能表达。例如:
1 | - name: zhangsan |
如果换成 JSON,对应写法就是:
1 | [ |
这里有一个很重要的语法点要先记住:YAML 对缩进非常敏感。
也就是说:
- 同一层级的属性要对齐
- 子属性必须比父级多一级缩进
- 数组里的对象,后续字段也要和第一个字段保持对齐
所以你在看当前项目的 app_config.yaml 时,可以直接按下面这样理解:
- 最外层的
logging、db_meta、db_dw、qdrant、embedding、es、llm,都是一级对象 - 每个对象下面再挂自己的子属性
- 这些结构最终会被加载成后面代码里的配置对象
如果只从“为什么项目里用 YAML,而不是 JSON”来理解,也可以记一句话:JSON 更像数据交换格式,YAML 更像工程配置格式。
因为在配置文件场景里,开发者更关心的是:
- 好不好读
- 好不好改
- 层级清不清楚
而在这些方面,YAML 通常会比 JSON 更合适。
2.3 配置加载工具
本项目使用的 YAML 配置加载工具为 OmegaConf,具体用法可参考 OmegaConf 官网。
官网:https://omegaconf.readthedocs.io/en/2.3_branch/index.html
这一节的核心主线很清楚:
- 先定义一组和 YAML 结构对应的
dataclass - 再读取 YAML 文件内容
- 最后把“配置结构”和“配置值”合并,转换成一个可以直接访问属性的配置对象
使用 OmegaConf 的好处很直接:配置项先有明确的结构定义,访问配置时也可以直接写成 app_config.xxx 这种更直观的形式,后面代码里自然就不用到处写死字符串键名。
用于读取配置文件的代码放置在 shopkeeper-agent/app/conf/app_config.py 文件中,你也可以根据以下路径在对应文件夹下创建对应文件。
核心内容如下:
1 | from dataclasses import dataclass |
执行文件验证,成功:
1 | (shopkeeper-agent) didilili@DidililiMacBook-Pro shopkeeper-agent % python3 app/conf/app_config.py |
这里有两个点特别值得先记住:
Path(__file__).parents[2] / 'conf' / 'app_config.yaml'
这行代码的作用,是基于当前文件所在位置,稳定地找到项目根目录下的配置文件,而不是写死某个容易出错的相对路径。OmegaConf.structured(AppConfig)+OmegaConf.load(...)+OmegaConf.merge(...)
这一组操作,本质是在做“先定义结构,再加载内容,最后把它们合并成一个配置对象”。
2.4 路径相关知识
在真正理解 OmegaConf 之前,建议先把“配置文件路径为什么要这样写”这件事单独看明白。因为这个知识点不只会用在 OmegaConf 上,后面你在读取提示词文件、日志目录、脚本配置文件时,也都会反复遇到。
如果你之前对这行路径代码不太熟,这里可以再展开理解一下。
先看这一行:
1 | config_file = Path(__file__).parents[2] / "conf" / "app_config.yaml" |
它里面包含了三个知识点:绝对路径、相对路径、pathlib 路径操作。
第一,什么是绝对路径,什么是相对路径?
- 绝对路径
是从系统根目录开始写出的完整路径,例如:/Users/tools/Desktop/agent/shopkeeper-agent/conf/app_config.yaml - 相对路径
是相对于某个“当前位置”去描述的路径,例如:conf/app_config.yaml
或者../conf/app_config.yaml
表面上看,相对路径更短,但它有一个很容易踩坑的地方:
在 Python 程序里,相对路径通常是相对于“当前程序启动时所在的工作目录”,而不是相对于当前这个
.py文件本身。
这意味着什么呢?
例如你写:
1 | OmegaConf.load("conf/app_config.yaml") |
如果你恰好是在 shopkeeper-agent/ 根目录启动程序,这个路径可能是对的。
但如果你是在别的目录下启动,或者通过其他脚本间接启动,这个相对路径就可能找不到文件。
所以在工程项目里,直接手写相对路径通常不够稳妥。
第二,__file__ 是什么?
__file__ 是 Python 提供的一个特殊变量,它表示当前这个 Python 文件自身的路径。
例如在 app/conf/app_config.py 里,__file__ 大致会是:
1 | /Users/tools/Desktop/agent/shopkeeper-agent/app/conf/app_config.py |
有了它,我们就不再依赖“程序是从哪个目录启动的”,而是可以从“当前文件自己在哪儿”出发去找配置文件。
第三,为什么要用 pathlib.Path?
因为路径拼接如果全靠字符串来写,会比较麻烦,也不够稳。
例如你手动拼字符串时,可能会遇到这些问题:上级目录怎么找;Windows 和 macOS / Linux 的路径分隔符不同;多层目录拼接时可读性差。
pathlib 就是 Python 标准库里专门用来处理路径的工具,它会帮我们把这些细节处理掉。
例如:
1 | Path(__file__) |
表示把当前文件路径包装成一个 Path 对象。
这样后面就可以更自然地做“找父目录”“拼子目录”这些操作。
这行代码里的每一部分,可以拆开理解成:
1 | Path(__file__) # 当前文件路径 |
其中:
parents[0]表示上一级目录parents[1]表示上两级目录parents[2]表示上三级目录
放到当前项目里:
- 当前文件是
shopkeeper-agent/app/conf/app_config.py - 上一级目录是
shopkeeper-agent/app/conf - 上两级目录是
shopkeeper-agent/app - 上三级目录是
shopkeeper-agent
所以:
1 | Path(__file__).parents[2] |
定位到的就是 shopkeeper-agent 项目根目录。接下来这一段:
1 | / "conf" / "app_config.yaml" |
是 pathlib 很方便的一种写法,表示继续往下拼接子路径。也就是把根目录再拼成:
1 | shopkeeper-agent/conf/app_config.yaml |
所以整行代码的完整含义就是:先从当前 app_config.py 文件出发,稳定地回到 shopkeeper-agent 根目录,再进入 conf/app_config.yaml,从而拿到配置文件的绝对路径。
这本质上是一种更可靠的工程写法:
- 不依赖当前命令在哪个目录执行
- 不手动硬拼字符串路径
- 路径写法更清晰,也更跨平台
如果把这一步展开成更直观的伪流程,就是:
1 | 当前文件 app/conf/app_config.py |
2.5 OmegaConf 加载说明
除了路径本身,OmegaConf 这一组调用也值得单独拆开理解。
先看这三行:
1 | context = OmegaConf.load(config_file) |
第一次看到这里,往往会觉得它有点绕。但它做的事情可以概括成一句话:先读出配置内容,再准备好配置结构,最后把两者合并并转成真正可用的配置对象。
可以分四步来看。
第一步,OmegaConf.load(config_file) 做了什么?
1 | context = OmegaConf.load(config_file) |
这一步的作用很直接:打开 YAML 文件,读取其中的配置内容,并把它转换成 OmegaConf 能处理的配置对象。粗略一点说,这里做的就是“先把配置文件内容读出来”。如果只是做到这一步,已经可以访问配置了,例如:
1 | context["db_meta"]["host"] |
或者某些场景下也可以写成点号访问。但问题是,这种方式在工程项目里还不够理想,因为:配置结构不够明确;编辑器提示不够友好;嵌套配置一多,使用体验会变差;某些键名写错时,不容易第一时间发现。
这也解释了为什么这里还需要进一步引入结构化配置。
第二步,OmegaConf.structured(AppConfig) 做了什么?
1 | schema = OmegaConf.structured(AppConfig) |
这一步不是在读取 YAML 内容,而是在读取我们自己定义好的 dataclass 结构。
也就是说,这里关注的重点不是“配置值是什么”,而是:
- 整个配置最外层有哪些字段
- 每个字段对应什么类型
- 某个字段下面是不是还有嵌套对象
例如:
1 |
|
它表达的就是:
es这组配置里应该有host- 还应该有
port - 还应该有
index_name
再比如:
1 |
|
它表达的是整个配置文件的顶层结构。也就是说,AppConfig 本质上就是 app_config.yaml 的“结构说明书”。
如果你写过 TypeScript,可以把这层关系类比成:为一份 JSON 或配置文件写的 interface / type——用来声明顶层有哪些字段、嵌套对象长什么样、标量大致是什么类型;这里的 dataclass 扮演的就是类似角色,只是校验与合并发生在 Python 运行时(配合 OmegaConf),而不是 TS 的编译期类型检查。
先这样区分就够了:context 是“配置内容”;schema 是“配置结构”。
第三步,为什么还要 merge?
1 | OmegaConf.merge(schema, context) |
这是最关键的一步。因为前两步分别只拿到了两样东西:schema 只有结构,没有具体值;context 有具体值,但不够结构化。
所以这里要做的就是把它们合在一起。
合并之后得到的结果,可以看作:
- 既知道有哪些字段
- 也知道这些字段的真实取值
- 同时还保留了结构化定义带来的好处
也就是说,不是单纯地“把 YAML 读出来”就结束了,而是要把“配置内容”和“配置结构”对齐起来。
第四步,为什么最后还要 to_object?
1 | app_config: AppConfig = OmegaConf.to_object(...) |
这一步的作用,是把前面合并后的配置,再转换成真正的 Python 对象。
这样做之后,访问配置时就会更自然:
1 | app_config.db_meta.host |
这种写法相比字典访问有几个明显优势:更直观;编辑器自动提示更好;嵌套访问更清晰;更接近很多人熟悉的“配置类对象”体验。
也就是说,如果只是把 YAML 读成字典,虽然也能用,但体验没有“转成对象”这么好。
可以把这整个过程记成下面这条链路:
1 | YAML 文件 |
如果再结合当前项目里的嵌套配置去看,这套写法就更容易理解了。
例如 YAML 里有这样一段:
1 | es: |
那么与之对应的结构就是:
1 |
|
而它最终挂载到总配置对象里的方式是:
1 |
|
于是最后我们就能自然地写出:
1 | app_config.es.host |
这也是这一整套写法最核心的价值:让 YAML 文件里的层级结构,平滑地映射成 Python 里的对象结构。
最终得到的 app_config,就是整个项目里可以全局使用的配置对象。例如:
1 | app_config.db_meta.host |
也就是说,后续代码在使用配置时,不再需要反复去解析 YAML 文件,而是直接访问这个对象即可。
本章小结:
这一篇的重点,不是把所有基础服务都真正连起来,而是先把工程结构和配置入口看明白。
- 从项目结构上看,后端主项目
shopkeeper-agent采用了比较清晰的分层组织:clients负责外部依赖、repositories负责数据访问、services负责业务逻辑、api负责接口暴露。 - 从运行依赖上看,MySQL、Qdrant、Elasticsearch、Embedding、后端 API 都已经在配置里占好了位置,后续所有客户端管理器都会围绕这些地址和端口展开。
- 从配置管理上看,本项目不是直接把 YAML 当字典到处传,而是通过
dataclass + OmegaConf把配置结构和配置内容合并成统一对象。
下一篇会正式进入检索基础服务,也就是 Qdrant 与 Elasticsearch。前者负责语义相似度召回,后者负责全文文本检索,它们一起构成了「电商问数」在检索层的核心能力。