4 - 电商问数:项目结构与基础服务配置管理


本章课程目标:

  • 先建立对 shopkeeper-agent 项目结构的整体认识,知道代码、配置、提示词和脚本分别放在哪里。
  • 理解当前项目默认依赖哪些服务端口,以及这些端口与后续基础服务之间的关系。
  • 掌握本项目的配置参数管理方式,理解 YAML 配置、dataclass 结构定义和 OmegaConf 加载链路。

学习建议: 这篇先看项目骨架,再看配置对象如何被创建和复用。重点不是记住每个目录名,而是理解 MySQL、Qdrant、ES、Embedding 为什么都应该从同一套配置体系取参数。读完后再看各类客户端代码时,可以顺手追一下:它们拿到的 host、port、key 到底从哪里来。

对应代码分支: 04-structure-config


这一章开始,我们正式进入「电商问数」的基础服务搭建部分。

这里的“基础服务”可以看作三类和业务无关、但项目运行离不开的内容:

  • 各类外部服务客户端
    例如 MySQLElasticsearchQdrantEmbedding 服务的客户端
  • 配置与日志
    例如配置文件加载、日志输出、通用基础能力
  • 项目结构约定
    也就是代码应该放在哪、脚本应该放在哪、提示词应该放在哪

如果再往整个项目的全局看,可以把后续开发内容大致分成三大块:

  1. 基础服务搭建
  2. 元数据知识库构建
  3. 问数智能体工作流搭建

本章先处理第一块,也就是先把后端项目运行所需的工程骨架和配置入口准备好。后面的 MySQL、Qdrant、Elasticsearch、Embedding、日志、脚本等基础服务能力,最终都是为后端主链路服务的。


1、项目目录结构

在开始写代码之前,先明确一件很重要的事:把项目结构规划清楚。

当前仓库中,后端项目 shopkeeper-agent 的核心结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
shopkeeper-agent/
├── app/ # 后端源码主目录
│ ├── agent/ # 问数智能体与 LangGraph 图流程,例如节点、状态、上下文、图编排
│ ├── api/ # 对外 HTTP 接口层,对应 FastAPI 路由、依赖注入和请求参数结构
│ ├── clients/ # 各类基础服务客户端,例如 MySQL、ES、Qdrant、Embedding 服务
│ ├── conf/ # 配置类与配置加载工具,把 YAML 配置转换成代码可直接使用的对象
│ ├── core/ # 通用基础能力,例如日志、生命周期管理、请求上下文等
│ ├── entities/ # 业务实体定义,承接比 ORM 更贴近业务含义的数据结构
│ ├── models/ # ORM 模型,主要对应 MySQL 中的表结构
│ ├── prompt/ # 提示词加载工具,负责读取和组织静态 Prompt 资源
│ ├── repositories/ # 数据访问层,封装 MySQL / Qdrant / ES 的具体读写逻辑
│ ├── scripts/ # 工具脚本,例如构建元数据知识库、初始化或同步数据
│ └── services/ # 业务逻辑层,负责把客户端、仓储层和智能体能力真正串起来
├── conf/ # 项目级 YAML 配置文件,例如数据库、向量库、ES、LLM、日志等配置
├── docker/ # 本地开发环境相关文件,例如 docker-compose 和自定义镜像资源
│ ├── elasticsearch/ # Elasticsearch 相关文件,例如 Dockerfile、插件或初始化资源
│ ├── embedding/ # Embedding 服务相关文件,例如模型目录或推理服务配置
│ └── mysql/ # MySQL 初始化 SQL、建表脚本或测试数据导入文件
├── logs/ # 本地运行时日志输出目录
└── prompts/ # 静态提示词文件,和 app/prompt 中的加载工具配合使用

这里需要特别注意一个设计原则:

所有源码统一放在 app 包下,而配置文件、提示词这类“非源码内容”尽量放在源码目录之外。

这样做的目的,是把“代码”和“配置 / 资源”明确分开,避免后面项目越写越乱。

如果你以前做过 Java 或 Spring Boot 开发,也可以这样类比:

  • api 有点像 controller
  • services 有点像 service
  • repositories 有点像 mapperrepository

先建立一个整体印象:

  • 源码放在 app
  • 配置放在 conf
  • 提示词放在 prompts
  • 脚本放在 app/scripts

这一节先记住: app 放源码,conf 放配置,prompts 放静态提示词;后面如果看到客户端、仓储、服务、脚本这些概念,先回到这张结构图里找它们的位置。


2、配置参数管理

这一章虽然属于基础服务部分,但不会先展开某个具体服务的接入,而是先把这些服务共同依赖的配置参数管理理顺。

原因很简单:这个项目里有很多组件都要依赖配置,例如:

  • MySQL 的地址、用户名、密码
  • Elasticsearch 的地址
  • Qdrant 的地址
  • Embedding 服务地址
  • 大模型的 API Key

