K 的一隅

Python FastAPI 后端实战

在现有架构里加能力:以导购接口为例

三层架构下新增业务能力的路径:Repository 不动边界、Service 编排、Router 薄封装;以 AI 导购为集成示例而非 LLM 教程。

8 分钟阅读 更新于 2026-07-28
本文目录

产品说:「在商品详情页加个 导购,根据 用户 历史推荐搭配。」若已有 API / Service / Repository 分层,正确姿势不是新开一个 main_ai.py 重写一遍,而是在现有 架构 里加一条竖切能力:接口 多一个路由,服务 多一个用例,必要时加外部客户端适配器——业务 规则仍留在 Service,数据访问仍经 Repository。下文用 导购 说明功能集成的切法;不教 prompt 工程,只讲 架构 上怎么接得干净。

外部能力再新,也不应成为绕过分层的借口。LLM、支付、短信在 架构 眼里都是「外部 服务」,接法与接支付网关同一套 Port/Client/Service 模式。

先画边界:新能力占哪几层

导购能力做什么不做什么
Router参数校验、调 Service、返回 DTO调 LLM、拼 SQL
Service用户 上下文、调 Catalog、调 Guide 客户端、组结果直接 httpx 散落多处
Repository用户 浏览记录、商品元数据不知道「推荐」语义
基础设施ShoppingGuideClient 封装外部 API进路由

业务 = 新 Service 方法 +(可选)新 Port 接口 + 新 接口 路由;除非新实体入库,否则不必动 Alembic。

竖切一条功能时,先问:有没有新持久化实体?导购若只读浏览记录 + 调外部模型,可能零 migration;若要存「曝光日志」才加表与 Repository 方法。避免为「接 AI」单独开一套 model 包,破坏三层 架构 的对称性。

Port:外部能力当依赖注入

把「问导购模型」抽象成接口,测试时换 Fake:

python
from typing import Protocol


class ShoppingGuidePort(Protocol):
    async def suggest(self, user_id: int, product_id: int, context: str) -> list[str]:
        """返回推荐 sku 或文案片段。"""
        ...


class HttpShoppingGuideClient:
    def __init__(self, base_url: str, api_key: str) -> None:
        self._base_url = base_url
        self._api_key = api_key

    async def suggest(self, user_id: int, product_id: int, context: str) -> list[str]:
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.post(
                f"{self._base_url}/suggest",
                json={"user_id": user_id, "product_id": product_id, "context": context},
                headers={"Authorization": f"Bearer {self._api_key}"},
            )
            resp.raise_for_status()
            data = resp.json()
            return list(data.get("items", []))

lifespan 或工厂里 app.state.guide_client = HttpShoppingGuideClient(...);依赖:

python
def get_guide_client(request: Request) -> ShoppingGuidePort:
    return request.app.state.guide_client


GuideDep = Annotated[ShoppingGuidePort, Depends(get_guide_client)]

Port 把「外部 服务 长什么样」定死在方法签名上;换供应商时只换 HttpShoppingGuideClient 实现,Service 与 Router 不动。本地开发可注册 StubGuideClient 返回固定 sku,不依赖外网。

Service:编排本地数据与外部 服务

python
class ProductGuideService:
    def __init__(
        self,
        product_repo: ProductRepository,
        history_repo: BrowseHistoryRepository,
        guide: ShoppingGuidePort,
    ) -> None:
        self._products = product_repo
        self._history = history_repo
        self._guide = guide

    async def build_guide(
        self,
        db: Session,
        user_id: int,
        product_id: int,
    ) -> GuideResult:
        product = self._products.get_by_id(db, product_id)
        if product is None:
            raise NotFoundError("product")
        recent = self._history.list_recent_skus(db, user_id, limit=10)
        context = f"current={product.sku}; recent={','.join(recent)}"
        suggestions = await self._guide.suggest(user_id, product_id, context)
        enriched = [
            self._products.get_by_sku(db, sku)
            for sku in suggestions
            if (sku := sku) and self._products.get_by_sku(db, sku)
        ]
        return GuideResult(product=product, suggestions=[p for p in enriched if p])

业务 规则在这里:用户 无历史时用空列表;外部返回的 sku 要在本地 Catalog 存在才暴露;异常转成 DomainError 而非把 httpx 异常冒给客户端。

降级策略也属 业务 决策:外部超时是返回空推荐(200 + 空列表)还是 503,应在 Service 或配置层统一,并写进 日志 方便复盘。不要把 timeout 吞掉又假装有推荐。

认证日志 的衔接

导购 接口 需要登录 用户UserDep),与 认证 篇一致;日志build_guide 入口打 guide_requested,字段含 user_idproduct_idrequest_id。将来若做 流式 导购,同一 Service 可拆 stream_guide,Router 换 StreamingResponse,Port 增流式方法——架构 仍是一脉。

Router:薄 接口

python
@app.get("/products/{product_id}/guide", response_model=GuideOut)
async def product_guide(
    product_id: int,
    db: DbDep,
    user: UserDep,
    service: Annotated[ProductGuideService, Depends(get_guide_service)],
) -> GuideOut:
    result = await service.build_guide(db, user.id, product_id)
    return GuideOut.from_result(result)

OpenAPI 自动带 认证UserDep)。若导购仅登录 用户 可用,不必在路由写 if。

DTO GuideOut 只暴露前端需要的字段,不要把外部 API 原始 JSON 原样返回——否则外部 schema 变更会直接冲击 接口 契约。版本化 接口 时动 DTO,不动 Port 的内部响应解析。

