logo

从零搭建 LLM 应用 CI:测试、评测与发布门禁

Published on

从零搭建 LLM 应用 CI:测试、评测与发布门禁

传统业务代码改完以后,跑单元测试、类型检查和构建,绿色就可以合并。LLM 应用没有这么简单:同一个 Prompt 可能因为模型版本、检索结果顺序、采样参数或外部工具返回变化,得到不同的答案。你不能要求每个字都一样,却仍然需要知道这次改动有没有让系统变差。

我见过最危险的发布方式,是开发者在聊天窗口里试了几个问题,觉得“回答不错”,然后直接把 Prompt 推到生产。几天以后,用户反馈答案开始漏字段,团队却找不到是哪次改动造成的,因为没有基线、没有评测集,也没有版本记录。

LLM 应用的 CI 不应该假装模型是一个完全确定的函数,而要把确定的部分测死,把不确定的部分用数据和阈值约束起来。

一、先把测试分成四层

代码层:类型、解析器、权限和业务规则
链路层:检索、工具调用、重试和状态机
质量层:答案相关性、忠实性和格式质量
发布层:成本、延迟、安全和灰度结果

第一层应该尽量快、尽量稳定,每次提交都运行;第二层验证组件之间是否正确协作;第三层允许一定波动,但要有固定数据集和明确阈值;第四层决定一个版本是否可以扩大流量。

把所有东西都交给模型评审,会让 CI 既慢又不稳定;只做代码单测,又测不到真实质量。分层的意义,是让每种问题由适合它的工具负责。

二、代码层先测试确定性行为

LLM 应用里有很多不应该交给模型猜的逻辑:权限过滤、Token 预算、JSON Schema、重试次数、工具参数和状态转换。这些地方完全可以用普通测试:

it('不会返回其他租户的检索结果', async () => {
  const result = await searchForScope(question, tenantA)

  expect(result.every((item) => item.tenantId === tenantA.id)).toBe(true)
})

it('工具参数不符合 Schema 时拒绝执行', async () => {
  await expect(executeTool(invalidCall)).rejects.toThrow('invalid schema')
})

这类测试不需要调用真实模型,速度快,也不会因为模型输出变化而抖动。能用代码证明的规则,就不要用自然语言 Prompt 祈祷模型遵守。

还应该给 Prompt 组装器、上下文裁剪器、引用格式化器和成本计算器写测试。这些小函数看起来不“智能”,却经常是线上问题的根源。

三、链路层用可控替身测试 Agent

工具调用和 Agent 状态机需要验证多步流程。可以用假的模型和假的工具返回固定事件:

const fakeModel = sequenceModel([
  { type: 'tool_call', name: 'searchOrder', args: { id: 'A1' } },
  { type: 'final', content: '订单已找到' },
])

const fakeTool = mockTool('searchOrder', {
  status: 'shipped',
})

然后测试:工具失败时是否重试,重复事件是否幂等,用户取消后是否停止后续动作,模型输出非法参数时是否暂停。不要让每次 CI 都依赖真实外部 API,否则网络抖动和供应商限流会把代码问题与环境问题混在一起。

真实 API 可以放在较低频的集成任务里运行,使用受控测试账号、固定数据和成本上限。测试环境的 API Key 不能进入仓库,也不能因为 CI 失败就无限重试烧钱。

四、黄金数据集是 LLM 应用的回归测试

质量评测要有一组固定样本,每条样本包含问题、期望行为、相关证据和不可接受结果:

type EvalCase = {
  id: string
  input: string
  expectedBehavior: string
  referenceChunks?: string[]
  forbiddenPatterns?: string[]
  riskLevel: 'low' | 'medium' | 'high'
}

样本来源应该包括真实失败案例、常见问题、长尾表达、无答案问题、权限边界和高风险操作。每次线上出现有代表性的错误,就把它加入回归集,并标记发现版本和修复版本。

测试集要固定版本。否则一边改系统,一边悄悄改测试题,最后任何版本都能“通过”。数据集本身也应该走代码审查,有新增、修改和删除记录。

五、不要用精确字符串比较所有模型答案

