我们用传统架构设计 AI 应用,处处别扭
过去一年半我做了三个 AI 项目:客服知识库、智能质检、合同审核助手。回头看,第一个项目(2023 年底的知识库)踩的坑最多,原因不是技术不熟,而是我在用设计传统后端系统的方式设计它。
具体表现是:设计了标准的三层架构(Controller / Service / DAO),把 LLM 调用当成"一个特殊的 DAO",服务之间同步 HTTP 调用,测试用断言,监控看响应时间和错误率。结果上线后问题不断——模型返回格式不对导致解析异常,同样的输入两次结果不同导致测试随机失败,成本在没人注意的地方悄悄翻倍。
第三个项目做完后,我大致摸清了 AI 原生应用在架构层面到底变了什么。这篇是我的总结。
一、从确定性到概率性
这是最根本的一条,其他所有变化都是从这里派生出来的。
传统后端系统里,getUserById(123) 调一万次返回一万个相同的结果。AI 系统里,askModel(prompt) 调两次可能给出两个不同答案,而且两个都"不算错"。这个特性往下传导,影响架构的每一层。
接口契约不再是"输入 → 输出"
传统接口可以这样定义:
/**
* 根据用户ID查询订单
* @throws OrderNotFoundException 订单不存在
*/
Order getOrderById(long id);
AI 接口没法这么写。你必须把"不确定"写进契约里:
public record ExtractResult(
ContractInfo value, // 抽取结果,字段可能为 null
double confidence, // 置信度
List<FieldIssue> issues // 哪些字段没把握,为什么
) {
public boolean needsReview() {
return confidence < 0.7 || !issues.isEmpty();
}
}
ExtractResult extractContract(MultipartFile file);
关键区别:"不确定的程度"和"不确定的原因"必须作为返回值的一部分,而不是抛异常。传统设计里,识别不出来就抛 ParseException;AI 系统里,"有 60% 把握认为甲方是 A 公司"是有价值的信息,不该丢掉。
这个改动的影响是连锁的。上层调用方不能写 if (result != null),得写 if (result.needsReview());数据库要存置信度字段;前端要区分"确定"和"待确认"两种展示。不确定性会像病毒一样扩散到整个系统,越早承认它成本越低。
校验层成为一等公民
传统系统里输入校验是为了防恶意输入。AI 系统里,输出校验比输入校验更重要,因为输出来自一个不可控的组件。
我们的做法是在架构里明确一层"Guardrail",独立于业务逻辑:
请求 → 意图识别 → 检索 → 模型生成 → 【Guardrail】 → 业务落库
│
┌───────────────┼───────────────┐
▼ ▼ ▼
结构校验 事实性校验 安全校验
(JSON Schema) (与检索源比对) (敏感词/PII)
其中"事实性校验"是 AI 系统特有的:把模型输出的每个关键事实跟检索到的原文比对,对不上的标记为"无法溯源"。我们在合同审核项目里做了这层,实测抓出 7.3% 的输出包含无法溯源的内容(主要是模型自己脑补的条款细节)。
测试方式必须重构
这一点很痛。我们的传统单测写满了 assertEquals,AI 功能的测试全得重写:
| 传统断言 | AI 场景的替代方案 |
|---|---|
assertEquals(expected, actual) | 断言关键字段 + 人工抽检 |
assertTrue(cond) | 断言"在 N 次采样中至少 K 次满足" |
| 单次运行通过 | 固定 temperature=0 后再跑断言 |
| 覆盖率达标即可 | 构建评测集,看整体指标而非单测通过率 |
我们现在的做法是三层测试:单元测试只测不依赖模型的部分(用 mock);集成测试用固定种子 + temperature=0 跑,允许失败重跑一次;真正的质量保证靠评测集(我们建了 800 条标注数据)在每次 prompt 变更时跑一遍,看指标有没有退化。
评测集的建设和维护,是这个架构里一笔持续的固定成本。我们专门有一个人花 20% 的精力维护它,这部分投入在传统项目里是不存在的。
二、从 CRUD 到意图驱动
传统系统的接口设计围绕资源:POST /orders、DELETE /orders/{id}。用户明确知道自己要做什么,系统负责执行。
AI 原生应用的入口是一个意图:"帮我把上个月超预算的订单整理出来发给张总。" 这句话里没有资源、没有动作,需要系统自己拆解成:查询订单 → 筛选 → 生成报表 → 发邮件。
架构上多出的一层:规划
这层在传统系统里不存在。它的职责是把意图转成可执行的步骤序列:
public record ExecutionPlan(
List<Step> steps,
boolean needsConfirmation, // 是否需要人工确认
String explanation // 向用户解释打算做什么
) {}
public interface Planner {
ExecutionPlan plan(String userIntent, UserContext ctx);
}
needsConfirmation 这个字段是实践教训换来的。我们第一版没有确认环节,用户说"帮我把张三的订单删了",系统真的就去删了。因为模型对意图的理解是概率性的,不可逆操作必须让用户确认。现在的规则是:涉及删除、支付、对外发送、权限变更的操作,一律先出计划让用户点确认。
执行引擎要有补偿能力
计划执行到第三步失败了,前两步怎么办?传统事务可以回滚,但 AI 计划里的步骤往往是外部副作用(发了邮件、调了第三方接口),回滚不了。
我们的执行引擎做了三件事:
- 每步记录状态:步骤级别的状态机持久化到数据库,支持断点续跑;
- 每步声明补偿动作:能补偿的(比如创建了一张草稿)就补偿,不能补偿的(比如已发送的通知)标记为"已发生";
- 失败时不猜测:计划执行失败后,不自动重试也不自动改计划,而是把已完成的部分和失败原因一起呈现给用户,让用户决定。
第三点跟直觉相反。我一开始让模型在失败时"自己想办法换个方案",结果它经常做出更离谱的事。后来改成老实报错,反而更好——概率性组件不适合做自主决策,尤其在已经出过错的上下文里。
三、状态从"数据"变成"上下文"
传统系统里,状态就是数据库里的行。AI 系统里,除了业务数据,还有一个关键状态是对话上下文,而它有几个麻烦特性:体积大(几万 token)、有时效性、需要裁剪、影响成本和延迟。
我们把上下文管理独立成一个模块,负责:
- 分层存储:系统提示词(不变)、长期记忆(用户画像,存数据库)、近期对话(存 Redis,保留最近 20 轮)、检索到的知识(临时),各层有不同的生命周期;
- 预算控制:每次请求按模型上下文窗口计算 token 预算,超了按优先级裁剪。我们的优先级是:当前轮次 > 系统提示 > 检索结果 > 历史对话摘要 > 历史对话原文;
- 历史压缩:超过 10 轮的对话,用模型生成摘要替换原文。这一招把长对话的 token 消耗降低了 64%。
public record ContextBudget(int total, int reservedForOutput) {
public int availableForInput() {
return total - reservedForOutput;
}
}
// 裁剪策略:按优先级排序后从低优先级开始丢弃
List<ContextBlock> trim(List<ContextBlock> blocks, ContextBudget budget) {
var sorted = blocks.stream()
.sorted(comparingInt(ContextBlock::priority).reversed())
.toList();
int used = 0;
List<ContextBlock> kept = new ArrayList<>();
for (var b : sorted) {
if (used + b.tokens() <= budget.availableForInput()) {
kept.add(b);
used += b.tokens();
} else {
log.info("context trimmed, dropped: {}", b.name());
}
}
return kept;
}
这里有个容易被忽略的点:裁剪要打日志和指标。我们上线后发现裁剪触发率高达 23%,说明上下文预算设得太紧,后来调整了历史压缩的触发阈值。如果没有这个指标,我们只会看到"模型好像变笨了",但不知道原因。
四、性能模型的改变
这一条工程上最直接。传统接口 P99 是几十毫秒,AI 接口是几秒,差两个数量级。带来的连锁反应:
| 维度 | 传统服务 | AI 服务 | 架构应对 |
|---|---|---|---|
| 单次耗时 | 10~100ms | 1~30s | 全链路异步化 + 流式输出 |
| 耗时方差 | 小 | 极大(输出长度决定) | 超时按 P99 而非均值设 |
| 单请求成本 | 忽略 | ¥0.001~0.5 | 预算控制 + 缓存 + 模型分级 |
| 并发模型 | 线程池 | 虚拟线程 + 信号量限并发 | 防止 GPU 侧过载 |
流式输出不只是体验优化,是架构必需。我们第一个项目没做流式,用户提交后干等 8 秒,放弃率 34%。改成流式后(首 token 700ms 内返回)放弃率降到 9%。这意味着整条链路要支持 SSE 或 WebSocket,包括中间的所有网关和代理——我们当时 nginx 没配 proxy_buffering off,流式被缓冲住了,白做。
location /api/chat/ {
proxy_pass http://ai-service;
proxy_buffering off; # 关键
proxy_cache off;
proxy_read_timeout 300s;
chunked_transfer_encoding on;
}
五、可观测性要重新设计
传统的三个黄金指标(QPS、延迟、错误率)在 AI 系统里严重不够。我们的监控面板现在多了这些:
- 质量指标:用户点踩率、重问率(同一个问题换个说法再问一次)、平均对话轮次。这三个是模型效果的真实代理指标,比任何离线评测都准;
- 成本指标:按业务线/用户/功能维度的实时 token 消耗和金额,日累计超预算告警;
- 溯源率:模型输出中能回溯到检索原文的比例,低于阈值说明幻觉风险上升;
- 降级率:走兜底逻辑的请求占比。这个指标特别重要,因为它悄悄失效时系统看起来一切正常。
还有一点:链路追踪要能记录 prompt 和响应(脱敏后)。我们用的是 OpenTelemetry,把每次模型调用的 model、token 数、temperature、耗时都记成 span attribute。排查"为什么这次回答很差"时,没有这些数据只能靠猜。
架构上的整体变化
把上面几条合起来,AI 原生应用相比传统后端,多了这么几层:
接入层(含流式、意图入口)
↓
【意图识别与路由】 ← 新增
↓
【上下文管理】 ← 新增
↓
【检索/工具编排】 ← 新增(RAG、Agent)
↓
模型调用层(含重试、降级)
↓
【Guardrail 校验】 ← 新增
↓
业务执行层(传统 CRUD)
↓
【评测与反馈闭环】 ← 新增
业务执行层还是老样子,但外面套了五层新东西。这就是为什么"接个大模型"听起来简单,做起来工作量远超预期——我们合同审核项目的代码分布是:业务逻辑 31%,AI 相关的新增层 47%,测试和评测 22%。
小结
总结成一句话:AI 原生架构的核心不是"怎么调用模型",而是"怎么在不确定的输出之上构建确定的系统"。
具体落下来是五件事:把不确定性显式化到接口契约里(置信度、问题清单);把校验层当一等公民;把规划、执行、补偿做成独立模块;把上下文当作需要精细管理的资源;把质量指标和成本指标纳入监控体系。
最后提醒一点:这些新增的层都有持续的维护成本,尤其是评测集。如果你只是做个内部小工具,没必要全套上马。我们第一个项目就是因为把架构做得太重,导致迭代速度严重下降。合适的做法是随着不确定性带来的损失增大,再逐步加这些层。