这些内容如果直接写死在源码里,会有两个明显问题:一旦环境变化,代码里就要到处改;配置和逻辑耦合在一起,不方便维护。

所以更合理的做法是:

  1. 把配置单独写到 YAML 文件里
  2. 再通过工具把 YAML 配置加载为 Python 对象
  3. 后续代码统一通过对象属性访问配置项

2.1 配置文件

本项目采用 YAML 文件管理配置参数。你也可以根据以下路径在对应文件夹下创建对应文件。

项目对应文件路径:shopkeeper-agent/conf/app_config.yaml

以下是项目当前使用的核心配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
logging:
file:
enable: true
level: INFO
path: logs
rotation: "10 MB"
retention: "7 days"
console:
enable: true
level: INFO

db_meta:
host: localhost
port: 3306
user: didilili
password: dili123
database: meta

db_dw:
host: localhost
port: 3306
user: didilili
password: dili123
database: dw

qdrant:
host: localhost
port: 6333
embedding_size: 1024

embedding:
host: localhost
port: 8081
model: BAAI/bge-large-zh-v1.5

es:
host: localhost
port: 9200
index_name: data_agent

llm:
model_name: gpt-5.2-codex
api_key: <api_key>
base_url: https://api.openai-proxy.org/v1

从这份配置里,也可以先快速对照每个配置分组的默认地址与作用:

配置分组 默认地址 作用
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_metadb_dw 这类 MySQL 服务,本身并没有浏览器管理页面,通常还是通过 NavicatDBeaver、命令行客户端等方式连接查看。

2.2 YAML 快速入门

YAML 本质上也是一种结构化数据格式,它和 JSON 很像,都可以表达:

  • 对象
  • 数组
  • 多层嵌套结构

只是 YAML 的写法通常更简洁,更适合人手维护配置文件。

YAML 与 JSON 在线转换工具:https://tool.ip138.com/yamljson/

例如,在 JSON 里,如果我们要表示一个对象,通常会写成这样:

1
2
3
4
5
{
"name": "zhangsan",
"age": 18,
"height": 1.8
}

换成 YAML,可以写成:

1
2
3
name: zhangsan
age: 18
height: 1.8

可以看到,YAML 不需要像 JSON 那样写大量花括号、引号和逗号,看起来会更清爽。

如果要表示数组,在 JSON 里常见的是:

1
["a", "b", "c"]

而在 YAML 里,最常见的写法是:

1
2
3
- a
- b
- c

当然,YAML 也支持更像 JSON 的方括号写法,只不过在配置文件场景里,更常见的还是这种用横杠表示数组元素的形式。

再进一步,如果是“对象数组”这种更复杂的结构,YAML 依然能表达。例如:

1
2
3
4
- name: zhangsan
age: 18
- name: lisi
age: 20

如果换成 JSON,对应写法就是:

1
2
3
4
5
6
7
8
9
10
[
{
"name": "zhangsan",
"age": 18
},
{
"name": "lisi",
"age": 20
}
]

这里有一个很重要的语法点要先记住:YAML 对缩进非常敏感。

也就是说:

  • 同一层级的属性要对齐
  • 子属性必须比父级多一级缩进
  • 数组里的对象,后续字段也要和第一个字段保持对齐

所以你在看当前项目的 app_config.yaml 时,可以直接按下面这样理解:

  • 最外层的 loggingdb_metadb_dwqdrantembeddingesllm,都是一级对象
  • 每个对象下面再挂自己的子属性
  • 这些结构最终会被加载成后面代码里的配置对象

如果只从“为什么项目里用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
from dataclasses import dataclass
from pathlib import Path

from omegaconf import OmegaConf


# 文件日志配置,对应 logging.file 这一组参数
@dataclass
class File:
enable: bool
level: str
path: str
rotation: str
retention: str


# 控制台日志配置,对应 logging.console 这一组参数
@dataclass
class Console:
enable: bool
level: str


# 把 file 和 console 两组日志配置再组合成 logging 总配置
@dataclass
class LoggingConfig:
file: File
console: Console


# 数据库配置
# 这里的结构既会给元数据库 db_meta 用,也会给数据仓库模拟库 db_dw 用
@dataclass
class DBConfig:
host: str
port: int
user: str
password: str
database: str


@dataclass
class QdrantConfig:
host: str
port: int
embedding_size: int


# Embedding 服务配置,对应 YAML 里的 embedding 分组
@dataclass
class EmbeddingConfig:
host: str
port: int
model: str


# Elasticsearch 配置,对应 YAML 里的 es 分组
@dataclass
class ESConfig:
host: str
port: int
index_name: str


# 大模型配置,对应 YAML 里的 llm 分组
@dataclass
class LLMConfig:
model_name: str
api_key: str
base_url: str