开放式回答可能有多种正确表达。可以把质量检查拆成几类:

  • 结构检查:JSON 是否符合 Schema,字段是否齐全。
  • 规则检查:是否包含禁止内容,引用是否真实存在。
  • 语义检查:是否回答问题,是否被证据支持。
  • 人工检查:高风险样本是否达到业务要求。

自动评审模型可以用于语义检查,但要有自己的校准集。不同评审模型可能偏爱更长、更自信或更像自己的答案,不能直接把一个分数当成客观真理。

type QualityResult = {
  schemaValid: boolean
  grounded: boolean
  relevant: boolean
  safe: boolean
  evaluator: 'rule' | 'judge-model' | 'human'
}

门禁可以设置为:结构化任务成功率必须 99%,有依据回答率不得下降超过 2%,高风险样本不能出现新增失败。目标要按任务定义,不要用一个模糊的“总体感觉分”覆盖所有问题。

六、把 Prompt 和模型版本当成代码

Prompt 不能只藏在业务代码里的长字符串中。它应该有版本、变更说明和测试关联:

type PromptManifest = {
  name: string
  version: string
  templateHash: string
  model: string
  temperature: number
  evalDataset: string
}

模型、Tokenizer、Embedding、Reranker、切分策略和工具 Schema 也要进入版本清单。一次“只改了 Prompt”的提交,可能影响成本、延迟、引用和安全行为,不能跳过评测。

七、CI 流水线可以这样分段

Pull Request
格式、类型、单元测试
链路测试与权限测试
小型黄金集快速评测
完整评测与成本检查
部署到灰度环境
线上指标稳定后扩大流量

小型评测集让开发者很快得到反馈,完整评测可以在合并或夜间任务中运行。不要因为完整评测太慢就完全不跑,而是把不同耗时的检查放在不同门禁。

CI 输出不应该只有“通过/失败”,还要保存评测报告:按类别的质量、失败样本、Token 用量、P95 延迟和与基线的差异。审查者需要知道版本为什么通过,不能只看到一个绿色勾。

八、发布门禁要防止回归

可以给关键指标设置相对阈值:

Recall@5 不得下降超过 1%
结构化输出成功率不得下降
P95 延迟不得上升超过 15%
单位成功任务成本不得上升超过 20%
高风险安全违规必须为 0

阈值不是越严越好。过于严格会让团队无法进行必要改进,过于宽松则等于没有门禁。关键是把可以接受的变化写出来,并对新增失败样本逐条分类。

通过离线门禁后也不要直接全量。小流量灰度,观察真实负反馈、超时、Token 和安全指标;一旦异常,自动停在当前比例并切回旧版本。回滚的模型和 Prompt 必须仍然可用,不能只保存“最新版”。

九、让 CI 本身也可靠

LLM 评测可能受到供应商网络、模型随机性和外部知识变化影响。需要把环境因素记录下来:模型版本、区域、采样参数、数据集版本和评测时间。对允许随机性的任务,可以重复运行多次,使用置信区间或稳定阈值,而不是一次结果决定生死。

同时设置预算和超时:单次 PR 最多调用多少次模型,整个任务最多花多少钱,超过后是失败还是转到异步评测。评测系统不能为了证明产品稳定,反而成为最不稳定、最昂贵的系统。

总结:绿色流水线应该代表值得信任

LLM 应用的 CI 最终要证明的不是“模型说了同一句话”,而是系统仍然满足它承诺的行为:权限没有越界,结构化输出可解析,检索证据没有明显退化,工具调用不会重复,成本和延迟在预算内,高风险错误没有新增。

我现在看一条绿色流水线,会继续问:它测到了什么,又没有测到什么?如果只是类型检查通过,却没有跑黄金数据集;如果评测分数很高,却没有保留失败样本;如果模型升级没有版本记录,那么这个绿色只是颜色,不是证据。

把确定性逻辑用单元测试锁住,把复杂链路用替身复现,把模型质量交给固定数据集,把发布决策交给阈值、灰度和回滚,LLM 应用才有机会像普通软件一样持续演进。模型可以有创造性,发布流程必须有记忆;只有这样,团队才敢不断改进,而不是每次上线都重新祈祷一次。

🤪 您也可以编辑此页: