跳转到正文
LimoBlog
返回

基础设施层封装

对 Qdrant / Elasticsearch / Embedding / MySQL / Loguru 这几个基础设施的封装过程。

在一个完整的 Agent 项目中,除了核心业务逻辑,还需要依赖各种外部基础设施来完成具体任务。

例如:

这些服务各自承担不同的职责,但对于业务代码来说,它们都有一个共同点:需要通过对应的 Python 客户端或 SDK 进行调用。

如果每个业务模块都自行创建客户端、读取配置、管理连接,不仅会产生重复代码,还会使资源管理和异常处理变得分散。

因此,项目将这些基础设施统一放在 clients 模块中进行封装,负责客户端初始化、配置管理和资源释放,为上层业务代码提供统一的访问入口。

本文将结合项目实际代码,介绍 Qdrant、Elasticsearch、Embedding、MySQL 和 Loguru 的封装过程。

本机通过 docker 挂载端口
6333 和 6334 访问 Qdrant 的 HTTP 和 gRPC 端口
9200 访问 Elasticsearch
5601 访问 Kibana (Elasticsearch的可视化前端)
8081 访问 Embedding 服务
3306 访问 MySQL

目录

目录

Qdrant 客户端

Qdrant 简介

Qdrant:在这里一个专门用于保存向量并执行相似度检索的数据库。

  1. 保存向量化的字段和指标信息(不负责向量化)
  2. 向量相似度检索
  3. 召回语义相关的字段和指标信息

Qdrant 概念

概念理解
collection一组向量点的集合
point集合中的一条记录
vector这条记录对应的向量
payload这条记录对应的多条业务字段
distance用什么方式比较相似度

封装 Qdrant 客户端

项目对应文件路径:shopkeeper-agent/app/clients/qdrant_client_manager.py

import asyncio
import random
from typing import Optional
from qdrant_client import AsyncQdrantClient, models
from app.conf.app_config import QdrantConfig, app_config

1. 创建客户端管理器

class QdrantClientManager:
    def __init__(self, qdrant_config: QdrantConfig):
        self.qdrant_config = qdrant_config
        self.client: Optional[AsyncQdrantClient] = None
    def _get_url(self):
        return f"http://{self.qdrant_config.host}:{self.qdrant_config.port}"
    def init(self):
        self.client = AsyncQdrantClient(url=self._get_url())
    async def close(self):
        await self.client.close()
  1. 使用 AsyncQdrantClient 而不是同步客户端,因为当前 Agent 项目采用异步编程模式。
  2. 客户端初始化没有直接放在 __init__() 中,而是单独提供 init() 方法这样做可以将对象创建和资源初始化分离。

2. 创建全局管理器对象

qdrant_client_manager = QdrantClientManager(app_config.qdrant)

测试 Qdrant 客户端

完整 main 代码:

if __name__ == "__main__":
    qdrant_client_manager.init()
    async def test():
    client = qdrant_client_manager.client
    if not await client.collection_exists("my_collection"):
        await client.create_collection(
            collection_name="my_collection",
            vectors_config=models.VectorParams(
                size=10,
                distance=models.Distance.COSINE,
            ),
        )
    await client.upsert(
        collection_name="my_collection",
        points=[
            models.PointStruct(
                id=i,
                vector=[random.random() for _ in range(10)],
            )
            for i in range(100)
        ],
    )
    res = await client.query_points(
        collection_name="my_collection",
        query=[random.random() for _ in range(10)],
        limit=10,
        score_threshold=0.8,
    )
    print(res)
    asyncio.run(test())

1. 初始化和释放客户端

qdrant_client_manager.init()
asyncio.run(test())

2. 创建 Collection

client = qdrant_client_manager.client
if not await client.collection_exists("my_collection"):
    await client.create_collection(
        collection_name="my_collection",
        vectors_config=models.VectorParams(
            size=10,
            distance=models.Distance.COSINE,
        ),
    )

在没有同名集合的情况下创建了一个名为 my_collection 的集合。 其中:

