← 返回博客列表

PromptOps:把 Prompt 当成代码管理的全套工程实践

2026 年线上 Agent 出事故,越来越多不是模型的问题,也不是工具的问题,而是有人改了一行 prompt。

最典型的剧情是这样的:周二下午产品同学说”客服 Agent 的开场白能不能更热情点”,工程师在 admin 后台直接编辑了系统 prompt 的第三段,点保存,灰度直接 100%。两天后数据组发现订单转化率掉了 8%——不是因为开场白,而是那句话末尾多了一个换行符,导致后面”输出 JSON”的指令被模型当成段落分隔,30% 的回复结构化输出失败,触发了兜底文案。

事故复盘出来的事实是:没人知道 prompt 什么时候改的、改了什么、谁改的、回滚到哪个版本

这就是 PromptOps 要解决的问题:把 prompt 从”配置项”升级为”和代码同等待遇的工程产物”。

Prompt 不是配置,是代码

很多团队把 prompt 当成配置文件管理,存数据库、存 Redis、存 admin 后台。这是问题的根源。

维度配置代码
修改频率偶尔频繁
影响范围局部、可隔离全局、行为级
测试需求一般无单测 + 集成 + 回归
版本管理KV 覆盖Git 全量历史
回滚成本改个字段切个 commit
评审流程改了就上PR + Review + CI

Prompt 的每个字符都直接影响模型输出。它的修改语义和代码完全一致:改了行为。但它的传统管理方式停留在 2018 年改 Nginx 配置的水平。这不合理。

PromptOps 的核心断言只有一句:prompt 是源代码,必须进 Git,必须跑 CI,必须有版本号,必须能 diff,必须能回滚。

五层工程能力

一套完整的 PromptOps 体系包含五层能力,从下到上:

  1. 存储层:Git 仓库 + 文件结构
  2. 加载层:版本化加载、模板渲染、变量注入
  3. 测试层:单测、回归集、评估指标
  4. 发布层:灰度、A/B、流量分桶
  5. 观测层:版本归因、性能对比、回滚链路

下面一层一层拆。

存储层:YAML 是最优解

最低成本的方案是把 prompt 写成 YAML 或 Markdown,按业务域分目录,进主仓库或独立的 prompts 仓库。

prompts/
├── customer_service/
│   ├── greeting.yaml          # 客服开场白
│   ├── intent_classify.yaml   # 意图分类
│   └── reply.yaml             # 回复生成
├── data_extraction/
│   └── invoice_parse.yaml
└── _shared/
    └── output_format.yaml     # 共享片段

每个 prompt 文件带元数据:

# prompts/customer_service/greeting.yaml
id: customer_service.greeting
version: 3.2.1
owner: "@team-cs"
model: claude-sonnet-4.5
temperature: 0.3
max_tokens: 500

system: |
  你是 Acme 公司的客服助手。请遵循以下规则:
  1. 回复必须用 JSON 格式输出
  2. 包含字段 reply, intent, need_human

user: |
  用户消息:{user_message}
  历史对话:{history}

variables:
  required: [user_message]
  optional: [history]

eval:
  dataset: greeting_v3.jsonl
  metrics: [json_valid_rate, intent_accuracy]
  baseline_score: 0.92

这套结构带来三个直接收益:

  • diff 可读:PR 里能直接看出”加了一个换行符”
  • owner 明确:谁改的可追责
  • 元数据驱动:模型、温度、评估集都跟 prompt 走,不会出现”线上跑的是 GPT-4o,回归用的是 Sonnet”的错配

加载层:用版本号锁定行为

应用代码里永远不要直接拼 prompt 字符串。所有 prompt 走 PromptManager 加载:

from prompt_manager import PromptManager

pm = PromptManager(repo="./prompts", default_revision="v2026.05.25")

prompt = pm.load(
    id="customer_service.greeting",
    version="3.2.1",  # 显式版本,禁止 latest
    variables={"user_message": msg, "history": history},
)

response = await llm.chat(
    model=prompt.model,
    messages=prompt.messages,
    temperature=prompt.temperature,
)

实现的关键点:

class PromptManager:
    def __init__(self, repo: str, default_revision: str):
        self.repo = Path(repo)
        self.default_revision = default_revision
        self._cache: dict[str, RenderedPrompt] = {}

    def load(self, id: str, version: str, variables: dict) -> RenderedPrompt:
        cache_key = f"{id}@{version}"
        if cache_key not in self._cache:
            raw = self._read_versioned(id, version)
            self._cache[cache_key] = self._parse(raw)
        template = self._cache[cache_key]
        return template.render(variables)

    def _read_versioned(self, id: str, version: str) -> str:
        # 从 Git tag 或独立目录里读对应版本
        path = self.repo / id.replace(".", "/") / f"{version}.yaml"
        if not path.exists():
            raise PromptVersionNotFound(id, version)
        return path.read_text()

三个不能省的设计:

  • 强制版本号:禁止 latest 之类的别名,改 prompt = 升版本 + 改调用方
  • 变量校验:required 变量缺失直接抛错,避免 None 被渲染成字符串 "None"
  • 加载缓存:同一版本的 prompt 进程内只解析一次

测试层:回归集是 PromptOps 的命门

prompt 改完不跑回归,等于代码改完不跑测试。但 prompt 测试有个尴尬:输出非确定

解法是分三层测:

# 1. 单元测:模板渲染、变量注入、JSON schema 校验
def test_greeting_renders():
    p = pm.load("customer_service.greeting", "3.2.1",
                {"user_message": "hi", "history": ""})
    assert "{user_message}" not in p.messages[1]["content"]
    assert p.model == "claude-sonnet-4.5"

