AI 应用后端工程化:从原型到可交付系统 · 第 01 篇 · 第一章 · 服务基础
从入口、配置、模型客户端、异常边界和测试五个位置,搭起一个能继续演进的后端骨架。
一个 AI 原型通常从十几行脚本开始:读取密钥、调用模型、打印结果。它很适合验证“模型能不能回答”,却回答不了另一个更现实的问题:当请求来自浏览器、配置可能缺失、外部服务会超时、代码需要被测试时,这段脚本还能不能稳定工作?
服务化的第一步不是堆目录,而是把变化速度不同的事情分开。HTTP 协议会变,模型供应商会换,错误展示会调整,核心业务却不应该跟着每次震动。
五个位置先站稳
可以把最早期的后端想成一家刚开门的小餐馆。门口负责接单,后厨负责做菜,仓库保存原料,值班经理处理异常,试菜员保证基本品质。任何一个角色都可以只有几行代码,但不能完全没有角色边界。
flowchart LR
Client[调用者] --> HTTP[HTTP 入口]
HTTP --> UseCase[用例函数]
UseCase --> Model[模型客户端]
Config[配置] --> Model
Model --> UseCase
UseCase --> HTTP
Error[统一异常边界] -.包住.-> HTTP
Test[自动测试] -.验证.-> UseCase
这五个位置分别解决不同问题:
- 入口负责把 HTTP 请求翻译成应用能理解的数据,不在这里拼 Prompt、查数据库或处理长流程。
- 用例描述“完成一次回答”需要哪些步骤,它不关心 Flask、FastAPI 还是命令行调用。
- 外部客户端封装模型 SDK,让超时、模型名和认证方式不扩散到每个业务函数。
- 异常边界把内部失败转换成稳定响应,同时保留日志中的真实原因。
- 测试入口绕过网络直接验证用例,避免所有测试都依赖真实模型和密钥。
最小不是一个文件
下面的例子没有引入复杂框架,却已经把外部依赖从业务逻辑中拿了出去。测试可以传入一个假的 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,核心行为获得自动验证入口。
五次提交很小,却形成了一个通用顺序:先让请求跑起来,再把外部依赖、失败和验证逐一变成明确边界。后续功能能否健康增长,往往在这里已经埋下答案。