为什么不重写 架构

  1. Repository 已封装 SQL,导购只是多读 browse_history——扩展方法即可,不复制查询。
  2. 事务 边界不变:读路径无 commit;若将来「记录导购曝光」再单独短 事务 写表。
  3. 测试:FakeShoppingGuideClient 固定返回 ["sku-a"],Service 单测不碰外网;Router 用 dependency_overrides
  4. 观测:日志 在 Service 打 guide_requested,带 request_idproduct_id,与 日志 篇一致。

集成 checklist

  1. 定义 Port + 实现(或 mock 实现供本地 dev)。

  2. Service 方法 + 单元测试。

  3. DTO GuideOut 与路由注册。

  4. Settings 增加 guide_api_urlguide_api_key

  5. 文档一句说明:依赖外部 服务,超时与降级策略(返回空列表 vs 503)写进 业务 决策,非 hidden 在 Client 里。

  6. 测试金字塔:Port Fake 测 Service;Router 测 override;可选一条集成测真 Client(标记 slow)。新 业务 上线前不应只有手动点 Swagger。

反模式:「AI 文件夹」堆一切

ai/router.pyai/db.pyai/models.py 与主 架构 平行,Soon 会出现双份 Session、双份异常处理。外部能力可以是 app/clients/guide.py业务 仍在 app/services/product_guide.py,与订单、库存并列,而不是「AI 孤岛」。

常见误解

误解 1:新能力 = 新 FastAPI 实例。多实例拆部署是运维阶段的事;代码层应先在同一 架构 内模块化。

误解 2:在路由里直接 await openai.ChatCompletion.create。外部 服务 调用应可替换、可测、可配超时。

误解 3:为 AI 单独跳过 Repository「反正只是推荐」。用户 历史、商品合法性仍是本地 业务 数据,必须走数据层。

扩展:同一模式接别的外部能力

导购 只是「外部 服务 + 本地 业务 编排」的一个实例。支付网关、短信、风控 scoring 都可以:

  • 定义 Port(接口协议);
  • 实现 Client(HTTP/SDK);
  • Service 里编排 Repository 与 Port;
  • Router 只暴露 DTO。

差别只在 业务 规则与是否 流式 返回。接 LLM 时不应获得「可以破坏分层」的豁免;恰恰相反,外部 服务 更不稳定,更该把超时、降级、日志 写在 Service,而不是在 接口 里堆 try/except。

团队维护上,所有 Port 实现放 app/clients/,所有用例放 app/services/,新人找代码的路径唯一,架构 文档也只需画一种竖切方式。

版本与开关

接口 可先加 Settings.guide_enabled:关闭时 Router 返回 404 或空推荐,无需拆部署。功能稳定后再默认开启。外部 服务 地址、超时、API key 全部进 Settings,与 配置 篇一致,避免 导购 成为环境里硬编码的特例。

与测试金字塔

Repository 扩展 list_recent_skus 后写 Repository 单测;ProductGuideService 用 Fake Port 测编排;Router 用 TestClient + override 用户 测 status 与 DTO 形状。不要只写一条连真 LLM 的 e2e——慢、脆、难 CI。架构 分层的目的之一就是让每一层可独立测;接 导购 不应破坏这条规则。

文档与契约

OpenAPI 描述 导购 接口 的 request/response schema,与订单 接口 同级维护。外部 LLM 服务 的 schema 若不稳定,在 Client 内做适配,不要让 OpenAPI 随第三方波动——对前端暴露的是稳定的 GuideOut

失败与超时 product 决策

外部 导购 服务 超时:返回空推荐(200)还是 503,是产品决策,不是框架默认。Service 层捕获 httpx.TimeoutException,打 日志 后走统一降级;Router 不 catch httpx 异常。架构 要求「外部失败如何呈现」在一处可见,而不是散落在多个 接口

依赖方向

ProductGuideService 依赖 Port 与 Repository,不依赖 FastAPI。接口 层依赖 Service——依赖箭头指向 domain,与三层 架构 篇一致。新增 导购 不会引入 from fastapi import ... 进 Service 文件。

流式 导购的共存

若产品后续要 流式 输出,同一 Service 增加 async generator 方法,Router 换 StreamingResponse;Port 增加 stream_tokens。集成 导购 时预留 Port 方法名,比日后把同步 Client 硬改成 流式 更省事。

业务能力 上线 checklist:Settings 键、Port、Service 测试、Router 与 OpenAPI、日志 事件名、降级策略——与是否 LLM 无关。按表走一遍,架构 不会随功能数量劣化。

导购 只是示范:下一项接风控、优惠券、直播弹幕,仍是 Port + Service + 薄 Router。外部 服务 换一批,架构 不动,这是系列三层篇希望你在本篇看到的落地。

读代码时按 Router → Service → Repository → Client 竖切找 导购,不应在四个目录里各发现一套 事务认证 写法——一致性比目录名更重要。

外部 服务 再花哨,Port 接口仍应是「输入 业务 id、输出 domain 对象或 DTO 片段」——保持这种克制,架构 才经得住功能叠加。

配置 篇联动:每个外部 Port 的实现类在 lifespan 启动 时注册到 app.stateSettings 提供 base_url 与 timeout——导购 与支付 Client 配置方式一致,运维少记一套规则。按此模式扩展 业务 能力即可,无需另起 架构

小结

在已有分层里加 导购:Router 增 接口,Service 增编排,Port 封装外部 服务,Repository 按需扩展读方法。架构 不用推倒重来;把 业务 与集成点放在该在的层,新能力就像加一条用例,而不是另起炉灶。