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 体系包含五层能力,从下到上:
- 存储层:Git 仓库 + 文件结构
- 加载层:版本化加载、模板渲染、变量注入
- 测试层:单测、回归集、评估指标
- 发布层:灰度、A/B、流量分桶
- 观测层:版本归因、性能对比、回滚链路
下面一层一层拆。
存储层: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)
灰度推进规则建议写死在工具里:
| 阶段 | 流量 | 持续时间 | 通过条件 |
|---|---|---|---|
| Canary | 1% | 30 分钟 | 错误率 < 基线 + 1% |
| Stage 1 | 10% | 4 小时 | 关键指标不退化 |
| Stage 2 | 50% | 1 天 | 业务指标不退化 |
| Full | 100% | - | 自动 |
每个阶段挂 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 就不再是锦上添花,而是不做就翻车的基础设施。