LLMOps 常被理解成"给模型加监控",真正卡住团队的却是另外三件事:每次改动不知道会不会退化、每次退化不知道是哪一层改的、每次成本上涨不知道涨在哪条链路。这三个问题对应三样工程设施:用例门禁、分层版本、成本账。把它们串成闭环,才叫 LLMOps。
1. LLMOps 管的是四条线
| 线 | 对象 | 失控表现 | 抓手 |
|---|---|---|---|
| 版本线 | prompt、模型、工具 schema | 改了不知道哪个生效 | 三者独立版本号 |
| 质量线 | 用例集与判据 | "感觉变好了" | LLMTestCase 门禁 |
| 成本线 | token、调用数、缓存命中 | 账单月增 40% 无人认领 | 四段式成本账 |
| 发布线 | 灰度比例、回滚 | 一次全量上线全站退化 | 按特征分桶放量 |
四条线必须有同一个关联键(trace_id / release_id),否则事后只能靠时间戳对齐,永远对不上。
2. 成本账:把 token 拆成四段
def cost_breakdown(calls):
agg = dict(prompt=0, completion=0, cached=0, retry=0)
for c in calls:
agg["prompt"] += c["prompt_tokens"]
agg["completion"] += c["completion_tokens"]
agg["cached"] += c.get("cached_tokens", 0)
agg["retry"] += c["prompt_tokens"] if c["attempt"] > 1 else 0
total = sum(agg.values())
return {k: round(v / total, 3) for k, v in agg.items()} | {"total_tokens": total}
| 段 | 来源 | 常见占比(典型量级) | 压缩手段 |
|---|---|---|---|
| prompt | 系统提示 + 检索片段 + 历史 | 50%–70% | 精简提示、分块、去重 |
| completion | 生成内容 | 20%–40% | 输出上限、结构化约束 |
| cached | 命中的前缀缓存 | 视场景 | 稳定前缀顺序、少改系统提示 |
| retry | 失败重试的重复输入 | 1%–10% | 退避抖动、幂等、前置校验 |
retry 段最容易被忽略,因为它散落在每次失败里。把它单独计数,往往能直接定位一个"某供应商偶发 500 导致全量重试"的问题。
按段统计还能回答一个关键问题:是输入太长,还是输出太长。这两个结论对应的改法完全不同——前者改检索与提示,后者改约束与模型档位。
3. 分层版本:三个独立版本号
| 变更对象 | 版本载体 | 回滚粒度 | 必须记录的字段 |
|---|---|---|---|
| prompt | prompt@v12(内容哈希) |
单点回滚 | 模板文本、变量列表 |
| 模型 | model@qwen3-32b-2026-05 |
换模型 | 供应商、上下文窗口、单价 |
| 工具 schema | tools@v7 |
单工具回滚 | 工具名、参数、是否幂等 |
合在一起构成 release_id = prompt@v12 + model@x + tools@v7。发布单上写这三段,而不是"后端 v2.3",否则线上退化时无法二分定位。
RELEASE = {"prompt": "v12", "model": "qwen3-32b-2026-05", "tools": "v7"}
def tag(rec):
return {**rec, "release_id": "/".join(f"{k}@{v}" for k, v in RELEASE.items())}
4. LLMTestCase:用例即门禁
LLMTestCase 的核心不是"跑一遍看输出像不像",而是把预期拆成可判定的断言,并允许三类判据混用。
from dataclasses import dataclass
@dataclass
class LLMTestCase:
name: str
input: dict
expected: dict
graders: list # ["exact", "schema", "llm_judge", "retrieval_hit"]
def run(self, target):
out = target(self.input)
fails = [g for g in self.graders if not GRADERS[g](self.expected, out)]
return {"name": self.name, "pass": not fails, "failed": fails, "out": out}
| 判据 | 可判定性 | 适合 | 误判来源 |
|---|---|---|---|
exact |
强 | 枚举、编号、金额 | 格式差异 |
schema |
强 | 结构化输出 | 语义对但字段错 |
contains |
中 | 关键要素是否出现 | 同义表述 |
retrieval_hit |
强 | RAG 召回 | 标注集质量 |
llm_judge |
弱 | 主观质量 | 评审模型自身偏差 |
铁律:门禁里 llm_judge 占比不要超过三成,且必须与至少一个强判据配对。全弱判据的门禁会稳定通过,然后线上翻车。
用例集规模也有讲究:50 条能挡住方向性错误,300 条以上才有统计意义。每次线上事故都应沉淀一条用例,这是唯一能让门禁随时间变强的机制。
5. 可复现任务作为回归底座
SWE-agent 这类"给定仓库 + 问题,产出补丁并通过测试"的基准,价值不在刷分,而在它把质量变成了可复现的判据:补丁跑不跑得过测试,是二值的。
| 底座类型 | 判据 | 适合验证 | 维护成本 |
|---|---|---|---|
| 单元用例集 | 断言 | 单点行为 | 低 |
| 仓库级修复任务(SWE-agent 式) | 测试通过率 | 多步工具使用、代码理解 | 中 |
| 端到端场景回放 | 最终状态 | 全链路 | 高 |
| 人工抽检 | 主观评分 | 风格、可用性 | 中 |
关键设计是冻结环境:镜像、依赖版本、测试命令全部固定。环境一漂,"通过率下降"就无法区分是模型退化还是依赖升级。
6. 灰度:按请求特征放量,不按百分比乱放
import hashlib
def bucket(user_id, release):
h = hashlib.sha256(f"{user_id}:{release}".encode()).hexdigest()
return int(h[:8], 16) % 100 # 0-99 稳定分桶
def pick(user_id, release, pct):
return "new" if bucket(user_id, release) < pct else "old"
| 放量维度 | 优点 | 风险 |
|---|---|---|
| 按用户哈希 | 同一用户稳定同一版本 | 大客户可能全落在实验组 |
| 按流量百分比 | 简单 | 同一用户来回切,体验断裂 |
| 按请求特征(长度/语言) | 精准限定风险面 | 需要可靠的特征提取 |
| 按内部账号先行 | 风险最低 | 样本不代表真实分布 |
灰度的三个必备开关:比例、白名单、一键回滚。缺了"一键回滚"的灰度只是分批上线。
7. Auto-review:自动评审能做什么、不能做什么
| 评审对象 | 自动化可行性 | 建议 |
|---|---|---|
| 格式/结构违规 | 高 | 完全自动,直接拦截 |
| 引用是否支撑结论 | 中 | 自动标记,人工抽检 |
| 事实正确性 | 低 | 只能做交叉检索提示 |
| 语气与品牌一致性 | 中 | 规则 + 少量标注 |
| 安全与合规表述 | 中高 | 规则优先,模型兜底 |
def auto_review(out, rules):
hits = [r for r in rules if r.match(out)]
if any(r.blocking for r in hits):
return {"decision": "block", "hits": [r.id for r in hits]}
if hits:
return {"decision": "flag", "hits": [r.id for r in hits]} # 进人工队列
return {"decision": "pass"}
自动评审的正确定位是分流器,不是裁判。让它直接决定"通过/拒绝",等于把误判率直接写进业务。
8. E2ETest:端到端要断言什么
端到端测试在 LLM 系统里最容易写成"跑一遍不报错就算过"。有效的断言只有四类:
| 断言 | 检查点 | 为什么必要 |
|---|---|---|
| 结构 | 输出 schema、必填字段 | 下游解析的前提 |
| 引用 | 引用片段是否真实存在 | 幻觉引用的唯一硬证据 |
| 副作用 | 数据库/工单状态变化 | 动作真的发生了 |
| 预算 | 步数、token、时延上限 | 防长尾拖垮 |
def e2e_assert(trace, case):
assert schema_ok(trace["output"], case["schema"])
assert all(c["doc_id"] in trace["retrieved"] for c in trace["citations"])
assert trace["db_delta"] == case["expect_delta"]
assert trace["steps"] <= case["max_steps"]
把"引用必须来自检索结果"写进断言,是拦幻觉最划算的一步——它不判对错,只判"你是不是编的"。
9. 排错:六种闭环失效
| 现象 | 原因 | 处理 |
|---|---|---|
| 门禁全绿但线上退化 | 用例集与真实分布脱节 | 每月从线上采样补用例 |
| 无法定位是哪层改动导致退化 | 三个版本号混在一个发布号里 | 拆成 prompt@/model@/tools@ |
| 成本涨了但调用量没涨 | 输入变长或被重试放大 | 按四段拆账,先看 retry |
| 灰度放量后指标剧烈震荡 | 按流量百分比切,用户来回切版本 | 改按用户哈希稳定分桶 |
| 回滚后仍异常 | 缓存里还是旧响应 | 版本号进缓存键 |
| 基准通过率突然下降 | 测试环境漂移 | 冻结镜像与依赖 |
10. 边界:这些事不该塞进 LLMOps
| 诉求 | 更该做的 | 理由 |
|---|---|---|
| 提升模型能力上限 | 换模型 / 微调 | 运营设施不改能力 |
| 修数据错误 | 数据治理 | 门禁只会稳定复现错误 |
| 降低单 token 单价 | 采购与路由 | 属商业与网关层 |
| 替代人工判断 | 明确判据 + 抽检 | 自动评审只能分流 |
11. 自测
- 成本账为什么要单独统计
retry段?它能暴露哪一类问题? release_id由哪三段组成?合成一个"后端版本号"会导致什么定位困难?- 门禁里
llm_judge占比过高会发生什么?如何配平? - 用 SWE-agent 式仓库任务做底座时,为什么必须冻结运行环境?
- 灰度为什么优先按用户哈希而不是按流量百分比?
- 端到端断言里"引用必须来自检索结果"拦的是哪类错误?它判不了什么?