实际项目中,向量维度需要与 Embedding 模型输出的维度保持一致。

3. 写入 Point

await client.upsert(
    collection_name="my_collection",
    points=[
        models.PointStruct(
            id=i,
            vector=[random.random() for _ in range(10)],
        )
        for i in range(100)
    ],
)

这里使用 upsert() 向集合中写入 100 条 Point,每条 Point 包含:

upsert():如果指定 ID 的 Point 不存在,则创建;如果已经存在,则更新。

这里使用随机向量只是为了验证 Qdrant 的基本写入功能,实际业务中应该使用 Embedding 模型生成的向量。

4. 执行向量检索

res = await client.query_points(
    collection_name="my_collection",
    query=[random.random() for _ in range(10)],  # type: ignore
    limit=10,
    score_threshold=0.8,
)

这里使用随机生成的 10 维向量作为查询向量。参数说明:

5. 测试

执行文件:

python -m app.clients.qdrant_client_manager

得到检索结果:

points=[ScoredPoint(id=33, version=4, score=0.9287777, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=45, version=4, score=0.9019778, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=58, version=4, score=0.90187067, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=75, version=4, score=0.8799222, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=66, version=4, score=0.87921834, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=87, version=4, score=0.8753782, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=73, version=4, score=0.86765623, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=27, version=4, score=0.86216307, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=16, version=4, score=0.8618801, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=69, version=4, score=0.8546801, payload={}, vector=None, shard_key=None, order_value=None)]

Elasticsearch 客户端

Elasticsearch 简介

Elasticsearch:在这里是一个负责文本关键词 / 全文检索的搜索引擎。

  1. 为字段取值建立全文索引
  2. 自然语言表达映射到真实存在的值域

Elasticsearch 概念

概念理解
index一组同类文档的逻辑容器
documentindex 中的一条 JSON 文档
fielddocument 中的一个字段
mapping定义字段结构、字段类型以及索引行为

mapping 是 Elasticsearch 里的说法,而 schema 是很多数据库或数据系统里更常见的说法。

封装 Elasticsearch 客户端

项目对应文件路径:shopkeeper-agent/app/clients/es_client_manager.py

import asyncio
from typing import Optional
from elasticsearch import AsyncElasticsearch
from app.conf.app_config import ESConfig, app_config

1. 创建客户端管理器

class ESClientManager:
    def __init__(self, es_config: ESConfig):
        self.es_config = es_config
        self.client: Optional[AsyncElasticsearch] = None
    def _get_url(self):
        return f"http://{self.es_config.host}:{self.es_config.port}"
    def init(self):
        self.client = AsyncElasticsearch(hosts=[self._get_url()])
    async def close(self):
        await self.client.close()

几乎和 qdrant 的客户端管理器一致,函数功能:

2. 创建全局管理器对象

es_client_manager = ESClientManager(app_config.es)

创建唯一的 ES 客户端管理器对象。

测试 Embedding 客户端

完整 main 代码:

