Administrator
发布于 2025-05-11 / 5359 阅读
27

AI 原生应用的架构范式转变

我们用传统架构设计 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 /ordersDELETE /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~100ms1~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 原生架构的核心不是"怎么调用模型",而是"怎么在不确定的输出之上构建确定的系统"

具体落下来是五件事:把不确定性显式化到接口契约里(置信度、问题清单);把校验层当一等公民;把规划、执行、补偿做成独立模块;把上下文当作需要精细管理的资源;把质量指标和成本指标纳入监控体系。

最后提醒一点:这些新增的层都有持续的维护成本,尤其是评测集。如果你只是做个内部小工具,没必要全套上马。我们第一个项目就是因为把架构做得太重,导致迭代速度严重下降。合适的做法是随着不确定性带来的损失增大,再逐步加这些层。

参考