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

---

**本章课程目标：**

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

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

**对应代码分支：** `04-structure-config`

---

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

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

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

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

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

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

---

## 1、项目目录结构

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

当前仓库中，后端项目 `shopkeeper-agent` 的核心结构如下：

```text
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` 有点像 `mapper` 或 `repository`

先建立一个整体印象：

- 源码放在 `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`

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

```yaml
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_meta`、`db_dw` 这类 `MySQL` 服务，本身并没有浏览器管理页面，通常还是通过 `Navicat`、`DBeaver`、命令行客户端等方式连接查看。

### 2.2 YAML 快速入门

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

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

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

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

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

```json
{
  "name": "zhangsan",
  "age": 18,
  "height": 1.8
}
```

换成 `YAML`，可以写成：

```yaml
name: zhangsan
age: 18
height: 1.8
```

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

如果要表示数组，在 `JSON` 里常见的是：

```json
["a", "b", "c"]
```

而在 `YAML` 里，最常见的写法是：

```yaml
- a
- b
- c
```

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

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

```yaml
- name: zhangsan
  age: 18
- name: lisi
  age: 20
```

如果换成 `JSON`，对应写法就是：

```json
[
  {
    "name": "zhangsan",
    "age": 18
  },
  {
    "name": "lisi",
    "age": 20
  }
]
```

这里有一个很重要的语法点要先记住：`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` 文件中，你也可以根据以下路径在对应文件夹下创建对应文件。

核心内容如下：

```python
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)
```

执行文件验证，成功：

```bash
(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` 上，后面你在读取提示词文件、日志目录、脚本配置文件时，也都会反复遇到。

如果你之前对这行路径代码不太熟，这里可以再展开理解一下。

先看这一行：

```python
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` 文件本身。

这意味着什么呢？

例如你写：

```python
OmegaConf.load("conf/app_config.yaml")
```

如果你恰好是在 `shopkeeper-agent/` 根目录启动程序，这个路径可能是对的。  
但如果你是在别的目录下启动，或者通过其他脚本间接启动，这个相对路径就可能找不到文件。

所以在工程项目里，**直接手写相对路径通常不够稳妥**。

**第二，`__file__` 是什么？**

`__file__` 是 Python 提供的一个特殊变量，它表示**当前这个 Python 文件自身的路径**。

例如在 `app/conf/app_config.py` 里，`__file__` 大致会是：

```python
/Users/tools/Desktop/agent/shopkeeper-agent/app/conf/app_config.py
```

有了它，我们就不再依赖“程序是从哪个目录启动的”，而是可以从“当前文件自己在哪儿”出发去找配置文件。

**第三，为什么要用 `pathlib.Path`？**

因为路径拼接如果全靠字符串来写，会比较麻烦，也不够稳。

例如你手动拼字符串时，可能会遇到这些问题：上级目录怎么找；Windows 和 macOS / Linux 的路径分隔符不同；多层目录拼接时可读性差。

`pathlib` 就是 Python 标准库里专门用来处理路径的工具，它会帮我们把这些细节处理掉。

例如：

```python
Path(__file__)
```

表示把当前文件路径包装成一个 `Path` 对象。  
这样后面就可以更自然地做“找父目录”“拼子目录”这些操作。

这行代码里的每一部分，可以拆开理解成：

```python
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`

所以：

```python
Path(__file__).parents[2]
```

定位到的就是 `shopkeeper-agent` 项目根目录。接下来这一段：

```python
/ "conf" / "app_config.yaml"
```

是 `pathlib` 很方便的一种写法，表示继续往下拼接子路径。也就是把根目录再拼成：

```text
shopkeeper-agent/conf/app_config.yaml
```

所以整行代码的完整含义就是：先从当前 `app_config.py` 文件出发，稳定地回到 `shopkeeper-agent` 根目录，再进入 `conf/app_config.yaml`，从而拿到配置文件的绝对路径。

这本质上是一种更可靠的工程写法：

- 不依赖当前命令在哪个目录执行
- 不手动硬拼字符串路径
- 路径写法更清晰，也更跨平台

如果把这一步展开成更直观的伪流程，就是：

```text
当前文件 app/conf/app_config.py
  -> 回到项目根目录 shopkeeper-agent
  -> 进入 conf 目录
  -> 找到 app_config.yaml
```

### 2.5 OmegaConf 加载说明

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

先看这三行：

```python
context = OmegaConf.load(config_file)
schema = OmegaConf.structured(AppConfig)
app_config: AppConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))
```

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

可以分四步来看。

**第一步，`OmegaConf.load(config_file)` 做了什么？**

```python
context = OmegaConf.load(config_file)
```

这一步的作用很直接：打开 YAML 文件，读取其中的配置内容，并把它转换成 `OmegaConf` 能处理的配置对象。粗略一点说，这里做的就是“先把配置文件内容读出来”。如果只是做到这一步，已经可以访问配置了，例如：

```python
context["db_meta"]["host"]
```

或者某些场景下也可以写成点号访问。但问题是，这种方式在工程项目里还不够理想，因为：配置结构不够明确；编辑器提示不够友好；嵌套配置一多，使用体验会变差；某些键名写错时，不容易第一时间发现。

这也解释了为什么这里还需要进一步引入**结构化配置**。

**第二步，`OmegaConf.structured(AppConfig)` 做了什么？**

```python
schema = OmegaConf.structured(AppConfig)
```

这一步不是在读取 YAML 内容，而是在读取我们自己定义好的 `dataclass` 结构。

也就是说，这里关注的重点不是“配置值是什么”，而是：

- 整个配置最外层有哪些字段
- 每个字段对应什么类型
- 某个字段下面是不是还有嵌套对象

例如：

```python
@dataclass
class ESConfig:
    host: str
    port: int
    index_name: str
```

它表达的就是：

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

再比如：

```python
@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`？**

```python
OmegaConf.merge(schema, context)
```

这是最关键的一步。因为前两步分别只拿到了两样东西：`schema` 只有结构，没有具体值；`context` 有具体值，但不够结构化。

所以这里要做的就是把它们合在一起。

合并之后得到的结果，可以看作：

- 既知道有哪些字段
- 也知道这些字段的真实取值
- 同时还保留了结构化定义带来的好处

也就是说，不是单纯地“把 YAML 读出来”就结束了，而是要把“配置内容”和“配置结构”对齐起来。

**第四步，为什么最后还要 `to_object`？**

```python
app_config: AppConfig = OmegaConf.to_object(...)
```

这一步的作用，是把前面合并后的配置，再转换成真正的 Python 对象。

这样做之后，访问配置时就会更自然：

```python
app_config.db_meta.host
app_config.es.host
app_config.llm.model_name
```

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

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

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

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

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

例如 YAML 里有这样一段：

```yaml
es:
  host: localhost
  port: 9200
  index_name: data_agent
```

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

```python
@dataclass
class ESConfig:
    host: str
    port: int
    index_name: str
```

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

```python
@dataclass
class AppConfig:
    es: ESConfig
```

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

```python
app_config.es.host
```

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

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

```python
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**。前者负责**语义相似度召回**，后者负责**全文文本检索**，它们一起构成了「电商问数」在检索层的核心能力。