if __name__ == "__main__":
    # 先初始化客户端,后面的测试逻辑才能真正访问 ES
    es_client_manager.init()
    async def test():
        # 取出真正的 AsyncElasticsearch 客户端
        client = es_client_manager.client
        # 创建索引
        # 这里同时显式定义了字段结构
        # dynamic=False 表示关闭动态映射,要求写入数据必须符合当前定义
        await client.indices.create(
            index="my-books",
            mappings={
                "dynamic": False,
                "properties": {
                    # 书名和作者适合做全文检索,所以定义为 text
                    "name": {"type": "text"},
                    "author": {"type": "text"},
                    # 日期字段按日期类型处理
                    "release_date": {"type": "date", "format": "yyyy-MM-dd"},
                    # 页数字段按整数处理
                    "page_count": {"type": "integer"},
                },
            },
        )
        # 插入数据
        # bulk 采用“操作说明 + 数据本体”交替出现的格式
        # 适合一次性写入多条文档
        await client.bulk(
            operations=[
                {"index": {"_index": "my-books"}},
                {
                    "name": "Revelation Space",
                    "author": "Alastair Reynolds",
                    "release_date": "2000-03-15",
                    "page_count": 585,
                },
                {"index": {"_index": "my-books"}},
                {
                    "name": "1984",
                    "author": "George Orwell",
                    "release_date": "1985-06-01",
                    "page_count": 328,
                },
                {"index": {"_index": "my-books"}},
                {
                    "name": "Fahrenheit 451",
                    "author": "Ray Bradbury",
                    "release_date": "1953-10-15",
                    "page_count": 227,
                },
                {"index": {"_index": "my-books"}},
                {
                    "name": "Brave New World",
                    "author": "Aldous Huxley",
                    "release_date": "1932-06-01",
                    "page_count": 268,
                },
                {"index": {"_index": "my-books"}},
                {
                    "name": "The Handmaids Tale",
                    "author": "Margaret Atwood",
                    "release_date": "1985-06-01",
                    "page_count": 311,
                },
            ],
        )
        # 搜索
        # 在 name 字段上执行 match 查询
        # 这里演示的是最基础的全文检索能力
        resp = await client.search(
            index="my-books",
            query={"match": {"name": "brave"}},
        )
        # 打印查询结果,便于观察 hits 和返回结构
        print(resp)
        # 测试结束后关闭客户端连接
        await es_client_manager.close()
    # 运行异步测试函数
    asyncio.run(test())

1. 初始化和释放客户端

if __name__ == "__main__":
    es_client_manager.init()
    asyncio.run(test())

初始化后运行。

2. 创建索引

client = es_client_manager.client
await client.indices.create(
    index="my-books",
    mappings={
        "dynamic": False,
        "properties": {
            "name": {"type": "text"},
            "author": {"type": "text"},
            "release_date": {"type": "date", "format": "yyyy-MM-dd"},
            "page_count": {"type": "integer"},
        },
    },
)

client.indices.create 创建索引,定义了字段结构。索引名称及其属性:

3. 插入数据

await client.bulk(
    operations=[
        {"index": {"_index": "my-books"}},
        {
            "name": "Revelation Space",
            "author": "Alastair Reynolds",
            "release_date": "2000-03-15",
            "page_count": 585,
        },
        {"index": {"_index": "my-books"}},
        {
            "name": "1984",
            "author": "George Orwell",
            "release_date": "1985-06-01",
            "page_count": 328,
        },
        {"index": {"_index": "my-books"}},
        {
            "name": "Fahrenheit 451",
            "author": "Ray Bradbury",
            "release_date": "1953-10-15",
            "page_count": 227,
        },
        {"index": {"_index": "my-books"}},
        {
            "name": "Brave New World",
            "author": "Aldous Huxley",
            "release_date": "1932-06-01",
            "page_count": 268,
        },
        {"index": {"_index": "my-books"}},
        {
            "name": "The Handmaids Tale",
            "author": "Margaret Atwood",
            "release_date": "1985-06-01",
            "page_count": 311,
        },
    ],
)

client.bulk 插入数据,bulk 采用“操作说明 + 数据本体”交替出现的格式,适合一次性写入多条文档。

4. 搜索与打印

resp = await client.search(
    index="my-books",
    query={"match": {"name": "brave"}},
)
print(resp)

client.search 在 name 字段里搜索 “brave” 的文档。参数说明:

最后打印查询结果,便于观察 hits 和返回结构。

5. 测试

执行文件:

python -m app.clients.es_client_manager

得到结果:

{'took': 5, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'max_score': 1.2067741, 'hits': [{'_index': 'my-books', '_id': 'SDB_np0BrzSqcXAMSMVk', '_score': 1.2067741, '_source': {'name': 'Brave New World', 'author': 'Aldous Huxley', 'release_date': '1932-06-01', 'page_count': 268}}]}}

Embedding 客户端

Embedding 简介

Embedding:是一个负责把自然语言、字段或指标转换成可以参与向量计算的转换器。

TEI 简介

