LLM 应用生产就绪清单
上线不是 demo 放大
我把一个内部客服助手从 demo 推到生产的第一周,日均 Token 费用从预算的 40 美元飙到 380 美元,P95 延迟从 demo 环境的 1.2 秒退化到 6.8 秒,用户投诉里有 12% 是模型输出了不存在的产品参数。
Demo 环境的问题在于:输入是精心挑选的,并发是假的,成本是按几次调用算的。生产环境会把这些假设全部打破。真实用户的输入比你想象的要脏、要长、要怪;失败路径比你 demo 时考虑的多三倍以上;成本和延迟会随着请求量线性甚至超线性增长。
上线前需要把模型输出纳入工程治理——版本化 Prompt、约束输出格式、建立评测管道、记录完整 trace、控制成本预算、处理安全风险、准备降级回滚方案。这篇文章整理了我从多次上线(以及翻车)中总结出的七个检查维度,附带可执行的清单和代码示例。
理论框架:生产就绪的七个维度
学术界对 GenAI 系统的生产就绪问题已经有了系统性的梳理。2024 年发表在 arXiv 上的 grey literature survey《A State-of-the-practice Release-readiness Checklist for Generative AI》从六个生命周期阶段(预训练与微调、Prompt 工程、部署前评估、部署策略、部署后监控、持续维护)整理了 LLM 应用从实验到生产的关键检查项1。Arthur AI 的工程博客则从实操角度将检查清单浓缩为三大支柱:监控与可观测性、评测体系、安全与护栏2。
我把这些来源和我的实际经验交叉对比,归纳出七个维度。这七个维度不是独立检查项,它们之间有依赖关系:
没有 Prompt 版本化,评测管道就无法区分「改了什么导致结果变差」。没有可观测性,成本和延迟就是黑箱。没有安全护栏,降级策略可能把用户导向更危险的兜底路径。
七个维度的详细检查
一、Prompt 版本化与输出契约
Prompt 是 LLM 应用的「代码」,但它往往散落在业务逻辑里、写在字符串常量中、甚至硬编码在前端。这种状态在 production 中是灾难性的——你无法回滚一个你不知道什么时候被改过的 Prompt。
| 维度 | 未版本化 | 已版本化 |
|---|---|---|
| 回滚能力 | 无法回滚,只能手动改代码重新部署 | 通过配置中心秒级回滚到上一版本 |
| A/B 测试 | 需要两套代码分支 | 同一代码,不同 Prompt 版本路由 |
| 评测关联 | 不知道评测对应哪个 Prompt | 评测结果与 Prompt 版本绑定 |
| 审计追溯 | 出了问题不知道谁改了什么 | 完整的变更历史和 diff |
Prompt 版本化不只是把字符串提到配置文件。更关键的是建立 Prompt 与输出格式的契约——模型返回什么结构、哪些字段是必须的、格式错误时怎么处理。
# ❌ 坏做法:Prompt 和解析逻辑散落在业务代码中,无版本控制
def answer_customer_question(question: str, context: list[str]):
prompt = f"""你是客服助手。根据以下信息回答用户问题:
{'\n'.join(context)}
用户问题:{question}
请用友好的语气回答。"""
response = llm.chat(prompt)
# 直接返回模型原始输出,没有任何格式约束
return response.text这段代码的问题:Prompt 是字符串拼接,没有版本追踪;输出没有格式约束,模型可能返回任何内容;没有处理模型拒答或格式错误的情况。
# ✅ 好做法:Prompt 外部化、版本化,输出有 JSON Schema 约束
# prompts/customer_service_v2.3.yaml
# version: "2.3"
# template: |
# 你是{brand_name}的客服助手。
# 根据以下参考文档回答用户问题。如果参考文档中没有相关信息,
# 明确告知用户「暂无相关信息」,不要编造。
# 输出格式必须为 JSON:
# {{"answer": string, "confidence": float, "sources": string[]}}
from prompt_registry import get_prompt
CUSTOMER_RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"answer": {"type": "string"},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"sources": {"type": "array", "items": {"type": "string"}},
},
"required": ["answer", "confidence", "sources"],
}
def answer_customer_question(question: str, context: list[str]) -> dict:
prompt = get_prompt("customer_service", version="2.3")
response = llm.chat(
prompt.format(brand_name="XX品牌", context=context, question=question),
response_format={"type": "json_schema", "schema": CUSTOMER_RESPONSE_SCHEMA},
)
parsed = parse_and_validate(response.text, CUSTOMER_RESPONSE_SCHEMA)
if parsed["confidence"] < 0.5:
return fallback_to_human(question, context)
return parsed好做法的核心变化:Prompt 从代码中分离,通过版本注册表管理;输出有明确的 JSON Schema 约束;低置信度自动降级到人工处理。
二、评测管道
评测是区分「感觉还行」和「真的能用」的分水岭。Arthur AI 的建议很直接:让评测结果是二值的(binary),不要让 LLM 在 1-10 分之间打分——模型在范围打分上极不一致2。
| 评测类型 | 执行时机 | 数据来源 | 目的 |
|---|---|---|---|
| 离线评测(Supervised Evals) | 发布前 | 固定 golden dataset | 验证 Prompt/模型/RAG 变更没有引入回归 |
| 在线评测(Continuous Evals) | 生产实时 | 真实用户流量 | 检测模型漂移、质量退化 |
| 红队测试(Red Teaming) | 发布前/定期 | 对抗性样本 | 发现安全漏洞、偏见和边界情况 |
| A/B 评测 | 灰度期间 | 分流真实流量 | 对比新旧版本的核心指标差异 |
评测样例集不是一次性产物,它需要随着线上 bad case 不断扩充。每次用户在反馈入口点了「踩」,这条对话就应该进入评测候选池。
# ❌ 坏做法:评测靠人肉看几个例子,用模糊标准判断
def manual_eval():
examples = ["问题1", "问题2", "问题3"]
for q in examples:
answer = llm.chat(q)
print(f"Q: {q}\nA: {answer}\n感觉怎么样?(y/n)")
# 依赖人工主观判断,不可复现,不可扩展# ✅ 好做法:结构化评测管道,二值判定,自动回归
from deepeval import assert_test
from deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetric
# golden dataset 每条包含:问题、期望答案要点、必须引用的文档 ID
golden_dataset = load_dataset("evals/customer_service_v47.jsonl")
def run_regression_eval(prompt_version: str):
relevancy_metric = AnswerRelevancyMetric(threshold=0.7)
faithfulness_metric = FaithfulnessMetric(threshold=0.8)
failures = []
for case in golden_dataset:
response = run_with_prompt_version(case.question, prompt_version)
# 二值判定:通过或不通过,不打分数
try:
assert_test(response, [relevancy_metric, faithfulness_metric])
except AssertionError:
failures.append({
"question": case.question,
"expected_sources": case.expected_sources,
"actual_sources": response.sources,
})
# 任何失败都阻断发布
if failures:
raise EvalGateError(
f"评测未通过:{len(failures)}/{len(golden_dataset)} 条失败",
failures=failures,
)三、可观测性:trace、日志和模型参数
传统应用的可观测性关注请求延迟、错误率和吞吐量。LLM 应用需要额外记录:完整 Prompt(包括 system prompt 和 user prompt)、模型参数(temperature、top_p、max_tokens)、Token 消耗(input/output 分别计数)、检索命中的文档片段、工具调用的输入输出。
Arthur AI 的原则是「You can't fix what you can't see」2。没有 trace 数据,你甚至不知道成本花在了哪里。
# ❌ 坏做法:只记录最终输出,出问题无法排查
def handle_request(user_input: str):
result = llm.chat(user_input)
logger.info(f"User: {user_input}, Response: {result.text[:100]}")
return result# ✅ 好做法:完整 trace 记录,包括 Prompt、参数、检索结果、Token 消耗
from opentelemetry import trace
import json
tracer = trace.get_tracer("llm-service")
def handle_request(user_input: str, session_id: str):
with tracer.start_as_current_span("llm_request") as span:
# 记录检索步骤
retrieved_docs = retriever.search(user_input, top_k=5)
span.set_attribute("retrieval.doc_count", len(retrieved_docs))
span.set_attribute("retrieval.doc_ids", json.dumps([d.id for d in retrieved_docs]))
# 构建完整 Prompt 并记录
prompt = build_prompt(user_input, retrieved_docs)
span.set_attribute("prompt.version", "2.3")
span.set_attribute("prompt.token_count", count_tokens(prompt))
# 调用模型,记录参数和消耗
response = llm.chat(
prompt,
model="gpt-4o",
temperature=0.2,
max_tokens=1024,
)
span.set_attribute("llm.model", "gpt-4o")
span.set_attribute("llm.temperature", 0.2)
span.set_attribute("llm.input_tokens", response.usage.prompt_tokens)
span.set_attribute("llm.output_tokens", response.usage.completion_tokens)
span.set_attribute("llm.cost_usd", calculate_cost(response.usage))
span.set_attribute("session.id", session_id)
return response有了这些 trace 数据,你可以回答「某个用户的 bad case 是因为检索没召回相关文档,还是模型忽略了指令」这类问题。没有 trace,你只能猜测。
四、成本预算
LLM 应用的成本结构和传统应用完全不同。传统应用的边际成本趋近于零(多一个请求只是多一点 CPU),但 LLM 应用的边际成本是实打实的 Token 费用。Morph 的成本优化指南给出了具体数据:模型路由可以节省 40-70%,上下文压缩可以减少 50-70% Token,缓存命中可以节省 90%——这五项策略叠加可以将总成本降低 70-85%3。
| 优化策略 | 成本节省 | 实施复杂度 | 适用场景 |
|---|---|---|---|
| 模型路由 | 40-70% | 低 | 60-80% 请求是常规任务时 |
| 上下文压缩 | 50-70% Token | 中 | 多轮对话,上下文越来越长 |
| Prompt 缓存 | 90%(缓存命中) | 低 | 系统提示词稳定、重复前缀多 |
| Prompt 精简 | 30-50% output Token | 低 | 系统提示词过长、few-shot 示例过多 |
| 批处理 | 50% | 低 | 非实时任务:评测、标注、回填 |
关键是:不要等上线后才发现成本失控。上线前就要根据预估的请求量、平均 Token 数和模型定价算出月度预算,并设置硬性告警阈值。
# ❌ 坏做法:所有请求都用最贵的模型,没有成本意识
def handle_any_request(user_input: str):
# 不管问题多简单,一律用 GPT-4o
return llm.chat(user_input, model="gpt-4o")# ✅ 好做法:模型路由 + 缓存 + 成本预算控制
from cost_tracker import CostTracker, BudgetExceededError
tracker = CostTracker(daily_budget_usd=100.0)
# 简单分类器判断任务复杂度,路由到不同模型
def route_to_model(user_input: str) -> str:
# 实际项目中可以用轻量分类模型或规则引擎
if is_simple_query(user_input):
return "gpt-4o-mini" # $0.15 / 1M input tokens
elif is_standard_query(user_input):
return "gpt-4o" # $2.50 / 1M input tokens
else:
return "gpt-4o" # 复杂任务才用最贵模型
def handle_request_with_budget(user_input: str) -> str:
model = route_to_model(user_input)
# 预算检查:如果今日已超预算,拒绝新请求并告警
if tracker.exceeded():
tracker.alert("Daily budget exceeded")
return "系统繁忙,请稍后重试"
response = llm.chat(user_input, model=model)
tracker.record(cost=calculate_cost(response.usage), model=model)
return response.text五、延迟 SLA
延迟对用户体验的影响是非线性的。1 秒以内的响应感觉是「即时的」,3 秒以上用户开始分心,10 秒以上大多数用户会离开或反复刷新。LLM 的流式输出(streaming)可以缓解感知延迟,但首 Token 时间(TTFT)仍然需要控制在合理范围。
| 场景 | TTFT 目标 | 总延迟目标 | 流式输出 |
|---|---|---|---|
| 实时对话 | < 800ms | 首段 < 2s | 必须 |
| 搜索增强回答 | < 1.5s | 完整 < 5s | 推荐 |
| 文档摘要 | < 3s | 完整 < 30s | 可选 |
| 批量处理 | 无要求 | 24h 内完成 | 不需要 |
延迟优化的核心杠杆:缩短输入 Token(上下文压缩、Prompt 精简)、缓存(避免重复计算)、模型选择(小模型延迟天然更低)、以及流式输出(让用户尽早看到部分结果)。
六、安全护栏
LLM 应用的安全风险与传统应用有本质区别。传统应用的安全边界是确定的(SQL 注入、XSS 有成熟的防御方案),但 LLM 的输入是自然语言,攻击面更模糊。arXiv 的综述论文特别指出了 Prompt Injection、PII 泄露和输出毒性三个核心风险1。
Arthur AI 建议将护栏分为两层:事前拦截(Pre-LLM)和事后拦截(Post-LLM)2。
| 护栏层 | 执行时机 | 检查内容 | 处置方式 |
|---|---|---|---|
| 事前拦截(Pre-LLM) | 用户输入到达模型前 | PII 检测与脱敏、Prompt Injection 检测、敏感数据拦截 | 拦截请求、返回提示 |
| 事后拦截(Post-LLM) | 模型输出返回用户前 | 幻觉检测、毒性内容过滤、输出格式验证、工具调用安全校验 | 拦截输出、触发自纠正循环 |
| 运行时监控 | 持续 | 异常 Token 消耗、异常请求模式、批量注入尝试 | 告警、限流、熔断 |
# ❌ 坏做法:用户输入直接拼入 Prompt,无任何安全检查
def dangerous_handler(user_input: str):
prompt = f"根据以下信息回答问题:{user_input}"
return llm.chat(prompt)# ✅ 好做法:双层护栏 + 自纠正循环
from guardrails import PIIDetector, PromptInjectionDetector, ToxicityFilter
pii_detector = PIIDetector()
injection_detector = PromptInjectionDetector()
toxicity_filter = ToxicityFilter()
def safe_handler(user_input: str) -> str:
# 事前拦截:PII 脱敏
sanitized_input, pii_found = pii_detector.redact(user_input)
if pii_found:
logger.warning(f"PII detected and redacted: {pii_found}")
# 事前拦截:Prompt Injection 检测
if injection_detector.detect(sanitized_input):
return "检测到异常输入,请重新提问。"
# 调用模型
response = llm.chat(build_prompt(sanitized_input))
# 事后拦截:毒性过滤
if toxicity_filter.is_toxic(response.text):
return generate_safe_response()
# 事后拦截:输出格式验证
validated = validate_output(response.text, expected_schema)
if not validated.ok:
# 自纠正:将格式错误反馈给模型重新生成(最多 1 次)
response = llm.chat(corrective_prompt(response.text, expected_schema))
return response.text七、降级与回滚
LLM 应用比其他系统更需要降级策略,因为它依赖的外部组件更多:模型 API 可能超时或返回错误、向量数据库可能检索失败、工具调用可能异常。没有降级策略的 LLM 应用,任何一个依赖出问题都会导致用户体验完全中断。
灰度发布是降低回滚成本的关键。不要一次性把所有流量切到新版本,而是按比例逐步放量:
| 阶段 | 流量比例 | 关注指标 | 持续时间 | 回滚条件 |
|---|---|---|---|---|
| 金丝雀 | 1-5% | 错误率、延迟 P95、用户反馈 | 2-4 小时 | 错误率 > 1% 或 P95 > SLA |
| 小流量 | 10-20% | 成本/请求、评测指标、客诉率 | 1-2 天 | 成本超预算 30% 或评测下降 |
| 半量 | 50% | 全量指标对比 | 2-3 天 | 任何核心指标显著退化 |
| 全量 | 100% | 持续监控 | 长期 | 持续监控,随时可回滚 |
回滚策略需要覆盖三个层面:Prompt 回滚(秒级,通过配置中心切换版本)、模型回滚(分钟级,切换到上一个稳定模型版本)、全链路回滚(切流量到旧版本服务)。
上线前检查清单
以下是按阶段分组的可执行清单,总计 28 项:
发布前(Pre-Deployment)
- Prompt 版本化:所有 Prompt 从代码中分离,通过版本注册表管理,支持秒级切换
- 输出契约:模型输出有 JSON Schema 或结构化约束,解析失败有兜底处理
- Golden Dataset:至少有 50+ 条覆盖核心场景的评测样例,包含期望答案和引用来源
- 离线评测通过:回归评测通过率 ≥ 95%,无关键场景失败
- 红队测试:完成至少一轮 Prompt Injection、越权和毒性测试
- PII 处理:用户输入中的 PII 在到达模型前被检测和脱敏
- 失败路径定义:模型拒答、格式错误、检索为空、工具调用失败、超时五种情况都有明确处理
灰度阶段(Canary Release)
- 成本预算:根据预估请求量计算月度预算,设置 80% 和 100% 两级告警
- 延迟基线:建立 TTFT 和总延迟的 P50/P95/P99 基线,设置 SLA 告警
- Trace 完整性:每次模型调用都记录完整 trace(Prompt、参数、Token、检索结果)
- 在线评测运行:Continuous Evals 在生产流量上持续运行,结果写入监控面板
- 灰度放量计划:制定 1% → 10% → 50% → 100% 的放量节奏和各级回滚条件
生产运行(Post-Deployment)
- 告警覆盖:成本超预算、延迟超 SLA、错误率上升、评测指标下降四类告警全部配置
- 反馈闭环:用户「踩」的反馈自动进入评测候选池,每周更新 Golden Dataset
- 模型漂移监控:对比当前输出分布与基线分布,检测概念漂移
- 回滚演练:至少完成一次 Prompt 回滚和一次模型回滚演练,验证回滚时间在分钟级内
- 定期红队:每月至少一轮红队测试,覆盖新出现的攻击向量
- 成本复盘:每月复盘实际成本 vs 预算,识别优化空间(缓存命中率、路由比例、上下文长度)
用证据决定上线
上线判断应来自评测结果、灰度数据和监控指标,而不是团队的主观感受。没有证据的上线,只是在把 demo 风险转嫁给用户。
我见过太多团队在 demo 效果不错的时候就急着全量放开,然后在第一周被成本、延迟和 bad case 打得措手不及。上述七个维度和 28 项清单不是为了阻止你上线,而是为了让你在上线后不用花三倍的代价去补救。
最后一点:这份清单不是一次性的。LLM 应用在运行中会持续变化——用户输入分布会变、模型提供商会更新模型、知识库会过期。检查清单需要随着应用的生命周期持续更新,每次重大变更后重新过一遍。
参考资料
Footnotes
-
Müller, M. et al. "A State-of-the-practice Release-readiness Checklist for Generative AI Software." arXiv:2403.18958 (2024). https://arxiv.org/html/2403.18958v1 ↩ ↩2
-
Arthur AI. "Your Checklist to Launch a Production-Ready AI Agent." Arthur AI Blog (2026). https://www.arthur.ai/blog/checklist-to-launch-a-production-ready-ai-agent ↩ ↩2 ↩3 ↩4
-
Morph. "LLM Cost Optimization: 5 Levers to Cut API Spend 70-85%." Morph Blog. https://www.morphllm.com/llm-cost-optimization ↩