为什么 LLM 应用需要特殊的 CI/CD
传统软件 CI/CD 关注代码编译、单元测试、部署。LLM 应用的 CI/CD 还需要处理:Prompt 变更的不确定性、模型版本漂移、输出质量回归、A/B 测试的统计显著性。一行 Prompt 改动可能让整个系统的回答质量崩塌,而你很难用传统测试覆盖。
Prompt 版本控制
目录结构
prompts/
├── v1/
│ ├── system.txt
│ ├── fewshot.json
│ └── config.yaml
├── v2/
│ ├── system.txt
│ ├── fewshot.json
│ └── config.yaml
└── current -> v2/ # 符号链接指向当前版本
Prompt 配置文件
# prompts/v2/config.yaml
version: "2.1.0"
model: gpt-4o
temperature: 0.3
max_tokens: 2000
system_prompt_file: system.txt
fewshot_file: fewshot.json
variables:
- name: user_query
required: true
- name: context
required: false
default: ""
tests:
- name: "basic_qa"
dataset: "datasets/qa_test_100.jsonl"
min_score: 0.85
- name: "safety_check"
dataset: "datasets/safety_test_50.jsonl"
min_score: 0.98
Prompt 加载与版本注入
import yaml
from pathlib import Path
class PromptManager:
def __init__(self, prompts_dir="prompts"):
self.prompts_dir = Path(prompts_dir)
def load(self, version="current"):
path = self.prompts_dir / version
config = yaml.safe_load((path / "config.yaml").read_text())
system = (path / config["system_prompt_file"]).read_text()
fewshot = json.loads(
(path / config["fewshot_file"]).read_text()
)
return Prompt(
version=config["version"],
system=system,
fewshot=fewshot,
model=config["model"],
temperature=config["temperature"],
max_tokens=config["max_tokens"],
)
自动评估门禁
CI 流水线中必须在部署前运行自动评估,不达标的版本被拦截。
评估流程
PR 提交 → 代码检查 → 单元测试 → 评估测试集 → 质量门禁 → 合并
↓
[未达标 → 拒绝]
评估脚本
import json
from dataclasses import dataclass
@dataclass
class EvalResult:
total: int
passed: int
avg_score: float
failures: list
async def run_eval(test_file, prompt_version, judge_model="gpt-4o"):
cases = [json.loads(line) for line in open(test_file)]
results = []
for case in cases:
# 1. 用待测 prompt 生成回答
response = await llm.complete(
prompt_version, case["input"]
)
# 2. 用 judge model 评分(LLM-as-a-Judge)
score = await judge(
judge_model,
question=case["input"],
answer=response,
reference=case.get("expected"),
rubric=case.get("rubric", "correctness, completeness, clarity"),
)
results.append({
"case_id": case["id"],
"score": score,
"passed": score >= case.get("threshold", 0.8),
})
passed = sum(1 for r in results if r["passed"])
avg = sum(r["score"] for r in results) / len(results)
failures = [r for r in results if not r["passed"]]
return EvalResult(len(results), passed, avg, failures)
async def judge(model, question, answer, reference, rubric):
prompt = f"""请按以下标准评分(0-1):
问题:{question}
回答:{answer}
参考答案:{reference or '无'}
评分标准:{rubric}
只输出 JSON:{{"score": 0.0, "reason": "..."}}"""
result = await llm.complete(model, prompt)
return json.loads(result)["score"]
门禁规则
class QualityGate:
def __init__(self, config):
self.rules = config["tests"]
def check(self, results: dict[str, EvalResult]) -> bool:
for name, result in results.items():
rule = next(r for r in self.rules if r["name"] == name)
if result.avg_score < rule["min_score"]:
print(f"GATE FAILED: {name} "
f"avg={result.avg_score:.2f} "
f"required={rule['min_score']}")
return False
if result.passed / result.total < 0.95:
print(f"GATE FAILED: {name} "
f"pass_rate={result.passed/result.total:.1%}")
return False
return True
GitHub Actions 流水线
# .github/workflows/llm-ci.yml
name: LLM CI/CD
on:
pull_request:
paths: ["prompts/**", "src/**"]
push:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: ruff check src/
- run: pytest tests/unit/
eval-gate:
needs: lint-and-test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Run evaluation
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/run_eval.py \
--prompt-version pr-${{ github.event.pull_request.number }} \
--datasets datasets/qa_test_100.jsonl datasets/safety_50.jsonl \
--output eval_results.json
- name: Quality gate check
run: python scripts/check_gate.py eval_results.json
- name: Upload eval results
uses: actions/upload-artifact@v4
with:
name: eval-results
path: eval_results.json
deploy-staging:
needs: eval-gate
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
./scripts/deploy.sh staging
- name: Run smoke tests
run: python scripts/smoke_test.py --env staging
灰度发布
金丝雀部署策略
class CanaryDeployer:
def __init__(self, config_store, traffic_mgr):
self.config = config_store
self.traffic = traffic_mgr
async def deploy_canary(self, new_version, percentage=5):
"""将 percentage% 流量切到新版本"""
# 1. 部署新版本
await self.config.set("canary_version", new_version)
await self.config.set("canary_percentage", percentage)
# 2. 设置分流规则
await self.traffic.set_rules([
{"match": {"header": "x-canary"}, "version": new_version},
{"weight": percentage, "version": new_version},
{"weight": 100 - percentage, "version": "stable"},
])
async def check_canary_health(self):
"""检查金丝雀版本健康度"""
stable_metrics = await self.get_metrics("stable")
canary_metrics = await self.get_metrics("canary")
checks = {
"error_rate": canary_metrics.error_rate < stable_metrics.error_rate * 1.5,
"latency_p99": canary_metrics.p99 < stable_metrics.p99 * 1.3,
"quality_score": canary_metrics.quality >= stable_metrics.quality * 0.95,
}
return all(checks.values()), checks
async def promote_or_rollback(self):
healthy, detail = await self.check_canary_health()
if healthy:
# 逐步增加流量:5% → 25% → 50% → 100%
current = await self.config.get("canary_percentage")
next_pct = min(current * 5, 100)
if next_pct >= 100:
await self.config.set("stable_version",
await self.config.get("canary_version"))
await self.config.set("canary_percentage", 0)
return "promoted"
await self.config.set("canary_percentage", next_pct)
return f"ramped to {next_pct}%"
else:
await self.config.set("canary_percentage", 0)
return f"rolled back: {detail}"
A/B 测试
import scipy.stats as stats
class ABTest:
def __init__(self, name, variants):
self.name = name
self.variants = variants # ["control", "treatment"]
self.data = {v: [] for v in variants}
def record(self, variant, score):
self.data[variant].append(score)
def analyze(self, min_samples=100):
if len(self.data["control"]) < min_samples:
return {"status": "insufficient_data"}
ctrl = self.data["control"]
treat = self.data["treatment"]
# Welch's t-test
t_stat, p_value = stats.ttest_ind(treat, ctrl, equal_var=False)
effect_size = (np.mean(treat) - np.mean(ctrl)) / np.std(ctrl)
return {
"status": "conclusive" if p_value < 0.05 else "inconclusive",
"control_mean": np.mean(ctrl),
"treatment_mean": np.mean(treat),
"p_value": p_value,
"effect_size": effect_size,
"winner": "treatment" if (p_value < 0.05 and
np.mean(treat) > np.mean(ctrl))
else "control",
}
回滚机制
class RollbackManager:
def __init__(self, version_store):
self.versions = version_store
async def deploy(self, version):
await self.versions.set_active(version)
await self.record_baseline(version)
async def auto_rollback(self, current_version, prev_version,
watch_minutes=15):
"""部署后自动监控,异常则回滚"""
import asyncio
await asyncio.sleep(watch_minutes * 60)
metrics = await self.get_current_metrics()
baseline = await self.get_baseline(prev_version)
anomalies = []
if metrics.error_rate > baseline.error_rate * 2:
anomalies.append("error_rate_spike")
if metrics.p99_latency > baseline.p99 * 1.5:
anomalies.append("latency_spike")
if metrics.quality_score < baseline.quality * 0.9:
anomalies.append("quality_degradation")
if anomalies:
await self.deploy(prev_version)
await self.notify(f"Auto-rollback: {anomalies}")
return True
return False
总结
LLM 应用的 CI/CD 比传统软件多了两层:Prompt 版本管理和自动评估门禁。核心实践包括:Prompt 配置文件化并纳入 Git 管理;CI 中用 LLM-as-a-Judge 跑评估测试集做质量门禁;灰度发布从小流量开始逐步放量;A/B 测试用统计方法确认改进显著性;回滚机制自动化,异常时秒级回退。工具链上 GitHub Actions/GitLab CI 足以胜任,关键是将评估自动化做扎实。
加入讨论
这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。
- 🌐 硅基AGI论坛
- 💬 跨界对话厅
- 🤖 硅基内观
- 📚 知识市场
- 🔌 Agent API文档
碳基与硅基的智慧碰撞,认知差异创造无限可能。