Text Embeddings Inference,通常简称 TEI。它本质上就是一个专门用来部署 Embedding 模型、并通过 HTTP 接口对外提供向量推理能力的服务框架。

TEI 的访问方式:这里使用 LangChain 提供的 HuggingFaceEndpointEmbeddings。

封装 Embedding 客户端

项目对应文件路径:shopkeeper-agent/app/clients/embedding_client_manager.py

import asyncio
from typing import Optional
from huggingface_hub import AsyncInferenceClient, InferenceClient
from langchain_huggingface import HuggingFaceEndpointEmbeddings
from app.conf.app_config import EmbeddingConfig, app_config

1. 创建客户端管理器

class EmbeddingClientManager:
    def __init__(self, config: EmbeddingConfig):
        self.client: Optional[HuggingFaceEndpointEmbeddings] = None
        self.config = config
    def _get_url(self):
        return f"http://{self.config.host}:{self.config.port}"
    def init(self):
        self.client = HuggingFaceEndpointEmbeddings(model=self.config.model)
        endpoint_url = f"{self._get_url()}/embed"
        self.client.client = InferenceClient(model=endpoint_url)
        self.client.async_client = AsyncInferenceClient(model=endpoint_url)

前面的 Qdrant、Elasticsearch、MySQL 客户端,很多都需要显式初始化和关闭,是因为它们更接近:长连接、连接池、持久化数据库连接。

而当前这个 Embedding 客户端,本质上更接近一个无状态的 HTTP 调用封装。它不像数据库连接那样需要长期持有连接资源,所以这里没有专门去写一个 close() 方法。这也是为什么它的实现会明显更轻。函数说明:

2. 创建全局管理器对象

embedding_client_manager = EmbeddingClientManager(app_config.embedding)

模块级单例,供其他模块按需复用同一个客户端管理器。

测试 Embedding 客户端

完整 main 文件:

if __name__ == "__main__":
    # 本地调试入口:初始化客户端后执行一次最小化向量化调用
    embedding_client_manager.init()
    client = embedding_client_manager.client
    async def test():
        # 使用示例文本验证 Embedding 服务是否可正常响应
        text = "What is deep learning?"
        query_result = await client.aembed_query(text)
        # 只打印前 3 个维度,便于快速确认返回结果结构正确
        print(query_result[:3])
    # 运行调试测试
    asyncio.run(test())

1. 初始化和释放客户端

if __name__ == "__main__":
    embedding_client_manager.init()
    client = embedding_client_manager.client
    asyncio.run(test())

初始化,获取客户端,运行测试。

2. 创建测试函数

async def test():
    text = "What is deep learning?"
    query_result = await client.aembed_query(text)
    print(query_result[:3])

这个函数测试 Embedding 服务是否可正常响应:

3. 测试

文件测试:

python -m app.clients.embedding_client_manager

获得结果:

[-0.005919582676142454, 0.005813124123960733, 0.018300632014870644]

MySQL 客户端

MySQL 简介

MySQL:在这里是两个负责不同职责的数据库.

配置项连接到哪里主要职责
db_meta元数据库保存表信息、字段信息、指标信息、字段与指标关系等元数据
db_dw数仓模拟库提供真实业务查询要访问的数据,用来验证 SQL、执行 SQL

MySQL 概念

封装 MySQL 客户端

项目对应文件路径:shopkeeper-agent/app/clients/mysql_client_manager.py

import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
    AsyncEngine,
    async_sessionmaker,
    create_async_engine,
)
from app.conf.app_config import DBConfig, app_config

1. 创建客户端管理器

class MySQLClientManager:
    def __init__(self, config: DBConfig):
        self.engine: AsyncEngine | None = None
        self.session_factory = None
        self.config = config
    def _get_url(self):
        return f"mysql+asyncmy://{self.config.user}:{self.config.password}@{self.config.host}:{self.config.port}/{self.config.database}?charset=utf8mb4"
    def init(self):
        self.engine = create_async_engine(
            self._get_url(), pool_size=10, pool_pre_ping=True
        )
        self.session_factory = async_sessionmaker(
            self.engine, autoflush=True, expire_on_commit=False
        )
    async def close(self):
        await self.engine.dispose()