# AppConfig 是整个项目配置的总入口
# 这里的字段名,需要和 app_config.yaml 的顶层字段保持一致
@dataclass
class AppConfig:
logging: LoggingConfig
db_meta: DBConfig
db_dw: DBConfig
qdrant: QdrantConfig
embedding: EmbeddingConfig
es: ESConfig
llm: LLMConfig


# 从当前文件 app/conf/app_config.py 出发,回到项目根目录
# 再定位到 conf/app_config.yaml 这个配置文件
config_file = Path(__file__).parents[2] / 'conf' / 'app_config.yaml'

# 读取 YAML 配置内容
context = OmegaConf.load(config_file)

# 根据 AppConfig 生成一份“结构化配置 schema”
schema = OmegaConf.structured(AppConfig)

# 把“配置结构”和“配置值”合并,再转换成真正可直接访问属性的对象
app_config: AppConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))

if __name__ == '__main__':
# 简单测试:验证配置是否能正常读取
print(app_config.es.host)

执行文件验证,成功:

1
2
(shopkeeper-agent) didilili@DidililiMacBook-Pro shopkeeper-agent % python3 app/conf/app_config.py
localhost

这里有两个点特别值得先记住:

  • 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
2
3
Path(__file__)          # 当前文件路径
Path(__file__).parents # 当前文件的父目录链
Path(__file__).parents[2]

其中:

  • 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
2
3
4
当前文件 app/conf/app_config.py
-> 回到项目根目录 shopkeeper-agent
-> 进入 conf 目录
-> 找到 app_config.yaml

2.5 OmegaConf 加载说明

除了路径本身,OmegaConf 这一组调用也值得单独拆开理解。

先看这三行:

1
2
3
context = OmegaConf.load(config_file)
schema = OmegaConf.structured(AppConfig)
app_config: AppConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))

第一次看到这里,往往会觉得它有点绕。但它做的事情可以概括成一句话:先读出配置内容,再准备好配置结构,最后把两者合并并转成真正可用的配置对象。

可以分四步来看。

第一步,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
2
3
4
5
@dataclass
class ESConfig:
host: str
port: int
index_name: str

它表达的就是:

  • es 这组配置里应该有 host
  • 还应该有 port
  • 还应该有 index_name

再比如:

1
2
3
4
5
6
7
8
9
@dataclass
class AppConfig:
logging: LoggingConfig
db_meta: DBConfig
db_dw: DBConfig
qdrant: QdrantConfig
embedding: EmbeddingConfig
es: ESConfig
llm: LLMConfig

它表达的是整个配置文件的顶层结构。也就是说,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
2
3
app_config.db_meta.host
app_config.es.host
app_config.llm.model_name

这种写法相比字典访问有几个明显优势:更直观;编辑器自动提示更好;嵌套访问更清晰;更接近很多人熟悉的“配置类对象”体验。

也就是说,如果只是把 YAML 读成字典,虽然也能用,但体验没有“转成对象”这么好。

可以把这整个过程记成下面这条链路:

1
2
3
4
5
YAML 文件
-> OmegaConf.load 读取配置内容
-> OmegaConf.structured 准备配置结构
-> OmegaConf.merge 合并结构和内容
-> OmegaConf.to_object 转成配置对象

如果再结合当前项目里的嵌套配置去看,这套写法就更容易理解了。

例如 YAML 里有这样一段:

1
2
3
4
es:
host: localhost
port: 9200
index_name: data_agent

那么与之对应的结构就是:

1
2
3
4
5
@dataclass
class ESConfig:
host: str
port: int
index_name: str

而它最终挂载到总配置对象里的方式是:

1
2
3
@dataclass
class AppConfig:
es: ESConfig

于是最后我们就能自然地写出:

1
app_config.es.host

这也是这一整套写法最核心的价值:让 YAML 文件里的层级结构,平滑地映射成 Python 里的对象结构。

最终得到的 app_config,就是整个项目里可以全局使用的配置对象。例如:

1
2
3
app_config.db_meta.host
app_config.es.host
app_config.llm.model_name

也就是说,后续代码在使用配置时,不再需要反复去解析 YAML 文件,而是直接访问这个对象即可。


本章小结:

这一篇的重点,不是把所有基础服务都真正连起来,而是先把工程结构和配置入口看明白。

  • 从项目结构上看,后端主项目 shopkeeper-agent 采用了比较清晰的分层组织:clients 负责外部依赖、repositories 负责数据访问、services 负责业务逻辑、api 负责接口暴露。
  • 从运行依赖上看,MySQL、Qdrant、Elasticsearch、Embedding、后端 API 都已经在配置里占好了位置,后续所有客户端管理器都会围绕这些地址和端口展开。
  • 从配置管理上看,本项目不是直接把 YAML 当字典到处传,而是通过 dataclass + OmegaConf 把配置结构和配置内容合并成统一对象。

下一篇会正式进入检索基础服务,也就是 Qdrant 与 Elasticsearch。前者负责语义相似度召回,后者负责全文文本检索,它们一起构成了「电商问数」在检索层的核心能力。