# 2. 行为测:固定数据集 + LLM-as-Judge
async def test_greeting_intent_accuracy():
    dataset = load_jsonl("greeting_v3.jsonl")  # 200 条带 ground truth
    results = await batch_run(prompt_id="customer_service.greeting",
                              version="3.2.1", dataset=dataset)
    accuracy = sum(r.intent == r.expected for r in results) / len(results)
    assert accuracy >= 0.92, f"准确率回退:{accuracy}"

# 3. 对比测:新版本 vs 当前线上版本
async def test_greeting_no_regression():
    dataset = load_jsonl("greeting_v3.jsonl")
    new = await batch_run(prompt_id, version="3.3.0", dataset=dataset)
    old = await batch_run(prompt_id, version="3.2.1", dataset=dataset)
    assert new.score >= old.score - 0.02, "相比线上版本退化超过 2%"

CI 里把这三层串起来,PR 触发:

# .github/workflows/prompt-ci.yml
on: pull_request
jobs:
  prompt-regression:
    steps:
      - run: pytest tests/prompts/unit/      # 5 秒
      - run: python eval/run_behavior.py     # 2 分钟,跑 200 条
      - run: python eval/compare_baseline.py # 5 分钟,新旧对比
      - uses: actions/comment-pr@v3
        with:
          body: |
            ## Prompt 回归报告
            - greeting v3.3.0: 0.94(基线 0.92,+2.2%)
            - reply v2.1.0: 0.88(基线 0.91,**-3.3% ⚠️**)

跑一次 5-8 分钟,比起线上事故的成本可以忽略。

发布层:灰度 + 自动回滚

回归过了不等于线上没事,真实用户分布永远和评估集不一样。所以新版本必须灰度。

最实用的灰度策略是基于 user_id 哈希分桶:

def get_prompt_version(user_id: str, prompt_id: str) -> str:
    config = load_rollout_config(prompt_id)
    # config = {"3.2.1": 95, "3.3.0": 5}  # 5% 灰度
    bucket = hash(f"{user_id}:{prompt_id}") % 100
    cumulative = 0
    for version, percent in config.items():
        cumulative += percent
        if bucket < cumulative:
            return version
    return config.default

prompt_version = get_prompt_version(user_id, "customer_service.greeting")
prompt = pm.load("customer_service.greeting", prompt_version, vars)

灰度推进规则建议写死在工具里:

阶段流量持续时间通过条件
Canary1%30 分钟错误率 < 基线 + 1%
Stage 110%4 小时关键指标不退化
Stage 250%1 天业务指标不退化
Full100%-自动

每个阶段挂 alarm,触发任一异常自动回滚到上一版本:

async def rollback_if_degraded(prompt_id: str):
    metrics = await get_metrics(prompt_id, window="10min")
    baseline = await get_baseline(prompt_id)
    if metrics.error_rate > baseline.error_rate + 0.01:
        await set_rollout(prompt_id, {baseline.version: 100})
        await notify_oncall(f"{prompt_id} 自动回滚")

观测层:把版本号打进每条日志

线上每次 LLM 调用都要带上 prompt_id 和 prompt_version,写进结构化日志:

logger.info("llm_call", extra={
    "prompt_id": "customer_service.greeting",
    "prompt_version": "3.3.0",
    "user_id": user_id,
    "model": prompt.model,
    "input_tokens": resp.usage.input_tokens,
    "output_tokens": resp.usage.output_tokens,
    "latency_ms": elapsed,
    "intent_predicted": parsed.intent,
})

之后所有线上指标都能按版本切分:

  • 错误率:3.3.0 是不是比 3.2.1 高
  • 成本:新版本平均 token 是不是更多
  • 延迟:模板变长有没有拖慢首 token
  • 业务指标:转化率、满意度按版本归因

这一层做扎实了,PromptOps 才真正闭环:能改、能测、能灰度、能回滚、能归因。

团队落地路线

不要一上来就把五层全建起来,按团队规模分阶段:

团队规模优先级不做什么
1-3 人Git 仓库 + 版本号 + 简单 PromptManager灰度、自动评估都先放放
4-10 人加 CI 回归集(50-100 条) + LLM-as-Judge复杂的 A/B 平台
10+ 人全套灰度、版本归因、自动回滚-
平台型团队独立 PromptOps 服务 + Web UI + 多租户-

工具选型

自建还是用现成方案?

方案适合不适合
纯 Git + 自写 Loader小团队、强工程文化非工程同学要改 prompt
Langfuse / PromptLayer中型团队,要 UI 但接受 SaaS强合规、不能出域
Helicone + 自建评估监控成熟,回归想自己掌控想要一站式
LangSmith Prompt Hub已用 LangChain 全家桶不用 LangChain
自建 PromptOps 平台平台型团队,复用价值高业务团队,ROI 不划算

强烈不推荐的是 admin 后台直接编辑 prompt 写数据库——这是事故温床。

落地 Checklist

照这张表自检,过 80% 就基本算把 prompt 当代码在管了:

  • 所有 prompt 在 Git 里,有 owner、有版本号
  • 应用代码不直接拼 prompt 字符串,全走 PromptManager
  • 调用时显式传版本号,禁止 latest
  • 每个 prompt 有 ≥ 50 条的回归数据集
  • PR 自动跑回归,对比基线分数
  • 新版本上线走灰度,不直接 100%
  • 灰度有自动回滚机制
  • 每条 LLM 调用日志带 prompt_version
  • 错误率、成本、业务指标能按版本切分
  • 回滚到任意历史版本 ≤ 1 分钟

prompt 是 2026 年最容易被低估的工程债。当你的 Agent 产品 prompt 数量超过 20 个、改动频率超过每周 5 次、调用量超过每天 100 万次,PromptOps 就不再是锦上添花,而是不做就翻车的基础设施。