加载中...
  • 从一段脚本到一个服务:后端项目的最小骨架loading

    AI 应用后端工程化:从原型到可交付系统 · 第 01 篇 · 第一章 · 服务基础

    从入口、配置、模型客户端、异常边界和测试五个位置,搭起一个能继续演进的后端骨架。

    一个 AI 原型通常从十几行脚本开始:读取密钥、调用模型、打印结果。它很适合验证“模型能不能回答”,却回答不了另一个更现实的问题:当请求来自浏览器、配置可能缺失、外部服务会超时、代码需要被测试时,这段脚本还能不能稳定工作?

    服务化的第一步不是堆目录,而是把变化速度不同的事情分开。HTTP 协议会变,模型供应商会换,错误展示会调整,核心业务却不应该跟着每次震动。

    五个位置先站稳

    可以把最早期的后端想成一家刚开门的小餐馆。门口负责接单,后厨负责做菜,仓库保存原料,值班经理处理异常,试菜员保证基本品质。任何一个角色都可以只有几行代码,但不能完全没有角色边界。

    flowchart LR
      Client[调用者] --> HTTP[HTTP 入口]
      HTTP --> UseCase[用例函数]
      UseCase --> Model[模型客户端]
      Config[配置] --> Model
      Model --> UseCase
      UseCase --> HTTP
      Error[统一异常边界] -.包住.-> HTTP
      Test[自动测试] -.验证.-> UseCase
    

    这五个位置分别解决不同问题:

    1. 入口负责把 HTTP 请求翻译成应用能理解的数据,不在这里拼 Prompt、查数据库或处理长流程。
    2. 用例描述“完成一次回答”需要哪些步骤,它不关心 Flask、FastAPI 还是命令行调用。
    3. 外部客户端封装模型 SDK,让超时、模型名和认证方式不扩散到每个业务函数。
    4. 异常边界把内部失败转换成稳定响应,同时保留日志中的真实原因。
    5. 测试入口绕过网络直接验证用例,避免所有测试都依赖真实模型和密钥。

    最小不是一个文件

    下面的例子没有引入复杂框架,却已经把外部依赖从业务逻辑中拿了出去。测试可以传入一个假的 TextModel,生产环境再传入真正实现。

    from dataclasses import dataclass
    from typing import Protocol
    
    
    class TextModel(Protocol):
        def complete(self, prompt: str) -> str:
            raise NotImplementedError
    
    
    @dataclass(frozen=True)
    class AnswerRequest:
        question: str
    
    
    def answer(request: AnswerRequest, model: TextModel) -> str:
        question = request.question.strip()
        if not question:
            raise ValueError("question must not be empty")
        return model.complete(f"请清楚回答:{question}")
    

    这段代码的重要部分不是 Protocol 这个语法,而是依赖方向:用例声明自己需要“一个能完成文本生成的对象”,而不是主动创建某个供应商的客户端。未来更换 SDK、增加重试或在测试中返回固定结果,都不需要改 answer

    异常要在边界上翻译

    常见错误是捕获所有异常后直接返回 200 OK 和一句“系统繁忙”。调用方无法区分参数错误、认证失败和上游超时,监控也会误以为请求成功。另一种极端是把 Python 堆栈直接返回给浏览器,这会泄露路径、配置甚至请求数据。

    合理做法是内部保留细节,对外使用稳定的错误协议。例如空问题返回 400,模型超时返回 503,未预料异常返回 500,并给每次失败分配请求 ID。错误码是调用者可以编程处理的契约,错误日志才是开发者排查问题的证据。

    测试也应该围绕契约,而不是围绕实现行数:空输入是否被拒绝,假模型是否收到预期 Prompt,模型异常是否被 HTTP 层映射成正确状态。这三项比“覆盖了某一行”更接近用户真正依赖的行为。

    一个容易走偏的版本

    如果在模块加载时读取密钥并创建模型单例,那么运行测试、查看帮助甚至导入一个工具函数,都可能因为缺少环境变量而失败。如果入口函数同时负责请求解析、Prompt 拼接、模型调用和异常格式化,第二个接口出现时只能复制整段代码。

    目录很多也不能自动解决这个问题。把同一团逻辑分散到十个文件,只会让阅读路径更长。判断边界是否有效,可以问一句:我能否在不启动 HTTP 服务、不连接真实模型的情况下验证核心用例? 如果不能,依赖仍然缠在一起。

    换个场景仍然成立

    同样的结构可以迁移到支付、地图、短信或对象存储服务。把 TextModel 换成 PaymentGateway,入口仍负责协议翻译,用例仍表达业务步骤,外部客户端仍隔离供应商。AI 应用并没有推翻后端工程,只是增加了一个更慢、更贵、输出更不确定的外部依赖,因此边界反而更重要。

    可以做一个十五分钟练习:为上面的 answer 写一个记录收到 Prompt 的假模型,再验证空白问题不会调用模型。随后更换一个完全不同的假实现,确认用例无需修改。留下测试输出,这就是“依赖已经被隔离”的可检查证据。

    这篇文章依据的演进记录

    • d8c360e:初始化代码仓库,让实验代码有了明确起点。
    • 20378b0:加入 HTTP 入口,脚本开始面对网络协议。
    • c5a9aec:接入模型服务,外部依赖进入运行路径。
    • b323533:补充异常处理,开始建立失败边界。
    • abeb96e:加入 pytest,核心行为获得自动验证入口。

    五次提交很小,却形成了一个通用顺序:先让请求跑起来,再把外部依赖、失败和验证逐一变成明确边界。后续功能能否健康增长,往往在这里已经埋下答案。

    本文目录
    本文目录