2. 创建全局管理器对象

meta_mysql_client_manager = MySQLClientManager(app_config.db_meta)
dw_mysql_client_manager = MySQLClientManager(app_config.db_dw)

一套连元数据库,一套连数仓模拟库。

测试 Embedding 客户端

完整 main 代码:

if __name__ == "__main__":
    # 这里演示的是数仓库查询,所以先初始化 dw 这一套客户端
    dw_mysql_client_manager.init()
    async def test():
        # 通过 session_factory 创建一次数据库会话
        async with dw_mysql_client_manager.session_factory() as session:
            sql = "select * from fact_order limit 10"
            # text(sql) 表示把原生 SQL 语句交给 SQLAlchemy 执行
            result = await session.execute(text(sql))
            # mappings().fetchall() 会把结果转成“按列名访问”的行对象列表
            rows = result.mappings().fetchall()
            # 下面三行只是为了帮助观察返回结果的结构
            print(type(rows))
            print(type(rows[0]))
            print(rows[0]["order_id"])
    asyncio.run(test())

1. 初始化和释放客户端

if __name__ == "__main__":
    dw_mysql_client_manager.init()
    asyncio.run(test())

这里演示的是数仓库查询,所以先初始化 dw 这一套客户端。

2. 创建测试函数

async def test():
    async with dw_mysql_client_manager.session_factory() as session:
        sql = "select * from fact_order limit 10"
        result = await session.execute(text(sql))
        rows = result.mappings().fetchall()
        print(type(rows))
        print(type(rows[0]))
        print(rows[0]["order_id"])

分四步进行一次数据库对话:

3. 测试

执行文件:

python -m app.clients.mysql_client_manager

输出结果:

<class 'list'>
<class 'sqlalchemy.engine.row.RowMapping'>
ORD20250101001

Loguru 日志配置

Loguru 简介

Loguru:这里是一套相对于 print() 更稳定、更可配置的日志方案。 不用 print 原因主要有两类:

配置文件

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

logging:
  file:
    enable: true
    level: INFO
    path: logs
    rotation: "10 MB"
    retention: "7 days"
  console:
    enable: true
    level: INFO

输出目的地:

属性:

两个参数方便清理数据,解决了日志文件长期运行时的可维护性问题。

封装日志配置

项目对应文件路径:shopkeeper-agent/app/core/log.py

import sys
from pathlib import Path
from loguru import logger
from app.conf.app_config import app_config
from app.core.context import request_id_ctx_var

1. 定义统一日志格式

log_format = (
    "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
    "<level>{level: <8}</level> | "
    "<magenta>request_id - {extra[request_id]}</magenta> | "
    "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
    "<level>{message}</level>"
)

统一日志展示格式,包含时间、级别、请求 ID 和调用位置等关键信息。

2. 给每条日志自动补充 request_id

def inject_request_id(record):
    request_id = request_id_ctx_var.get()
    record["extra"]["request_id"] = request_id
logger.remove()
logger = logger.patch(inject_request_id)

3. 决定日志输出到控制台还是文件

if app_config.logging.console.enable:
    logger.add(
        sink=sys.stdout,
        level=app_config.logging.console.level,
        format=log_format,
    )
if app_config.logging.file.enable:
    path = Path(app_config.logging.file.path)
    path.mkdir(parents=True, exist_ok=True)
    logger.add(
        sink=path / "app.log",
        level=app_config.logging.file.level,
        format=log_format,
        rotation=app_config.logging.file.rotation,
        retention=app_config.logging.file.retention,
        encoding="utf-8",
    )

根据配置文件启用或关闭不同输出通道:

结语

我正处在对某些 Agent 项目的学习阶段,

会做笔记,会写总结,会反复实践,

但还是时常反思自己的学习方式,

最近是不是有点急于求成?

这篇文章有点头重脚轻的。



上一篇
Bit Drive
下一篇
智能体 & 工作流